# Sydonia Toolkit — full documentation
> Concatenation of the entire Sydonia Toolkit documentation for LLM
> ingestion. Generated by scripts/gen_llms_full.py from the docs sources.
> Canonical site: https://francoischastel.github.io/sydonia-toolkit/latest/
---
:material-anchor: SYDONIA · ASYCUDA World · PostgreSQL
# Query Sydonia: write friendly, run genuine
Write customs analytics against a **friendly logical model** — `declaration`,
`declaration_item`, `hs_code`, `tax_amount` — and **compile them to genuine
ASYCUDA World (SYDONIA) SQL you can actually run**, read-only, against a real
instance. The abstraction is easy; the output is real. Underneath is a faithful,
**fully-sourced** reconstruction of the whole customs model — now the logical
layer the compiler maps *from*.
55tables across 8 modules
49 / 6documented / inferred
100%cited public sourcing
0errors on a clean load
## Why it exists
ASYCUDA World is the customs platform used by **100+ countries**, but its
internal database schema is **wide, denormalised and proprietary** — painful to
query for analytics. So this project does two things. It rebuilds an
**information-equivalent reference model** using *only* public documentation —
official public technical table descriptions, national ASYCUDA World user manuals,
and open ISO/UN/WCO standards, every table traceable to a citation or honestly
flagged as inferred. And it ships a [**query compiler**](compiler/index.md) that
lets you write against that clean model and **compile to genuine Sydonia SQL** —
so the friendly names are the ergonomics, and the runnable statement is real.
The modeled version is **ASYCUDA World (v4)** — the current standard. The
[platform section](platform/index.md) covers the full version lineage
(ASYCUDA++ → World → ASY5), the XML wire formats, and where external systems
can integrate, so you can place this model against the deployment you actually
have.
!!! quote "Scope, stated plainly"
This is a **functional/reference reconstruction** for sandbox, integration,
analytics and training use. It is **not** an attempt to obtain or reproduce
UNCTAD's proprietary internal schema, and it contains no pirated software or
data from any live customs system.
## What you can do with it
- :material-cog-transfer:{ .lg .middle } **Compile to genuine Sydonia SQL**
---
Write a query against the friendly logical model; the compiler rewrites it
into runnable ASYCUDA World SQL via a CTE prelude. **Write friendly, run
genuine.**
[:octicons-arrow-right-24: The query compiler](compiler/index.md)
- :material-database-search:{ .lg .middle } **Query a real ASYCUDA World**
---
Point the same queries and skills at a live instance — read-only, metadata
only — for analytics, feature extraction and selectivity on *your* data.
[:octicons-arrow-right-24: Querying Sydonia](querying-sydonia/index.md)
- :material-database-import:{ .lg .middle } **Stand up a customs sandbox**
---
One `psql` command loads a clean schema, seed reference data, and a fully
worked manifest→release example. PostgreSQL 14+.
[:octicons-arrow-right-24: Quickstart](getting-started/quickstart.md)
- :material-file-tree:{ .lg .middle } **Understand the customs domain**
---
A guided map of manifests, the SAD declaration, valuation build-up,
per-item taxes, selectivity lanes and the document lifecycle.
[:octicons-arrow-right-24: Customs concepts](getting-started/concepts.md)
- :material-magnify:{ .lg .middle } **Query real join paths**
---
A cookbook of the join paths that matter: declaration → item → tax line,
manifest → B/L → cargo, assessed-vs-paid reconciliation.
[:octicons-arrow-right-24: Querying guide](guides/querying.md)
- :material-source-branch:{ .lg .middle } **Extend it safely**
---
Add tables and columns while keeping the conventions and the provenance
trail intact, so your fork stays as auditable as the original.
[:octicons-arrow-right-24: Extending guide](guides/extending.md)
- :material-shield-check:{ .lg .middle } **Trust the sourcing**
---
Every `CREATE TABLE` carries a `-- src:` or `-- inferred` tag. Coverage and
an official fit/gap analysis are published in full.
[:octicons-arrow-right-24: Provenance](provenance/index.md)
- :material-robot-happy:{ .lg .middle } **Drive it with Agent Skills**
---
A suite of standard Agent Skills — installable into any agent (Claude Code,
Cursor, Codex, …) via `npx skills add` — that set up, query, seed, extend and
validate the model directly inside your own codebase.
[:octicons-arrow-right-24: Agent Skills](skills/index.md)
- :material-earth:{ .lg .middle } **Know the platform**
---
ASYCUDA itself: the version lineage (v1 → World → ASY5), the XML wire
formats, the integration doors, and the clearance process.
[:octicons-arrow-right-24: The platform](platform/index.md)
- :material-brain:{ .lg .middle } **Build ML on it**
---
The research-backed blueprint for ML on declarations and plugging a risk
engine into selectivity — prototyped on this schema.
[:octicons-arrow-right-24: ML on customs data](guides/ml-risk-engine.md)
## A 30-second taste
```bash
createdb customs_sandbox
psql -v ON_ERROR_STOP=1 -d customs_sandbox -f Sydonia/schema/asycuda.sql
psql -v ON_ERROR_STOP=1 -d customs_sandbox -f Sydonia/schema/seed_reference.sql
psql -v ON_ERROR_STOP=1 -d customs_sandbox -f Sydonia/examples/e2e.sql
```
```text
--- Declaration summary ---
reg | type | status | lane | total_items | total_cif_value
-------+------+----------+------+-------------+-----------------
C 427 | IM4 | released | RED | 2 | 63300.0000
--- Total assessed vs receipt ---
total_assessed | receipt_amount
----------------+----------------
12132.5000 | 12132.5000
```
A single import declaration — two items, freight and insurance apportioned to
per-item CIF, duties and VAT calculated, routed to the RED lane, inspected,
paid and released — inserts with full referential integrity.
## For large-language-model consumption
This site publishes an [`llms.txt`](llms.txt) index and a concatenated
[`llms-full.txt`](llms-full.txt) so you can hand the entire model to an LLM in
one paste. See the [Agent Skills](skills/index.md) to wire it into an
agent workflow.
---
Sydonia Toolkit is independent and reconstructed from public
documentation. ASYCUDA and SYDONIA are programmes of UNCTAD; this project is not
affiliated with or endorsed by UNCTAD.
---
# Getting started
Three short pages take you from an empty database to understanding what every
table means.
- :material-rocket-launch:{ .lg .middle } **Quickstart**
---
The fastest path: create a database, load three SQL files, and watch a full
customs declaration flow through to release.
[:octicons-arrow-right-24: Quickstart](quickstart.md)
- :material-cog:{ .lg .middle } **Installation**
---
Prerequisites, PostgreSQL versions, Docker vs local, the `asycuda` schema,
idempotent reloads, and teardown.
[:octicons-arrow-right-24: Installation](installation.md)
- :material-school:{ .lg .middle } **Customs concepts**
---
A domain primer — manifest, bill of lading, the SAD declaration, valuation,
taxes, selectivity and the lifecycle — mapped to tables.
[:octicons-arrow-right-24: Customs concepts](concepts.md)
New to the customs domain? Read **Customs concepts** first — it makes the schema
read like a story instead of 55 tables.
Once the sandbox is up, the main event is querying: **[Querying
Sydonia](../querying-sydonia/index.md)** explains the real ASYCUDA World tables,
and **[the query compiler](../compiler/index.md)** turns friendly logical queries
into genuine Sydonia SQL you can run on a live system.
---
# Quickstart
Load the model and run the end-to-end example against a throwaway PostgreSQL
database. Total time: about a minute.
## Prerequisites
- **PostgreSQL 14 or newer** (`psql`, `createdb` on your `PATH`).
- A local server you can create a database on. No superuser needed — an ordinary
role that can `CREATE DATABASE` and `CREATE SCHEMA` is enough.
!!! tip "No local PostgreSQL?"
Spin one up with Docker in one line:
```bash
docker run --name customs -e POSTGRES_PASSWORD=customs -p 5432:5432 -d postgres:16
```
Then prefix the commands below with the connection, e.g.
`psql "postgresql://postgres:customs@localhost:5432/customs_sandbox" -f ...`.
## 1 · Get the files
```bash
git clone https://github.com/FrancoisChastel/sydonia-toolkit.git
cd sydonia-toolkit
```
The SQL lives under `Sydonia/`:
```text
Sydonia/schema/asycuda.sql # the model — 55 tables, 8 modules
Sydonia/schema/seed_reference.sql # reference / code-table seed data
Sydonia/examples/e2e.sql # a full manifest → release worked example
```
## 2 · Create a database and load the model
```bash
createdb customs_sandbox
psql -v ON_ERROR_STOP=1 -d customs_sandbox -f Sydonia/schema/asycuda.sql
psql -v ON_ERROR_STOP=1 -d customs_sandbox -f Sydonia/schema/seed_reference.sql
```
`-v ON_ERROR_STOP=1` makes `psql` exit non-zero on the first error, so a clean
run is a real signal. The schema creates and uses a dedicated **`asycuda`**
schema (namespace) — your `public` schema stays untouched.
## 3 · Run the end-to-end example
```bash
psql -v ON_ERROR_STOP=1 -d customs_sandbox -f Sydonia/examples/e2e.sql
```
You should see a declaration assembled and reconciled:
```text
--- Declaration summary ---
reg | type | status | lane | total_items | total_cif_value
-------+------+----------+------+-------------+-----------------
C 427 | IM4 | released | RED | 2 | 63300.0000
--- Items with tax totals ---
item_number | hs_code | customs_value | taxes
-------------+---------+---------------+-----------
1 | 851712 | 42200.0000 | 6541.0000
2 | 610910 | 21100.0000 | 5591.5000
--- Total assessed vs receipt ---
total_assessed | receipt_amount
----------------+----------------
12132.5000 | 12132.5000
--- Lifecycle trail ---
sort_order | code | note
------------+------------+-------------------------------------
1 | stored | Draft captured
2 | registered | Validated & registered (C 427)
3 | assessed | Assessed: duties & taxes calculated
4 | paid | Paid — receipt RCPT-2026-0427
5 | released | Release order issued
```
That is a complete import: a manifest arrives from Shanghai, a broker files an
IM4 declaration with two items, freight and insurance are apportioned to
per-item CIF, duty and VAT are assessed, selectivity routes it RED, an officer
inspects it, payment is receipted, and a release order is issued — all with
foreign keys intact.
## 4 · Look around
```bash
psql -d customs_sandbox
```
```sql
SET search_path TO asycuda, public; -- (1)!
\dt -- list the 55 tables
SELECT count(*) FROM declaration_item; -- 2
\d+ declaration -- inspect the SAD general segment
```
1. Every table lives in the `asycuda` schema. Set the `search_path` once per
session and you can use bare table names.
## Next steps
- :material-school: **Understand what you loaded** — the
[customs concepts](concepts.md) primer maps the domain to the tables.
- :material-magnify: **Start querying** — the
[querying guide](../guides/querying.md) has the join paths and an analytics
cookbook.
- :material-map: **See the shape** — the
[entity-relationship diagram](../schema/erd.md) renders every foreign key.
- :material-check-decagram: **Trust it** — the
[validation skill](../skills/index.md) re-runs the clean-load and provenance
checks on demand.
## Tear it down
```bash
dropdb customs_sandbox
```
---
# Installation
The model is plain SQL — there is nothing to compile and no runtime dependency
beyond PostgreSQL itself.
## Requirements
| Requirement | Notes |
|-------------|-------|
| **PostgreSQL 14+** | The schema deliberately avoids 15-only features so it loads on 14. Tested on 14–16. |
| `psql` + `createdb` | Ships with any PostgreSQL client install. |
| A role that can `CREATE DATABASE` / `CREATE SCHEMA` | No superuser required. |
## The three SQL files (and load order)
Order matters — objects reference each other:
```text
1. Sydonia/schema/asycuda.sql creates schema `asycuda` + 55 tables + indexes
2. Sydonia/schema/seed_reference.sql fills the ref_* / code tables
3. Sydonia/examples/e2e.sql (optional) a worked manifest → release example
```
Load them with `ON_ERROR_STOP=1` so any problem fails loudly:
```bash
createdb customs_sandbox
psql -v ON_ERROR_STOP=1 -d customs_sandbox -f Sydonia/schema/asycuda.sql
psql -v ON_ERROR_STOP=1 -d customs_sandbox -f Sydonia/schema/seed_reference.sql
psql -v ON_ERROR_STOP=1 -d customs_sandbox -f Sydonia/examples/e2e.sql # optional
```
## The `asycuda` schema
Everything is created inside a dedicated PostgreSQL **schema** (namespace)
called `asycuda`, not in `public`. This keeps the model self-contained and easy
to drop, and lets it coexist with your own tables.
```sql
-- from asycuda.sql
DROP SCHEMA IF EXISTS asycuda CASCADE; -- (1)!
CREATE SCHEMA asycuda;
SET search_path TO asycuda, public;
```
1. The load is **idempotent**: re-running `asycuda.sql` drops and recreates the
whole schema, so you always get a clean slate. Anything you put *inside*
`asycuda` is discarded on reload — keep your own tables elsewhere.
To use bare table names in a session, set the search path once:
```sql
SET search_path TO asycuda, public;
SELECT count(*) FROM declaration; -- instead of asycuda.declaration
```
To make it the default for a database or role:
```sql
ALTER DATABASE customs_sandbox SET search_path TO asycuda, public;
```
## Verify the load
```sql
SET search_path TO asycuda, public;
-- 55 base tables expected
SELECT count(*) FROM information_schema.tables
WHERE table_schema = 'asycuda' AND table_type = 'BASE TABLE';
-- reference data present (transport modes, statuses, lanes, …)
SELECT count(*) FROM ref_transport_mode; -- 9
SELECT code, name FROM ref_selectivity_lane ORDER BY code;
```
!!! success "What a clean install looks like"
`asycuda.sql` and `seed_reference.sql` complete with **zero errors**, the
table count is **55**, and `e2e.sql` prints the declaration summary with
`total_assessed = receipt_amount`. Anything else is a real failure — read the
`psql` output.
## Docker one-liner
```bash
docker run --name customs -e POSTGRES_PASSWORD=customs -p 5432:5432 -d postgres:16
export PGURL="postgresql://postgres:customs@localhost:5432"
createdb "$PGURL/customs_sandbox" 2>/dev/null || \
psql "$PGURL/postgres" -c 'CREATE DATABASE customs_sandbox;'
for f in schema/asycuda.sql schema/seed_reference.sql examples/e2e.sql; do
psql -v ON_ERROR_STOP=1 "$PGURL/customs_sandbox" -f "Sydonia/$f"
done
```
## Troubleshooting
| Symptom | Cause / fix |
|---------|-------------|
| `permission denied to create database` | Use a role with `CREATEDB`, or create the DB as an admin and load into it. |
| `schema "asycuda" already exists` errors mid-file | You edited the file and removed the `DROP SCHEMA` guard — restore it, or `DROP SCHEMA asycuda CASCADE;` first. |
| `relation "…" does not exist` in the seed/e2e | Files loaded out of order — always load `asycuda.sql` **first**. |
| Bare table names "not found" in `psql` | Run `SET search_path TO asycuda, public;` for the session. |
| Syntax errors on `GENERATED ALWAYS AS IDENTITY` | Your server is < PostgreSQL 10 — upgrade; the model targets 14+. |
## Automate it with an agent
The [`customs-schema-setup`](../skills/index.md) Agent Skill does all of the
above for you — creating the database, loading the files in order, and reporting
a clean/failed result — and [`customs-validate`](../skills/index.md) re-runs the
full done-condition checks. The skills install into any agent (Claude Code,
Cursor, Codex, …) via `npx skills add`.
## Tear it down
```bash
dropdb customs_sandbox
# or, to keep the database but drop just the model:
psql -d customs_sandbox -c 'DROP SCHEMA asycuda CASCADE;'
```
---
# Customs concepts
The schema reads like a story once you know the domain. This page is that story:
how goods move through customs, and which tables capture each step. Names in
`code font` are tables you can find in the [schema reference](../schema/index.md).
## The big picture
```mermaid
flowchart LR
A[Carrier files
manifest] --> B[Broker files
SAD declaration]
B --> C[Valuation
& assessment]
C --> D{Selectivity
lane}
D -->|GREEN| F[Release]
D -->|YELLOW/RED| E[Documentary /
physical check]
E --> G[Payment
& receipt]
C --> G
G --> F
```
Two documents drive everything:
1. A **manifest** — what the *carrier* says is on board the ship/plane.
2. A **declaration (the SAD)** — what the *importer/broker* declares to customs,
item by item, so duty and tax can be assessed and the goods released.
Everything else — valuation, taxes, selectivity, payment, warehousing — hangs
off those two.
## Reference / configuration backbone
Before any transaction exists, customs needs **code tables**: countries, currencies,
ports, tariff codes, tax types, procedure codes, package types, and so on. These
are the `ref_*` tables, grounded in international standards:
| Concept | Table | Standard |
|---------|-------|----------|
| Countries | `ref_country` | ISO 3166 |
| Currencies | `ref_currency` | ISO 4217 |
| Ports / places | `ref_location` | UN/LOCODE |
| Commodity codes | `ref_hs_tariff` | Harmonized System (self-referential hierarchy) |
| Procedure codes | `ref_cpc_regime` | Customs Procedure Codes |
| Package types | `ref_package_type` | UN/ECE Rec 21 |
| Container size-types | `ref_container_type` | ISO 6346 |
| Delivery terms | `ref_incoterm` | Incoterms |
Coded columns elsewhere carry a **foreign key** to one of these tables rather
than repeating the code and its name — see [Reference &
configuration](../schema/reference-config.md).
## Traders and users
An **economic operator** — importer, exporter, consignee, declarant, broker or
carrier — is a `trader`, keyed by its Tax Identification Number (TIN). One trader
can play several roles (`trader_role`). People who log into the system are
`sys_user` rows.
## Manifest and cargo
When a vessel arrives, the carrier lodges a **manifest** (`manifest`) — voyage,
ports, dates, and totals. Each consignment on board is a **bill of lading**
(`bill_of_lading`, or air waybill):
- A **master** B/L covers a whole container from carrier to carrier.
- A **house** B/L is one consignee's consignment inside it (**degroupage** /
consolidation). The model captures this with a self-reference:
`bill_of_lading.master_bl_id → bill_of_lading.id`.
Physical `container` rows (with ISO 6346 size-types and seals) and the goods
lines on each B/L (`manifest_cargo_item`) complete the cargo picture. See
[Manifest & cargo](../schema/manifest.md).
## The declaration — the SAD
The **Single Administrative Document (SAD)** is the internationally standardised
customs declaration form (54 boxes, 8 parts). ASYCUDA models it as two segments,
and so does this schema:
- A **general segment** — one per consignment — is the `declaration` table:
parties, regime, transport, invoice totals, dates, status and selectivity lane.
- Repeating **item segments** are `declaration_item` rows: one per commodity
line, with HS code, origin, mass, packages, procedure and the **statistical /
customs value** that becomes the tax base.
```mermaid
erDiagram
declaration ||--o{ declaration_item : "boxes 31–49, one per line"
declaration_item ||--o{ declaration_tax_line : "per-tax: base·rate·amount"
declaration_item ||--o| item_value_note : "apportioned CIF"
```
See [Declaration (the SAD)](../schema/declaration.md).
## Valuation — how the tax base is built
Duty and tax are charged on the **customs value**, usually **CIF** (Cost,
Insurance, Freight). But an invoice is often FOB (goods only), with freight and
insurance quoted for the *whole* shipment. The **valuation note** builds the
value up and apportions the shared costs down to each item:
- `valuation_note` — declaration-level build-up:
`invoice FOB + freight + insurance + other → total CIF`.
- `item_value_note` — the same costs **apportioned per item** (by value share)
to produce each item's CIF, which is the tax base.
In the worked example, $3,000 freight + $300 insurance on a $60,000 FOB shipment
split 2:1 across the two items, giving item CIFs of $42,200 and $21,100.
## Taxes
Each item is taxed once per applicable **tax type** (import duty, VAT, excise,
fees). A `declaration_tax_line` records, for one item and one tax:
```text
tax_base · rate_percent (or specific_amount) · tax_amount · mode_of_payment
```
Taxes can cascade — VAT is often charged on *(customs value + import duty)* —
which is why the base is stored per line rather than derived. Totals are
computed by query, not stored (see the [querying guide](../guides/querying.md)).
## Selectivity — the risk lanes
Not every declaration is inspected. **Selectivity** routes each one into a lane:
| Lane | Meaning |
|------|---------|
| :material-circle:{ style="color:#16a34a" } **GREEN** | Automatic release |
| :material-circle:{ style="color:#ca8a04" } **YELLOW** | Documentary check |
| :material-circle:{ style="color:#dc2626" } **RED** | Physical examination |
| :material-circle:{ style="color:#2563eb" } **BLUE** | Released now, audited later |
`ref_selectivity_lane` defines the lanes, `risk_criterion` the rules that trigger
them, `selectivity_result` the lane a declaration landed in, and `inspection_act`
the officer's examination record. See [Selectivity & risk](../schema/selectivity.md).
## Accounting — payment and receipt
Once assessed, the declarant pays. `payment` records the settlement (cash or
against a deferred-payment `account`), `receipt` is the issued receipt, and
`account_movement` is the ledger entry. `guarantee` holds securities/bonds used
by suspense regimes. See [Accounting](../schema/accounting.md).
## Transit and suspense
Some goods are **not** cleared for home use immediately — duty is *suspended*:
- **Warehousing** — stored in a bonded `ref_warehouse` (`warehouse_entry` /
`warehouse_exit`) until later entry.
- **Transit** — moved under customs control between offices
(`transit_declaration`, with departure/transit/destination offices and a
guarantee).
- **Temporary admission** — imported for a limited time then re-exported
(`temporary_admission`).
See [Transit & suspense](../schema/transit-suspense.md).
## The document lifecycle
A declaration walks a fixed path, recorded in `declaration_status_history`:
```mermaid
stateDiagram-v2
direction LR
[*] --> stored
stored --> registered
registered --> assessed
assessed --> paid
paid --> released
released --> [*]
registered --> queried
registered --> cancelled
```
Manifests have their own lifecycle (`manifest_status_history`), and every
significant action is written to the cross-cutting `audit_log`.
## Where to go next
- [Schema overview](../schema/index.md) — the 8 modules in one map.
- [Worked example](../guides/worked-example.md) — this whole story, as SQL.
- [Data dictionary](../schema/data-dictionary.md) — every column, defined.
- [The platform](../platform/index.md) — ASYCUDA itself: versions, wire
formats, and the real clearance state machine behind this lifecycle.
---
# Querying Sydonia
ASYCUDA World — **Sydonia** in its French-speaking installs — does not keep a
tidy relational model under the hood. Its physical database is **wide,
denormalised, and optimised for the Java engine**, not for the analyst. The
general segment is copied into every item row, HS codes are shattered across five
columns, code and name live side by side with no foreign key, and every reference
table carries a validity window. UNCTAD **does not publish the physical schema**,
so most of what follows is reconstructed from the public layer — the official
Tables Description documents (**S013 reference, S014 declaration, S015 manifest,
S016 accounting**) and the XML wire format.
This section is the part of the toolbox about **querying the real thing**. It
walks the real table families you would hit against a live Sydonia RDBMS, names
the columns that *are* publicly pinned, and is honest about the ones that are not.
!!! warning "The must-request gap — exact physical names are instance-specific"
A handful of column and table names **are** public and appear verbatim below,
because they show up in the [Tables Description](../platform/asycuda-world.md)
and the [XML messages](../platform/xml-messages.md): the `SAD_Tax`
`COD`/`BSE`/`RAT`/`AMT`/`MOP`/`TYP` roots, the HS split `TAR_HSC_NB1..5`, the
`PTY_RED`/`YEL`/`GRE`/`BLU` colour flags, `VIT_CIF`/`VIT_STV`, the `INSTANCE_ID`
engine key, and `VALID_FROM`/`VALID_TO`. **Everything else is the shape, not
the exact spelling.** The remaining column names on these pages follow AW's
prefix conventions and match the toolbox's [mock database](https://github.com/FrancoisChastel/sydonia-toolkit/blob/master/Sydonia/adapters/mock_asycuda_world.sql)
— treat them as *plausible defaults you must confirm against your instance*,
not as guaranteed identifiers. When you get real access, the physical names
are the first thing to request.
## Two ways to query
- :material-database-search:{ .lg .middle } **Directly, against the real tables**
---
Write SQL against `SAD_General_Segment`, `SAD_Item`, `SAD_Tax`, `GEN_TAB`,
`BOL_TAB`, the `UN*` reference tables — and handle the denormalisation
yourself. This section documents those tables so you can.
[:octicons-arrow-right-24: Declaration tables](declaration-tables.md)
- :material-wand:{ .lg .middle } **Via the logical model + compiler**
---
Write friendly SQL against the toolbox's clean logical names; the **query
compiler** rewrites it into genuine Sydonia SQL — dedup, HS concat, validity
filtering and all — so you never touch the gotchas by hand.
[:octicons-arrow-right-24: The query compiler](../compiler/index.md)
Both hit the same real database. Direct SQL gives you full control and nothing
between you and the engine; the compiler trades that for correctness-by-default on
the five things that reliably bite (see [joins & gotchas](joins-and-gotchas.md)).
## The real table families
The physical schema falls into four layers. Reference tables sit underneath
everything (codes + validity); manifests describe cargo that arrives; declarations
clear it; tax and selectivity hang off each declaration.
```mermaid
flowchart TD
subgraph ref["Reference — S013 (UN* / xx*TAB)"]
R1["UNCTYTAB · UNCURTAB · UNCUOTAB
UNTAXTAB · UNHS*TAB · UNCP*TAB
(code + name inline · VALID_FROM/TO)"]
end
subgraph man["Manifest — S015"]
M1["GEN_TAB
general segment"]
M2["BOL_TAB
bills of lading"]
M3["CTN_TAB / BOL_CTN_TAB
containers + goods lines"]
M1 --> M2 --> M3
end
subgraph dec["Declaration — S014"]
D1["SAD_General_Segment
one declaration (repeated per item)"]
D2["SAD_Item
per commodity · TAR_HSC_NB1..5"]
D1 --> D2
end
subgraph tax["Tax & selectivity — S014/S016"]
T1["SAD_Tax
COD/BSE/RAT/AMT/MOP/TYP"]
T2["SAD_SELECTIVITY · INSP_ACT_TAB
PTY_* colour flags"]
end
ref -.->|codes| man
ref -.->|codes| dec
man -->|write-off| dec
D2 --> T1
D1 --> T2
```
## The four sub-pages
- :material-file-document-outline:{ .lg .middle } **Declaration tables**
---
`SAD_General_Segment`, `SAD_Item`, `SAD_Tax` — the SAD spine, the HS split,
the valuation build-up and the tax roots, with real-SQL examples.
[:octicons-arrow-right-24: Declaration tables](declaration-tables.md)
- :material-ferry:{ .lg .middle } **Manifest tables**
---
`GEN_TAB`, `BOL_TAB`, `CTN_TAB` / `BOL_CTN_TAB` — the cargo manifest, its
bills of lading and containers, master ⇄ house.
[:octicons-arrow-right-24: Manifest tables](manifest-tables.md)
- :material-table-key:{ .lg .middle } **Reference tables**
---
The `UN*` / `xx*TAB` code catalogue — code + name inline (no FK) and the
`VALID_FROM`/`VALID_TO` temporal-validity pattern.
[:octicons-arrow-right-24: Reference tables](reference-tables.md)
- :material-alert-octagon-outline:{ .lg .middle } **Joins & gotchas**
---
The five things that bite — `INSTANCE_ID`, repeated general segment, HS split,
code+name inline, validity windows — each with symptom and fix.
[:octicons-arrow-right-24: Joins & gotchas](joins-and-gotchas.md)
## Related
- [ASYCUDA World — the modeled version](../platform/asycuda-world.md) — what the
real platform is and where the public/private line falls.
- [XML messages & the wire format](../platform/xml-messages.md) — the other public
window onto the field-level model.
- [How faithful is the reconstruction?](../provenance/fit.md) — the table-by-table
official-vs-toolbox fit, the source of every real name here.
- [Useful queries](../guides/useful-queries.md) — worked queries against the clean
logical model.
---
# Querying the declaration tables
The declaration — the **Single Administrative Document** — is the core of
ASYCUDA World's data. Physically it lives in three tables you will query most
often: **`SAD_General_Segment`** (consignment level), **`SAD_Item`** (per
commodity) and **`SAD_Tax`** (per tax line). These names come straight from the
official S014 Declaration Tables and are used verbatim by the toolbox's
[compiler mapping](https://github.com/FrancoisChastel/sydonia-toolkit/blob/master/compiler/mappings/asycuda-world.yml).
!!! info "What is pinned vs. instance-specific"
Column names printed in **bold** are publicly documented (S014 / the XML
layer) and appear verbatim in the [fit analysis](../provenance/fit.md) — the
`SAD_Tax` roots, `TAR_HSC_NB1..5`, `VIT_CIF`/`VIT_STV`, the `PTY_*` flags,
`INSTANCE_ID`. Plain-code names (e.g. `SGS_CUO_COD`) follow AW's prefix
conventions and match the bundled mock DB, but the exact spelling on a real
instance is *instance-specific* — confirm it.
## `SAD_General_Segment` — one declaration, repeated per item
The general segment carries the consignment-level facts: office, type, parties,
currency, totals, status and the selectivity colour flags. The catch that defines
querying it: **AW repeats the entire general segment into every item row** — so a
naïve join over items double-counts it. Deduplicate with `DISTINCT` on
**`INSTANCE_ID`** (see [joins & gotchas](joins-and-gotchas.md#repeated-general-segment)).
| Physical column | Meaning | Logical equivalent |
|-----------------|---------|--------------------|
| **`INSTANCE_ID`** | Engine key — the declaration's identity | `declaration.id` |
| `SGS_CUO_COD` | Customs office code | `declaration.office_id` |
| `SGS_TYP_COD` | Declaration model/type (IM4, EX1…) | `declaration.declaration_type_id` |
| `SGS_REG_NBR`, `SGS_REG_DAT` | Registration serial + date | `declaration.registration_number` · `registration_date` |
| `SGS_DEC_REF` | Declarant reference (LRN/UCR) | `declaration.trader_reference` |
| `SGS_EXP_COD`, `SGS_CNE_COD`, `SGS_DCL_COD` | Exporter · consignee · declarant | `declaration.exporter_id` · `consignee_id` · `declarant_id` |
| `SGS_CUR_COD` | Invoice currency | `declaration.currency_id` |
| `SGS_INV_AMT`, `SGS_CIF_AMT` | Total invoice · total CIF | `declaration.total_invoice_amount` · `total_cif_value` |
| **`STA`** | Lifecycle status code | `declaration.status_id` |
| **`PTY_RED`**, **`PTY_YEL`**, **`PTY_GRE`**, **`PTY_BLU`** | Selectivity lane colour flags (`'1'`/`'0'`) | `declaration.selectivity_lane_id` |
The **`STA`** status code drives the clearance state machine (stored →
registered → assessed → paid → released); the **`PTY_*`** flags are a
colour *domain*, not a foreign key — exactly one is set to `'1'`. Decode them with
a `CASE`:
```sql
-- Declarations registered this month, with their assigned lane.
SELECT g.INSTANCE_ID,
g.SGS_REG_NBR,
g.SGS_REG_DAT,
g.STA AS status,
CASE WHEN g.PTY_RED = '1' THEN 'RED'
WHEN g.PTY_YEL = '1' THEN 'YELLOW'
WHEN g.PTY_GRE = '1' THEN 'GREEN'
WHEN g.PTY_BLU = '1' THEN 'BLUE' END AS lane
FROM SAD_General_Segment g
WHERE g.SGS_REG_DAT >= DATE '2026-07-01'
ORDER BY g.SGS_REG_DAT;
```
## `SAD_Item` — one row per commodity line
Each item carries the HS code, origin, mass, packages and the valuation values.
Two documented shapes matter here:
- **HS is split across `TAR_HSC_NB1..5`** — five national-precision fragments you
must concatenate to get a full commodity code
([gotcha](joins-and-gotchas.md#hs-split)).
- **`VIT_CIF` is the customs value** (the tax base) and **`VIT_STV` the
statistical value**; the build-up columns **`VIT_FOB` / `VIT_FRT` / `VIT_INS`**
hold FOB + apportioned freight + insurance that sum toward CIF.
| Physical column | Meaning | Logical equivalent |
|-----------------|---------|--------------------|
| **`INSTANCE_ID`** | Item identity | `declaration_item.id` |
| `ITM_SGS_ID` | Parent general-segment key | `declaration_item.declaration_id` |
| `ITM_NBR` | Item/line number | `declaration_item.item_number` |
| **`TAR_HSC_NB1..5`** | HS code, split into 5 fragments | `declaration_item.hs_code` (concatenated) |
| **`VIT_CIF`** | Customs value (the tax base) | `declaration_item.customs_value` |
| **`VIT_STV`** | Statistical value | `declaration_item.statistical_value` |
| **`VIT_FOB`**, **`VIT_FRT`**, **`VIT_INS`** | FOB + apportioned freight + insurance → CIF | `item_value_note.item_fob` · `apportioned_freight` · `apportioned_insurance` |
| `ITM_NET_MAS`, `ITM_GRS_MAS` | Net / gross mass | `declaration_item.net_mass` · `gross_mass` |
| `ITM_ORG_COD` | Country of origin | `declaration_item.country_origin_id` |
| `ITM_PKG_NBR` | Number of packages | `declaration_item.number_of_packages` |
```sql
-- Items on one declaration, with the HS code reassembled and the value build-up.
SELECT i.ITM_NBR,
i.TAR_HSC_NB1 || i.TAR_HSC_NB2 || i.TAR_HSC_NB3
|| i.TAR_HSC_NB4 || i.TAR_HSC_NB5 AS hs_code,
i.ITM_ORG_COD AS origin,
i.VIT_FOB, i.VIT_FRT, i.VIT_INS,
i.VIT_CIF AS customs_value
FROM SAD_Item i
WHERE i.ITM_SGS_ID = 1 -- the declaration's INSTANCE_ID
ORDER BY i.ITM_NBR;
```
!!! note "The build-up is denormalised onto the item"
Unlike the toolbox's separate `item_value_note` table, the real `SAD_Item`
carries `VIT_FOB`/`VIT_FRT`/`VIT_INS`/`VIT_CIF` *inline* on the same row. The
invoice is usually FOB but duty is charged on CIF, so this is where the tax
base is assembled per item.
## `SAD_Tax` — the tax lines (the pinned roots)
`SAD_Tax` is the one table whose **column roots are fully public**. One row per
item per applicable tax; because taxes cascade (VAT on customs value + duty), the
base is stored per line rather than recomputed.
| Physical column | Meaning | Logical equivalent |
|-----------------|---------|--------------------|
| **`INSTANCE_ID`** | Tax-line identity | `declaration_tax_line.id` |
| `TAX_ITM_ID` | Parent item key | `declaration_tax_line.declaration_item_id` |
| **`COD`** | Tax type (duty / VAT / excise / fee) | `declaration_tax_line.tax_type_id` |
| **`BSE`** | Tax base (amount the rate applies to) | `declaration_tax_line.tax_base` |
| **`RAT`** | Rate (ad valorem) | `declaration_tax_line.rate_percent` |
| **`AMT`** | Computed amount | `declaration_tax_line.tax_amount` |
| **`MOP`** | Mode of payment | `declaration_tax_line.mode_of_payment` |
| **`TYP`** | Manual (`'1'`) vs auto-calculated | `declaration_tax_line.is_manual` |
```sql
-- Duty & VAT collected per declaration, from the real tax roots.
SELECT g.INSTANCE_ID,
g.SGS_REG_NBR,
sum(x.AMT) FILTER (WHERE x.COD = 'IMP') AS import_duty,
sum(x.AMT) FILTER (WHERE x.COD = 'VAT') AS vat,
sum(x.AMT) AS total_tax
FROM SAD_General_Segment g
JOIN SAD_Item i ON i.ITM_SGS_ID = g.INSTANCE_ID
JOIN SAD_Tax x ON x.TAX_ITM_ID = i.INSTANCE_ID
GROUP BY g.INSTANCE_ID, g.SGS_REG_NBR
ORDER BY total_tax DESC;
```
!!! tip "Manual taxes and totals — the derived siblings"
- Manual (officer-keyed) taxes live in **`SAD_Ask_Tax`** in the official
model; the toolbox folds them into `SAD_Tax` via the **`TYP`** flag
(`TYP = '1'`).
- **`SAD_Global_Taxes`** and **`SAD_Tax_Totals`** are *summary* tables. Prefer
to **derive** these by aggregating `SAD_Tax` (as above) rather than trusting
a possibly-stale total — the toolbox never stores them.
## Assemble a full declaration
Because the general segment repeats, dedupe it before you total taxes — or you
count the header once per item.
```sql
-- Declaration header + item lines + per-item tax, correctly deduplicated.
WITH decl AS (
SELECT DISTINCT g.INSTANCE_ID, g.SGS_REG_NBR, g.STA
FROM SAD_General_Segment g
WHERE g.SGS_DEC_REF = 'REF-2026-0001'
)
SELECT d.SGS_REG_NBR,
i.ITM_NBR,
i.TAR_HSC_NB1 || i.TAR_HSC_NB2 || i.TAR_HSC_NB3
|| i.TAR_HSC_NB4 || i.TAR_HSC_NB5 AS hs_code,
i.VIT_CIF AS customs_value,
sum(x.AMT) AS taxes
FROM decl d
JOIN SAD_Item i ON i.ITM_SGS_ID = d.INSTANCE_ID
LEFT JOIN SAD_Tax x ON x.TAX_ITM_ID = i.INSTANCE_ID
GROUP BY d.SGS_REG_NBR, i.ITM_NBR, i.TAR_HSC_NB1, i.TAR_HSC_NB2,
i.TAR_HSC_NB3, i.TAR_HSC_NB4, i.TAR_HSC_NB5, i.VIT_CIF
ORDER BY i.ITM_NBR;
```
The same query, written against the friendly logical names and compiled for you,
is on the [declaration schema page](../schema/declaration.md#example-assemble-a-declaration).
## Related
- [Manifest tables](manifest-tables.md) — the cargo the declaration writes off.
- [Reference tables](reference-tables.md) — decode `SGS_CUO_COD`, `COD`, `ITM_ORG_COD`.
- [Joins & gotchas](joins-and-gotchas.md) — the dedup, HS-concat and validity traps.
- [XML messages](../platform/xml-messages.md) — the SAD box → XML tag → column map.
- [Declaration schema](../schema/declaration.md) — the same fields, normalised.
---
# Querying the manifest tables
The **cargo manifest** is what the carrier declares is on board — the upstream
document the importer's declaration later writes off against. Physically it sits
in the S015 Manifest Tables: **`GEN_TAB`** (the manifest general segment),
**`BOL_TAB`** (bills of lading) and **`CTN_TAB`** / **`BOL_CTN_TAB`**
(containers), plus goods lines within each B/L. These table names come from the
official S015 description and the [fit analysis](../provenance/fit.md#manifest-module-official-s015-our-tables);
the XML equivalent is the **AWMDS** stream documented in
[XML messages](../platform/xml-messages.md#cargo-manifest-awmds).
!!! warning "Manifest column names are almost entirely instance-specific"
S015 pins the **table** names (`GEN_TAB`, `BOL_TAB`, `CTN_TAB`,
`BOL_CTN_TAB`) and the *field semantics* (voyage, ports, totals, parties,
seals…), but **not the physical column names** — unlike `SAD_Tax`, no public
root list exists for the manifest columns. The `GEN_*` / `BOL_*` / `CTN_*`
names below follow AW's prefix conventions and are *illustrative defaults*.
Confirm every one against your instance before running.
## `GEN_TAB` — the manifest general segment
One row per manifest: office, voyage/flight, carrier and shipping agent, ports of
loading/unloading, dates, declared totals and status. It is the parent of every
bill of lading on the voyage.
| Physical column | Meaning | Logical equivalent |
|-----------------|---------|--------------------|
| **`INSTANCE_ID`** | Manifest identity (engine key) | `manifest.id` |
| `GEN_CUO_COD` | Customs office of arrival | `manifest.office_id` |
| `GEN_REG_YER`, `GEN_REG_NBR`, `GEN_REG_DAT` | Registration year / number / date | `manifest.manifest_year` · `registration_number` |
| `GEN_VOY_NBR` | Voyage / flight number | `manifest.voyage_number` |
| `GEN_TSP_IDE` | Identity of transport (vessel) | `manifest.identity_of_transport` |
| `GEN_CAR_COD` + `GEN_CAR_NAM` | Carrier — **code and name inline** | `manifest.carrier_id` |
| `GEN_AGT_COD` + `GEN_AGT_NAM` | Shipping agent (code + name inline) | `manifest.shipping_agent_id` |
| `GEN_POL_COD`, `GEN_POD_COD` | Place of loading / unloading (UN/LOCODE) | `manifest.place_departure_id` · `place_destination_id` |
| `GEN_DEP_DAT`, `GEN_ARR_DAT` | Departure / arrival dates | `manifest.date_of_departure` · `date_of_arrival` |
| `GEN_TOT_BOL`, `GEN_TOT_PKG`, `GEN_TOT_CTN`, `GEN_TOT_GRS` | Total B/Ls · packages · containers · gross mass | `manifest.total_bols` · `total_packages` · `total_containers` · `total_gross_mass` |
| `STA` | Manifest lifecycle status | `manifest.status_id` |
!!! note "Carrier is code + name inline"
Like all AW coded fields, the carrier is stored as **`GEN_CAR_COD` and
`GEN_CAR_NAM` together on the same row** — there is no FK to a carrier table.
See [code+name inline](joins-and-gotchas.md#code-name-inline).
## `BOL_TAB` — the bills of lading
One row per transport document. A **master** B/L covers a full container moving
carrier-to-carrier; a **house** B/L is one consignee's consignment inside it
(**degroupage**), expressed by a self-reference on the previous-master B/L
reference.
| Physical column | Meaning | Logical equivalent |
|-----------------|---------|--------------------|
| **`INSTANCE_ID`** | B/L identity | `bill_of_lading.id` |
| `BOL_GEN_ID` | Parent manifest key | `bill_of_lading.manifest_id` |
| `BOL_REF` | B/L reference number | `bill_of_lading.bl_reference` |
| `BOL_LIN_NBR`, `BOL_SUB_NBR` | Line / sub-line number | — |
| `BOL_NAT_COD` | Nature (22 export / 23 import / 24 transit / 28 transhipment) | `bill_of_lading.bl_nature_id` |
| `BOL_MST_REF` | Previous **master** B/L reference (degroupage) | `bill_of_lading.master_bl_id` |
| `BOL_EXP_COD`, `BOL_CNE_COD`, `BOL_NOT_COD` | Exporter · consignee · notify party | — |
| `BOL_PKG_NBR`, `BOL_GRS_MAS`, `BOL_VOL` | Packages · gross mass · volume | `bill_of_lading.number_of_packages` · `gross_mass` |
| `BOL_FRT_AMT`, `BOL_INS_AMT` | Freight / insurance value + currency | — |
```sql
-- All bills of lading on one voyage, with package and mass totals per B/L.
SELECT b.BOL_REF,
b.BOL_NAT_COD AS nature,
b.BOL_PKG_NBR AS packages,
b.BOL_GRS_MAS AS gross_mass,
CASE WHEN b.BOL_MST_REF IS NULL THEN 'master' ELSE 'house' END AS bl_role
FROM GEN_TAB g
JOIN BOL_TAB b ON b.BOL_GEN_ID = g.INSTANCE_ID
WHERE g.GEN_VOY_NBR = 'V2026-042'
ORDER BY b.BOL_REF;
```
## `CTN_TAB` / `BOL_CTN_TAB` — containers & goods lines
Containers are attached to a B/L. Depending on the instance they are held in a
standalone **`CTN_TAB`** or a B/L-scoped **`BOL_CTN_TAB`** junction; both carry
the container reference, ISO 6346 type, empty/full flag, seals, weights, volume
and dangerous-goods flag. Goods/commodity lines (HS, description, packages) sit
within each B/L.
| Physical column | Meaning | Logical equivalent |
|-----------------|---------|--------------------|
| **`INSTANCE_ID`** | Container identity | `container.id` |
| `CTN_BOL_ID` | Parent B/L key | `container.bl_id` |
| `CTN_REF` | Container reference (ISO 6346) | `container.container_number` |
| `CTN_TYP_COD` | Container type code | `container.container_type_id` |
| `CTN_IND` | Empty / full indicator | `container.empty_full` |
| `CTN_PKG_NBR` | Packages inside | `container.number_of_packages` |
| `CTN_GRS_MAS`, `CTN_VOL` | Gross mass · volume | `container.gross_mass` · `volume` |
| `CTN_SEAL` | Seal numbers | `container.seals` |
| `CTN_DGR` | Dangerous-goods flag | `container.dangerous_goods` |
```sql
-- Containers and their B/Ls for an arriving voyage, with fill status.
SELECT b.BOL_REF,
c.CTN_REF,
c.CTN_TYP_COD AS iso_type,
CASE WHEN c.CTN_IND = 'F' THEN 'full' ELSE 'empty' END AS fill,
c.CTN_GRS_MAS AS gross_mass
FROM GEN_TAB g
JOIN BOL_TAB b ON b.BOL_GEN_ID = g.INSTANCE_ID
JOIN CTN_TAB c ON c.CTN_BOL_ID = b.INSTANCE_ID
WHERE g.GEN_VOY_NBR = 'V2026-042'
ORDER BY b.BOL_REF, c.CTN_REF;
```
!!! info "Vehicle sub-segment — a known gap"
The manifest XML also defines a **vehicle sub-segment** (chassis/VIN/engine/make
for RoRo cargo). It is documented but **not modelled** as a table in the
toolbox — noted in [Coverage](../provenance/coverage.md). If your instance
carries it, the physical name is instance-specific; request it.
## Grounded in the wire format
Each of these tables maps onto a segment of the **AWMDS** cargo XML stream
(root ``), so the same field semantics are visible two ways:
| AWMDS element | Manifest table | Toolbox table |
|---------------|----------------|---------------|
| `` | `GEN_TAB` | `manifest` |
| `` | `BOL_TAB` | `bill_of_lading` |
| `` | `CTN_TAB` / `BOL_CTN_TAB` | `container` |
| `` / `` | goods lines in `BOL_TAB` | `manifest_cargo_item` |
## Related
- [Declaration tables](declaration-tables.md) — what clears this cargo.
- [Reference tables](reference-tables.md) — decode office, nature, container type.
- [Joins & gotchas](joins-and-gotchas.md) — `INSTANCE_ID` keys and code+name inline.
- [XML messages — AWMDS](../platform/xml-messages.md#cargo-manifest-awmds) — the cargo stream.
- [Manifest schema](../schema/manifest.md) — the same fields, normalised.
---
# Querying the reference tables
Everything coded on a declaration or manifest — the office, the country of
origin, the currency, the tax type, the HS chapter, the procedure — resolves
through ASYCUDA World's **reference tables**: the S013 `UN*` / `xx*TAB` code
catalogue. Two patterns define how you query them, and both differ sharply from
the toolbox's normalised `ref_*` model.
!!! info "Two prefixes, one catalogue"
You will see both **`UN*`** (the shared UNCTAD base, e.g. `UNCTYTAB`) and
**`xx*TAB`** (national instance tables, where `xx` is the country code) for
the same domains. The domain roots (`CTY`, `CUR`, `CUO`, `TAX`, `HS`, `CP`…)
are documented in [S013](../platform/asycuda-world.md); the exact prefix on
your instance is instance-specific.
## Pattern 1 — code + name stored inline (no foreign key)
The toolbox normalises coded fields into an FK pointing at a `ref_*` table. The
**real AW schema does the opposite**: it stores the **code *and* its name
together on the same row** of the referencing table — e.g. a manifest carries
both `GEN_CAR_COD` and `GEN_CAR_NAM`. The reference table then holds the same
`_COD` + `_NAM` pair as its own two columns.
This is confirmed in the [fit analysis](../provenance/fit.md): *"store code **and**
name inline (`GEN_CAR_COD` + `GEN_CAR_NAM`)"* versus the toolbox's *"FK to a
`ref_*` table"*. Practically, it means **you often do not need to join at all** —
the human-readable name is already sitting beside the code. When you *do* join a
reference table (to filter on validity, or to pull an attribute not denormalised
onto the fact row), you join **on the code**, not on a surrogate id
([details](joins-and-gotchas.md#code-name-inline)).
## Pattern 2 — temporal validity (`VALID_FROM` / `VALID_TO`)
Every `UN*` reference table carries **`VALID_FROM`** and **`VALID_TO`** dates. A
code is only valid for a declaration if the declaration's date falls inside that
window — a currency, tariff line or tax rate can be superseded, and the old row
stays in the table so historical declarations still resolve correctly. An open
`VALID_TO` (`NULL`) means "still current".
The toolbox collapses this to an `is_active` boolean (plus explicit
`valid_from/to` on rate tables), but against the real database **you must filter
by date yourself**, or you will match retired codes and multiply rows.
```sql
-- Currently-valid customs offices (as of today).
SELECT o.CUO_COD, o.CUO_NAM
FROM UNCUOTAB o
WHERE o.VALID_FROM <= CURRENT_DATE
AND (o.VALID_TO IS NULL OR o.VALID_TO >= CURRENT_DATE)
ORDER BY o.CUO_COD;
```
```sql
-- Resolve a code as it was valid ON a specific declaration date — the correct,
-- point-in-time lookup for historical declarations.
SELECT t.TAX_COD, t.TAX_NAM
FROM UNTAXTAB t
WHERE t.TAX_COD = 'VAT'
AND t.VALID_FROM <= DATE '2026-07-06'
AND (t.VALID_TO IS NULL OR t.VALID_TO >= DATE '2026-07-06');
```
!!! warning "Filter to one valid row, or you double-count"
Skipping the validity predicate is the classic reference-table bug: a code
that was re-issued has **two rows**, so an unfiltered join returns each fact
twice. Always constrain to the row valid on the relevant date. The
[query compiler](../compiler/index.md) injects this filter automatically.
## The main reference tables (S013 crib)
Drawn from the [fit analysis](../provenance/fit.md#reference-module-official-s013-un-tables-our-ref_-tables)
direct-match list. Every one carries the code + name inline and the
`VALID_FROM`/`VALID_TO` window.
| AW table | Domain | Toolbox `ref_*` |
|----------|--------|-----------------|
| `xxCTYTAB` / `UNCTYTAB` | Countries | `ref_country` |
| `xxCURTAB` | Currencies | `ref_currency` |
| `xxRATTAB` | Exchange rates | `ref_exchange_rate` |
| `xxCUOTAB` / `UNCUOTAB` | Customs offices | `ref_customs_office` |
| `xxLOCTAB` | Locations (UN/LOCODE) | `ref_location` |
| `xxMOTTAB` | Transport modes | `ref_transport_mode` |
| `xxPKGTAB` | Package types | `ref_package_type` |
| `xxCTNTAB` | Container types | `ref_container_type` |
| `xxUOMTAB` | Units of measure | `ref_unit_of_measure` |
| `xxTODTAB` | Incoterms | `ref_incoterm` |
| `xxHS1-6TAB` / `xxTARTAB` | Tariff / HS | `ref_hs_tariff` |
| `xxCP1/3/4TAB` | Procedures (CPC / regime) | `ref_cpc_regime` |
| `xxTAXTAB` / `UNTAXTAB` | Tax types | `ref_tax_type` |
| `xxRULTAB` / `xxTAXTAR` | Tax rules / rates | `ref_tax_rate` |
| `xxATDTAB` | Document types | `ref_document_type` |
| `xxCP3TAB` | Exemption / relief codes | `ref_exemption_code` |
| `xxMODTAB` | Declaration types | `ref_declaration_type` |
| `xxNATTAB` | B/L nature | `ref_bl_nature` |
| `xxWHSTAB` | Warehouses | `ref_warehouse` |
| `xxCAR/DEC/CMP/PRPTAB` | Economic operators | `trader` (+ `trader_role`) |
!!! note "Some `UN*` tables are folded inline, not modelled separately"
Domains such as preference (`xxPRFTAB`), valuation method (`xxVAMTAB`), means
of payment (`xxMOPTAB`) and quota (`xxQUOTAB`) exist as reference tables in AW
but the toolbox keeps them as plain coded columns on the fact row rather than
as separate `ref_*` tables — see the [fit analysis](../provenance/fit.md) and
[Coverage](../provenance/coverage.md) for the full list and rationale.
## A join that resolves codes correctly
Putting both patterns together — decode a declaration's office and origin against
reference tables, valid on the registration date:
```sql
SELECT g.SGS_REG_NBR,
o.CUO_NAM AS office,
ctry.CTY_NAM AS origin_country
FROM SAD_General_Segment g
JOIN SAD_Item i ON i.ITM_SGS_ID = g.INSTANCE_ID
JOIN UNCUOTAB o ON o.CUO_COD = g.SGS_CUO_COD
AND o.VALID_FROM <= g.SGS_REG_DAT
AND (o.VALID_TO IS NULL OR o.VALID_TO >= g.SGS_REG_DAT)
JOIN UNCTYTAB ctry ON ctry.CTY_COD = i.ITM_ORG_COD
AND ctry.VALID_FROM <= g.SGS_REG_DAT
AND (ctry.VALID_TO IS NULL OR ctry.VALID_TO >= g.SGS_REG_DAT)
WHERE g.SGS_DEC_REF = 'REF-2026-0001';
```
## Related
- [Joins & gotchas](joins-and-gotchas.md) — code+name inline and validity, in depth.
- [Declaration tables](declaration-tables.md) · [Manifest tables](manifest-tables.md) — the codes that resolve here.
- [The query compiler](../compiler/index.md) — writes the validity filter for you.
- [Reference & config schema](../schema/reference-config.md) — the normalised `ref_*` side.
---
# Joins & gotchas
Querying the real ASYCUDA World database is not hard because the SQL is exotic —
it is hard because the schema is **wide and denormalised** in five specific ways
that quietly produce wrong answers. Each one below is drawn from the
["Aspect" table in the fit analysis](../provenance/fit.md#verdict) and reproduced
in the toolbox's [mock database](https://github.com/FrancoisChastel/sydonia-toolkit/blob/master/Sydonia/adapters/mock_asycuda_world.sql).
For each: the **symptom** you will see, and the **fix**.
## 1. `INSTANCE_ID` — the engine keys {: #instance-id }
AW keys rows on **`INSTANCE_ID`** (the object-engine identity), not on a
business-friendly surrogate. Child rows reference their parent's `INSTANCE_ID`
through a `*_*_ID` column — `SAD_Item.ITM_SGS_ID` → `SAD_General_Segment.INSTANCE_ID`,
`SAD_Tax.TAX_ITM_ID` → `SAD_Item.INSTANCE_ID`, `BOL_TAB.BOL_GEN_ID` →
`GEN_TAB.INSTANCE_ID`.
!!! danger "Symptom"
You join on a registration number or a reference string and get duplicates or
misses — those are not unique keys. Or you assume an auto-increment `id` and
find none.
**Fix** — always traverse via `INSTANCE_ID` and the `*_ID` parent pointers:
```sql
JOIN SAD_Item i ON i.ITM_SGS_ID = g.INSTANCE_ID -- item → general segment
JOIN SAD_Tax x ON x.TAX_ITM_ID = i.INSTANCE_ID -- tax → item
```
## 2. The general segment is repeated into every item {: #repeated-general-segment }
The single most surprising shape: **AW copies the entire general segment into
every `SAD_Item` row** (and, on the cargo side, into every `BOL_TAB` row). There
is not one header row and N item rows — there are N rows that each carry the full
header *plus* their item.
!!! danger "Symptom"
Header-level totals come out multiplied by the item count. `sum(SGS_CIF_AMT)`
over a joined result counts the CIF once per item; a declaration with 3 items
reports 3× its value.
**Fix** — deduplicate the header on `INSTANCE_ID` before aggregating:
```sql
WITH decl AS (
SELECT DISTINCT g.INSTANCE_ID, g.SGS_REG_NBR, g.SGS_CIF_AMT
FROM SAD_General_Segment g
)
SELECT sum(SGS_CIF_AMT) AS total_cif FROM decl; -- counts each declaration once
```
## 3. HS code is split across `TAR_HSC_NB1..5` {: #hs-split }
The commodity code is stored as **five national-precision fragments** —
`TAR_HSC_NB1`, `TAR_HSC_NB2`, `TAR_HSC_NB3`, `TAR_HSC_NB4`, `TAR_HSC_NB5` — not a
single `hs_code`. The first fragments give the HS-2/HS-4/HS-6 international code;
the later ones carry national precision and may be blank.
!!! danger "Symptom"
Filtering `WHERE hs_code = '851712'` finds nothing — there is no such column.
Grouping "by HS" groups by one fragment and merges unrelated commodities.
**Fix** — concatenate the fragments (and be aware trailing fragments can be
empty strings, not `NULL`):
```sql
SELECT i.TAR_HSC_NB1 || i.TAR_HSC_NB2 || i.TAR_HSC_NB3
|| i.TAR_HSC_NB4 || i.TAR_HSC_NB5 AS hs_code
FROM SAD_Item i;
-- prefix match on a 6-digit code:
-- WHERE i.TAR_HSC_NB1 || i.TAR_HSC_NB2 || i.TAR_HSC_NB3 = '851712'
```
## 4. Code and name are stored inline — there is no FK {: #code-name-inline }
AW stores a coded value as **the code and its name together on the same row**
(`GEN_CAR_COD` + `GEN_CAR_NAM`), not as an FK into a lookup table. The reference
table holds the same `_COD` + `_NAM` pair.
!!! danger "Symptom"
You look for a foreign-key `carrier_id` to join and there isn't one. Or you
join a reference table on a numeric id and match nothing — the join key is the
**code string**, and validity is unfiltered so you may match a retired row.
**Fix** — the name is usually already on the row, so *no join is needed*; when
you do join a reference table, join **on the code**, and add the validity
predicate (gotcha 5):
```sql
-- the name is inline — no join required:
SELECT g.GEN_CAR_COD, g.GEN_CAR_NAM FROM GEN_TAB g;
-- when you must join, join on the code + filter validity:
JOIN UNCTYTAB c ON c.CTY_COD = i.ITM_ORG_COD
AND c.VALID_FROM <= g.SGS_REG_DAT
AND (c.VALID_TO IS NULL OR c.VALID_TO >= g.SGS_REG_DAT)
```
## 5. Reference tables carry `VALID_FROM` / `VALID_TO` {: #validity }
Every `UN*` reference table keeps **superseded codes** alongside current ones,
distinguished by a **`VALID_FROM`/`VALID_TO`** window. A code is correct for a
declaration only if the declaration's date falls inside it; `VALID_TO IS NULL`
means still current.
!!! danger "Symptom"
A reference join returns two rows for one code (old + new), silently doubling
the fact rows. Or a historical declaration resolves against today's re-issued
code and shows the wrong name/rate.
**Fix** — constrain every reference join to the row valid on the relevant date:
```sql
JOIN UNTAXTAB t ON t.TAX_COD = x.COD
AND t.VALID_FROM <= g.SGS_REG_DAT
AND (t.VALID_TO IS NULL OR t.VALID_TO >= g.SGS_REG_DAT)
```
## The compiler handles all five for you
Every fix above is mechanical — which is exactly why the toolbox's
[**query compiler**](../compiler/index.md) bakes them in. You write friendly SQL
against clean logical names; it emits **genuine Sydonia SQL** with the
`INSTANCE_ID` traversal, the `DISTINCT` dedup, the HS `concat`, the code-keyed
reference views and the `VALID_FROM`/`VALID_TO` filter already applied — so you
never re-derive them and never ship a query that silently double-counts.
!!! example "Before / after — logical vs compiled"
**You write** (against the logical model — no gotchas visible):
```sql
SELECT hs_code, sum(customs_value) AS value
FROM declaration_item
GROUP BY hs_code;
```
**The compiler emits** (against the real `SAD_Item` — gotchas 1, 3 handled):
```sql
SELECT concat(i.TAR_HSC_NB1, i.TAR_HSC_NB2, i.TAR_HSC_NB3,
i.TAR_HSC_NB4, i.TAR_HSC_NB5) AS hs_code,
sum(i.VIT_CIF) AS value
FROM SAD_Item i
GROUP BY concat(i.TAR_HSC_NB1, i.TAR_HSC_NB2, i.TAR_HSC_NB3,
i.TAR_HSC_NB4, i.TAR_HSC_NB5);
```
The mapping that drives this rewrite —
[`compiler/mappings/asycuda-world.yml`](https://github.com/FrancoisChastel/sydonia-toolkit/blob/master/compiler/mappings/asycuda-world.yml)
— is the same one whose physical names this section documents, so the docs and
the tooling never drift apart.
## Quick reference
| # | Gotcha | Symptom | Fix |
|:-:|--------|---------|-----|
| 1 | `INSTANCE_ID` keys | Duplicates/misses on business keys | Traverse via `INSTANCE_ID` + `*_ID` pointers |
| 2 | General segment repeated | Header totals × item count | `DISTINCT` header on `INSTANCE_ID` |
| 3 | HS split `NB1..5` | No `hs_code` column; wrong grouping | Concatenate the 5 fragments |
| 4 | Code + name inline | No FK; join on wrong key | Read inline name, or join on the code |
| 5 | `VALID_FROM`/`VALID_TO` | Double rows; wrong historical code | Filter to the row valid on the date |
## Related
- [Declaration tables](declaration-tables.md) · [Manifest tables](manifest-tables.md) · [Reference tables](reference-tables.md)
- [The query compiler](../compiler/index.md) — the automated way past all five.
- [How faithful is the reconstruction?](../provenance/fit.md) — the source "Aspect" table.
- [Useful queries](../guides/useful-queries.md) — worked queries on the logical model.
---
# The query compiler
**Write friendly, run genuine.** You write analytics queries against the
toolbox's clean [logical model](../schema/index.md) — `declaration`,
`declaration_item`, `tax_amount`, `hs_code` — and the compiler rewrites them into
**genuine ASYCUDA World SQL** that executes against the real, wide, denormalised
physical schema. The friendly names are the ergonomics; the compiled statement is
what a real SYDONIA instance would run.
!!! abstract "The pivot, in one line"
This toolbox is no longer only a *reference model*. Its primary job is now to
let you **query a real ASYCUDA World** — you write against the reconstruction,
and the compiler turns that into runnable Sydonia SQL. The reconstruction
became the **logical layer** the compiler maps *from*.
## The abstraction in one picture
```text
query spec (no SQL) ──build──► LOGICAL SQL ──compile──► GENUINE SYDONIA SQL
from / where / select declaration, SAD_General_Segment,
tax_amount, hs_code SAD_Tax.AMT, TAR_HSC…
```
Two entry points, one destination:
```mermaid
flowchart LR
A[query spec
YAML · no SQL] -->|build| B[LOGICAL SQL
friendly names]
B2[LOGICAL SQL
hand-written] --> C
B -->|compile| C[GENUINE SYDONIA SQL
runs on real AW]
C -->|read-only| D[(real / mock
ASYCUDA World)]
style B fill:#0f766e22,stroke:#0f766e
style C fill:#d9770622,stroke:#d97706
```
## Why an abstraction at all
A real ASYCUDA World database is **wide, denormalised, and mostly non-public**.
Writing analytics directly against it is painful, because:
| The real schema does this… | …so a raw query must |
|----------------------------|----------------------|
| Keys are engine `INSTANCE_ID` values | know the engine key convention |
| The general segment is repeated into **every item row** | `DISTINCT` the header itself |
| The HS code is split across `TAR_HSC_NB1..5` | concatenate five columns |
| Code **and** name are stored inline (`GEN_CAR_COD` + `GEN_CAR_NAM`) | fake a foreign key by code |
| `UN*` / `xx*TAB` reference rows carry `VALID_FROM` / `VALID_TO` | filter to currently-valid rows |
| Tax roots are `COD` / `BSE` / `RAT` / `AMT` / `MOP` on `SAD_Tax` | remember the terse field roots |
The logical model hides every one of these. You write
`di.hs_code`, `sum(tl.tax_amount)` and `d.selectivity_lane_id = 'RED'`; the
compiler bakes the gotchas into the output.
## How it works — the CTE prelude
The compiler scans your query for the **logical tables** it references, then
prepends each one as a **Common Table Expression** that `SELECT`s-and-aliases
from the real ASYCUDA World tables (per the [mapping](mapping.md)). Your query is
left **untouched** — the CTEs simply make the friendly names resolve to the real
schema. The result is **one standalone statement**, runnable anywhere, needing no
privilege to create views.
## Before and after
You write this friendly logical query:
```sql
SELECT di.hs_code, sum(tl.tax_amount) AS taxes
FROM declaration_item di
JOIN declaration_tax_line tl ON tl.declaration_item_id = di.id
GROUP BY di.hs_code;
```
`python -m compiler compile` turns it into this genuine Sydonia SQL — note the
`concat` of the split HS code and the `SAD_Tax` field roots, all injected by the
prelude while your `SELECT` stays exactly as written:
```sql
WITH
declaration_item AS (
SELECT
i.INSTANCE_ID AS id,
i.ITM_SGS_ID AS declaration_id,
i.ITM_NBR AS item_number,
concat(i.TAR_HSC_NB1, i.TAR_HSC_NB2, i.TAR_HSC_NB3, i.TAR_HSC_NB4, i.TAR_HSC_NB5) AS hs_code,
i.VIT_CIF AS customs_value,
i.VIT_STV AS statistical_value,
i.ITM_NET_MAS AS net_mass,
i.ITM_GRS_MAS AS gross_mass,
i.ITM_ORG_COD AS country_origin_id,
i.ITM_PKG_NBR AS number_of_packages
FROM SAD_Item i
),
declaration_tax_line AS (
SELECT
x.INSTANCE_ID AS id,
x.TAX_ITM_ID AS declaration_item_id,
x.COD AS tax_type_id,
x.BSE AS tax_base,
x.RAT AS rate_percent,
x.AMT AS tax_amount,
x.MOP AS mode_of_payment,
(x.TYP = '1') AS is_manual
FROM SAD_Tax x
)
SELECT di.hs_code, sum(tl.tax_amount) AS taxes
FROM declaration_item di
JOIN declaration_tax_line tl ON tl.declaration_item_id = di.id
GROUP BY di.hs_code;
```
!!! success "The round-trip guarantee"
The **same** logical query returns the **same** results whether run on the
reconstruction sandbox (logical) or compiled and run on the mock physical
database (genuine). See [Running the compiled SQL](running.md).
## Install
The compiler is pure standard library except for **PyYAML** (the mapping is
human-edited YAML):
```bash
pip install pyyaml
python -m compiler compile my_query.sql
```
## Where to go next
- :material-code-braces:{ .lg .middle } **Write logical SQL**
---
Author queries against the friendly names, `compile` them from a file or
stdin, and see what the compiler detects and rewrites.
[:octicons-arrow-right-24: Logical SQL & compile](logical-sql.md)
- :material-form-select:{ .lg .middle } **Build without SQL**
---
A tiny YAML query spec — `from` / `join` / `where` / `select` — that becomes
logical SQL, then genuine Sydonia SQL.
[:octicons-arrow-right-24: The query builder](builder.md)
- :material-map:{ .lg .middle } **The mapping**
---
How each logical table maps to its real AW source, per-instance overrides,
and materialising persistent views with `emit-views`.
[:octicons-arrow-right-24: The mapping](mapping.md)
- :material-play-circle:{ .lg .middle } **Run it**
---
The sandbox, the mock ASYCUDA World database (proving the round-trip), and a
real instance — read-only.
[:octicons-arrow-right-24: Running the SQL](running.md)
## Related
- [Querying Sydonia](../querying-sydonia/index.md) — the wider story of running
against a real ASYCUDA World, and the [joins and gotchas](../querying-sydonia/joins-and-gotchas.md).
- [Querying the model](../guides/querying.md) and
[useful queries](../guides/useful-queries.md) — the logical queries the compiler
turns genuine.
- [Running on a real ASYCUDA World](../platform/running-on-real-asycuda.md) — the
deployment, FDW and ETL detail behind the compiled SQL.
!!! note "Scope"
The compiler targets read **analytics** queries (`SELECT` / `WITH`) — the
project's use case. It is not a general SQL transpiler and deliberately does
**not** handle writes or DDL.
---
# Writing logical SQL
**Logical SQL is just SQL against the friendly names.** The names you use — the
tables and columns of the [reconstructed schema](../schema/index.md) —
`declaration`, `declaration_item`, `declaration_tax_line`, `ref_country`,
`hs_code`, `tax_amount` — are exactly the [logical layer](../schema/index.md) the
compiler maps *from*. If a query runs on the sandbox, it compiles.
!!! tip "You already know the names"
Everything in the [querying guide](../guides/querying.md) and the
[useful queries](../guides/useful-queries.md) library **is** logical SQL. Any
of those queries can be piped straight through `compile`.
## The `compile` command
`compile` reads one logical SQL statement and prints the genuine Sydonia SQL. It
takes a **file**, or `-` for **stdin**:
```bash
# from a file
python -m compiler compile my_query.sql
# from stdin
echo "SELECT * FROM declaration WHERE selectivity_lane_id = 'RED'" \
| python -m compiler compile -
```
Add `--logical` to stop *before* compiling and just echo the logical SQL back —
handy when the SQL comes from the [builder](builder.md) and you want to inspect
it first:
```bash
python -m compiler compile my_query.sql --logical
```
!!! note "One dependency"
The compiler needs **PyYAML** (`pip install pyyaml`) to read the mapping.
Nothing else — it is otherwise pure standard library.
## What the compiler detects and rewrites
The mechanism is the **CTE prelude**. The compiler:
1. **Scans** your query for logical table names — literal- and comment-aware, so
a table name inside a string or a `--` comment never triggers a false match.
2. **Emits a CTE** for each referenced table, `SELECT`-ing and aliasing from the
real ASYCUDA World source per the [mapping](mapping.md), in the mapping's
declaration order (stable, dependency-friendly output).
3. **Prepends** those CTEs to your query. If your query already starts with
`WITH`, the compiler *splices* its CTEs in front of your list rather than
nesting a second `WITH`.
4. **Drops** a leading `SET search_path …;` — irrelevant once the friendly names
are resolved by CTEs.
Your original `SELECT` body is otherwise left byte-for-byte unchanged.
## The gotchas it bakes in
Each CTE quietly handles a shape difference so your query never sees it:
| Real-schema gotcha | What the CTE does |
|--------------------|-------------------|
| Engine `INSTANCE_ID` keys | aliased `AS id` so surrogate-PK joins keep working |
| General segment repeated per item row | `declaration` is emitted `SELECT DISTINCT` — one row per declaration |
| HS code split `TAR_HSC_NB1..5` | `concat(...)` into a single `hs_code` |
| Code + name stored inline | `ref_*` CTEs are code-keyed (`id` := the business code) |
| `VALID_FROM` / `VALID_TO` on reference rows | a `WHERE now()::date BETWEEN …` validity filter |
| Colour flags `PTY_RED/YEL/GRE/BLU` | a `CASE` collapses them to a `selectivity_lane_id` code |
## A worked example — declaration header
The `declaration` logical table demaps the header's repetition and the colour
flags. This logical query:
```sql
SELECT id, registration_number, selectivity_lane_id
FROM declaration
WHERE selectivity_lane_id = 'RED';
```
compiles to (note `SELECT DISTINCT` and the injected `CASE`):
```sql
WITH
declaration AS (
SELECT DISTINCT
g.INSTANCE_ID AS id,
g.SGS_CUO_COD AS office_id,
g.SGS_TYP_COD AS declaration_type_id,
g.SGS_REG_NBR AS registration_number,
g.SGS_REG_DAT AS registration_date,
g.SGS_DEC_REF AS trader_reference,
g.SGS_CNE_COD AS consignee_id,
g.SGS_DCL_COD AS declarant_id,
g.SGS_EXP_COD AS exporter_id,
g.SGS_CUR_COD AS currency_id,
g.SGS_INV_AMT AS total_invoice_amount,
g.SGS_CIF_AMT AS total_cif_value,
g.STA AS status_id,
CASE WHEN g.PTY_RED = '1' THEN 'RED' WHEN g.PTY_YEL = '1' THEN 'YELLOW'
WHEN g.PTY_GRE = '1' THEN 'GREEN' WHEN g.PTY_BLU = '1' THEN 'BLUE' END AS selectivity_lane_id
FROM SAD_General_Segment g
)
SELECT id, registration_number, selectivity_lane_id
FROM declaration
WHERE selectivity_lane_id = 'RED';
```
## A worked example — a reference table
Reference tables carry the validity filter automatically. This logical query:
```sql
SELECT c.name FROM ref_country c WHERE c.id = 'CN';
```
compiles to — the code-keyed `id`, the code-keyed `iso_alpha2`, and the
`VALID_FROM`/`VALID_TO` filter, all injected:
```sql
WITH
ref_country AS (
SELECT
c.CTY_COD AS id,
c.CTY_COD AS iso_alpha2,
c.CTY_NAM AS name
FROM UNCTYTAB c
WHERE now()::date BETWEEN c.VALID_FROM AND coalesce(c.VALID_TO, DATE '9999-12-31')
)
SELECT c.name FROM ref_country c WHERE c.id = 'CN';
```
!!! info "Placeholders warn, not fail"
If the active mapping (base + [overrides](mapping.md)) still contains an
unfilled `{{placeholder}}`, `compile` prints the SQL and emits a
`-- warning:` on stderr naming the placeholders — a nudge to supply a
per-instance overrides file, not a hard error.
## Test the result read-only
Compiled SQL is genuine Sydonia SQL — so validate it **before** you trust it on
real data. Both paths are **privacy-preserving**: they check the query against
schema metadata and a read-only plan, and never return row data.
```bash
# the bundled shell tester (read-only, metadata only)
skills/customs-query/scripts/test_query.sh ""
```
The [`customs-query`](../skills/index.md) skill drives the same
**customs-query-tester** MCP from plain English — safe against a database holding
real customs declarations. See [Running the compiled SQL](running.md) for how to
point it at the sandbox, the mock database, or a real instance.
## Related
- [The query builder](builder.md) — generate logical SQL from a no-SQL spec.
- [The mapping](mapping.md) — what each logical name resolves to, and how to
override it per instance.
- [Querying the model](../guides/querying.md) ·
[Useful queries](../guides/useful-queries.md) — the logical query library.
---
# The query builder
The **builder** turns a small, no-SQL **query spec** into logical SQL — which the
[compiler](logical-sql.md) then turns into genuine Sydonia SQL. It is deliberately
thin: a convenience over logical SQL for people who would rather describe a query
than write it, **not** a new query language.
```text
query spec (YAML) ──build──► LOGICAL SQL ──compile──► GENUINE SYDONIA SQL
```
## The spec shape
A spec is YAML (or JSON). It maps directly onto the clauses of a `SELECT`:
```yaml
from: declaration
join:
- declaration_item on declaration_item.declaration_id = declaration.id
- declaration_tax_line on declaration_tax_line.declaration_item_id = declaration_item.id
where:
- declaration.selectivity_lane_id = 'RED'
select:
- declaration_item.hs_code
- sum(declaration_tax_line.tax_amount) as taxes
group_by: [declaration_item.hs_code]
order_by: [taxes desc]
limit: 10
```
### The keys
Only `from` and `select` are required; everything else is optional. Each key
accepts a single value or a list.
| Key | Accepts | Becomes |
|-----|---------|---------|
| `from` | a logical table name (**required**) | `FROM ` |
| `select` | expression(s) (**required**) | `SELECT , …` |
| `join` | `" on "` **or** a `{table, on, type}` object | `JOIN ON ` |
| `where` | condition(s), AND-ed | `WHERE … AND …` |
| `group_by` | expression(s) | `GROUP BY …` |
| `having` | condition(s), AND-ed | `HAVING … AND …` |
| `order_by` | expression(s) | `ORDER BY …` |
| `limit` | an integer | `LIMIT n` |
!!! note "Two ways to write a join"
The string form `"declaration_item on declaration_item.declaration_id = declaration.id"`
always produces an inner `JOIN`. For an outer join, use the object form and
set `type`:
```yaml
join:
- table: inspection_act
on: inspection_act.declaration_id = declaration.id
type: left join
```
## Worked example — spec to logical to genuine
Run the builder on the spec above with `--logical` to see the generated
**logical SQL**:
```bash
python -m compiler build my_spec.yml --logical
```
```sql
SELECT declaration_item.hs_code,
sum(declaration_tax_line.tax_amount) as taxes
FROM declaration
JOIN declaration_item ON declaration_item.declaration_id = declaration.id
JOIN declaration_tax_line ON declaration_tax_line.declaration_item_id = declaration_item.id
WHERE declaration.selectivity_lane_id = 'RED'
GROUP BY declaration_item.hs_code
ORDER BY taxes desc
LIMIT 10;
```
Drop the `--logical` flag and the same command runs it through the compiler to
produce **genuine Sydonia SQL** — the three referenced logical tables become
CTEs over `SAD_General_Segment`, `SAD_Item` and `SAD_Tax`, and your body is
left as written:
```bash
python -m compiler build my_spec.yml
```
```sql
WITH
declaration AS (
SELECT DISTINCT
g.INSTANCE_ID AS id,
…
CASE WHEN g.PTY_RED = '1' THEN 'RED' … END AS selectivity_lane_id
FROM SAD_General_Segment g
),
declaration_item AS (
SELECT
i.INSTANCE_ID AS id,
i.ITM_SGS_ID AS declaration_id,
concat(i.TAR_HSC_NB1, i.TAR_HSC_NB2, i.TAR_HSC_NB3, i.TAR_HSC_NB4, i.TAR_HSC_NB5) AS hs_code,
…
FROM SAD_Item i
),
declaration_tax_line AS (
SELECT
x.TAX_ITM_ID AS declaration_item_id,
x.AMT AS tax_amount,
…
FROM SAD_Tax x
)
SELECT declaration_item.hs_code,
sum(declaration_tax_line.tax_amount) as taxes
FROM declaration
JOIN declaration_item ON declaration_item.declaration_id = declaration.id
JOIN declaration_tax_line ON declaration_tax_line.declaration_item_id = declaration_item.id
WHERE declaration.selectivity_lane_id = 'RED'
GROUP BY declaration_item.hs_code
ORDER BY taxes desc
LIMIT 10;
```
The builder and the compiler share the same `--mapping` and `--overrides`
options, so a spec targets a specific instance exactly the way raw logical SQL
does — see [the mapping](mapping.md).
## When to prefer raw logical SQL instead
The builder is intentionally minimal. Reach for **[logical SQL](logical-sql.md)**
directly whenever a query needs anything the spec can't express, including:
- **CTEs / `WITH`** — window-function preludes, pre-aggregation before a join
(as in the [effective-rate](../guides/useful-queries.md) and league-table
queries).
- **`LATERAL`** subqueries — e.g. the assessed-vs-paid reconciliation.
- **Window functions**, `FILTER (WHERE …)`, `percentile_cont`, `DISTINCT ON`, set
operations, or anything else beyond the flat clause list above.
There is no loss of power in dropping to logical SQL — the compiler treats both
identically. The spec is simply the easiest on-ramp for straightforward
`from / join / where / select / group_by` reports.
## Related
- [Writing logical SQL](logical-sql.md) — the target of the builder, and the
richer queries the spec can't cover.
- [The mapping](mapping.md) — how referenced tables resolve, and per-instance
overrides.
- [Running the compiled SQL](running.md) — where to actually run the output.
---
# The mapping
The **mapping** is the single source of truth the compiler reads to build every
CTE — and the same file drives the persistent-view adapter. It lives at
[`compiler/mappings/asycuda-world.yml`](https://github.com/FrancoisChastel/sydonia-toolkit/blob/master/compiler/mappings/asycuda-world.yml)
and maps each of the toolbox's friendly logical tables to its real ASYCUDA World
source: the physical table, and for every logical column the physical expression
that produces it.
## The structure
Each entry under `tables:` describes one logical table. A typical entry:
```yaml
tables:
declaration:
source: SAD_General_Segment
alias: g
distinct: true # AW repeats the general segment per item -> one row per declaration
columns:
id: g.INSTANCE_ID
office_id: g.SGS_CUO_COD
registration_number: g.SGS_REG_NBR
status_id: g.STA
selectivity_lane_id: >-
CASE WHEN g.PTY_RED = '1' THEN 'RED' WHEN g.PTY_YEL = '1' THEN 'YELLOW'
WHEN g.PTY_GRE = '1' THEN 'GREEN' WHEN g.PTY_BLU = '1' THEN 'BLUE' END
```
| Field | Meaning |
|-------|---------|
| `source` | the real AW physical table this logical table reads from |
| `alias` | the alias bound to that source inside the CTE (default `t`) |
| `columns` | a map of **logical → physical**: each logical column name to the SQL expression that produces it (`i.VIT_CIF`, a `concat(...)`, a `CASE`, …) |
| `distinct` | emit `SELECT DISTINCT` — used for `declaration`, whose header is repeated into every item row |
| `valid` | `{from:, to:}` — adds a `now()::date BETWEEN … AND coalesce(…, DATE '9999-12-31')` validity filter for `UN*`/`xx*TAB` reference tables |
| `where` | an extra predicate AND-ed into the CTE |
| `raw` | a literal CTE body used for tables with **no real catalogue** — e.g. selectivity lanes and declaration statuses are materialised from a `VALUES` list |
The compiler renders each entry as a CTE (`build_cte`) — that's the whole
mechanism. Column expressions are copied verbatim into the `SELECT`, aliased to
the logical name.
## What the default targets
The default mapping targets the **publicly-documented physical shape**. Some names
are pinned because the public docs fix them; the rest follow AW's prefix
conventions:
| Pinned from public docs | Following AW conventions (instance-specific) |
|-------------------------|----------------------------------------------|
| `SAD_Tax` roots `COD` / `BSE` / `RAT` / `AMT` / `MOP` / `TYP` | the `SGS_*` general-segment columns |
| `TAR_HSC_NB1..5` HS split | `SAD_STATUS_LOG`, `INSP_ACT_TAB`, `SEL_PARAM_TAB` names |
| `PTY_*` colour flags | the `UN*TAB` / `xx*TAB` reference table names |
| `VIT_CIF` / `VIT_STV` valuation build-up | operator/trader source `UNOPTAB` |
| `VALID_FROM` / `VALID_TO` on reference rows | |
!!! warning "Exact physical names are instance-specific"
A **real** deployment's physical table and column names are
version/instance-specific and **not public**. The defaults here are what the
bundled [mock database](running.md) uses; for a real instance, pin the true
names in a **per-instance overrides file**.
## Per-instance overrides
Overrides are a second YAML file **deep-merged over the base** mapping — you
override **only what differs**, and everything else is inherited. See
[`compiler/mappings/overrides.example.yml`](https://github.com/FrancoisChastel/sydonia-toolkit/blob/master/compiler/mappings/overrides.example.yml):
```yaml
# overrides.example.yml — only the differences
tables:
declaration:
source: SAD_GEN # this instance calls it SAD_GEN
columns:
office_id: g.GEN_OFFICE # override just this column's expression
declaration_tax_line:
source: SAD_TAXATION # and the tax table SAD_TAXATION
# COD/BSE/RAT/AMT/MOP are documented roots — usually no override needed
```
Pass it with `--overrides` on either `compile` or `build`:
```bash
python -m compiler compile q.sql --overrides compiler/mappings/myinstance.yml
```
The merge is recursive: setting `columns.office_id` for `declaration` replaces
**only that column's expression**, leaving every other column and every other
table exactly as the base mapping defines them.
!!! tip "Use the mock as your worked example"
The default names line up with the bundled mock ASYCUDA World database. Get
your compiled SQL running against the mock first; then write the overrides for
the real instance — you are only changing names, not logic.
## Materialise persistent views with `emit-views`
The compiler prelude is per-query and needs no privileges. If instead you want
**persistent compatibility views** — logical tables physically present as
`CREATE VIEW` objects over the real schema, so *any* tool can query them —
`emit-views` renders the whole mapping as `CREATE OR REPLACE VIEW`:
```bash
python -m compiler emit-views > Sydonia/adapters/asycuda_world_compat.sql
python -m compiler emit-views --overrides compiler/mappings/myinstance.yml > my_compat.sql
```
It emits the schema preamble and one view per logical table (same CTE body,
wrapped as a view):
```sql
-- Generated from compiler/mappings by `python -m compiler emit-views`.
CREATE SCHEMA IF NOT EXISTS asycuda;
SET search_path TO asycuda, public;
CREATE OR REPLACE VIEW ref_country AS
SELECT
c.CTY_COD AS id,
c.CTY_COD AS iso_alpha2,
c.CTY_NAM AS name
FROM UNCTYTAB c
WHERE now()::date BETWEEN c.VALID_FROM AND coalesce(c.VALID_TO, DATE '9999-12-31');
```
This is the same adapter documented at
[running on a real ASYCUDA World](../platform/running-on-real-asycuda.md) — the
CTE prelude and the persistent views are two renderings of **one** mapping, so
they always agree.
## Related
- [Writing logical SQL](logical-sql.md) — the queries these mappings resolve.
- [Running the compiled SQL](running.md) — the sandbox, the mock database, and a
real instance.
- [Running on a real ASYCUDA World](../platform/running-on-real-asycuda.md) — the
deployment / FDW / ETL detail behind the persistent views.
- [Schema overview](../schema/index.md) — the logical layer these expressions map
*from*.
---
# Running the compiled SQL
The compiler produces one standalone genuine Sydonia statement. This page is
about **where you run it**. There are three targets, each with a different job:
```mermaid
flowchart LR
A[sandbox
reconstruction schema] -->|develop| B[logical SQL]
B -->|compile| C[genuine Sydonia SQL]
C -->|prove| D[mock ASYCUDA World
documented physical shape]
C -->|run read-only| E[(real instance)]
style A fill:#0f766e22,stroke:#0f766e
style E fill:#d9770622,stroke:#d97706
```
| Target | Job | Runs |
|--------|-----|------|
| **Sandbox** | develop and verify logical queries | logical SQL |
| **Mock AW DB** | prove the compiled SQL actually runs and matches | genuine SQL |
| **Real instance** | run against your own declarations, read-only | genuine SQL |
## 1 · The reconstruction sandbox — develop logical queries
Load the toolbox's normalised `asycuda` schema as usual, and write **logical
SQL** against it directly. This is where you iterate — the friendly names, the
seeded [end-to-end example](../guides/worked-example.md), fast feedback:
```bash
createdb customs_sandbox
psql -v ON_ERROR_STOP=1 -d customs_sandbox -f Sydonia/schema/asycuda.sql
psql -v ON_ERROR_STOP=1 -d customs_sandbox -f Sydonia/schema/seed_reference.sql
psql -v ON_ERROR_STOP=1 -d customs_sandbox -f Sydonia/examples/e2e.sql
```
Everything in the [querying guide](../guides/querying.md) and the
[useful queries](../guides/useful-queries.md) library runs here. When a logical
query gives the answer you want, `compile` it for the targets below.
## 2 · The mock ASYCUDA World database — prove the round-trip
[`Sydonia/adapters/mock_asycuda_world.sql`](https://github.com/FrancoisChastel/sydonia-toolkit/blob/master/Sydonia/adapters/mock_asycuda_world.sql)
is a **mock ASYCUDA World physical database**: tables in the documented
wide/denormalised AW shape (`SAD_General_Segment`, `SAD_Item`, `SAD_Tax`, the
`UN*` reference tables), with the exact column names the default mapping targets,
seeded from the toolbox's end-to-end example reshaped into that physical shape.
It is the executable stand-in for a real deployment — no non-public instance
required.
```bash
createdb aw_mock
psql -v ON_ERROR_STOP=1 -d aw_mock -f Sydonia/adapters/mock_asycuda_world.sql
# compile a logical query and run the genuine SQL straight into the mock DB
python -m compiler compile my_query.sql \
| psql -d aw_mock -c 'SET search_path TO aw, public;' -f -
```
!!! success "The round-trip guarantee (verified in CI)"
The **same** logical query returns the **same** results whether run on the
reconstruction sandbox (logical) or compiled and run on the mock physical
database (genuine). Run the friendly query on the sandbox, `compile | psql`
it into the mock, and compare — the numbers match. That is the proof the
abstraction is faithful: **write friendly, run genuine.**
## 3 · A real ASYCUDA World instance — read-only
Two ways to run against a real deployment; both are read-only.
**Run compiled SQL directly.** Compile with your
[per-instance overrides](mapping.md) so the CTEs address the real physical names,
then run the output against the live database:
```bash
python -m compiler compile my_query.sql \
--overrides compiler/mappings/myinstance.yml \
| psql "$CUSTOMS_DB"
```
**Or load persistent views once.** Materialise the mapping as compatibility views
with [`emit-views`](mapping.md) and load them into the real database, so any tool
can address the friendly names without a compile step:
```bash
python -m compiler emit-views --overrides compiler/mappings/myinstance.yml \
| psql "$CUSTOMS_DB"
```
Point **`CUSTOMS_DB`** at the real DSN (ideally a **`SELECT`-only role** on a
**read-replica**), and set `CUSTOMS_SCHEMA` to `asycuda` (or wherever you created
the views).
!!! note "Read-only via the tester — metadata only, never row data"
Validate compiled SQL through the **customs-query-tester** MCP — driven by
the [`customs-query`](../skills/index.md) skill, or the bundled
`skills/customs-query/scripts/test_query.sh`. It returns column names/types,
an aggregate row count and duration — and **never** row data — so it is safe
against a database holding real customs declarations. The compiled SQL and the
views are read-only by construction, so the privacy guarantees hold.
## Deployment, FDW and ETL
Pointing at a real instance raises questions this page doesn't: cross-dialect
deployments (Oracle / MS SQL Server / MySQL), foreign-data-wrapper front-ends
(`oracle_fdw` / `tds_fdw`), the `SELECT`-only role and read-replica setup, and the
**ETL-into-the-reference-model** alternative for bulk analytics and model
training.
All of that is covered in depth in
[**Running on a real ASYCUDA World**](../platform/running-on-real-asycuda.md) —
the compiler is the easiest path to *running the queries*; that page is the
deployment detail behind it.
## The recipes at a glance
| I want to… | Command |
|------------|---------|
| Develop a logical query | run it on the **sandbox** (`customs_sandbox`) |
| Prove the compiled SQL runs | `python -m compiler compile q.sql \| psql -d aw_mock -c 'SET search_path TO aw, public;' -f -` |
| Run against a real instance | `python -m compiler compile q.sql --overrides …myinstance.yml \| psql "$CUSTOMS_DB"` |
| Install persistent views once | `python -m compiler emit-views --overrides …myinstance.yml \| psql "$CUSTOMS_DB"` |
| Validate without reading rows | the [`customs-query`](../skills/index.md) skill / `test_query.sh` (metadata only) |
## Related
- [The mapping](mapping.md) — overrides and `emit-views`.
- [Writing logical SQL](logical-sql.md) — authoring and testing the queries.
- [Running on a real ASYCUDA World](../platform/running-on-real-asycuda.md) — the
FDW / ETL / privacy deep dive.
- [Querying Sydonia](../querying-sydonia/index.md) ·
[joins and gotchas](../querying-sydonia/joins-and-gotchas.md).
---
# Schema overview
One file, `Sydonia/schema/asycuda.sql`, defines **55 tables** across **8
modules** and loads top-to-bottom into a dedicated `asycuda` schema. This page
is the map; each module has its own page, and every column is defined in the
[data dictionary](data-dictionary.md).
!!! tip "This is now the logical layer"
This reconstructed model is the **friendly logical layer** the
[query compiler](../compiler/index.md) maps *from*: you write queries against
the table and column names below, and the compiler rewrites them into genuine
ASYCUDA World SQL you can run. **Write friendly, run genuine.**
The modeled system is **ASYCUDA World (v4)**, grounded in the official public
technical table descriptions — see
[the platform](../platform/index.md) for the version lineage and
[ASYCUDA World in depth](../platform/asycuda-world.md) for how this normalised
model relates to the real (unpublished) physical schema.
## Provenance at a glance
Every `CREATE TABLE` is tagged. A table is either grounded in a cited public
source or honestly marked as a modelling inference — never left ambiguous.
documented · 49
grounded in a cited source (`-- src: `)
inferred · 6
introduced by modelling judgement (`-- inferred`)
The six inferred tables are the RBAC set (`sys_role`, `sys_permission`,
`sys_user_role`, `sys_role_permission`) and the `trader_role` junction — see
[Coverage](../provenance/coverage.md).
## The eight modules
```mermaid
flowchart TD
R[1 · Reference / config
ref_* code tables]
T[1 · Traders & users
trader · sys_user]
M[2 · Manifest & cargo
manifest · bill_of_lading]
D[3 · Declaration — the SAD
declaration · declaration_item]
S[4 · Selectivity & risk
selectivity_result]
A[5 · Accounting
payment · receipt]
X[6 · Transit & suspense
warehouse · transit]
L[7 · Audit / workflow
audit_log]
R --> T
R --> M
R --> D
T --> M
T --> D
M --> D
D --> S
D --> A
D --> X
D --> L
style R fill:#0f766e22,stroke:#0f766e
style D fill:#d9770622,stroke:#d97706
```
| # | Module | Tables | What it captures |
|---|--------|:------:|------------------|
| 1 | [Reference & configuration](reference-config.md) | 26 | Code tables (countries, currencies, HS, taxes, offices…) + traders & users |
| 2 | [Manifest & cargo](manifest.md) | 5 | Carrier manifest, bills of lading, containers, cargo lines |
| 3 | [Declaration (the SAD)](declaration.md) | 9 | The declaration general + item segments, valuation, taxes, documents |
| 4 | [Selectivity & risk](selectivity.md) | 3 | Risk criteria, lane assignment, inspection acts |
| 5 | [Accounting](accounting.md) | 5 | Accounts, payments, receipts, ledger movements, guarantees |
| 6 | [Transit & suspense](transit-suspense.md) | 5 | Warehousing, transit, temporary admission, warehouses |
| 7 | [Audit & workflow](audit.md) | 2 | Cross-cutting audit log and status-history pattern |
*(Module 1 is split into a reference-tables group and a small traders/users group
on the same page; counts sum to 55.)*
## Conventions
The model follows a small, consistent set of rules — the same ones you should
keep when [extending it](../guides/extending.md):
| Rule | Choice |
|------|--------|
| Namespace | everything in schema `asycuda` |
| Naming | `snake_case`; `ref_` for code tables, `sys_` for system/RBAC |
| Primary keys | surrogate `bigint GENERATED ALWAYS AS IDENTITY` |
| Business keys | real codes (HS, office, TIN, receipt no.) kept `UNIQUE NOT NULL` |
| Coded columns | a foreign key to a `ref_*` table (not an inline code+name pair) |
| Money | `numeric(18,4)` |
| Mass / quantity | `numeric(18,3)` |
| Status lifecycles | a `ref_*_status` table + a `*_status_history` child |
| Provenance | `-- src: ` or `-- inferred` on every `CREATE TABLE` |
## See the whole shape
- **[Entity-relationship diagram](erd.md)** — every foreign key, rendered from
the loaded schema.
- **[Data dictionary](data-dictionary.md)** — every table and column with type,
nullability and source.
---
# Reference & configuration
mostly documented
5 inferred (RBAC + trader_role)
The backbone. Before any manifest or declaration exists, customs needs **code
tables** — countries, currencies, tariff codes, tax types, procedure codes — plus
the **traders** who transact and the **users** who operate the system.
*(GOAL §4.1.)*
## The `ref_*` code tables
Each coded value elsewhere in the model is a **foreign key** into one of these,
rather than an inline code+name pair. Most are grounded directly in the ISO/UN/WCO
standard the customs form references.
| Table | Purpose | Standard / source |
|-------|---------|-------------------|
| `ref_country` | Countries (origin, export, destination, nationality) | ISO 3166 · S008, S013 |
| `ref_currency` | Currencies for invoice / freight / insurance | ISO 4217 · S008, S013 |
| `ref_exchange_rate` | Rate to convert invoice currency (SAD box 23) | S013 (`xxRATTAB`) |
| `ref_customs_office` | Customs offices | S008, S013 |
| `ref_location` | Places / ports of loading, unloading, departure | UN/LOCODE · S008 |
| `ref_transport_mode` | Mode of transport (1 Sea … 9 Unknown) | S008 (full code list) |
| `ref_package_type` | Kind of packages | UN/ECE Rec 21 · S008 |
| `ref_container_type` | Container size-type | ISO 6346 · S008 |
| `ref_unit_of_measure` | Supplementary / statistical units (box 41) | S013 (`xxUOMTAB`) |
| `ref_incoterm` | Delivery terms (box 20) | Incoterms · S012, S003 |
| `ref_hs_tariff` | Commodity codes, self-referential hierarchy (box 33) | Harmonized System · S003, S008 |
| `ref_cpc_regime` | Customs Procedure Codes / regimes (box 37) | S003 (`xxCP*TAB`) |
| `ref_tax_type` | Duty / tax / fee types (box 47) | S003 (`xxTAXTAB`) |
| `ref_tax_rate` | Applicable rate per tax / commodity / origin | S013 (`xxRULTAB`) |
| `ref_document_type` | Attached / supporting document types (box 44) | S003, S008 |
| `ref_exemption_code` | Additional National Codes granting relief (box 37) | S003 |
| `ref_declaration_type` | Declaration type codes (box 1: IM4, EX1…) | S003 |
| `ref_declaration_status` | Declaration lifecycle statuses | S002 |
| `ref_manifest_status` | Manifest lifecycle statuses | S015 |
| `ref_bl_nature` | Transport-document nature (imports/exports/transit) | S008 |
| `ref_selectivity_lane` | Green / yellow / red / blue lanes | S002, S005 |
| `ref_warehouse` | Bonded / customs warehouses (box 49) | S003, S013 |
!!! note "The reference-table pattern"
Code tables carry a surrogate `id` PK **and** the real business `code`
(`UNIQUE NOT NULL`). Rate-like tables (`ref_exchange_rate`, `ref_tax_rate`)
add `valid_from` / `valid_to`; the rest use an `is_active` boolean. This mirrors
the official `VALID_FROM` / `VALID_TO` temporal pattern seen in the UNCTAD
reference tables, simplified for a reference model.
## Traders and economic operators
| Table | Purpose | Provenance |
|-------|---------|------------|
| `trader` | Economic operators keyed by TIN — importer, exporter, consignee, declarant, broker, carrier | documented S003, S008 |
| `trader_role` | The role(s) a trader may act in | inferred normalisation |
A single `trader` can appear as exporter on one declaration and consignee on
another; `trader_role` normalises the roles rather than duplicating the party.
## System users and access control
| Table | Purpose | Provenance |
|-------|---------|------------|
| `sys_user` | People who log in — customs staff, brokers, traders | documented S002 |
| `sys_role`, `sys_permission`, `sys_user_role`, `sys_role_permission` | Role-based access control | inferred |
ASYCUDA World has role-based menus, but the reference docs publish no user/role
schema — so the RBAC tables are an honest modelling inference (see
[Coverage](../provenance/coverage.md)).
## Resolving a coded column
Because coded columns are foreign keys, "human-readable" queries join to the
`ref_*` table:
```sql
SET search_path TO asycuda, public;
SELECT d.registration_number,
ty.code AS decl_type,
off.name AS office,
cur.iso_code AS currency
FROM declaration d
JOIN ref_declaration_type ty ON ty.id = d.declaration_type_id
JOIN ref_customs_office off ON off.id = d.office_id
JOIN ref_currency cur ON cur.id = d.currency_id;
```
See every column in the [data dictionary](data-dictionary.md#module-reference-configuration-goal-41).
---
# Manifest & cargo
documented — grounded in the official
UNCTAD manifest XML spec (S008) and national manifest manuals (S006, S007, S010,
S011) and the official `GEN_TAB` / `BOL_TAB` / `CTN_TAB` descriptions (S015).
What the **carrier** declares is on board. This is the upstream document the
importer's declaration later writes off against. *(GOAL §4.2.)*
## Tables
| Table | Purpose |
|-------|---------|
| `manifest` | General segment: carrier, voyage/flight, ports, dates, totals, office |
| `bill_of_lading` | A transport document (B/L / AWB). Master ⇄ house via self-reference |
| `container` | Physical containers on a B/L — ISO 6346 type, seals, weights, reefer |
| `manifest_cargo_item` | Goods/commodity lines within a B/L |
| `manifest_status_history` | Manifest lifecycle transitions |
```mermaid
erDiagram
manifest ||--o{ bill_of_lading : "carries"
bill_of_lading ||--o{ bill_of_lading : "master ⇄ house"
bill_of_lading ||--o{ container : "stuffed in"
bill_of_lading ||--o{ manifest_cargo_item : "goods lines"
container ||--o{ manifest_cargo_item : "packed as"
manifest ||--o{ manifest_status_history : "lifecycle"
```
## Master vs house — degroupage
A **master** bill of lading covers a full container moving carrier-to-carrier; a
**house** bill of lading is one consignee's consignment inside it. Consolidation
(**degroupage**) is modelled with a self-reference:
```sql
-- a house B/L points at its master
bill_of_lading.master_bl_id → bill_of_lading.id
bill_of_lading.is_master boolean
```
## Key columns — `manifest`
| Column | Type | Meaning |
|--------|------|---------|
| `office_id` | FK | Customs office of arrival |
| `manifest_year`, `registration_number` | int | Registration serial |
| `voyage_number`, `identity_of_transport` | text | Voyage / vessel identity |
| `transport_mode_id`, `nationality_id` | FK | Mode + carrier nationality |
| `carrier_id`, `shipping_agent_id` | FK → `trader` | Parties |
| `place_departure_id`, `place_destination_id` | FK → `ref_location` | Ports |
| `date_of_departure`, `date_of_arrival` | date | Voyage dates |
| `total_bols`, `total_packages`, `total_containers`, `total_gross_mass` | num | Declared totals |
| `status_id` | FK | Current lifecycle status |
## Example — a manifest with its consignments
```sql
SET search_path TO asycuda, public;
SELECT m.voyage_number,
bl.bl_reference,
nat.name AS bl_nature,
bl.number_of_packages,
bl.gross_mass,
count(ci.id) AS goods_lines
FROM manifest m
JOIN bill_of_lading bl ON bl.manifest_id = m.id
JOIN ref_bl_nature nat ON nat.id = bl.bl_nature_id
LEFT JOIN manifest_cargo_item ci ON ci.bl_id = bl.id
WHERE m.voyage_number = 'V2026-042'
GROUP BY m.voyage_number, bl.bl_reference, nat.name,
bl.number_of_packages, bl.gross_mass;
```
Full columns in the [data dictionary](data-dictionary.md#module-manifest-cargo-goal-42).
!!! info "Known gap"
The manifest XML also defines a **vehicle sub-segment** (chassis/VIN/engine for
RoRo cargo). It is documented in the research log but not modelled as a table,
as it sits off the end-to-end path. Tracked in [Coverage](../provenance/coverage.md).
---
# Declaration — the SAD
documented — the **Single
Administrative Document**, grounded box-by-box in S003 (FSM declaration guide),
S001 (SAD overview) and the official `SAD_General_Segment` / `SAD_Item` /
`SAD_Tax` descriptions (S014).
The core of the model. The **SAD** is the internationally standardised customs
declaration — one **general segment** per consignment plus repeating **item
segments**, one per commodity line. *(GOAL §4.3.)*
## Tables
| Table | Purpose |
|-------|---------|
| `declaration` | General segment — parties, regime, transport, invoice totals, status, lane |
| `declaration_item` | Item segment — HS code, origin, mass, procedure, values (the tax base) |
| `valuation_note` | Declaration-level value build-up → total CIF |
| `item_value_note` | Freight/insurance apportioned per item → item CIF |
| `declaration_tax_line` | Per item, per tax: base · rate · amount · mode of payment |
| `declaration_attached_document` | Invoice, licence, permit, certificate (box 44) |
| `declaration_previous_document` | Write-off against a manifest B/L or prior declaration (box 40) |
| `declaration_status_history` | Lifecycle transitions (stored → … → released) |
```mermaid
erDiagram
declaration ||--o{ declaration_item : "1..n items"
declaration ||--o| valuation_note : "value build-up"
declaration_item ||--o| item_value_note : "apportioned CIF"
declaration_item ||--o{ declaration_tax_line : "per-tax"
declaration ||--o{ declaration_attached_document : "box 44"
declaration ||--o{ declaration_previous_document : "box 40 write-off"
declaration ||--o{ declaration_status_history : "lifecycle"
declaration_item ||--o{ declaration_attached_document : "item-level docs"
```
## The general segment — `declaration`
One row per consignment. Selected columns (see the
[data dictionary](data-dictionary.md#module-declaration-the-sad-goal-43) for all):
| Column | SAD box | Meaning |
|--------|:------:|---------|
| `office_id` | 29 | Office of entry |
| `declaration_type_id` | 1 | Model/type (IM4, EX1…) |
| `cpc_id` | 37 | Customs Procedure Code / regime |
| `registration_serial`, `registration_number`, `registration_date` | A | Registration identity |
| `exporter_id`, `consignee_id`, `declarant_id`, `financial_id` | 2/8/14/9 | Parties |
| `country_export_id`, `country_origin_id`, `country_destination_id` | 15/34/17 | Countries |
| `incoterm_id`, `delivery_place` | 20 | Delivery terms |
| `total_invoice_amount`, `currency_id`, `exchange_rate` | 22/23 | Invoice value |
| `total_freight`, `total_insurance`, `total_cif_value` | — | Value totals |
| `selectivity_lane_id` | — | Assigned lane (GREEN/YELLOW/RED/BLUE) |
| `status_id` | — | Current lifecycle status |
| `manifest_id` | — | Links to the arriving manifest |
## The item segment — `declaration_item`
One row per commodity line (SAD boxes 31–49). The **customs value** on each item
is the tax base:
| Column | SAD box | Meaning |
|--------|:------:|---------|
| `item_number` | 32 | Line number |
| `hs_id`, `hs_code` | 33 | Commodity code |
| `country_origin_id` | 34 | Origin |
| `cpc_id`, `national_procedure` | 37 | Procedure |
| `number_of_packages`, `package_type_id`, `marks_and_numbers` | 31 | Packaging |
| `gross_mass`, `net_mass` | 35/38 | Mass |
| `supplementary_qty`, `supplementary_uom_id` | 41 | Statistical quantity |
| `item_price`, `valuation_method_code` | 42/43 | Price & method |
| `statistical_value`, `customs_value` | 46/— | **The tax base** (CIF) |
## Valuation — building the tax base
Invoices are usually **FOB** (goods only), but duty is charged on **CIF**. The
valuation note builds the value up, then apportions shared freight/insurance down
to each item by value share:
```text
valuation_note : invoice FOB + freight + insurance + other = total CIF
item_value_note: item FOB + apportioned freight + apportioned insurance = item CIF
```
In the worked example, $3,000 freight + $300 insurance on a $60,000 FOB shipment
split 2:1, giving item CIFs of **$42,200** and **$21,100**.
## Taxes — `declaration_tax_line`
One row per item, per applicable tax. Because taxes cascade (VAT is charged on
*customs value + import duty*), the base is stored per line rather than derived:
| Column | `SAD_Tax` | Meaning |
|--------|:---------:|---------|
| `tax_type_id` | `COD` | Which tax (import duty, VAT, excise, fee) |
| `tax_base` | `BSE` | Amount the rate applies to |
| `rate_percent` | `RAT` | Ad-valorem rate |
| `specific_amount` | — | Specific (per-unit) component |
| `tax_amount` | `AMT` | Calculated amount |
| `mode_of_payment` | `MOP` | Cash / account / … |
| `is_manual` | `TYP` | Manually entered vs automatically calculated |
## Documents
- `declaration_attached_document` — supporting documents at header **or** item
level (invoice `380`, bill of lading `705`, licence `911`…), SAD box 44.
- `declaration_previous_document` — SAD box 40: writes each item off against the
**manifest bill of lading** (`bl_id`) or a **previous declaration**
(`prev_declaration_id`), with packages/mass written off.
## Example — assemble a declaration
```sql
SET search_path TO asycuda, public;
SELECT di.item_number,
di.hs_code,
di.customs_value,
sum(tl.tax_amount) AS taxes
FROM declaration d
JOIN declaration_item di ON di.declaration_id = d.id
LEFT JOIN declaration_tax_line tl ON tl.declaration_item_id = di.id
WHERE d.trader_reference = 'REF-2026-0001'
GROUP BY di.item_number, di.hs_code, di.customs_value
ORDER BY di.item_number;
```
See the [worked example](../guides/worked-example.md) for the full flow, and the
[querying guide](../guides/querying.md) for more join paths.
---
# Selectivity & risk
documented — lanes and inspection
grounded in S002 and S005; the risk-criteria catalogue in the official
`SEL_*_PARAM_TAB` selectivity tables (S014).
Customs cannot inspect every consignment. **Selectivity** routes each declaration
into a risk **lane**, balancing facilitation against control. *(GOAL §4.6.)*
## The four lanes
| Lane | Requires exam | Meaning |
|------|:-------------:|---------|
| :material-circle:{ style="color:#16a34a" } **GREEN** | no | Automatic release; customs reserves the right to examine |
| :material-circle:{ style="color:#ca8a04" } **YELLOW** | yes | Documentary check by an assigned officer |
| :material-circle:{ style="color:#dc2626" } **RED** | yes | Physical examination of the goods |
| :material-circle:{ style="color:#2563eb" } **BLUE** | no | Released now, selected for post-clearance audit |
## Tables
| Table | Purpose |
|-------|---------|
| `ref_selectivity_lane` | The lane catalogue (defined in [Reference & config](reference-config.md)) |
| `risk_criterion` | Rules that target a lane (e.g. high-risk HS chapter, first-time importer) |
| `selectivity_result` | The lane a specific declaration was routed to, when and by which criterion |
| `inspection_act` | The officer's examination record for yellow/red declarations |
```mermaid
erDiagram
declaration ||--o{ selectivity_result : "routed to a lane"
ref_selectivity_lane ||--o{ selectivity_result : "which lane"
risk_criterion ||--o{ selectivity_result : "why"
ref_selectivity_lane ||--o{ risk_criterion : "targets"
declaration ||--o{ inspection_act : "examined by"
```
The declaration also carries its current lane inline on
`declaration.selectivity_lane_id` for fast filtering, while `selectivity_result`
preserves the full routing history and reason.
## Example — inspection outcomes by lane
```sql
SET search_path TO asycuda, public;
SELECT lane.code AS lane,
count(*) AS declarations,
count(ia.id) AS inspected,
count(*) FILTER (WHERE ia.result = 'conform') AS conform
FROM declaration d
JOIN ref_selectivity_lane lane ON lane.id = d.selectivity_lane_id
LEFT JOIN inspection_act ia ON ia.declaration_id = d.id
GROUP BY lane.code
ORDER BY lane.code;
```
Full columns in the [data dictionary](data-dictionary.md#module-selectivity-risk-goal-46).
---
# Accounting
documented — grounded in the official
accounting tables (S016: receipts, `TAX_TAB`, account transactions in/out) and
S003 boxes 48/B.
Once a declaration is assessed, the declarant **pays**, a **receipt** is issued,
and the money is recorded against an **account** ledger. *(GOAL §4.4.)*
## Tables
| Table | Purpose |
|-------|---------|
| `account` | Trader deferred-payment / credit / prepayment / guarantee account (box 48) |
| `payment` | Settlement of an assessed declaration — cash or against an account (box B) |
| `receipt` | The receipt issued on payment |
| `account_movement` | Ledger debit/credit against an account, with running balance |
| `guarantee` | Security / bond backing suspense regimes (box 52) |
```mermaid
erDiagram
declaration ||--o{ payment : "settles"
payment ||--o| receipt : "issues"
account ||--o{ payment : "charged to"
account ||--o{ account_movement : "ledger"
payment ||--o{ account_movement : "posts"
trader ||--o{ account : "holds"
trader ||--o{ guarantee : "lodges"
```
Payment mode is flexible: a `NULL` `account_id` on `payment` means **cash**;
otherwise it draws on the trader's `account`, and an `account_movement` records
the debit and the resulting balance.
## Example — assessed vs paid reconciliation
```sql
SET search_path TO asycuda, public;
SELECT d.registration_number,
assessed.total_tax,
p.amount AS paid,
r.receipt_number
FROM declaration d
JOIN LATERAL (
SELECT sum(tl.tax_amount) AS total_tax
FROM declaration_item di
JOIN declaration_tax_line tl ON tl.declaration_item_id = di.id
WHERE di.declaration_id = d.id
) assessed ON true
LEFT JOIN payment p ON p.declaration_id = d.id
LEFT JOIN receipt r ON r.payment_id = p.id
WHERE d.trader_reference = 'REF-2026-0001';
```
A healthy declaration has `total_tax = paid = receipt.total_amount`. Full columns
in the [data dictionary](data-dictionary.md#module-accounting-goal-44).
---
# Transit & suspense
documented — grounded in the
Suspense Declarations manual (S019), the official warehouse/transit tables
(`SUS_WH_IN`, `MAN_TRANSIT_TAB`; S014/S015) and S003 boxes 49–53.
Not all goods are cleared for home use on arrival — duty can be **suspended**
while goods are warehoused, moved under control, or temporarily imported.
*(GOAL §4.5.)*
## The suspense regimes
| Regime | What happens | Tables |
|--------|--------------|--------|
| **Warehousing** | Goods stored in a bonded warehouse until later entry | `ref_warehouse`, `warehouse_entry`, `warehouse_exit` |
| **Transit** | Goods moved under customs control between offices | `transit_declaration` |
| **Temporary admission** | Imported for a limited time, then re-exported | `temporary_admission` |
All three lean on `guarantee` (from [Accounting](accounting.md)) to secure the
suspended duty.
```mermaid
erDiagram
declaration ||--o| transit_declaration : "transit extension"
declaration ||--o{ warehouse_entry : "placed in warehouse"
warehouse_entry ||--o{ warehouse_exit : "removed (write-off)"
ref_warehouse ||--o{ warehouse_entry : "stored at"
declaration ||--o| temporary_admission : "TA regime"
guarantee ||--o{ transit_declaration : "secures"
guarantee ||--o{ temporary_admission : "secures"
```
## Key details
- **`transit_declaration`** extends a declaration with the principal (box 50),
the offices of departure / transit / destination (boxes 51/53), the securing
`guarantee` (box 52), itinerary, seals and a time limit.
- **`warehouse_entry` / `warehouse_exit`** track goods into and out of a
`ref_warehouse`; the exit is the ex-warehouse write-off.
- **`temporary_admission`** records the time limit and links to the eventual
re-export declaration (`re_export_declaration_id`).
## Example — goods currently in a warehouse
```sql
SET search_path TO asycuda, public;
SELECT w.code AS warehouse,
we.entry_date,
we.packages,
we.gross_mass
FROM warehouse_entry we
JOIN ref_warehouse w ON w.id = we.warehouse_id
LEFT JOIN warehouse_exit wx ON wx.warehouse_entry_id = we.id
WHERE wx.id IS NULL -- not yet removed
ORDER BY we.entry_date;
```
Full columns in the [data dictionary](data-dictionary.md#module-transit-suspense-goal-45).
---
# Audit & workflow
documented (audit log · S013 `LogTable`)
history shape inferred
Cross-cutting bookkeeping: **who did what, when**, and how each document moved
through its lifecycle. *(GOAL §4.7.)*
## The audit log
`audit_log` is a single, generic who/what/when trail. Rather than a foreign key
per entity, it stores the affected entity by name and surrogate id, so any table
can be audited uniformly:
| Column | Meaning |
|--------|---------|
| `entity_name` | Table / document type affected (e.g. `declaration`) |
| `entity_id` | Surrogate id of the affected row |
| `action` | `insert` / `update` / `status_change` / `print` … |
| `actor_id` | The `sys_user` responsible |
| `occurred_at` | Timestamp (`timestamptz`, defaults to `now()`) |
| `detail` | Free-text description |
## The status-history pattern
Lifecycles are modelled consistently across the schema: a `ref_*_status` catalogue
plus a `*_status_history` child that records each transition. Two instances exist:
| History table | Tracks | Status catalogue |
|---------------|--------|------------------|
| `declaration_status_history` | stored → registered → assessed → paid → released | `ref_declaration_status` |
| `manifest_status_history` | stored → registered → amended → closed | `ref_manifest_status` |
```mermaid
erDiagram
declaration ||--o{ declaration_status_history : "transitions"
ref_declaration_status ||--o{ declaration_status_history : "status"
sys_user ||--o{ declaration_status_history : "changed by"
sys_user ||--o{ audit_log : "acted"
```
This keeps the **current** status on the parent (`declaration.status_id`) for
fast filtering while preserving the **full trail** in the history child.
## Example — the lifecycle trail of a declaration
```sql
SET search_path TO asycuda, public;
SELECT st.sort_order,
st.code,
h.changed_at,
u.login_name AS changed_by,
h.note
FROM declaration_status_history h
JOIN ref_declaration_status st ON st.id = h.status_id
LEFT JOIN sys_user u ON u.id = h.changed_by
JOIN declaration d ON d.id = h.declaration_id
WHERE d.trader_reference = 'REF-2026-0001'
ORDER BY h.changed_at;
```
Full columns in the [data dictionary](data-dictionary.md#module-audit-workflow-cross-cutting-goal-47).
---
# Entity-relationship diagram
This diagram is generated from the **loaded schema's foreign keys**, so it always
matches `Sydonia/schema/asycuda.sql`. The `ref_*` / `sys_*` tables are the
code/config backbone; the manifest and declaration clusters are the operational
core. Attributes are abbreviated to primary/business keys for legibility — see
the [data dictionary](data-dictionary.md) for the full column list.
```mermaid
erDiagram
account {
bigint id
character_varying_20_ account_number
}
account_movement {
bigint id
}
audit_log {
bigint id
}
bill_of_lading {
bigint id
integer line_number
character_varying_35_ bl_reference
character_varying_3_ bl_type_code
}
container {
bigint id
character_varying_17_ ctn_reference
character_varying_10_ dangerous_goods_code
}
declaration {
bigint id
integer registration_number
character_varying_35_ trader_reference
character_varying_20_ assessment_number
}
declaration_attached_document {
bigint id
character_varying_60_ document_reference
}
declaration_item {
bigint id
smallint item_number
character_varying_12_ hs_code
character_varying_3_ preference_code
character_varying_17_ container_reference
character_varying_3_ valuation_method_code
}
declaration_previous_document {
bigint id
character_varying_60_ reference
}
declaration_status_history {
bigint id
}
declaration_tax_line {
bigint id
}
guarantee {
bigint id
character_varying_30_ reference
}
inspection_act {
bigint id
}
item_value_note {
bigint id
}
manifest {
bigint id
integer registration_number
character_varying_17_ voyage_number
}
manifest_cargo_item {
bigint id
integer line_number
character_varying_6_ hs_code
}
manifest_status_history {
bigint id
}
payment {
bigint id
}
receipt {
bigint id
character_varying_20_ receipt_number
}
ref_bl_nature {
bigint id
character_varying_2_ code
}
ref_container_type {
bigint id
character_varying_4_ code
}
ref_country {
bigint id
character_varying_2_ iso_alpha2
character_varying_3_ iso_alpha3
character_varying_3_ numeric_code
}
ref_cpc_regime {
bigint id
character_varying_7_ cpc_code
}
ref_currency {
bigint id
character_varying_3_ iso_code
character_varying_3_ numeric_code
}
ref_customs_office {
bigint id
character_varying_5_ office_code
}
ref_declaration_status {
bigint id
character_varying_15_ code
}
ref_declaration_type {
bigint id
character_varying_4_ code
}
ref_document_type {
bigint id
character_varying_6_ code
}
ref_exchange_rate {
bigint id
}
ref_exemption_code {
bigint id
character_varying_6_ code
}
ref_hs_tariff {
bigint id
character_varying_12_ hs_code
}
ref_incoterm {
bigint id
character_varying_3_ code
}
ref_location {
bigint id
character_varying_5_ unlocode
}
ref_manifest_status {
bigint id
character_varying_15_ code
}
ref_package_type {
bigint id
character_varying_2_ code
}
ref_selectivity_lane {
bigint id
character_varying_6_ code
}
ref_tax_rate {
bigint id
}
ref_tax_type {
bigint id
character_varying_6_ code
}
ref_transport_mode {
bigint id
character_varying_3_ code
}
ref_unit_of_measure {
bigint id
character_varying_6_ code
}
ref_warehouse {
bigint id
character_varying_10_ code
}
risk_criterion {
bigint id
character_varying_20_ code
}
selectivity_result {
bigint id
}
sys_permission {
bigint id
character_varying_40_ code
}
sys_role {
bigint id
character_varying_30_ code
}
sys_role_permission {
bigint role_id
bigint permission_id
}
sys_user {
bigint id
character_varying_40_ login_name
}
sys_user_role {
bigint user_id
bigint role_id
}
temporary_admission {
bigint id
}
trader {
bigint id
character_varying_20_ tin
}
trader_role {
bigint id
}
transit_declaration {
bigint id
}
valuation_note {
bigint id
}
warehouse_entry {
bigint id
}
warehouse_exit {
bigint id
}
account ||--o{ account_movement : ""
account ||--o{ payment : ""
bill_of_lading ||--o{ bill_of_lading : "self"
bill_of_lading ||--o{ container : ""
bill_of_lading ||--o{ declaration_previous_document : ""
bill_of_lading ||--o{ manifest_cargo_item : ""
container ||--o{ manifest_cargo_item : ""
declaration ||--o{ declaration_attached_document : ""
declaration ||--o{ declaration_item : ""
declaration ||--o{ declaration_previous_document : ""
declaration ||--o{ declaration_status_history : ""
declaration ||--o{ inspection_act : ""
declaration ||--o{ payment : ""
declaration ||--o{ selectivity_result : ""
declaration ||--o{ temporary_admission : ""
declaration ||--o{ transit_declaration : ""
declaration ||--o{ valuation_note : ""
declaration ||--o{ warehouse_entry : ""
declaration ||--o{ warehouse_exit : ""
declaration_item ||--o{ declaration_attached_document : ""
declaration_item ||--o{ declaration_previous_document : ""
declaration_item ||--o{ declaration_tax_line : ""
declaration_item ||--o{ item_value_note : ""
guarantee ||--o{ temporary_admission : ""
guarantee ||--o{ transit_declaration : ""
manifest ||--o{ bill_of_lading : ""
manifest ||--o{ declaration : ""
manifest ||--o{ manifest_status_history : ""
payment ||--o{ account_movement : ""
payment ||--o{ receipt : ""
ref_bl_nature ||--o{ bill_of_lading : ""
ref_container_type ||--o{ container : ""
ref_country ||--o{ declaration : ""
ref_country ||--o{ declaration_item : ""
ref_country ||--o{ manifest : ""
ref_country ||--o{ ref_customs_office : ""
ref_country ||--o{ ref_location : ""
ref_country ||--o{ ref_tax_rate : ""
ref_country ||--o{ trader : ""
ref_cpc_regime ||--o{ declaration : ""
ref_cpc_regime ||--o{ declaration_item : ""
ref_cpc_regime ||--o{ ref_tax_rate : ""
ref_currency ||--o{ account : ""
ref_currency ||--o{ bill_of_lading : ""
ref_currency ||--o{ declaration : ""
ref_currency ||--o{ guarantee : ""
ref_currency ||--o{ payment : ""
ref_currency ||--o{ ref_exchange_rate : ""
ref_currency ||--o{ valuation_note : ""
ref_customs_office ||--o{ declaration : ""
ref_customs_office ||--o{ manifest : ""
ref_customs_office ||--o{ ref_warehouse : ""
ref_customs_office ||--o{ sys_user : ""
ref_customs_office ||--o{ transit_declaration : ""
ref_declaration_status ||--o{ declaration : ""
ref_declaration_status ||--o{ declaration_status_history : ""
ref_declaration_type ||--o{ declaration : ""
ref_document_type ||--o{ declaration_attached_document : ""
ref_exemption_code ||--o{ declaration_item : ""
ref_hs_tariff ||--o{ declaration_item : ""
ref_hs_tariff ||--o{ ref_hs_tariff : "self"
ref_hs_tariff ||--o{ ref_tax_rate : ""
ref_incoterm ||--o{ declaration : ""
ref_location ||--o{ bill_of_lading : ""
ref_location ||--o{ declaration : ""
ref_location ||--o{ manifest : ""
ref_manifest_status ||--o{ manifest : ""
ref_manifest_status ||--o{ manifest_status_history : ""
ref_package_type ||--o{ bill_of_lading : ""
ref_package_type ||--o{ declaration_item : ""
ref_package_type ||--o{ manifest_cargo_item : ""
ref_selectivity_lane ||--o{ declaration : ""
ref_selectivity_lane ||--o{ risk_criterion : ""
ref_selectivity_lane ||--o{ selectivity_result : ""
ref_tax_type ||--o{ declaration_tax_line : ""
ref_tax_type ||--o{ ref_tax_rate : ""
ref_transport_mode ||--o{ declaration : ""
ref_transport_mode ||--o{ manifest : ""
ref_unit_of_measure ||--o{ declaration_item : ""
ref_unit_of_measure ||--o{ ref_hs_tariff : ""
ref_warehouse ||--o{ declaration_item : ""
ref_warehouse ||--o{ warehouse_entry : ""
risk_criterion ||--o{ selectivity_result : ""
sys_permission ||--o{ sys_role_permission : ""
sys_role ||--o{ sys_role_permission : ""
sys_role ||--o{ sys_user_role : ""
sys_user ||--o{ audit_log : ""
sys_user ||--o{ declaration : ""
sys_user ||--o{ declaration_status_history : ""
sys_user ||--o{ inspection_act : ""
sys_user ||--o{ manifest_status_history : ""
sys_user ||--o{ payment : ""
sys_user ||--o{ selectivity_result : ""
sys_user ||--o{ sys_user_role : ""
trader ||--o{ account : ""
trader ||--o{ bill_of_lading : ""
trader ||--o{ declaration : ""
trader ||--o{ guarantee : ""
trader ||--o{ manifest : ""
trader ||--o{ ref_warehouse : ""
trader ||--o{ sys_user : ""
trader ||--o{ trader_role : ""
trader ||--o{ transit_declaration : ""
warehouse_entry ||--o{ warehouse_exit : ""
```
---
# Data dictionary
Every table and column in the model, grouped by module. Each entry lists the
column's type, nullability, and — at the table level — its provenance
(`-- src: ` or `-- inferred`). This page is generated from the live
PostgreSQL catalog after loading `Sydonia/schema/asycuda.sql`, so it is exact.
!!! tip "Looking for one table?"
Use the search box (press /) or your browser's find — every table
name is a heading anchor. For the visual shape, see the
[entity-relationship diagram](erd.md).
## Module: REFERENCE / CONFIGURATION (GOAL §4.1)
### `ref_country`
*Source:* `src: S013, S008`
*Purpose:* ISO 3166 countries; used for origin, export, destination, nationality (S008).
| Column | Type | Null | Notes |
|--------|------|------|-------|
| id | bigint | NOT NULL | |
| iso_alpha2 | character varying(2) | NOT NULL | |
| iso_alpha3 | character varying(3) | nullable | |
| numeric_code | character varying(3) | nullable | |
| name | character varying(100) | NOT NULL | |
| is_active | boolean | NOT NULL | |
### `ref_currency`
*Source:* `src: S013, S008`
*Purpose:* ISO 4217 currencies for invoice / freight / insurance values (S008, S003 box 22).
| Column | Type | Null | Notes |
|--------|------|------|-------|
| id | bigint | NOT NULL | |
| iso_code | character varying(3) | NOT NULL | |
| numeric_code | character varying(3) | nullable | |
| name | character varying(80) | NOT NULL | |
| minor_units | smallint | NOT NULL | |
| is_active | boolean | NOT NULL | |
### `ref_exchange_rate`
*Source:* `src: S013`
*Purpose:* Exchange rate applied to convert invoice currency (SAD box 23); table shape inferred.
| Column | Type | Null | Notes |
|--------|------|------|-------|
| id | bigint | NOT NULL | |
| currency_id | bigint | NOT NULL | |
| rate | numeric(18,6) | NOT NULL | |
| valid_from | date | NOT NULL | |
| valid_to | date | nullable | |
### `ref_customs_office`
*Source:* `src: S013, S008`
*Purpose:* Customs offices (S008 customs_office_code AN5; S003 office of entry box 29).
| Column | Type | Null | Notes |
|--------|------|------|-------|
| id | bigint | NOT NULL | |
| office_code | character varying(5) | NOT NULL | |
| name | character varying(120) | NOT NULL | |
| country_id | bigint | nullable | |
| is_active | boolean | NOT NULL | |
### `ref_location`
*Source:* `src: S013, S008`
*Purpose:* UN/LOCODE places/ports of loading, unloading, departure, destination (S008).
| Column | Type | Null | Notes |
|--------|------|------|-------|
| id | bigint | NOT NULL | |
| unlocode | character varying(5) | NOT NULL | |
| name | character varying(120) | NOT NULL | |
| country_id | bigint | nullable | |
| is_port | boolean | NOT NULL | |
### `ref_transport_mode`
*Source:* `src: S013, S008`
*Purpose:* Mode of transport 1=Sea 2=Rail 3=Road 4=Air 5=Postal 6=Multimodal 7=Fixed 8=Inland waterways 9=Unknown (S008).
| Column | Type | Null | Notes |
|--------|------|------|-------|
| id | bigint | NOT NULL | |
| code | character varying(3) | NOT NULL | |
| name | character varying(40) | NOT NULL | |
### `ref_package_type`
*Source:* `src: S013, S008`
*Purpose:* Kind-of-packages codes, UN/ECE Rec 21 alpha-2 (S008 package_type_code; S003 box 31).
| Column | Type | Null | Notes |
|--------|------|------|-------|
| id | bigint | NOT NULL | |
| code | character varying(2) | NOT NULL | |
| name | character varying(80) | NOT NULL | |
### `ref_container_type`
*Source:* `src: S013, S008`
*Purpose:* Container size-type per ISO 6346:1995 (S008 type_of_container).
| Column | Type | Null | Notes |
|--------|------|------|-------|
| id | bigint | NOT NULL | |
| code | character varying(4) | NOT NULL | |
| name | character varying(80) | NOT NULL | |
### `ref_unit_of_measure`
*Source:* `src: S013`
*Purpose:* Units for supplementary quantity / statistical units (SAD box 41, S003); values inferred.
| Column | Type | Null | Notes |
|--------|------|------|-------|
| id | bigint | NOT NULL | |
| code | character varying(6) | NOT NULL | |
| name | character varying(60) | NOT NULL | |
### `ref_incoterm`
*Source:* `src: S013, S003, S012`
*Purpose:* Incoterms delivery-terms codes (SAD box 20, S003; S012).
| Column | Type | Null | Notes |
|--------|------|------|-------|
| id | bigint | NOT NULL | |
| code | character varying(3) | NOT NULL | |
| name | character varying(60) | NOT NULL | |
| edition | character varying(9) | nullable | |
### `ref_hs_tariff`
*Source:* `src: S013, S003`
*Purpose:* Harmonized System commodity codes with self-referential hierarchy (SAD box 33, S003; S008 HS 6-digit).
| Column | Type | Null | Notes |
|--------|------|------|-------|
| id | bigint | NOT NULL | |
| hs_code | character varying(12) | NOT NULL | |
| parent_id | bigint | nullable | |
| description | character varying(400) | NOT NULL | |
| uom_id | bigint | nullable | |
| is_active | boolean | NOT NULL | |
### `ref_cpc_regime`
*Source:* `src: S013, S003`
*Purpose:* Customs Procedure Codes / regimes (SAD box 37, S003): requested(2)+previous(2) extended code.
| Column | Type | Null | Notes |
|--------|------|------|-------|
| id | bigint | NOT NULL | |
| cpc_code | character varying(7) | NOT NULL | |
| name | character varying(120) | NOT NULL | |
| regime_group | character varying(30) | nullable | |
| is_suspense | boolean | NOT NULL | |
| is_active | boolean | NOT NULL | |
### `ref_tax_type`
*Source:* `src: S013, S003`
*Purpose:* Duty/tax/fee types calculated per item (SAD box 47, S003).
| Column | Type | Null | Notes |
|--------|------|------|-------|
| id | bigint | NOT NULL | |
| code | character varying(6) | NOT NULL | |
| name | character varying(80) | NOT NULL | |
| is_ad_valorem | boolean | NOT NULL | |
### `ref_tax_rate`
*Source:* `src: S013`
*Purpose:* Applicable rate per tax type / commodity / regime / origin (SAD box 47 implies rates); table shape inferred.
| Column | Type | Null | Notes |
|--------|------|------|-------|
| id | bigint | NOT NULL | |
| tax_type_id | bigint | NOT NULL | |
| hs_id | bigint | nullable | |
| cpc_id | bigint | nullable | |
| origin_id | bigint | nullable | |
| rate_percent | numeric(9,4) | nullable | |
| specific_amount | numeric(18,4) | nullable | |
| valid_from | date | NOT NULL | |
| valid_to | date | nullable | |
### `ref_document_type`
*Source:* `src: S013, S003, S008`
*Purpose:* Attached/supporting document types (SAD box 44, S003; S008 attached_document_code).
| Column | Type | Null | Notes |
|--------|------|------|-------|
| id | bigint | NOT NULL | |
| code | character varying(6) | NOT NULL | |
| name | character varying(120) | NOT NULL | |
### `ref_exemption_code`
*Source:* `src: S013, S003`
*Purpose:* Additional National Codes granting special duty/tax treatment (SAD box 37 national procedure, S003).
| Column | Type | Null | Notes |
|--------|------|------|-------|
| id | bigint | NOT NULL | |
| code | character varying(6) | NOT NULL | |
| name | character varying(120) | NOT NULL | |
| description | character varying(300) | nullable | |
### `ref_declaration_type`
*Source:* `src: S013, S003`
*Purpose:* Declaration type codes (SAD box 1, S003): first letters model, digit = extended procedure.
| Column | Type | Null | Notes |
|--------|------|------|-------|
| id | bigint | NOT NULL | |
| code | character varying(4) | NOT NULL | |
| name | character varying(80) | NOT NULL | |
| direction | character varying(10) | NOT NULL | |
### `ref_declaration_status`
*Source:* `src: S014, S002`
*Purpose:* Declaration lifecycle: stored->registered->assessed->paid->released (+queried/cancelled) (S002).
| Column | Type | Null | Notes |
|--------|------|------|-------|
| id | bigint | NOT NULL | |
| code | character varying(15) | NOT NULL | |
| name | character varying(60) | NOT NULL | |
| sort_order | smallint | NOT NULL | |
### `ref_manifest_status`
*Source:* `src: S015`
*Purpose:* Manifest lifecycle (stored/registered/amended/closed); inferred from S006/S011 workflow.
| Column | Type | Null | Notes |
|--------|------|------|-------|
| id | bigint | NOT NULL | |
| code | character varying(15) | NOT NULL | |
| name | character varying(60) | NOT NULL | |
| sort_order | smallint | NOT NULL | |
### `ref_bl_nature`
*Source:* `src: S013, S008`
*Purpose:* Transport-document nature codes (S008 Bol_nature).
| Column | Type | Null | Notes |
|--------|------|------|-------|
| id | bigint | NOT NULL | |
| code | character varying(2) | NOT NULL | |
| name | character varying(60) | NOT NULL | |
### `ref_selectivity_lane`
*Source:* `src: S014, S002, S005`
*Purpose:* Selectivity lanes green/yellow/red/blue (S002, S005).
| Column | Type | Null | Notes |
|--------|------|------|-------|
| id | bigint | NOT NULL | |
| code | character varying(6) | NOT NULL | |
| name | character varying(40) | NOT NULL | |
| requires_exam | boolean | NOT NULL | |
| description | character varying(200) | nullable | |
## Module: TRADERS & SYSTEM USERS (GOAL §4.1)
### `trader`
*Source:* `src: S013, S003, S008`
*Purpose:* Economic operators: importer/exporter/consignee/declarant/broker/carrier, keyed by TIN (S003, S008).
| Column | Type | Null | Notes |
|--------|------|------|-------|
| id | bigint | NOT NULL | |
| tin | character varying(20) | NOT NULL | |
| name | character varying(140) | NOT NULL | |
| address | character varying(200) | nullable | |
| country_id | bigint | nullable | |
| is_active | boolean | NOT NULL | |
### `trader_role`
*Source:* `inferred`
*Purpose:* Roles a trader may act in; inferred normalisation of the single trader/economic-operator concept.
| Column | Type | Null | Notes |
|--------|------|------|-------|
| id | bigint | NOT NULL | |
| trader_id | bigint | NOT NULL | |
| role | character varying(20) | NOT NULL | |
### `sys_user`
*Source:* `src: S002`
*Purpose:* System users (customs staff, brokers, traders); registration required (S002).
| Column | Type | Null | Notes |
|--------|------|------|-------|
| id | bigint | NOT NULL | |
| login_name | character varying(40) | NOT NULL | |
| full_name | character varying(120) | NOT NULL | |
| trader_id | bigint | nullable | |
| office_id | bigint | nullable | |
| is_active | boolean | NOT NULL | |
### `sys_role`
*Source:* `inferred`
*Purpose:* RBAC roles; inferred (ASYCUDA World has role-based menus but no public schema).
| Column | Type | Null | Notes |
|--------|------|------|-------|
| id | bigint | NOT NULL | |
| code | character varying(30) | NOT NULL | |
| name | character varying(80) | NOT NULL | |
### `sys_permission`
*Source:* `inferred`
*Purpose:* RBAC permissions; inferred.
| Column | Type | Null | Notes |
|--------|------|------|-------|
| id | bigint | NOT NULL | |
| code | character varying(40) | NOT NULL | |
| name | character varying(120) | NOT NULL | |
### `sys_user_role`
*Source:* `inferred`
| Column | Type | Null | Notes |
|--------|------|------|-------|
| user_id | bigint | NOT NULL | |
| role_id | bigint | NOT NULL | |
### `sys_role_permission`
*Source:* `inferred`
| Column | Type | Null | Notes |
|--------|------|------|-------|
| role_id | bigint | NOT NULL | |
| permission_id | bigint | NOT NULL | |
## Module: MANIFEST / CARGO (GOAL §4.2)
### `manifest`
*Source:* `src: S015, S008, S006`
*Purpose:* Cargo manifest general segment: carrier, voyage, ports, dates, totals, office (S008, S006).
| Column | Type | Null | Notes |
|--------|------|------|-------|
| id | bigint | NOT NULL | |
| office_id | bigint | NOT NULL | |
| manifest_year | smallint | NOT NULL | |
| registration_number | integer | nullable | |
| voyage_number | character varying(17) | NOT NULL | |
| transport_mode_id | bigint | NOT NULL | |
| identity_of_transport | character varying(40) | nullable | |
| nationality_id | bigint | nullable | |
| registration_ref | character varying(35) | nullable | |
| master_name | character varying(70) | nullable | |
| carrier_id | bigint | nullable | |
| shipping_agent_id | bigint | nullable | |
| place_departure_id | bigint | nullable | |
| place_destination_id | bigint | nullable | |
| date_of_departure | date | nullable | |
| date_of_arrival | date | nullable | |
| date_of_last_discharge | date | nullable | |
| total_bols | integer | nullable | |
| total_packages | numeric(18,3) | nullable | |
| total_containers | integer | nullable | |
| total_gross_mass | numeric(18,3) | nullable | |
| tonnage_net | numeric(18,3) | nullable | |
| tonnage_gross | numeric(18,3) | nullable | |
| status_id | bigint | nullable | |
| created_at | timestamp with time zone | NOT NULL | |
### `bill_of_lading`
*Source:* `src: S015, S008, S006`
*Purpose:* Transport document (B/L/AWB). House B/L = a consignment; master_bl_id models consolidation/degroupage (S008, S006, S010).
| Column | Type | Null | Notes |
|--------|------|------|-------|
| id | bigint | NOT NULL | |
| manifest_id | bigint | NOT NULL | |
| line_number | integer | NOT NULL | |
| bl_reference | character varying(35) | NOT NULL | |
| bl_nature_id | bigint | NOT NULL | |
| bl_type_code | character varying(3) | nullable | |
| is_master | boolean | NOT NULL | |
| master_bl_id | bigint | nullable | |
| exporter_id | bigint | nullable | |
| consignee_id | bigint | nullable | |
| notify_id | bigint | nullable | |
| place_loading_id | bigint | nullable | |
| place_unloading_id | bigint | nullable | |
| number_of_packages | numeric(18,3) | nullable | |
| package_type_id | bigint | nullable | |
| gross_mass | numeric(18,3) | nullable | |
| goods_description | character varying(2000) | nullable | |
| volume_m3 | numeric(18,3) | nullable | |
| freight_indicator | character varying(2) | nullable | |
| freight_value | numeric(18,4) | nullable | |
| freight_currency_id | bigint | nullable | |
| customs_value | numeric(18,4) | nullable | |
| insurance_value | numeric(18,4) | nullable | |
### `container`
*Source:* `src: S015, S008`
*Purpose:* Containers per B/L; reference/size-type follow ISO 6346 (S008 ctn_segment).
| Column | Type | Null | Notes |
|--------|------|------|-------|
| id | bigint | NOT NULL | |
| bl_id | bigint | NOT NULL | |
| ctn_reference | character varying(17) | NOT NULL | |
| container_type_id | bigint | nullable | |
| number_of_packages | integer | nullable | |
| empty_full | character varying(10) | nullable | |
| seal1 | character varying(10) | nullable | |
| seal2 | character varying(10) | nullable | |
| empty_weight | numeric(18,3) | nullable | |
| goods_weight | numeric(18,3) | nullable | |
| volume_m3 | numeric(18,3) | nullable | |
| dangerous_goods_code | character varying(10) | nullable | |
| goods_description | character varying(500) | nullable | |
### `manifest_cargo_item`
*Source:* `src: S015, S008`
*Purpose:* Goods/commodity lines within a transport document (S008 Goods_segment/Commodity_Segment).
| Column | Type | Null | Notes |
|--------|------|------|-------|
| id | bigint | NOT NULL | |
| bl_id | bigint | NOT NULL | |
| line_number | integer | NOT NULL | |
| hs_code | character varying(6) | nullable | |
| goods_description | character varying(500) | NOT NULL | |
| number_of_packages | numeric(18,3) | nullable | |
| package_type_id | bigint | nullable | |
| gross_mass | numeric(18,3) | nullable | |
| container_id | bigint | nullable | |
### `manifest_status_history`
*Source:* `inferred`
*Purpose:* Manifest status transitions; inferred lifecycle table.
| Column | Type | Null | Notes |
|--------|------|------|-------|
| id | bigint | NOT NULL | |
| manifest_id | bigint | NOT NULL | |
| status_id | bigint | NOT NULL | |
| changed_at | timestamp with time zone | NOT NULL | |
| changed_by | bigint | nullable | |
| note | character varying(200) | nullable | |
## Module: DECLARATION (THE SAD) (GOAL §4.3)
### `declaration`
*Source:* `src: S014, S003, S001`
*Purpose:* SAD general segment — one per consignment: parties, regime, transport, invoice totals, status, selectivity (S003 boxes 1-49/B, S001).
| Column | Type | Null | Notes |
|--------|------|------|-------|
| id | bigint | NOT NULL | |
| office_id | bigint | NOT NULL | |
| declaration_type_id | bigint | NOT NULL | |
| cpc_id | bigint | nullable | |
| registration_serial | character varying(2) | nullable | |
| registration_number | integer | nullable | |
| registration_date | date | nullable | |
| trader_reference | character varying(35) | nullable | |
| exporter_id | bigint | nullable | |
| consignee_id | bigint | nullable | |
| declarant_id | bigint | nullable | |
| financial_id | bigint | nullable | |
| country_export_id | bigint | nullable | |
| country_origin_id | bigint | nullable | |
| country_destination_id | bigint | nullable | |
| country_last_consign_id | bigint | nullable | |
| trading_country_id | bigint | nullable | |
| incoterm_id | bigint | nullable | |
| delivery_place | character varying(120) | nullable | |
| transport_mode_border_id | bigint | nullable | |
| transport_mode_inland_id | bigint | nullable | |
| border_transport_identity | character varying(60) | nullable | |
| place_of_discharge_id | bigint | nullable | |
| total_items | smallint | nullable | |
| total_packages | numeric(18,3) | nullable | |
| currency_id | bigint | nullable | |
| total_invoice_amount | numeric(18,4) | nullable | |
| exchange_rate | numeric(18,6) | nullable | |
| total_freight | numeric(18,4) | nullable | |
| total_insurance | numeric(18,4) | nullable | |
| total_cif_value | numeric(18,4) | nullable | |
| selectivity_lane_id | bigint | nullable | |
| status_id | bigint | NOT NULL | |
| assessment_number | character varying(20) | nullable | |
| assessment_date | date | nullable | |
| manifest_id | bigint | nullable | |
| created_by | bigint | nullable | |
| created_at | timestamp with time zone | NOT NULL | |
### `declaration_item`
*Source:* `src: S014, S003`
*Purpose:* SAD item segment (boxes 31-49): commodity, origin, mass, procedure, valuation, statistical value = tax base (S003).
| Column | Type | Null | Notes |
|--------|------|------|-------|
| id | bigint | NOT NULL | |
| declaration_id | bigint | NOT NULL | |
| item_number | smallint | NOT NULL | |
| hs_id | bigint | nullable | |
| hs_code | character varying(12) | NOT NULL | |
| goods_description | character varying(500) | nullable | |
| country_origin_id | bigint | nullable | |
| cpc_id | bigint | nullable | |
| national_procedure | character varying(3) | nullable | |
| exemption_id | bigint | nullable | |
| preference_code | character varying(3) | nullable | |
| number_of_packages | numeric(18,3) | nullable | |
| package_type_id | bigint | nullable | |
| marks_and_numbers | character varying(200) | nullable | |
| container_reference | character varying(17) | nullable | |
| gross_mass | numeric(18,3) | nullable | |
| net_mass | numeric(18,3) | nullable | |
| supplementary_qty | numeric(18,3) | nullable | |
| supplementary_uom_id | bigint | nullable | |
| item_price | numeric(18,4) | nullable | |
| valuation_method_code | character varying(3) | nullable | |
| adjustment_indicator | numeric(9,4) | nullable | |
| statistical_value | numeric(18,4) | nullable | |
| customs_value | numeric(18,4) | nullable | |
| quota | character varying(20) | nullable | |
| warehouse_id | bigint | nullable | |
| warehouse_days | integer | nullable | |
### `valuation_note`
*Source:* `src: S003, S017`
*Purpose:* Declaration-level value build-up: freight + insurance + other -> CIF customs value (S003 valuation note).
| Column | Type | Null | Notes |
|--------|------|------|-------|
| id | bigint | NOT NULL | |
| declaration_id | bigint | NOT NULL | |
| invoice_currency_id | bigint | nullable | |
| total_invoice_fob | numeric(18,4) | nullable | |
| external_freight | numeric(18,4) | nullable | |
| internal_freight | numeric(18,4) | nullable | |
| insurance | numeric(18,4) | nullable | |
| other_costs | numeric(18,4) | nullable | |
| total_cif | numeric(18,4) | nullable | |
### `item_value_note`
*Source:* `src: S014, S003`
*Purpose:* Freight/insurance apportioned per item to produce item CIF, the tax base (S003 valuation note; box 46).
| Column | Type | Null | Notes |
|--------|------|------|-------|
| id | bigint | NOT NULL | |
| declaration_item_id | bigint | NOT NULL | |
| item_fob | numeric(18,4) | nullable | |
| apportioned_freight | numeric(18,4) | nullable | |
| apportioned_insurance | numeric(18,4) | nullable | |
| apportioned_other | numeric(18,4) | nullable | |
| item_cif | numeric(18,4) | nullable | |
### `declaration_tax_line`
*Source:* `src: S014, S003`
*Purpose:* Per-item per-tax calculation: base, rate, amount, mode of payment (SAD box 47, S003).
| Column | Type | Null | Notes |
|--------|------|------|-------|
| id | bigint | NOT NULL | |
| declaration_item_id | bigint | NOT NULL | |
| tax_type_id | bigint | NOT NULL | |
| tax_base | numeric(18,4) | NOT NULL | |
| rate_percent | numeric(9,4) | nullable | |
| specific_amount | numeric(18,4) | nullable | |
| tax_amount | numeric(18,4) | NOT NULL | |
| mode_of_payment | character varying(6) | nullable | |
| is_manual | boolean | NOT NULL | |
### `declaration_attached_document`
*Source:* `src: S014, S003, S008`
*Purpose:* Attached/supporting documents at header or item level (SAD box 44, S003; S008).
| Column | Type | Null | Notes |
|--------|------|------|-------|
| id | bigint | NOT NULL | |
| declaration_id | bigint | NOT NULL | |
| declaration_item_id | bigint | nullable | |
| document_type_id | bigint | NOT NULL | |
| document_reference | character varying(60) | nullable | |
| document_date | date | nullable | |
### `declaration_previous_document`
*Source:* `src: S014, S003, S008`
*Purpose:* SAD box 40: links items to the manifest B/L or a previous declaration (write-off) (S003, S008).
| Column | Type | Null | Notes |
|--------|------|------|-------|
| id | bigint | NOT NULL | |
| declaration_id | bigint | NOT NULL | |
| declaration_item_id | bigint | nullable | |
| bl_id | bigint | nullable | |
| prev_declaration_id | bigint | nullable | |
| reference | character varying(60) | nullable | |
| written_off_packages | numeric(18,3) | nullable | |
| written_off_mass | numeric(18,3) | nullable | |
### `declaration_status_history`
*Source:* `src: S014, S002`
*Purpose:* Declaration status transitions over its lifecycle (S002).
| Column | Type | Null | Notes |
|--------|------|------|-------|
| id | bigint | NOT NULL | |
| declaration_id | bigint | NOT NULL | |
| status_id | bigint | NOT NULL | |
| changed_at | timestamp with time zone | NOT NULL | |
| changed_by | bigint | nullable | |
| note | character varying(200) | nullable | |
## Module: SELECTIVITY / RISK (GOAL §4.6)
### `risk_criterion`
*Source:* `src: S014`
*Purpose:* Risk/selectivity criteria driving lane assignment; inferred (S002 references criteria, no public schema).
| Column | Type | Null | Notes |
|--------|------|------|-------|
| id | bigint | NOT NULL | |
| code | character varying(20) | NOT NULL | |
| name | character varying(120) | NOT NULL | |
| target_lane_id | bigint | nullable | |
| is_active | boolean | NOT NULL | |
### `selectivity_result`
*Source:* `src: S014, S002, S005`
*Purpose:* Lane assigned to a declaration when selectivity is triggered (S002, S005).
| Column | Type | Null | Notes |
|--------|------|------|-------|
| id | bigint | NOT NULL | |
| declaration_id | bigint | NOT NULL | |
| lane_id | bigint | NOT NULL | |
| criterion_id | bigint | nullable | |
| triggered_at | timestamp with time zone | NOT NULL | |
| officer_id | bigint | nullable | |
| note | character varying(300) | nullable | |
### `inspection_act`
*Source:* `src: S014, S002, S005`
*Purpose:* Inspection/examination act for red/yellow declarations, feeding release (S002, S005).
| Column | Type | Null | Notes |
|--------|------|------|-------|
| id | bigint | NOT NULL | |
| declaration_id | bigint | NOT NULL | |
| officer_id | bigint | nullable | |
| inspected_at | timestamp with time zone | nullable | |
| result | character varying(20) | nullable | |
| findings | character varying(1000) | nullable | |
## Module: ACCOUNTING (GOAL §4.4)
### `account`
*Source:* `src: S016, S003`
*Purpose:* Trader deferred-payment/credit/prepayment accounts (SAD box 48, S003).
| Column | Type | Null | Notes |
|--------|------|------|-------|
| id | bigint | NOT NULL | |
| account_number | character varying(20) | NOT NULL | |
| trader_id | bigint | nullable | |
| account_type | character varying(20) | NOT NULL | |
| currency_id | bigint | nullable | |
| balance | numeric(18,4) | NOT NULL | |
| is_active | boolean | NOT NULL | |
### `payment`
*Source:* `src: S016, S003, S005`
*Purpose:* Payment of assessed amount, cash or against an account (S003 box B, S005).
| Column | Type | Null | Notes |
|--------|------|------|-------|
| id | bigint | NOT NULL | |
| declaration_id | bigint | NOT NULL | |
| account_id | bigint | nullable | |
| amount | numeric(18,4) | NOT NULL | |
| currency_id | bigint | nullable | |
| mode_of_payment | character varying(20) | NOT NULL | |
| paid_at | timestamp with time zone | NOT NULL | |
| paid_by | bigint | nullable | |
### `receipt`
*Source:* `src: S016, S003`
*Purpose:* Receipt issued on payment of a declaration (SAD box B, S003).
| Column | Type | Null | Notes |
|--------|------|------|-------|
| id | bigint | NOT NULL | |
| payment_id | bigint | NOT NULL | |
| receipt_number | character varying(20) | NOT NULL | |
| receipt_date | date | NOT NULL | |
| total_amount | numeric(18,4) | NOT NULL | |
### `account_movement`
*Source:* `src: S016`
*Purpose:* Ledger movements against an account; inferred.
| Column | Type | Null | Notes |
|--------|------|------|-------|
| id | bigint | NOT NULL | |
| account_id | bigint | NOT NULL | |
| payment_id | bigint | nullable | |
| movement_type | character varying(10) | NOT NULL | |
| amount | numeric(18,4) | NOT NULL | |
| balance_after | numeric(18,4) | nullable | |
| moved_at | timestamp with time zone | NOT NULL | |
### `guarantee`
*Source:* `src: S019, S003`
*Purpose:* Security/guarantee for suspense regimes (SAD box 52 references it, S003); table shape inferred.
| Column | Type | Null | Notes |
|--------|------|------|-------|
| id | bigint | NOT NULL | |
| reference | character varying(30) | NOT NULL | |
| trader_id | bigint | nullable | |
| guarantee_type | character varying(20) | nullable | |
| amount | numeric(18,4) | NOT NULL | |
| currency_id | bigint | nullable | |
| valid_from | date | nullable | |
| valid_to | date | nullable | |
| is_active | boolean | NOT NULL | |
## Module: TRANSIT & SUSPENSE (GOAL §4.5)
### `transit_declaration`
*Source:* `src: S014, S019, S003`
*Purpose:* Transit declaration extension: principal, offices, guarantee, itinerary (SAD boxes 50-53, S003).
| Column | Type | Null | Notes |
|--------|------|------|-------|
| id | bigint | NOT NULL | |
| declaration_id | bigint | NOT NULL | |
| principal_id | bigint | nullable | |
| office_departure_id | bigint | nullable | |
| office_transit_id | bigint | nullable | |
| office_destination_id | bigint | nullable | |
| guarantee_id | bigint | nullable | |
| itinerary | character varying(300) | nullable | |
| seals_affixed | character varying(200) | nullable | |
| time_limit_date | date | nullable | |
### `ref_warehouse`
*Source:* `src: S013, S019, S003`
*Purpose:* Customs/bonded warehouses (SAD box 49 warehouse identification, S003).
| Column | Type | Null | Notes |
|--------|------|------|-------|
| id | bigint | NOT NULL | |
| code | character varying(10) | NOT NULL | |
| name | character varying(120) | NOT NULL | |
| office_id | bigint | nullable | |
| keeper_id | bigint | nullable | |
| is_active | boolean | NOT NULL | |
### `warehouse_entry`
*Source:* `src: S014, S019`
*Purpose:* Goods placed under warehousing regime; inferred from S003 box 49 suspense.
| Column | Type | Null | Notes |
|--------|------|------|-------|
| id | bigint | NOT NULL | |
| warehouse_id | bigint | NOT NULL | |
| declaration_id | bigint | nullable | |
| entry_date | date | NOT NULL | |
| packages | numeric(18,3) | nullable | |
| gross_mass | numeric(18,3) | nullable | |
### `warehouse_exit`
*Source:* `src: S014, S019`
*Purpose:* Removal of goods from warehouse (write-off); inferred.
| Column | Type | Null | Notes |
|--------|------|------|-------|
| id | bigint | NOT NULL | |
| warehouse_entry_id | bigint | NOT NULL | |
| declaration_id | bigint | nullable | |
| exit_date | date | NOT NULL | |
| packages | numeric(18,3) | nullable | |
| gross_mass | numeric(18,3) | nullable | |
### `temporary_admission`
*Source:* `src: S019`
*Purpose:* Temporary admission suspense regime with time limit and re-export link; inferred (S003 box 49).
| Column | Type | Null | Notes |
|--------|------|------|-------|
| id | bigint | NOT NULL | |
| declaration_id | bigint | NOT NULL | |
| guarantee_id | bigint | nullable | |
| time_limit_date | date | nullable | |
| re_export_declaration_id | bigint | nullable | |
## Module: AUDIT / WORKFLOW (cross-cutting) (GOAL §4.7)
### `audit_log`
*Source:* `src: S013`
*Purpose:* Cross-cutting who/what/when audit trail; inferred.
| Column | Type | Null | Notes |
|--------|------|------|-------|
| id | bigint | NOT NULL | |
| entity_name | character varying(60) | NOT NULL | |
| entity_id | bigint | nullable | |
| action | character varying(20) | NOT NULL | |
| actor_id | bigint | nullable | |
| occurred_at | timestamp with time zone | NOT NULL | |
| detail | character varying(1000) | nullable | |
---
# The ASYCUDA platform
The [Schema](../schema/index.md) section documents **our reconstruction**. This
section documents **ASYCUDA itself** — the software the reconstruction models —
so you can place the toolbox against the real system you are working with:
which generation it is, how its data leaves the system, and where an external
system can plug in.
!!! success "Which version does this toolbox model?"
**ASYCUDA World (v4)** — the current standard, used by 100+ countries. The
schema is grounded in public UNCTAD/ASYCUDA programme table descriptions plus
ASYCUDA World national manuals and message specs (see
[Sources](../provenance/sources.md)). If your deployment is ASYCUDA World,
this model is a direct reference; if it is ASYCUDA++ or ASY5, see the
[version lineage](versions.md) for what changes.
## Which version do *you* have?
Three quick fingerprints (details on the [versions page](versions.md)):
| If you see… | You are on |
|-------------|-----------|
| A **Java** client launched via `.jnlp` (Java Web Start), declarations exported as **XML** files | **ASYCUDA World (v4)** — what this toolbox models |
| A **C++** thick client, **UN/EDIFACT** messages (CUSDEC/CUSRES), Oracle/Informix/Sybase | **ASYCUDA++ (v3)** — legacy, same SAD data model, different exchange layer |
| Cloud-native microservices (Quarkus, Kubernetes, Kafka), REST/event APIs | **ASY5 / New Generation** — phased rollout (Angola live Jan 2026) |
The SAD declaration model — general segment + item segments — is stable across
++ and World, so the [schema](../schema/index.md) remains a valid reference for
both; what changes between generations is the technology and the integration
surfaces.
## Explore
- :material-timeline-clock:{ .lg .middle } **Version lineage**
---
Five generations from 1981 to ASY5 — what each changed, and how to
fingerprint the one in front of you.
[:octicons-arrow-right-24: Versions](versions.md)
- :material-earth:{ .lg .middle } **ASYCUDA World**
---
The modeled version in depth: the XML e-document model, the client, the
closed protocol, and how our schema relates to the real one.
[:octicons-arrow-right-24: ASYCUDA World](asycuda-world.md)
- :material-xml:{ .lg .middle } **XML messages & wire format**
---
The SAD Box → XML tag → toolbox column map, the `
` wire-format
gotcha, the AWMDS manifest stream, WCO DM and EDIFACT.
[:octicons-arrow-right-24: XML messages](xml-messages.md)
- :material-connection:{ .lg .middle } **Integration surfaces**
---
The doors that actually exist — RDBMS/ETL, ASYHUB, Cargo-XML, XML import,
ASY5 — and the specs you must request.
[:octicons-arrow-right-24: Integration](integration.md)
- :material-traffic-light:{ .lg .middle } **Selectivity & clearance**
---
The clearance state machine (C/L/PRN serials) and the four-lane risk model,
mapped to our tables.
[:octicons-arrow-right-24: Selectivity & clearance](selectivity-clearance.md)
- :material-book-open-variant:{ .lg .middle } **Further reading**
---
The curated public-document map — official manuals, national field guides,
real XML samples, ML datasets — and what is restricted.
[:octicons-arrow-right-24: Resources](resources.md)
## Doing ML on customs data?
That is what this platform knowledge is for: the
[ML risk-engine guide](../guides/ml-risk-engine.md) turns it into a working
blueprint — features, labels, the lane loop, and how to prototype on this
toolbox's schema before you have access to a live system.
---
Platform facts on these pages are distilled from the public ASYCUDA record —
UNCTAD programme documents, national customs manuals, recovered schemas and real
declaration samples. Where something is not publicly documented — the physical DB
schema, the ASYHUB API — these pages say so plainly rather than guessing.
---
# Version lineage
ASYCUDA is one system with three names and five generations. The name localises
by language — **ASYCUDA** (EN) = **SYDONIA** (FR) = **SIDUNEA** (ES) — but the
software family is the same. UNCTAD has shipped it since 1981, and every country
that runs it runs one of these generations.
Version matters because it changes two things you actually care about: the
**data model** (how a declaration is stored and structured) and the
**integration surfaces** (how, or whether, you can read and write data from
outside). A tool built for ASYCUDA World's XML e-documents does not map onto
ASYCUDA++'s EDIFACT-and-Oracle world, and neither maps onto the cloud-native
microservices of the new generation. Identify the generation before you assume
anything about the schema.
## The five generations
```mermaid
flowchart LR
V1["v1 · 1981
trade statistics"]
V2["v2 / v2.7 · 1985–95
full clearance"]
V3["ASYCUDA++ v3 · 1992+
C++ · EDIFACT · legacy"]
V4["ASYCUDA World v4 · 2002+
Java · XML · current standard"]
V5["ASY5 / New Generation · 2024–26
cloud-native · Angola live Jan 2026"]
V1 --> V2 --> V3 --> V4 --> V5
style V4 fill:#d9770622,stroke:#d97706
```
ASYCUDA World (v4) is the current standard — around **90%** of the 100+ using
countries and territories are on it or migrating to it. ASYCUDA++ is legacy but
still live in a few countries mid-migration; the new generation ("ASY5") is in
phased rollout, with **Angola live since January 2026**.
## Comparison across generations
| Version | Era | Tech stack | Database | Architecture | Data model | Who runs it |
|---|---|---|---|---|---|---|
| **v1** | 1981–84 | DOS-era micro | Flat files | Standalone micro | Trade-statistics capture | First ECOWAS countries — statistics only |
| **v2 / v2.7** | 1985–95 | C, DOS/UNIX | Flat-file / Informix | LAN client + file server | Full clearance | Many developing countries in the 1990s; some legacy holdouts |
| **ASYCUDA++ (v3)** | 1992–present (legacy) | **C++**, UN/EDIFACT | **Oracle / Informix / Sybase** | Thick client-server | Relational; EDIFACT (CUSDEC); MODSEL selectivity | Rolled out late-90s–2000s; a few countries still live, mid-migration |
| **ASYCUDA World (v4)** | 2002–present (**current standard**) | **100% Java**, XML documents, Java Web Start client | Oracle, MS SQL Server, MySQL, PostgreSQL, DB2, Sybase, Informix | Web / store-and-forward, N-tier | **XML e-document model**; SAD = general segment + item segments | 100+ countries/territories; ~90% on or migrating to it |
| **ASY5 / New Generation** | 2024–26 (Angola live Jan 2026) | Cloud-native microservices: **Quarkus, Kubernetes, Kafka, Keycloak, VueJS**; open-source | Cloud data platform | Modular microservices, REST / event | Data-driven, WCO-DM aligned; native + third-party AI risk | Phased rollout; Angola first |
## How to tell which version you have
The three current generations leave distinct fingerprints. If you know the
client and the wire format, you know the version.
- :material-language-java:{ .lg .middle } **ASYCUDA World (v4)**
---
100% Java. The client is a **Java Web Start thick client** — a `.jnlp`
launch file pulling ~130 JARs over HTTPS, main class
`so.kernel.client.DesktopMain`, connecting to the server on a custom TCP
port (e.g. `//host:2016/`). Business data moves as **non-namespaced XML
e-documents**. If you see a JNLP launcher and XML exports, it is v4.
- :material-language-cpp:{ .lg .middle } **ASYCUDA++ (v3)**
---
A **C++ thick client** in a client-server deployment, backed by a relational
**Oracle / Informix / Sybase** database. Data exchange is **UN/EDIFACT** —
the `CUSDEC` message is the declaration. Selectivity runs through the MODSEL
module. No XML e-documents, no Java client.
- :material-cloud-outline:{ .lg .middle } **ASY5 / New Generation**
---
**Cloud-native microservices** — Quarkus, Kubernetes, Kafka, Keycloak, a
VueJS front end — and open-source. Integration is REST and event-based
(Kafka topics), with a first-class hook for native and third-party AI risk
signals. In phased rollout; Angola went live in January 2026.
## Modules
Across ASYCUDA World and the new generation, functional modules extend the core
clearance system:
- **ASYHUB** — external-system data hub (an API for authorised entities).
- **ASYFCI** — Fast Cargo Integration (the cargo-manifest / AWMDS handler).
- **ASYADN** — advance data (pre-arrival manifest information).
- **ASYREC** — relief consignments (humanitarian).
- **eCITES** — endangered-species trade permits.
- **ASYCUDA Single Window** — connects partner government agencies for licences,
permits and certificates.
## An accuracy caveat
Not every developing country runs ASYCUDA. Region is not a reliable signal:
**Senegal never adopted SYDONIA** — it built its own **GAINDE** system — and some
countries run other platforms entirely. Confirm the target administration before
you assume it is on ASYCUDA at all.
!!! note "This toolbox models ASYCUDA World (v4)"
The Sydonia Toolkit reconstructs the data model of **ASYCUDA World
(v4)** specifically — the current standard, and the version whose field-level
model is public through its XML layer and national manuals. It does **not**
model the ASYCUDA++ relational schema or the ASY5 microservice data platform.
See [ASYCUDA World — the modeled version](asycuda-world.md) for what that
means for the schema, and [Sources](../provenance/sources.md) for the
official documents behind it.
---
# ASYCUDA World — the modeled version
This is the version the Sydonia Toolkit models. Everything in the
[Schema](../schema/index.md) tab is a reconstruction of **ASYCUDA World (v4)** —
so it is worth knowing what the real platform is, how it stores data, and where
the line falls between what is public and what is not.
## What ASYCUDA World is
ASYCUDA World has been UNCTAD's current standard since **2002**, running in
**100+ countries and territories**. It is **100% Java**, built as a **web /
store-and-forward N-tier** system. The store-and-forward design is deliberate:
the client does not need a permanent connection to the server, which is what
makes it workable in countries with weak or intermittent telecoms — a declaration
can be captured offline and forwarded when a connection returns.
It is database-agnostic. A national instance can run on **Oracle, MS SQL Server,
MySQL, PostgreSQL, DB2, Sybase, or Informix**. The application layer is the same
Java stack regardless of which database sits underneath.
## The XML e-document model
ASYCUDA World does not store business objects the way you might expect a
relational system to. Every business object — a declaration, a manifest, a
receipt — is a **non-namespaced XML document**. The Java client can **Export XML
File** and **Import XML File** for these documents, and this XML layer is the
public window onto the field-level model.
The declaration — the Single Administrative Document (SAD) — is structured as two
kinds of segment:
- A **general segment**: one per consignment, carrying parties, transport,
valuation totals and identification.
- Repeating **item segments**: one per commodity line, each with HS code, origin,
mass, value and taxes.
- Plus up to five **additional forms** extending the item detail.
Field values follow a small set of data types:
| Type | Meaning |
|------|---------|
| `INT` | integer |
| `N#` | decimal number |
| `AN#` | alphanumeric |
| `DATE` | date, `yyyy-MM-dd` |
| `TIME` | time, `hh:mm:ss` |
| `BOOLEAN` | true / false |
One declaration per file; multiple items per declaration; extra tags are ignored
on import. The mechanics of the wire format, the `` root and the
Box → tag map are covered in [XML messages](xml-messages.md).
## The client and its closed protocol
The ASYCUDA World client is a **Java Web Start thick client**: a `.jnlp` launch
file pulls roughly **130 JARs** over HTTPS, the main class is
`so.kernel.client.DesktopMain`, and it connects to the national server over a
**custom TCP port** (for example `//host:2016/`). That connection is a
**proprietary object/XML protocol — not SOAP, not REST.**
!!! warning "Do not integrate at the client protocol"
The client-to-server socket (`so.kernel`, port ~2016) is a closed,
proprietary protocol. It is not a supported integration surface, and building
against it is fragile and unsanctioned. Real integration happens elsewhere —
the RDBMS, ASYHUB, IATA Cargo-XML, or XML file import. See
[Integration surfaces](integration.md) for the doors that actually exist.
## What this toolbox models
The toolbox reconstructs the ASYCUDA World data model as a clean, normalised
PostgreSQL schema. It is grounded in public UNCTAD/ASYCUDA programme table
descriptions covering the four table families:
| Family | Covers |
|--------|--------|
| Reference tables | the `UN*` code-table catalogue |
| Declaration tables | `SAD_General_Segment`, `SAD_Item`, `SAD_Tax`, selectivity, inspection… |
| Manifest tables | `GEN_TAB`, `BOL_TAB`, containers, transit |
| Accounting tables | receipts, `TAX_TAB`, account transactions |
See [Sources](../provenance/sources.md) for the full registry.
There is a deliberate difference between the official model and ours. ASYCUDA
World's **official physical schema is wide and denormalised** — and it is **not
published** by UNCTAD. What this toolbox provides is an **information-equivalent
reference**: a normalised model that carries the same field-level information,
restructured to be readable and queryable. It is not a byte-for-byte copy of the
real tables; it is a faithful reconstruction of what they hold.
The table-by-table mapping between our reconstruction and the official tables —
including the deliberate structural differences — is in
[Official fit & gap](../provenance/fit.md), and the schema itself starts at the
[Schema overview](../schema/index.md).
## Where the public docs live
The field-level model is public *only through the XML layer and national
manuals* — never as a published database schema. The documentation lives in a few
predictable places:
- **Programme materials:** the public UNCTAD/ASYCUDA introductory and SAD
processing manuals and general platform documentation.
- **National customs portals:** each country documents its own instance. These
SAD field guides and broker manuals are the richest public window onto the
model.
- **Restricted (member customs only):** gated learning platforms, credentialed
developer repositories and the core functional and technical reference manuals.
Request access through a national customs ASYCUDA project team or the UNCTAD
ASYCUDA programme.
More on the shape of this landscape is on the [Resources](resources.md) page.
## Next
- [XML messages](xml-messages.md) — the `` document format and the
Box → tag map.
- [Integration surfaces](integration.md) — the real doors for reading and
writing ASYCUDA data.
- [Selectivity & clearance](selectivity-clearance.md) — the risk lanes and the
clearance state machine.
---
# XML messages & the wire format
In ASYCUDA World (v4) every business object — a declaration, a cargo manifest —
is an **XML e-document**. The Java client's *Export XML File* / *Import XML File*
buttons round-trip these files, and XML is the lingua franca between AW and any
system that speaks the WCO Data Model.
That matters here for one reason: **UNCTAD does not publish ASYCUDA's physical
database schema.** The field-level model is public *only through the XML layer*
(plus national customs manuals). So the XML is the one public window into the
same fields this toolbox reconstructs as PostgreSQL tables — which is why the
three-way map below (SAD box → AW XML tag → our column) is the core of this page.
!!! warning "Spec vs. reality — the wire-format trap"
The idealised UNCTAD specification documents a tree rooted at `` /
`` / ``. **Real exported/imported AW instances use a different
root and naming** — verified identical across recovered schema definitions,
national sample XMLs, and independent integrator codebases:
- Root element is **``** (not ``).
- Adds top-level ``, ``, ``,
and **``** (holding `Sad_flow` I/E, ``, ``,
`Place_of_declaration`, `Date_of_declaration`).
- The item tariff block is **``** (not ``).
- `` carries ``, ``, ``
(each with `Serial_number` / `Number` / `Date`).
**Practical rule:** use the **spec for field semantics/formats** and a
**real sample for exact structure**. Target the ``-rooted format —
take the exact structure from a real exported declaration or a recovered
schema definition rather than from the idealised spec.
Every declaration has **two segments**: a **general segment** (consignment-level
— parties, transport, valuation totals) and repeating **item segments** (one per
commodity — HS code, origin, value, quantity, taxes). Both map cleanly onto our
[`declaration`](../schema/declaration.md) and `declaration_item` tables.
## The three-way field map
Columns: **SAD box** · **field** · **AW XML tag** (spec naming) · **Open Customs
Toolbox column** in the [`asycuda`](../schema/data-dictionary.md) schema. Where
our reconstruction has no counterpart, the cell reads **—**.
### General segment (consignment level)
| SAD box | Field | AW XML tag | Toolbox column |
|:-------:|-------|------------|----------------|
| A | Customs office | `Identification/Office_segment/Customs_clearance_office_code` | `declaration.office_id` |
| A | Registration identity | `Identification/Registration` (Serial/Number/Date) | `declaration.registration_serial` · `registration_number` · `registration_date` |
| 1 | Declaration type / procedure | `Identification/Type/Type_of_declaration`, `General_procedure_code` | `declaration.declaration_type_id` |
| 2 | Exporter / consignor | `Traders/Exporter/Exporter_code`, `Exporter_name` | `declaration.exporter_id` |
| 3 | Forms (X of Y) | — | — |
| 5 | Total items | (count of `- `) | `declaration.total_items` |
| 6 | Total packages | `Identification/Total_number_of_packages` | `declaration.total_packages` |
| 7 | Declarant reference (LRN/UCR) | `Declarant/Reference/Number` | `declaration.trader_reference` |
| 8 | Importer / consignee | `Traders/Consignee/Consignee_code`, `Consignee_name` | `declaration.consignee_id` |
| 9 | Financial-settlement party | `Traders/Financial/Financial_code` | `declaration.financial_id` |
| 10 | Country of last consignment | `General_information/Country/...` | `declaration.country_last_consign_id` |
| 11 | Trading country | `General_information/Country/Trading_country` | `declaration.trading_country_id` |
| 12 | Value details | `General_information/Value_details` | — |
| 14 | Declarant / representative | `Declarant/Declarant_code`, `Declarant_representative` | `declaration.declarant_id` |
| 15 | Country of dispatch/export | `General_information/Country/Export/Export_country_code` | `declaration.country_export_id` |
| 16 | Country of origin (header) | `General_information/Country/...` | `declaration.country_origin_id` |
| 17 | Country of destination | `General_information/Country/Destination/Destination_country_code` | `declaration.country_destination_id` |
| 18 | Transport identity & nationality (dep/arr) | `Transport/Means_of_transport/Departure_arrival_information/Identity`, `Nationality` | `declaration.border_transport_identity` |
| 19 | Container flag (0/1) | `Transport/Container_flag` | — |
| 20 | Delivery terms (INCOTERMS) | `Transport/Delivery_terms/Code`, `Place` | `declaration.incoterm_id` · `delivery_place` |
| 21 | Border transport identity & nationality | `Transport/Means_of_transport/Border_information/Identity`, `Nationality` | `declaration.border_transport_identity` |
| 22 | Currency & total invoiced | `Valuation/Invoice/Currency_code`, `Amount_foreign_currency` | `declaration.currency_id` · `total_invoice_amount` |
| 23 | Exchange rate (system-filled) | `Currency_rate` | `declaration.exchange_rate` |
| 24 | Nature of transaction | `Financial/Financial_transaction/Code_1`, `Code_2` | — |
| 25 | Border mode of transport | `Transport/.../Border_information/Mode` | `declaration.transport_mode_border_id` |
| 26 | Inland mode | `Transport/.../Inland_mode_of_transport` | `declaration.transport_mode_inland_id` |
| 27 | Place of loading/unloading | `Transport/Place_of_loading/Code`, `Name` | `declaration.place_of_discharge_id` |
| 28 | Financial & banking data | `Financial/Bank`, `Financial/Terms` | — |
| 29 | Office of entry/exit | `Transport/Border_office/Code` | `declaration.office_id` |
| 30 | Location of goods | `Transport/Location_of_goods` | — |
| B | Accounting (assessment) | `Identification/Assessment` (Serial/Number/Date) | `declaration.assessment_number` · `assessment_date` |
### Item segment (per commodity, repeats)
| SAD box | Field | AW XML tag | Toolbox column |
|:-------:|-------|------------|----------------|
| 31 | Packages & goods description | `Packages/Number_of_packages`, `Marks1/2_of_packages`, `Kind_of_packages_code` | `declaration_item.number_of_packages` · `package_type_id` · `marks_and_numbers` · `goods_description` |
| 32 | Item number | (position of `
- `) | `declaration_item.item_number` |
| 33 | Commodity code (HS) | `Tariff/Harmonized_system/Commodity_code`, `Precision_1..4` | `declaration_item.hs_id` · `hs_code` |
| 34 | Country of origin | `Goods_description/Country_of_origin_code` | `declaration_item.country_origin_id` |
| 35 | Gross mass (kg) | `Valuation_item/Weight/Gross_weight_itm` | `declaration_item.gross_mass` |
| 36 | Preference code | `Tariff/Preference_code` | `declaration_item.preference_code` |
| 37 | Procedure (CPC: extended + national) | `Tariff/Extended_customs_procedure`, `National_customs_procedure` | `declaration_item.cpc_id` · `national_procedure` |
| 38 | Net mass (kg) | `Valuation_item/Weight/Net_weight_itm` | `declaration_item.net_mass` |
| 39 | Quota | `Tariff/Quota/Quota_code` | `declaration_item.quota` |
| 40 | Previous document / summary declaration | `Previous_document/Summary_declaration`, `Previous_document_reference` | `declaration_previous_document.reference` (+ `bl_id` / `prev_declaration_id`) |
| 41 | Supplementary units | `Tariff/Supplementary_unit/Supplementary_unit_quantity`, `_code` | `declaration_item.supplementary_qty` · `supplementary_uom_id` |
| 42 | Item price | `Valuation_item/Invoice/Amount_foreign_currency`, `Currency_code` | `declaration_item.item_price` |
| 43 | Valuation method (WTO 1–6) | `Tariff/Valuation_method_code` | `declaration_item.valuation_method_code` |
| 44 | Additional info / documents | `Attached_documents/Attached_document`, `Additional_information/Licence_number` | `declaration_attached_document.*` |
| 45 | Adjustment factor | `Valuation_item/Rate_of_adjustment` | `declaration_item.adjustment_indicator` |
| 46 | Statistical / customs value | `Statistical_value` | `declaration_item.statistical_value` · `customs_value` |
| 47 | Calculation of taxes | `Taxation/Taxation_line` (see below) | `declaration_tax_line.*` |
| 48 | Deferred payment | `Financial/Deffered_payment_reference` | — |
| 49 | Warehouse identification | `Warehouse/Identification`, `Delay` | `declaration_item.warehouse_id` · `warehouse_days` |
!!! note "Transit & official boxes"
Transit boxes 50–53 + C/D map to `Transit/Principal`, `Financial/Guarantee`,
`Transit/Destination/Office`, `Transit/Signature` — see
[transit & suspense](../schema/transit-suspense.md). Box B (accounting) and
D/J (control results) are official/server-side; only the assessment identity
is reconstructed (`declaration.assessment_number`, `assessment_date`).
### The taxation-line sub-table (box 47)
Each `
- ` carries one `` plus one `` with repeating
``. Confirmed from real declaration XML and a recovered schema
definition:
| AW XML field | Meaning | Toolbox column |
|--------------|---------|----------------|
| `Duty_tax_code` | Tax type (DOG/DDI duty; TVA/TGC VAT; DA excise; RS statistical…) | `declaration_tax_line.tax_type_id` |
| `Duty_tax_Base` | Tax base | `declaration_tax_line.tax_base` |
| `Duty_tax_rate` | Rate (ad valorem, e.g. `0.2`) | `declaration_tax_line.rate_percent` |
| `Duty_tax_amount` | Computed amount | `declaration_tax_line.tax_amount` |
| `Duty_tax_MP` | Mode of payment (1 payable / 0 guaranteed) | `declaration_tax_line.mode_of_payment` |
| `Duty_tax_Type_of_calculation` | Calculation type | `declaration_tax_line.is_manual` (manual vs auto flag) |
| `Item_taxes_amount`, `Global_taxes` | Item / declaration totals | — (derived) |
!!! tip "Assessed lines are server-generated"
Computed taxation lines and the assigned selectivity lane are **generated
server-side at assessment** — they appear in the fuller CUSDEC/CUSRES export,
not in a trader's inbound import-XML. See
[selectivity & clearance](selectivity-clearance.md).
## Valuation build-up
Invoices are usually FOB but duty is charged on CIF, so AW carries a value
build-up. Each `` sub-element (`Invoice`, `External_freight`,
`Internal_freight`, `Insurance`, `Other_cost`, `Deduction`) carries an
`Amount_foreign_currency` + `Currency_code`; the declaration-level ``
adds `Calculation_working_mode` (0 = apportion per value / 1 = per weight /
2 = none). This maps directly onto our two value-note tables:
- `valuation_note` — declaration-level: `total_invoice_fob` + `external_freight`
+ `internal_freight` + `insurance` + `other_costs` → `total_cif`.
- `item_value_note` — per item: `item_fob` + `apportioned_freight` +
`apportioned_insurance` + `apportioned_other` → `item_cif` (the tax base).
See [declaration → valuation](../schema/declaration.md#valuation-building-the-tax-base)
for the worked split.
## Cargo manifest — AWMDS
The cargo manifest is a separate XML stream, handled by the **ASYFCI** module
(ASYCUDA Fast Cargo Integration) and validated by **`Awmds.xsd`**. Root element
**``** ("ASYCUDA World Manifest Data Stream"), two segments:
- **``** — voyage/office IDs, totals, transport info (carrier,
shipping agent), load/unload UN/LOCODEs, base64 ``
(RFC 2045), coloader, previous-manifest id → our
[`manifest`](../schema/manifest.md) header.
- **``** (1..∞, one per bill of lading) — `Bol_nature`
(22 = exports / 23 = imports / 24 = in-transit / 28 = transhipment), traders
(exporter / notify / consignee), a repeating `` (container type,
empty/full, marks, temperature, dangerous-goods) and `` /
`` (HS) → our `bill_of_lading`, `container` and
`manifest_cargo_item` rows.
Companion streams share ``: **Degroupage** (``,
`Awbolds.xsd`) and **Coloader** (``, `Awmcds.xsd`). These XSDs ship
inside the ASYFCI client — they are not canonical UNCTAD downloads; national
portals mirror the spec PDFs.
| AWMDS element | Toolbox table |
|---------------|---------------|
| `` | `manifest` |
| `` | `bill_of_lading` (`bl_nature_id` ← `Bol_nature`) |
| `` | `container` |
| `` / `` | `manifest_cargo_item` |
## Standards lineage
- **WCO Data Model**
ASYCUDA is "compatible with the WCO data model." WCO DM **v3.6.0 (May 2016)**
shipped a conformance report of the ASYCUDA information model to the WCO DM
for SAD import/export data — the mapping bridge. Current WCO DM is **v4.2.0
(July 2025)**; UNCTAD's public page still references "version 3" (stale).
!!! note "Evidence gap"
No public source confirms ASYCUDA natively implements the full **GOVCBR**
message envelope — conformance is documented at the data-element/SAD
level, not as a GOVCBR message-structure implementation.
- **EDIFACT lineage (ASYCUDA++)**
ASYCUDA++ used UN/EDIFACT; AW moved primary exchange to XML while staying
++-compatible. Relevant messages: **CUSDEC** (declaration = SAD), **CUSRES**
(response), **CUSCAR** (cargo report), **CUSREP** (conveyance), **CONTRL**
(syntax ack). Public EDIFACT CUSDEC XSDs are generic UN/EDIFACT, not
ASYCUDA-specific.
- **IATA Cargo-XML (air cargo)**
An official IATA + UNCTAD program. AW accepts **XFFM** (flight manifest),
**XFWB** (master AWB), **XFZB** (house AWB) and returns **XFNM** (response:
Processed/Received/Rejected). Requires AW **v4.3.2+**; transport is
deployment-dependent — **SOAP** or **SMTP/email** for the same message set.
## Related
- [Declaration — the SAD](../schema/declaration.md) · [Manifest & cargo](../schema/manifest.md)
- [Data dictionary](../schema/data-dictionary.md) · [Querying guide](../guides/querying.md)
- [Integration surfaces](integration.md) · [Selectivity & clearance](selectivity-clearance.md)
- [How faithful is the reconstruction?](../provenance/fit.md)
---
# Integration surfaces
There is **no public REST API**, **no single master WSDL**, and the thick-client
protocol is closed. And yet ASYCUDA World is integrated with banks, port
community systems, partner-government agencies and external risk engines every
day — through four or five real doors, not the front one. This page maps those
doors, the gaps you must formally request, and how this reconstruction stands in
for the biggest gap of all.
!!! warning "Do not integrate at the client protocol"
The AW desktop client is a **Java Web Start thick client**
(`ASYCUDAWorld.jnlp`, ~130 JARs over HTTPS, main class
**`so.kernel.client.DesktopMain`**) that connects to the server on a **custom
TCP port** (e.g. `//host:2016/`) with a **proprietary object/XML protocol —
NOT SOAP or REST**. The `so.kernel.*` / `so.util.*` namespace is UNCTAD's
internal kernel; this channel is effectively closed to third parties. **Do
not build an integration by speaking the client protocol.**
## The integration doors (ranked by how documented/proven they are)
| Surface | What it is | Fit |
|---------|------------|-----|
| **Direct RDBMS / ETL** | AW stores everything in Oracle/MSSQL/MySQL/PostgreSQL. BI and warehouses pull via SQL / replication / ETL against a read-replica. | **Best for training-data extraction** — but the schema is not public (needs DBA + schema discovery). |
| **ASYHUB** | UNCTAD's open, cloud-native, microservice data hub: *"provides an API for authorized entities to access customs data and documents,"* supports pre-arrival risk analysis, extendable connectors, ETL + monitoring dashboard. Aligned to WCO DM v3.8.1. | **Best sanctioned real-time path** — the intended external-integration door. API spec not public (request it). |
| **IATA Cargo-XML (SOAP/SMTP)** | Machine interface for air-cargo manifests (XFFM/XFWB/XFZB → XFNM), per-filer login/password, per-country endpoints. | Viable **pre-arrival cargo feed**; endpoints are credential-gated. |
| **XML file import** | External systems generate conformant `` XML, loaded via the client's *Import XML File*. Some integrators UI-automate the client. | Declaration in/out **without a DB** — but not a real-time API. |
| **ASY5 event/REST** | New-generation microservices expose Kafka topics + REST; the risk pipeline ingests external "signals." | **Friendliest** once the target is on ASY5 (Angola only, Jan 2026); specs not yet public. |
!!! note "DTI is not an API"
**Direct Trader Input (DTI)** — prepare/validate/print/submit a SAD and get a
registration/acceptance reference — is a **UI capability of the thick
client**, not a REST endpoint. Programmatic declaration submission goes
through the XML file path (SAD XML) or the SOAP services, not DTI. AW also
ships **built-in reporting / Business-Intelligence alerts** — useful for
aggregates, less so for row-level training data.
## Architecture at a glance
```mermaid
flowchart LR
subgraph EXT["External systems"]
ML["Risk / ML engine"]
CARGO["Cargo reporter"]
PGA["PGA / bank / port"]
end
subgraph DOORS["Integration doors"]
DB[("Direct RDBMS / ETL")]
HUB["ASYHUB API"]
CXML["IATA Cargo-XML (SOAP/SMTP)"]
XML["XML file import"]
ASY5["ASY5 event/REST"]
end
subgraph AW["ASYCUDA World"]
SRV["AW server (so.kernel, TCP ~2016)"]
RDBMS[("Customs DB")]
SRV --- RDBMS
end
ML --> DB
ML --> HUB
CARGO --> CXML
CARGO --> XML
PGA --> HUB
ML -. future .-> ASY5
DB --> RDBMS
HUB --> SRV
CXML --> SRV
XML --> SRV
ASY5 --> SRV
```
## Where this toolbox fits
The single most important non-public gap is the **physical DB schema** — the one
thing you need to design an ETL job, a warehouse target, an integration test, or
the shape of your training data *before* you get real access.
**This reconstruction is the public stand-in for that gap.** 55 tables in the
[`asycuda`](../schema/index.md) schema, provenance-tagged against the public XML
and manuals, let you:
- Design ETL and warehouse targets against a concrete, credible column model
instead of guessing — then remap to the real schema once a DBA hands it over.
- Build and run integration tests against realistic data before you have a
sandbox: [load the schema](../guides/loading.md) into any Postgres.
- Prototype training-data shapes and feature pipelines against the same fields
the real system exposes.
The [skills](../skills/index.md) (`customs-query`, `customs-seed`,
`customs-extend`) drive exactly this: query, populate and adapt the model to your
deployment's quirks before you connect anything real.
## What you must request
None of the following is publicly documented. Request from your national customs
administration or UNCTAD:
1. **Physical DB schema / ERD** — the biggest gap for both training-data
extraction and write-back.
2. **ASYHUB API specification** (OpenAPI/WSDL, auth, message catalog) — the
single most important unknown for sanctioned real-time integration.
3. **ASY5 "third-party AI → risk profile" signal payload format & endpoint.**
4. **Asysel admin data model** — criterion operators, priority, validity, AND/OR
syntax, score→lane thresholds (deliberately hidden to prevent gaming).
5. **The exact selectivity-injection write-path** (criteria table? trader
profile? signal table? DB trigger?).
6. **Tax-rules "customs taxation language"** grammar + the national code lists
(tariff, tax-type, CPC/ANC, exemptions, offices, currencies).
7. **Inspection-Act read access** (illicit flag + recovered revenue) — required
for ML labels and the feedback loop.
**Channels to obtain restricted docs:** your national customs ASYCUDA project
team or the UNCTAD ASYCUDA programme — via its gated learning platforms and
credentialed developer repositories for member customs administrations.
!!! tip "Confirm these three before building"
- Which **generation** — ASYCUDA++, World, or ASY5? (ASY5/ASYHUB are far
friendlier.)
- **When does selectivity fire** — before or after assessment?
- **Real-time vs batch** scoring, and **pre-arrival vs at-assessment**?
## Bespoke integrations (no published spec)
**Single Window** connects traders ↔ customs ↔ partner-government agencies for
LPCO admissibility documents via WCO-DM XML (eCITES for endangered-species
permits is a concrete example). **e-Payment** generates a reference number, the
trader pays 24×7, and success is posted back to ASYCUDA in real time — bespoke
per bank and country. **NII/scanner** and **port-community** integrations exist
in deployments but carry no published, standardized interface spec.
## Related
- [Selectivity & clearance](selectivity-clearance.md) — the lane model and where
a risk score is injected.
- [The ML risk engine path](../guides/ml-risk-engine.md) — reading, scoring,
injecting and feeding back.
- [XML messages & the wire format](xml-messages.md) — the file-import door in
detail.
- [Resources & documents](resources.md) — where the public and restricted specs
live.
---
# Running on a real ASYCUDA World
**The goal of this whole toolbox is to help you put your own ASYCUDA data to
work — for analytics, machine-learning risk models, and smarter selectivity.**
The reference schema, the queries and the skills are the vehicle; your live
ASYCUDA World database is the destination. This page is the bridge between them.
!!! tip "The easiest path: the query compiler"
You usually don't need to hand-write the adapter below. The
[**query compiler**](../compiler/index.md) compiles logical queries into
genuine ASYCUDA World SQL on the fly (a CTE prelude, no view-creation
privilege needed), and can also emit these persistent compatibility views for
you (`emit-views`, with your per-instance overrides). Start there; this page is
the deployment, FDW and ETL detail behind it — read on when you need the bulk
ETL alternative or the cross-dialect / read-replica specifics.
!!! abstract "Why you're here"
You want to analyse real declarations, engineer features, train a
fraud / valuation model, or feed risk signals back into the clearance lanes —
on *your* data. Put the bridge below in place once, and the
[useful queries](../guides/useful-queries.md), the
[`customs-query` skill](../skills/index.md), and the
[ML risk-engine blueprint](../guides/ml-risk-engine.md) all run against the
real system unchanged.
Everything in this toolbox is written against a **normalised reference model**:
`declaration`, `declaration_item`, `declaration_tax_line`, a wall of `ref_*`
code tables, snake_case names, surrogate `id` keys, foreign keys. That model is
readable and queryable — it is exactly what you want for analytics and ML.
A **real** ASYCUDA World database is the opposite. It is a **wide, denormalised**
physical schema optimised for the AW engine: `SAD_General_Segment`, `SAD_Item`,
`SAD_Tax`, `GEN_TAB`/`BOL_TAB`/`CTN_TAB`, `UN*`/`xx*TAB` reference tables. Codes
and their names are stored **inline** on the row (`GEN_CAR_COD` *and*
`GEN_CAR_NAM`), the general segment is **repeated into every item row**, keys are
engine `INSTANCE_ID` values, and the commodity code is **split across
`TAR_HSC_NB1..NB5`**.
And the deepest problem: **ASYCUDA World's physical schema is not public.** The
field-level model is documented only through the XML layer and national manuals
([XML messages](xml-messages.md)); the exact physical table and column names are
the [#1 "must-request" gap](integration.md). So this page describes an approach
that stays honest about that gap — you supply the last mile, once.
Two ways to bridge the two worlds — both in service of getting your data into
analytics, ML and selectivity:
- **Approach 1 — compatibility views** — run our queries, skills and feature
extraction **live** on the real database.
- **Approach 2 — ETL into the reference model** — copy into a clean snapshot for
**bulk analytics and model training**.
---
## Approach 1 — compatibility views (recommended for running our queries live)
Create **read-only views**, in schema `asycuda`, named **exactly** like our
tables, each one `SELECT`-ing and aliasing from the real AW tables. Because our
queries address tables unqualified via `search_path=asycuda,public`, and the
views carry our column names, **every query we write — and the whole
`customs-query` skill — runs unchanged** against the live system. No rewrites.
A worked mini-example. The declaration header comes from
`SAD_General_Segment`; note the de-duplication (AW repeats the general segment
into each item row) and the inline office code exposed as our `office_id`:
```sql
CREATE SCHEMA IF NOT EXISTS asycuda;
SET search_path TO asycuda, public;
-- our `declaration` <- AW SAD_General_Segment
CREATE OR REPLACE VIEW declaration AS
SELECT DISTINCT ON (g.INSTANCE_ID)
g.INSTANCE_ID AS id, -- engine key -> our surrogate id
g.SGS_CUO_COD AS office_id, -- inline office code -> code-keyed ref_customs_office.id
g.SGS_REG_NBR AS registration_number,
g.SGS_REG_DAT AS registration_date,
g.STA AS status_id, -- lifecycle STA code
CASE -- PTY colour flags -> our lane code
WHEN g.PTY_RED = '1' THEN 'RED'
WHEN g.PTY_YEL = '1' THEN 'YELLOW'
WHEN g.PTY_GRE = '1' THEN 'GREEN'
ELSE NULL
END AS selectivity_lane_id
-- … the remaining columns of our declaration contract …
FROM SAD_General_Segment g -- TODO(instance): confirm real table name
ORDER BY g.INSTANCE_ID;
```
The tax lines come from `SAD_Tax`, whose field roots **`COD` / `BSE` / `RAT` /
`AMT` / `MOP`** are documented ([FIT.md](../provenance/fit.md),
[box 47](xml-messages.md)), so they map directly — no guessing:
```sql
-- our `declaration_tax_line` <- AW SAD_Tax
CREATE OR REPLACE VIEW declaration_tax_line AS
SELECT
x.INSTANCE_ID AS id,
x.TAX_ITM_ID AS declaration_item_id, -- TODO(instance): confirm link column
x.COD AS tax_type_id, -- SAD_Tax COD -> code-keyed ref_tax_type.id
x.BSE AS tax_base, -- SAD_Tax BSE
x.RAT AS rate_percent, -- SAD_Tax RAT (ad valorem)
x.AMT AS tax_amount, -- SAD_Tax AMT
x.MOP AS mode_of_payment, -- SAD_Tax MOP (1 payable / 0 guaranteed)
(x.TYP = '1') AS is_manual -- SAD_Tax TYP manual/automatic flag
FROM SAD_Tax x; -- TODO(instance): confirm real table name
```
The full template — every core table (`ref_*`, `trader`, `declaration`,
`declaration_item`, `declaration_tax_line`, `manifest`, `bill_of_lading`,
`container`) — is in
[`Sydonia/adapters/asycuda_world_compat.sql`](https://github.com/FrancoisChastel/sydonia-toolkit/blob/master/Sydonia/adapters/asycuda_world_compat.sql),
with a [README](https://github.com/FrancoisChastel/sydonia-toolkit/blob/master/Sydonia/adapters/README.md)
covering the workflow.
!!! warning "The template will not run as-is"
The physical column names are **instance/version-specific and non-public**.
Every place the public docs don't pin an exact name is marked
`-- TODO(instance):` for a DBA to fill once, using the real schema/ERD you
[request](integration.md). The names that *are* pinned (`SAD_Tax`
`COD`/`BSE`/`RAT`/`AMT`/`MOP`; the `GEN_*`/`BOL_*`/`SGS_*` prefixes) come
from `FIT.md`/`xml-messages.md` and are your worked example.
!!! note "Read-only, so the privacy guarantees hold"
Views are read-only; the adapter never writes to AW's own objects. So the
[`customs-query-tester`](../skills/index.md) — which returns metadata only,
never rows — stays safe even when `CUSTOMS_DB` points at a database holding
real declarations.
**Not on PostgreSQL?** ASYCUDA World is database-agnostic (Oracle, MS SQL Server,
MySQL, PostgreSQL, DB2…). If the instance is on Oracle/MSSQL, either create the
equivalent views there in AW's own dialect, or stand up a Postgres front-end that
imports the real tables as **foreign tables** via `oracle_fdw` / `tds_fdw`, then
build these views on top. The view **contracts** (the column set each must
expose) are identical whichever route you take.
---
## Approach 2 — ETL into the reference model (for bulk analytics / ML)
Compatibility views are ideal for **running our queries live** on modest result
sets. For **bulk analytics or training data** — where you want a full local copy
in the clean normalised shape — copy the data instead of viewing through it:
1. **Extract** — from a **read-replica** (never primary), or from `` /
AWMDS XML exports produced by the client's *Export XML File* path.
2. **Transform** — reshape to our schema using the *same* `FIT.md` /
`xml-messages.md` mapping. The adapter's `SELECT … AS …` lines are, in effect,
the transform written out.
3. **Load** — into a separate Postgres carrying our `asycuda` schema
([load it](../guides/loading.md)).
4. **Query the copy** — now every skill, notebook and feature pipeline runs
against a stable, normalised snapshot, decoupled from the live engine.
This is the path the [ML risk-engine guide](../guides/ml-risk-engine.md) assumes
for reading, feature-building and scoring at volume.
---
## The shape differences you must handle
Every divergence below is drawn from [`FIT.md`](../provenance/fit.md); the
adapter handles each one explicitly so your queries never have to.
| Aspect | Real ASYCUDA World | How the adapter handles it |
|--------|--------------------|----------------------------|
| **Table names** | terse codes — `GEN_TAB`, `BOL_TAB`, `SAD_General_Segment`, `SAD_Item`, `xxCTYTAB` | a view named like *our* table (`manifest`, `declaration`, `ref_country`) selects from each real source |
| **Coded fields** | code **and** name stored **inline** (`GEN_CAR_COD` + `GEN_CAR_NAM`) — no FK | `ref_*` views are **code-keyed** (`id` := the business code); operational views expose the inline code as our `*_id`, so `office_id = ref_customs_office.id` resolves on the code — no fabricated surrogate keys |
| **General segment repeated** | the general segment is copied into **every** `SAD_Item` row | the `declaration` header view **de-duplicates** (`SELECT DISTINCT ON (INSTANCE_ID)` / keyed header source) to one row per declaration |
| **Keys** | engine `INSTANCE_ID` / `InstanceId` | aliased **`AS id`** so our surrogate-PK joins keep working |
| **HS code** | split across `TAR_HSC_NB1..NB5` (national precision) | `declaration_item` **concatenates** the five parts into our single `hs_code` |
| **Reference validity** | `UN*`/`xx*TAB` rows carry `VALID_FROM` / `VALID_TO` | each `ref_*` view filters to current rows: `WHERE now()::date BETWEEN valid_from AND coalesce(valid_to, DATE '9999-12-31')` |
| **Taxes** | `SAD_Tax` with roots `COD`/`BSE`/`RAT`/`AMT`/`MOP`/`TYP` (+ `SAD_Ask_Tax`, `SAD_Global_Taxes`, `SAD_Tax_Totals`) | `declaration_tax_line` maps `COD→tax_type_id`, `BSE→tax_base`, `RAT→rate_percent`, `AMT→tax_amount`, `MOP→mode_of_payment`, `TYP→is_manual`; declaration/global totals stay **derived by query** |
---
## Privacy and safety
The adapter is read-only by construction, which is what makes it safe to point at
a database holding real declarations — trader TINs, invoice values, inspection
findings:
- **Views only** — no writes, no DDL against AW's own objects.
- Point **`CUSTOMS_DB`** at the real database, ideally via a **`SELECT`-only
role** and a **read-replica**. A grant snippet ships at the bottom of the SQL
template.
- The [`customs-query-tester`](../skills/index.md) then returns **metadata
only** — column names/types, an aggregate row count, duration — and *never*
row data. The model gets an oracle, not a window.
Set `CUSTOMS_DB` to the real DSN and `CUSTOMS_SCHEMA=asycuda` (or `compat` if you
created the views in a `compat` schema), and every skill behaves as it does on
the sandbox.
---
## What you must still obtain
The adapter is a faithful, well-annotated **contract** — but two things are
genuinely non-public and only your deployment can supply them:
1. **The real physical schema / column names** — the #1 gap. This is what turns
every `-- TODO(instance)` into a real name. Request it via the channels on
the [integration surfaces](integration.md) page.
2. **The deployment generation / version** — ASYCUDA++, World, or ASY5 — because
the physical names differ between them. Confirm it against
[version lineage](versions.md).
Once you have both, filling the template is mechanical, and the
`customs-query-tester` will confirm — **metadata only, no rows read** — that each
view's column set and join paths resolve against the live database.
---
## Related
- [Official fit & gap](../provenance/fit.md) — the table-by-table mapping the
crib is grounded in.
- [XML messages & the wire format](xml-messages.md) — the SAD box → AW XML tag →
our column three-way map.
- [Integration surfaces](integration.md) — the real doors, and what you must
request.
- [Version lineage](versions.md) — telling which generation a deployment runs.
- [Querying guide](../guides/querying.md) ·
[Useful queries](../guides/useful-queries.md) — the queries this adapter lets
you run live.
---
# Selectivity & clearance
This page describes how a declaration actually **moves through ASYCUDA World**:
the clearance **state machine** and the **four-lane risk routing** performed by
the selectivity module (internally **"Asysel"**). It is the platform behaviour.
For *our* tables — `ref_selectivity_lane`, `risk_criterion`,
`selectivity_result`, `inspection_act` — see
[Selectivity & risk](../schema/selectivity.md) in the schema section.
!!! note "Where these facts come from"
The lane model and criterion structure are grounded in public ASYCUDA
programme material and national brokers' manuals; the status model comes
chiefly from national declaration-processing manuals. The **Asysel admin data
model** (operators, priorities, score→lane thresholds) is deliberately **not
public** — kept hidden to prevent gaming. Everything below is reconstructed
from the public layer.
## The clearance state machine
The central object is the **SAD** (the declaration). It moves through a fixed
lifecycle, colour-coded in the ASYCUDA "Finder". Three statuses stamp a
reference with a serial prefix — the fingerprints you see on paperwork:
| Status | Reference prefix | Meaning |
|--------|:---------------:|---------|
| **STORED** | — | Captured, freely amendable before assessment |
| **REGISTERED** | **C** | Legal status + Customs Reference No. assigned |
| **ASSESSED** (liquidated) | **L** | Duties computed; amendments locked |
| **PAID** | **PRN** | ASYCUDA receipt issued (Payment Reference No.) |
| **SELECTED** | — | Red/Yellow held pending checks |
| **QUERIED** | — | Officer raises a question in the Inspection Act; broker responds |
| **RELEASED** | — | Checks done → Release Order (automatic for Green/Blue) |
| **EXITED** | — | Goods gate-out |
| **CANCELLED** | — | Assessment voided (supports refund) |
```mermaid
stateDiagram-v2
[*] --> STORED : Store
STORED --> STORED : Retrieve / modify
STORED --> REGISTERED : Validate / Register (C)
REGISTERED --> ASSESSED : Assess / liquidate (L)
ASSESSED --> PAID : Pay (PRN)
PAID --> routed : Trigger selectivity
ASSESSED --> routed : Trigger selectivity
state routed <>
routed --> RELEASED : GREEN / BLUE (auto)
routed --> SELECTED : YELLOW / RED (hold)
SELECTED --> SELECTED : Examine / doc-check (Inspection Act)
SELECTED --> QUERIED : Query
QUERIED --> SELECTED : Query response
SELECTED --> RELEASED : Clear (validate Inspection Act)
RELEASED --> EXITED : Gate-out
EXITED --> [*]
STORED --> CANCELLED : Cancel
REGISTERED --> CANCELLED : Cancel
ASSESSED --> CANCELLED : Cancel
CANCELLED --> [*]
```
The French vocabulary you will meet in SYDONIA installs runs in parallel:
*saisie → stockée → enregistrée → liquidée → acquittée → circuit → mainlevée /
BAE → sortie*. **AMENDED / RECTIFIED** is an event (retrieve + modify), not a
terminal state.
## The four lanes
Selectivity routes every declaration — **typically after assessment/payment** —
into one of four lanes, configured nationally by the Customs Risk Management
Unit. Legacy ASYCUDA++/SYDONIA had only three circuits (green/yellow/red);
**BLUE is an ASYCUDA World addition**.
| Lane | Requires exam | Meaning |
|------|:-------------:|---------|
| :material-circle:{ style="color:#16a34a" } **GREEN** | no | Auto-release; customs still reserves the right to examine |
| :material-circle:{ style="color:#ca8a04" } **YELLOW** | yes | Documentary check only |
| :material-circle:{ style="color:#dc2626" } **RED** | yes | Documentary check + physical examination; examiner completes the **Inspection Act** |
| :material-circle:{ style="color:#2563eb" } **BLUE** | no | Released now, **Post-Clearance Audit** verifies later |
Risk tiers commonly map Green (lowest) → Blue (low) → Yellow (medium) → Red
(high).
## The criteria model
A **criterion** is *"an instruction to control the content of some fields of the
declaration"* — a condition on fields mapped to a control channel (lane). Two
things make it powerful for ML integration:
- **Two-level scoring.** Criteria fire at **declaration level** *and* at
**trader level** — a profile keyed to the importer **TIN**. A strong trader
profile can override a declaration-level flag; AEOs go to Red only by
mandatory low-rate random selection.
- **Any element is usable.** *"All the data elements of the declaration and of
the B/L are usable by the selectivity"* — HS/tariff code, origin, importer /
exporter / declarant TINs, office, declared value versus a reference-price DB,
Incoterms, currency, goods description, Box 44 permit references, CPC, any
manifest / bill-of-lading field. This is what extends control to pre-arrival.
Two scoring generations coexist. **(A) Classic rule-based** — per-criterion
weights configured nationally (a typical scheme classes importer / origin /
tariff each low/med/high by fraud rate → Red if ≥1 high or ≥2 medium; Yellow if
1 medium; else Green). **(B) ML "Dynamic Selectivity"** (AW v4.4+, ~2021) —
UNCTAD's **native** ML component that *"assigns a score and the degree of
inspection"* from declarant / importer / origin, self-updating from inspection
feedback.
!!! tip "The random slot is the injection point"
A distinct **random selectivity** layer re-routes a percentage of green
declarations to red — commonly around **1–3%** — so procedures
stay unpredictable. A separate random function even assigns *which officer*
verifies (anti-collusion). **This random slot is the natural injection point
for an ML "exploration" strategy** — the exploitation/exploration split that
the [ML risk-engine guide](../guides/ml-risk-engine.md) builds on.
## When selectivity fires — the timing switch
!!! warning "This sets your ML scoring deadline"
*When* selectivity fires is a **per-country switch**. Documented African and
Caribbean deployments run selectivity **after assessment/payment**; ASYCUDA
also supports **selectivity before assessment**. Your integration must know
which mode the target runs — it changes the deadline by which your ML engine
must have scored the declaration. Confirm this before building anything
real-time. See the [ML risk-engine guide](../guides/ml-risk-engine.md#going-live).
## Mapping the platform to our model
Our schema represents most — but not all — of this behaviour. The honest map:
| Platform concept | Our model | Notes |
|------------------|-----------|-------|
| Status lifecycle (STORED…RELEASED) | `ref_declaration_status` rows; current on `declaration.status_id` | Statuses we store: **stored / registered / assessed / paid / released / queried / cancelled** |
| Status transitions over time | `declaration_status_history` (`status_id`, `changed_at`, `changed_by`) | The full audit trail of the state machine |
| The four lanes | `ref_selectivity_lane` (`code`, `requires_exam`) | Green / Yellow / Red / Blue |
| A criterion → target lane | `risk_criterion` (`code`, `name`, `target_lane_id`) | Criterion *operators / priorities / weights* are **not modelled** — they are not public |
| The lane a declaration was routed to, and why | `selectivity_result` (`lane_id`, `criterion_id`, `triggered_at`, `officer_id`); current lane cached on `declaration.selectivity_lane_id` | Preserves the routing history and reason |
| Examiner's outcome (the ML label) | `inspection_act` (`result`, `findings`, `inspected_at`, `officer_id`) | The feedback signal for the learning loop |
| Payment / receipt (PRN) | `payment`, `receipt` | The PAID transition |
### Two honest gaps
The full clearance state machine has two states our status catalogue does **not**
carry as `ref_declaration_status` rows:
!!! warning "SELECTED and EXITED are not statuses in our model"
- **SELECTED** — the "held pending checks" state — has **no direct status
row**. It *is* representable indirectly: a row in `selectivity_result`
routing the declaration to Yellow/Red, plus (optionally) an
`inspection_act`, expresses "this declaration was selected". The
declaration's own `status_id` stays at its last true status
(e.g. `paid`) until it moves to `released`.
- **EXITED** — the physical **gate-out** — is **not modelled at all**. Our
lifecycle ends at `released`; there is no exit / gate event table and no
`exited` status. If you need to model goods leaving the premises, you must
[extend the schema](../guides/extending.md) yourself.
State these gaps plainly in any analysis — do not treat a `released`
declaration as evidence the goods have physically exited.
## Next
- :material-robot-outline:{ .lg .middle } **Build the loop**
---
Turn this behaviour into a working risk engine — features, labels, the
read → score → inject → feedback loop, and how to prototype on this schema.
[:octicons-arrow-right-24: ML on customs data](../guides/ml-risk-engine.md)
- :material-connection:{ .lg .middle } **The doors**
---
Where an external engine actually plugs in — RDBMS/ETL, ASYHUB,
Cargo-XML, ASY5 — and the specs you must request.
[:octicons-arrow-right-24: Integration surfaces](integration.md)
---
# Further reading & document map
UNCTAD does not publish ASYCUDA's physical database schema or a machine-readable
API catalog. The field-level model is public **only through the XML layer and
national customs manuals** — so knowing *which kinds* of documents exist, and
where they normally live, is half the battle. This page describes the shape of
that landscape without pointing at any one document.
!!! note "This complements the schema's own citations"
[Sources](../provenance/sources.md) lists the documents our schema **actually
cites** (each resolving to a `-- src:` tag). *This* page is broader: a general
guide to the kinds of public reading that help you understand ASYCUDA the
platform, not just our reconstruction of it.
## The kinds of documentation, and where they live
Understanding ASYCUDA from public material means drawing on a few recurring
categories. None of them is a single authoritative schema — together they are the
public window onto the model.
### Public programme materials
The ASYCUDA programme publishes introductory and processing manuals, message
specifications and general platform documentation. These describe the SAD
workflow, the XML e-document model and the overall system at a conceptual level.
They are the canonical starting point for what the platform *is*.
### National customs manuals — the field-level goldmines
ASYCUDA World is deployed as a national instance, and each administration
documents its own instance. These national user guides, broker manuals and SAD
field guides define the declaration boxes and fields concretely — often in more
practical detail than the general programme material — because they describe the
system as traders and officers actually use it. They are typically hosted on the
national customs administration's own portal.
### Real schemas, samples & code
The most useful public artefacts for the actual XML wire format are the real
instances themselves: exported declaration and manifest XML, recovered schema
definitions, and integrator code that reads or writes the `` format.
These show what real instances export, as opposed to the idealised specification.
### Open standards
The surrounding standards are fully public and stable: ISO country/currency
codes, the UN trade and transport code lists, the WCO Harmonized System and Data
Model, Incoterms, and the WTO valuation framework. ASYCUDA aligns to these, so
they are load-bearing background reading in their own right.
### Public customs-ML research and open datasets
For the analytics and risk-engine side, there is a body of public customs
machine-learning research and a small number of open, downloadable customs
datasets. These describe feature schemas, modelling approaches and evaluation
methods generically — enough to prototype a risk model before you have access to
real declaration history. See the [ML risk-engine guide](../guides/ml-risk-engine.md)
for how that research shapes the blueprint.
## Restricted — request via your national customs or the ASYCUDA programme
The load-bearing technical documents are **not public**. The physical database
schema / ERD, the ASYHUB API specification, the ASY5 risk-signal payload format,
the selectivity admin data model, and Inspection-Act read access are all obtained
by request — through your national customs administration's ASYCUDA project team
or the UNCTAD ASYCUDA programme. Gated learning platforms and credentialed
developer repositories exist for member customs administrations but are not open
to the public.
What to put on the request list:
| Request | Why it matters |
|---------|----------------|
| Physical **DB schema / ERD** | The biggest gap — needed for training-data extraction and selectivity write-back |
| **ASYHUB API specification** (OpenAPI/WSDL, auth, message catalog) | The single most important unknown for sanctioned real-time integration |
| **ASY5 "third-party AI → risk profile" signal payload format & endpoint** | The forward-looking injection point; format not yet public |
| **Selectivity admin data model** (operators, priority, validity, score→lane thresholds) | Deliberately hidden to prevent gaming |
| **Inspection-Act read access** (illicit flag + recovered revenue) | The ML labels and the feedback loop depend on it |
---
# Guides
Task-oriented walkthroughs for working with the model day to day.
!!! tip "Querying a real ASYCUDA World system?"
These guides use the friendly **logical model**. To run the same queries on a
live Sydonia, see **[Querying Sydonia](../querying-sydonia/index.md)** (the
real tables) and **[the query compiler](../compiler/index.md)** (write
friendly, run genuine).
- :material-database-arrow-down:{ .lg .middle } **Loading the schema**
---
Integration patterns beyond the quickstart — CI, test resets, loading
alongside your own tables, and namespacing.
[:octicons-arrow-right-24: Loading](loading.md)
- :material-magnify:{ .lg .middle } **Querying the model**
---
The join paths that matter, plus an analytics cookbook: revenue by HS,
assessed-vs-paid, lane throughput, warehouse stock.
[:octicons-arrow-right-24: Querying](querying.md)
- :material-source-branch:{ .lg .middle } **Extending the schema**
---
Add tables and columns while keeping the conventions — and the provenance
trail — intact.
[:octicons-arrow-right-24: Extending](extending.md)
- :material-play-box:{ .lg .middle } **Worked example**
---
`e2e.sql`, narrated step by step: manifest → declaration → valuation →
taxes → selectivity → payment → release.
[:octicons-arrow-right-24: Worked example](worked-example.md)
- :material-table-search:{ .lg .middle } **Useful queries**
---
A growing, verified library of analytical queries — effective rates,
valuation outliers, selectivity hit-rates — with a copy-paste format.
[:octicons-arrow-right-24: Useful queries](useful-queries.md)
- :material-brain:{ .lg .middle } **ML on customs data**
---
The risk-engine blueprint: features mapped to SAD boxes and columns, labels
from the Inspection Act, the selectivity loop.
[:octicons-arrow-right-24: ML on customs data](ml-risk-engine.md)
---
# Loading the schema
The [installation page](../getting-started/installation.md) covers first-time
setup. This guide is about **integrating loading into a workflow** — tests, CI,
and living alongside your application's own tables.
## Load order (always)
```text
1. Sydonia/schema/asycuda.sql # schema + tables (drops & recreates `asycuda`)
2. Sydonia/schema/seed_reference.sql # reference / code data
3. Sydonia/examples/e2e.sql # optional worked example
```
`asycuda.sql` begins with `DROP SCHEMA IF EXISTS asycuda CASCADE;`, so **every
load is a clean reset** of the `asycuda` namespace.
## Living alongside your own tables
Because the model lives entirely in the `asycuda` schema, it coexists with your
application. Keep your tables in `public` (or your own schema); reference the
model explicitly or via `search_path`:
```sql
-- your app tables in public, the customs model in asycuda
SELECT d.registration_number, my.note
FROM asycuda.declaration d
JOIN public.my_annotations my ON my.declaration_id = d.id;
```
!!! warning "Reloading discards `asycuda` contents"
Re-running `asycuda.sql` drops the whole `asycuda` schema. Never put data you
want to keep **inside** `asycuda` — keep it in your own schema and reference
across.
## Reset between tests
The idempotent load makes per-suite resets trivial:
```bash
# fast reset: reload schema + seed only (skip the e2e example)
psql -v ON_ERROR_STOP=1 -d "$TEST_DB" -f Sydonia/schema/asycuda.sql
psql -v ON_ERROR_STOP=1 -d "$TEST_DB" -f Sydonia/schema/seed_reference.sql
```
For many fast resets, `DROP SCHEMA asycuda CASCADE;` + reload is cheaper than
recreating the database.
## Continuous integration
A minimal GitHub Actions job that proves a clean load on every push:
```yaml
jobs:
load:
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env: { POSTGRES_PASSWORD: postgres }
ports: ['5432:5432']
options: >-
--health-cmd pg_isready --health-interval 10s
--health-timeout 5s --health-retries 5
env:
PGPASSWORD: postgres
PSQL: psql -v ON_ERROR_STOP=1 -h localhost -U postgres -d customs
steps:
- uses: actions/checkout@v4
- run: createdb -h localhost -U postgres customs
- run: $PSQL -f Sydonia/schema/asycuda.sql
- run: $PSQL -f Sydonia/schema/seed_reference.sql
- run: $PSQL -f Sydonia/examples/e2e.sql
```
The [`customs-validate`](../skills/index.md) skill wraps exactly this check for
local use.
## Loading into a specific schema name
Prefer a different namespace? The model hardcodes `asycuda`, but you can rename
after loading, or `sed` the three `asycuda` references at the top of the file:
```bash
sed 's/\basycuda\b/customs/g' Sydonia/schema/asycuda.sql | psql -v ON_ERROR_STOP=1 -d mydb -f -
# remember to apply the same rename to seed_reference.sql and e2e.sql (their SET search_path lines)
```
## Verify the load programmatically
```sql
SET search_path TO asycuda, public;
SELECT
(SELECT count(*) FROM information_schema.tables
WHERE table_schema='asycuda' AND table_type='BASE TABLE') AS tables, -- 55
(SELECT count(*) FROM ref_transport_mode) AS transport_modes, -- 9
(SELECT count(*) FROM ref_selectivity_lane) AS lanes; -- 4
```
---
# Querying the model
The model is normalised, so coded values are foreign keys and totals are derived
by query. This page is the cookbook of join paths you will reach for.
!!! tip "Write here, run on a real Sydonia"
Every query on this page is written against the friendly **logical model**.
To run the same SQL on a live ASYCUDA World database, pipe it through the
[query compiler](../compiler/index.md) — it rewrites the friendly names into
genuine Sydonia SQL over the real tables. See [Querying
Sydonia](../querying-sydonia/index.md) for the real tables themselves.
!!! note "Set the search path first"
Every table is in the `asycuda` schema. Run this once per session (or
`ALTER DATABASE … SET search_path`) so you can use bare names:
```sql
SET search_path TO asycuda, public;
```
## The two spines
Almost every query walks one of two chains:
```text
manifest → bill_of_lading → container / manifest_cargo_item (cargo spine)
declaration → declaration_item → declaration_tax_line (declaration spine)
↘ item_value_note (per-item CIF)
```
## Assemble a full declaration
Resolve the coded foreign keys into human-readable columns:
```sql
SELECT d.registration_serial || ' ' || d.registration_number AS reg,
ty.code AS type,
off.name AS office,
exp.name AS exporter,
imp.name AS consignee,
st.code AS status,
lane.code AS lane,
d.total_cif_value
FROM declaration d
JOIN ref_declaration_type ty ON ty.id = d.declaration_type_id
JOIN ref_customs_office off ON off.id = d.office_id
JOIN trader exp ON exp.id = d.exporter_id
JOIN trader imp ON imp.id = d.consignee_id
JOIN ref_declaration_status st ON st.id = d.status_id
JOIN ref_selectivity_lane lane ON lane.id = d.selectivity_lane_id
WHERE d.trader_reference = 'REF-2026-0001';
```
## Items with their taxes
```sql
SELECT di.item_number,
di.hs_code,
di.customs_value,
jsonb_object_agg(tt.code, tl.tax_amount) AS taxes,
sum(tl.tax_amount) AS total_tax
FROM declaration_item di
JOIN declaration_tax_line tl ON tl.declaration_item_id = di.id
JOIN ref_tax_type tt ON tt.id = tl.tax_type_id
WHERE di.declaration_id = (SELECT id FROM declaration WHERE trader_reference='REF-2026-0001')
GROUP BY di.item_number, di.hs_code, di.customs_value
ORDER BY di.item_number;
```
## Cargo spine — manifest to goods lines
```sql
SELECT m.voyage_number,
bl.bl_reference,
ctn.ctn_reference,
ci.hs_code,
ci.goods_description,
ci.number_of_packages
FROM manifest m
JOIN bill_of_lading bl ON bl.manifest_id = m.id
LEFT JOIN container ctn ON ctn.bl_id = bl.id
JOIN manifest_cargo_item ci ON ci.bl_id = bl.id
WHERE m.voyage_number = 'V2026-042'
ORDER BY bl.line_number, ci.line_number;
```
## Assessed vs paid reconciliation
The single most useful integrity query — do the taxes, the payment and the
receipt agree?
```sql
SELECT d.registration_number,
assessed.total_tax,
p.amount AS paid,
r.total_amount AS receipted,
assessed.total_tax = coalesce(r.total_amount, 0) AS balanced
FROM declaration d
JOIN LATERAL (
SELECT sum(tl.tax_amount) AS total_tax
FROM declaration_item di
JOIN declaration_tax_line tl ON tl.declaration_item_id = di.id
WHERE di.declaration_id = d.id
) assessed ON true
LEFT JOIN payment p ON p.declaration_id = d.id
LEFT JOIN receipt r ON r.payment_id = p.id;
```
## Revenue by tax type
```sql
SELECT tt.code AS tax,
count(*) AS lines,
sum(tl.tax_amount) AS revenue
FROM declaration_tax_line tl
JOIN ref_tax_type tt ON tt.id = tl.tax_type_id
GROUP BY tt.code
ORDER BY revenue DESC;
```
## Revenue by HS chapter
```sql
SELECT left(di.hs_code, 2) AS hs_chapter,
sum(di.customs_value) AS declared_value,
sum(tl.tax_amount) AS revenue
FROM declaration_item di
LEFT JOIN declaration_tax_line tl ON tl.declaration_item_id = di.id
GROUP BY 1
ORDER BY revenue DESC NULLS LAST;
```
## Selectivity throughput
```sql
SELECT lane.code AS lane,
count(*) AS declarations,
count(*) FILTER (WHERE ia.result = 'conform') AS conform,
round(100.0 * count(ia.id) / count(*), 1) AS pct_inspected
FROM declaration d
JOIN ref_selectivity_lane lane ON lane.id = d.selectivity_lane_id
LEFT JOIN inspection_act ia ON ia.declaration_id = d.id
GROUP BY lane.code
ORDER BY lane.code;
```
## Trace a write-off to the manifest
Which manifest bill of lading did each declared item come from?
```sql
SELECT d.registration_number,
di.item_number,
di.hs_code,
bl.bl_reference,
pd.written_off_packages,
pd.written_off_mass
FROM declaration_previous_document pd
JOIN declaration d ON d.id = pd.declaration_id
JOIN declaration_item di ON di.id = pd.declaration_item_id
JOIN bill_of_lading bl ON bl.id = pd.bl_id
ORDER BY d.registration_number, di.item_number;
```
## Goods still in a warehouse
```sql
SELECT w.code AS warehouse, we.entry_date, we.packages, we.gross_mass
FROM warehouse_entry we
JOIN ref_warehouse w ON w.id = we.warehouse_id
LEFT JOIN warehouse_exit wx ON wx.warehouse_entry_id = we.id
WHERE wx.id IS NULL
ORDER BY we.entry_date;
```
## Save the common joins as views
If you query the model a lot, wrap the resolved joins in a view:
```sql
CREATE VIEW asycuda.v_declaration_summary AS
SELECT d.id,
d.registration_serial || ' ' || d.registration_number AS reg,
ty.code AS type, st.code AS status, lane.code AS lane,
d.total_items, d.total_cif_value
FROM declaration d
JOIN ref_declaration_type ty ON ty.id = d.declaration_type_id
JOIN ref_declaration_status st ON st.id = d.status_id
JOIN ref_selectivity_lane lane ON lane.id = d.selectivity_lane_id;
```
!!! tip "Let an Agent Skill write the joins"
The [`customs-query`](../skills/index.md) skill knows these join paths and the
`search_path` requirement, so you can ask for a report in plain English and get
a correct query back.
---
# Useful queries
The [querying guide](querying.md) teaches the join paths — the two spines, how
coded columns resolve to `ref_*` names, why totals are derived rather than
stored. This page is the next step: a **growing library of ready analytical
reports** built on those paths, each verified against the seeded sandbox. Add
yours by copying the template.
!!! note "Set the search path once"
Every table lives in the `asycuda` schema. Run this once per session so bare
table names resolve. Each fenced block below repeats it to stay
copy-paste self-contained.
```sql
SET search_path TO asycuda, public;
```
!!! tip "These are logical queries — compile them to genuine Sydonia SQL"
Every query here is written against the friendly **logical model**. To run the
same SQL on a real ASYCUDA World instance, pipe it through the
[**query compiler**](../compiler/index.md): it rewrites each into genuine
Sydonia SQL over the real physical tables. Write friendly, run genuine.
!!! abstract "Built for analytics, ML and selectivity"
These are more than dashboards. The `risk`, `valuation` and `trader` queries
below are the **feature-extraction and selectivity-analytics building blocks**
for a model — effective rates, unit-price outliers, importer discrepancy
profiles, criterion hit-rates. See the
[ML risk-engine guide](ml-risk-engine.md) for how these features feed a
fraud / valuation model and the clearance lanes, and
[running on a real ASYCUDA World](../platform/running-on-real-asycuda.md) to
run them on *your own* declarations.
## The entry format
Every query is one **entry** with a fixed shape — keep the headings and their
order exactly:
````markdown
### { #stable-slug }
**Intent:** one line — what question it answers.
**Tags:** `revenue` · `hs` · `trader` (pick from the vocabulary below)
**Verified:** ✓ against the seeded sandbox
```sql
SET search_path TO asycuda, public;
SELECT …;
```
**How it works:** 2–4 sentences on the join path and any windowing.
**Variations:** optional bullet list of tweaks.
````
### The tag vocabulary
Pick one or more from this closed set; add a new tag here first if you truly
need one, so the vocabulary stays the single source of truth.
| Tag | Use it for |
|-----|------------|
| `revenue` | duty, VAT and fee amounts; fiscal totals and shares |
| `valuation` | customs value, CIF build-up, unit prices, value integrity |
| `risk` | selectivity lanes, criteria, inspection outcomes |
| `trader` | importer, exporter, declarant/broker analytics |
| `manifest` | cargo, bills of lading, write-offs, reconciliation |
| `quality` | data-integrity checks that should return **zero rows** |
| `time` | dwell times, lifecycle durations, throughput over time |
| `suspense` | warehousing, transit, temporary admission, guarantees |
### To add a query
1. Copy the template block above into the right `## ` category (or start a new one).
2. Keep the four bold headings and the fenced `sql` block; write an imperative title and a stable `{ #slug }`.
3. Run it read-only against your sandbox and fix any error before you commit it:
```bash
PGOPTIONS='-c default_transaction_read_only=on -c search_path=asycuda,public' \
psql -X -d customs_sandbox -c ""
```
4. Only then tick **Verified: ✓**. An unverified query does not belong in the library.
!!! tip "Integrity checks are supposed to return nothing"
A `quality`-tagged query that returns **zero rows** on clean data has
passed — it found no defects. That is the expected result, not an empty
report.
---
## Revenue & fiscal
### Effective duty rate by HS chapter { #effective-rate-by-chapter }
**Intent:** what proportion of declared customs value is actually collected as tax, grouped by tariff chapter.
**Tags:** `revenue` · `valuation`
**Verified:** ✓ against the seeded sandbox
```sql
SET search_path TO asycuda, public;
WITH item_tax AS ( -- collapse an item's tax lines to one row first
SELECT di.id, left(di.hs_code, 2) AS hs_chapter,
di.customs_value, sum(tl.tax_amount) AS item_tax
FROM declaration_item di
JOIN declaration_tax_line tl ON tl.declaration_item_id = di.id
GROUP BY di.id, di.hs_code, di.customs_value
)
SELECT hs_chapter,
sum(customs_value) AS customs_value,
sum(item_tax) AS total_tax,
round(100.0 * sum(item_tax) / nullif(sum(customs_value), 0), 2) AS effective_rate_pct
FROM item_tax
GROUP BY hs_chapter
ORDER BY effective_rate_pct DESC NULLS LAST;
```
**How it works:** The inner CTE collapses an item's several tax lines to one row *first* — otherwise summing `customs_value` after the tax join double-counts the value once per tax line. Only then is value and tax rolled up per chapter and divided.
**Variations:** group by `di.hs_id` + join `ref_hs_tariff` for the full code; filter `WHERE tt.code = 'IMP'` for a duty-only rate.
### Rank revenue concentration with a running share { #revenue-concentration }
**Intent:** which HS chapters carry the revenue, and how few of them make up most of it (Pareto view).
**Tags:** `revenue` · `hs`
**Verified:** ✓ against the seeded sandbox
```sql
SET search_path TO asycuda, public;
WITH chapter_rev AS (
SELECT left(di.hs_code, 2) AS hs_chapter,
sum(tl.tax_amount) AS revenue
FROM declaration_item di
JOIN declaration_tax_line tl ON tl.declaration_item_id = di.id
GROUP BY 1
)
SELECT hs_chapter,
revenue,
round(100.0 * revenue / sum(revenue) OVER (), 1) AS pct_of_total,
round(100.0 * sum(revenue) OVER (ORDER BY revenue DESC)
/ sum(revenue) OVER (), 1) AS running_pct
FROM chapter_rev
ORDER BY revenue DESC;
```
**How it works:** Two window frames over the same result: `SUM() OVER ()` gives the grand total for each row's share, and `SUM() OVER (ORDER BY revenue DESC)` accumulates a running total down the ranking, reaching 100 at the last row.
**Variations:** swap the chapter expression for `d.consignee_id` (join `trader`) to concentrate by importer; wrap with `WHERE running_pct <= 80` for the vital-few chapters.
### Split duty, VAT and other taxes per office { #duty-vat-split }
**Intent:** how each customs office's collection breaks down between import duty, VAT and everything else.
**Tags:** `revenue`
**Verified:** ✓ against the seeded sandbox
```sql
SET search_path TO asycuda, public;
SELECT off.office_code,
off.name AS office,
sum(tl.tax_amount) FILTER (WHERE tt.code = 'IMP') AS import_duty,
sum(tl.tax_amount) FILTER (WHERE tt.code = 'VAT') AS vat,
sum(tl.tax_amount) FILTER (WHERE tt.code NOT IN ('IMP','VAT')) AS other_taxes,
round(100.0 * sum(tl.tax_amount) FILTER (WHERE tt.code = 'VAT')
/ nullif(sum(tl.tax_amount), 0), 1) AS vat_share_pct
FROM declaration d
JOIN ref_customs_office off ON off.id = d.office_id
JOIN declaration_item di ON di.declaration_id = d.id
JOIN declaration_tax_line tl ON tl.declaration_item_id = di.id
JOIN ref_tax_type tt ON tt.id = tl.tax_type_id
GROUP BY off.office_code, off.name
ORDER BY off.office_code;
```
**How it works:** `FILTER (WHERE …)` turns one pass over the tax lines into several conditional sums, so duty, VAT and residual taxes land in separate columns without a self-join. Codes come from `ref_tax_type`.
**Variations:** add `d.registration_date` to the grouping for a monthly breakdown per office.
## Valuation quality
### Flag unit-price outliers against the per-HS median { #unit-price-outliers }
**Intent:** which item lines are priced far from the typical value-per-kilo for their HS code — a classic under-valuation signal.
**Tags:** `valuation` · `risk`
**Verified:** ✓ against the seeded sandbox
```sql
SET search_path TO asycuda, public;
WITH unit AS (
SELECT di.id, di.hs_code,
(di.customs_value / nullif(di.net_mass, 0))::numeric AS unit_price
FROM declaration_item di
WHERE di.net_mass > 0 AND di.customs_value IS NOT NULL
),
hs_median AS ( -- percentile_cont is an ordered-set aggregate, not a window fn
SELECT hs_code,
percentile_cont(0.5) WITHIN GROUP (ORDER BY unit_price)::numeric AS median_unit_price
FROM unit GROUP BY hs_code
)
SELECT u.hs_code,
round(u.unit_price, 4) AS unit_price,
round(m.median_unit_price, 4) AS hs_median,
round(100.0 * (u.unit_price - m.median_unit_price)
/ nullif(m.median_unit_price, 0), 1) AS deviation_pct
FROM unit u
JOIN hs_median m ON m.hs_code = u.hs_code
ORDER BY abs(u.unit_price - m.median_unit_price) DESC;
```
**How it works:** The first CTE derives a value-per-kilo per item; the second computes the median per HS code. `percentile_cont` is an ordered-set aggregate — it cannot be a window function, so the median is grouped in its own CTE and joined back. Rows are ordered by absolute deviation, largest first.
**Variations:** filter `WHERE abs(deviation_pct) > 40` for strong outliers only; group the median by `left(hs_code, 4)` (heading) for thin datasets.
### Reconcile item customs value against the valuation note { #value-vs-valuation-note }
**Intent:** find item lines whose declared `customs_value` disagrees with the per-item CIF built up in the valuation note.
**Tags:** `valuation` · `quality`
**Verified:** ✓ against the seeded sandbox — returns zero rows on clean data
```sql
SET search_path TO asycuda, public;
SELECT d.registration_number, di.item_number, di.hs_code,
di.customs_value, ivn.item_cif,
(di.customs_value - ivn.item_cif) AS difference
FROM declaration_item di
JOIN declaration d ON d.id = di.declaration_id
JOIN item_value_note ivn ON ivn.declaration_item_id = di.id
WHERE di.customs_value IS DISTINCT FROM ivn.item_cif
ORDER BY abs(di.customs_value - ivn.item_cif) DESC;
```
**How it works:** `item_value_note.item_cif` is the freight-and-insurance-apportioned value that *should* equal the item's `customs_value`. `IS DISTINCT FROM` treats NULLs safely, so a missing value on either side surfaces rather than hides. Any row returned is an integrity break to investigate.
## Risk & selectivity
### Measure criterion hit-rate and discrepancy rate { #criterion-hit-rate }
**Intent:** for each risk criterion, how often it fires, how often that leads to inspection, and how often inspection finds a discrepancy.
**Tags:** `risk`
**Verified:** ✓ against the seeded sandbox
```sql
SET search_path TO asycuda, public;
SELECT rc.code AS criterion,
rc.name,
count(sr.id) AS times_triggered,
count(ia.id) AS inspections,
count(ia.id) FILTER (WHERE ia.result = 'discrepancy') AS discrepancies,
round(100.0 * count(ia.id) FILTER (WHERE ia.result = 'discrepancy')
/ nullif(count(ia.id), 0), 1) AS discrepancy_rate_pct
FROM risk_criterion rc
LEFT JOIN selectivity_result sr ON sr.criterion_id = rc.id
LEFT JOIN inspection_act ia ON ia.declaration_id = sr.declaration_id
GROUP BY rc.code, rc.name
ORDER BY discrepancy_rate_pct DESC NULLS LAST, times_triggered DESC;
```
**How it works:** Walks `risk_criterion → selectivity_result` (each firing) then to the `inspection_act` of the same declaration; `LEFT JOIN` keeps criteria that never fired. A high discrepancy rate on a frequently-firing criterion is well targeted; one that fires often but finds nothing is a candidate to retire.
### Profile importers by discrepancy history { #trader-discrepancy-profile }
**Intent:** which importers have a track record of inspection discrepancies — a signal to feed back into risk profiling.
**Tags:** `risk` · `trader`
**Verified:** ✓ against the seeded sandbox
```sql
SET search_path TO asycuda, public;
SELECT tr.tin,
tr.name AS importer,
count(DISTINCT d.id) AS declarations,
count(DISTINCT ia.declaration_id) AS inspected,
count(ia.id) FILTER (WHERE ia.result = 'discrepancy') AS discrepancies,
round(100.0 * count(ia.id) FILTER (WHERE ia.result = 'discrepancy')
/ nullif(count(ia.id), 0), 1) AS discrepancy_rate_pct
FROM declaration d
JOIN trader tr ON tr.id = d.consignee_id
LEFT JOIN inspection_act ia ON ia.declaration_id = d.id
GROUP BY tr.tin, tr.name
HAVING count(ia.id) > 0
ORDER BY discrepancy_rate_pct DESC, declarations DESC;
```
**How it works:** Joins each declaration to its consignee (`consignee_id → trader`) and to any inspection acts. `HAVING count(ia.id) > 0` restricts to importers actually inspected, so the rate is meaningful; the sort brings the worst offenders to the top.
## Trader analytics
### Build the importer league table { #importer-league-table }
**Intent:** rank importers by volume, total customs value and total tax paid.
**Tags:** `trader` · `revenue`
**Verified:** ✓ against the seeded sandbox
```sql
SET search_path TO asycuda, public;
WITH decl_tax AS (
SELECT di.declaration_id, sum(tl.tax_amount) AS decl_tax
FROM declaration_item di
JOIN declaration_tax_line tl ON tl.declaration_item_id = di.id
GROUP BY di.declaration_id
)
SELECT tr.tin,
tr.name AS importer,
count(DISTINCT d.id) AS declarations,
sum(d.total_cif_value) AS total_cif,
sum(dt.decl_tax) AS total_tax
FROM declaration d
JOIN trader tr ON tr.id = d.consignee_id
LEFT JOIN decl_tax dt ON dt.declaration_id = d.id
GROUP BY tr.tin, tr.name
ORDER BY total_tax DESC NULLS LAST;
```
**How it works:** Tax is pre-aggregated to one row per declaration in the CTE *before* the join, so summing per importer never multiplies by the item count. `total_cif_value` is the stored declaration total, safe to sum directly.
**Variations:** swap `d.consignee_id` for `d.exporter_id` to rank exporters instead.
### Rank broker (declarant) activity { #declarant-activity }
**Intent:** how much clearance work each broker handles, and across how many distinct importers.
**Tags:** `trader`
**Verified:** ✓ against the seeded sandbox
```sql
SET search_path TO asycuda, public;
SELECT br.tin,
br.name AS declarant,
count(*) AS declarations_lodged,
count(DISTINCT d.consignee_id) AS distinct_importers,
sum(d.total_cif_value) AS total_cif_handled
FROM declaration d
JOIN trader br ON br.id = d.declarant_id
GROUP BY br.tin, br.name
ORDER BY declarations_lodged DESC;
```
**How it works:** The declarant (SAD box 14) is a `trader` in its own right, referenced by `declaration.declarant_id`. Counting distinct `consignee_id` shows whether a broker serves one client or many.
## Manifest reconciliation
### Reconcile manifested against written-off packages { #manifest-writeoff-reconciliation }
**Intent:** per bill of lading, compare the packages the carrier manifested with the packages actually written off on declarations — surfacing under- or over-declaration.
**Tags:** `manifest` · `quality`
**Verified:** ✓ against the seeded sandbox
```sql
SET search_path TO asycuda, public;
WITH manifested AS (
SELECT bl.id AS bl_id, bl.bl_reference,
sum(ci.number_of_packages) AS manifested_packages
FROM bill_of_lading bl
JOIN manifest_cargo_item ci ON ci.bl_id = bl.id
GROUP BY bl.id, bl.bl_reference
),
written_off AS (
SELECT pd.bl_id, sum(pd.written_off_packages) AS written_off_packages
FROM declaration_previous_document pd
WHERE pd.bl_id IS NOT NULL GROUP BY pd.bl_id
)
SELECT m.bl_reference,
m.manifested_packages,
coalesce(w.written_off_packages, 0) AS written_off_packages,
m.manifested_packages - coalesce(w.written_off_packages, 0) AS remaining_packages
FROM manifested m
LEFT JOIN written_off w ON w.bl_id = m.bl_id
ORDER BY remaining_packages DESC;
```
**How it works:** One CTE totals the cargo lines per B/L; the other totals what declarations wrote off against it via `declaration_previous_document` (SAD box 40). `remaining_packages` above zero is cargo not yet cleared; below zero means more was declared than manifested — an anomaly.
## Lifecycle timing
### Measure dwell time between status transitions { #status-dwell-time }
**Intent:** how long a declaration sits at each stage — registered → assessed → paid → released.
**Tags:** `time`
**Verified:** ✓ against the seeded sandbox
```sql
SET search_path TO asycuda, public;
WITH trail AS (
SELECT d.registration_number, st.code AS status, h.changed_at,
lead(st.code) OVER w AS next_status,
lead(h.changed_at) OVER w AS next_changed_at
FROM declaration_status_history h
JOIN declaration d ON d.id = h.declaration_id
JOIN ref_declaration_status st ON st.id = h.status_id
WINDOW w AS (PARTITION BY h.declaration_id ORDER BY h.changed_at)
)
SELECT registration_number,
status || ' -> ' || next_status AS transition,
changed_at, next_changed_at - changed_at AS dwell
FROM trail
WHERE next_status IS NOT NULL
ORDER BY registration_number, changed_at;
```
**How it works:** `lead()` looks one row ahead within each declaration's history (ordered by `changed_at`), so every row knows its successor status and time. The interval between them is the dwell at the current stage; the final status has no successor and drops out.
**Variations:** aggregate `avg(next_changed_at - changed_at)` grouped by the transition for a mean dwell per stage across all declarations.
## Data quality
### List item lines carrying no tax { #items-without-tax }
**Intent:** find declaration items with no tax line at all — usually a data-entry gap rather than a genuine zero-rating.
**Tags:** `quality`
**Verified:** ✓ against the seeded sandbox — returns zero rows on clean data
```sql
SET search_path TO asycuda, public;
SELECT d.registration_number, di.item_number, di.hs_code, di.customs_value
FROM declaration_item di
JOIN declaration d ON d.id = di.declaration_id
WHERE NOT EXISTS (
SELECT 1 FROM declaration_tax_line tl WHERE tl.declaration_item_id = di.id
)
ORDER BY d.registration_number, di.item_number;
```
**How it works:** An anti-join with `NOT EXISTS` keeps only items with no matching tax line. Genuine relief should still show a zero-amount line with an exemption code, so a truly empty item is worth a second look.
### Catch assessed tax that does not match the receipt { #assessed-vs-receipted }
**Intent:** flag declarations where the sum of tax lines differs from the amount actually receipted.
**Tags:** `quality` · `revenue`
**Verified:** ✓ against the seeded sandbox — returns zero rows on clean data
```sql
SET search_path TO asycuda, public;
SELECT d.registration_number, assessed.total_tax,
r.total_amount AS receipted,
assessed.total_tax - coalesce(r.total_amount, 0) AS variance
FROM declaration d
JOIN LATERAL (
SELECT sum(tl.tax_amount) AS total_tax
FROM declaration_item di
JOIN declaration_tax_line tl ON tl.declaration_item_id = di.id
WHERE di.declaration_id = d.id
) assessed ON true
LEFT JOIN payment p ON p.declaration_id = d.id
LEFT JOIN receipt r ON r.payment_id = p.id
WHERE assessed.total_tax IS DISTINCT FROM coalesce(r.total_amount, 0)
ORDER BY abs(assessed.total_tax - coalesce(r.total_amount, 0)) DESC;
```
**How it works:** A `LATERAL` subquery totals the tax per declaration; the payment and receipt are joined in. This is the sibling of the querying guide's assessed-vs-paid check, but *filtered* to keep only the mismatches — a focused exception report rather than a full reconciliation.
### Find released declarations missing a required inspection act { #released-without-inspection }
**Intent:** released declarations on an exam-requiring lane that have no inspection act on file — a control gap.
**Tags:** `quality` · `risk`
**Verified:** ✓ against the seeded sandbox — returns zero rows on clean data
```sql
SET search_path TO asycuda, public;
SELECT d.registration_number, lane.code AS lane, st.code AS status
FROM declaration d
JOIN ref_selectivity_lane lane ON lane.id = d.selectivity_lane_id
JOIN ref_declaration_status st ON st.id = d.status_id
WHERE lane.requires_exam
AND st.code = 'released'
AND NOT EXISTS (
SELECT 1 FROM inspection_act ia WHERE ia.declaration_id = d.id
)
ORDER BY d.registration_number;
```
**How it works:** If a lane's `requires_exam` is true, release should be preceded by an `inspection_act`. The `NOT EXISTS` anti-join returns any declaration that reached `released` without one — a procedural breach worth auditing.
---
## Run these on a real ASYCUDA World system
Everything here targets the toolbox's normalised `asycuda` schema, not the real
ASYCUDA World physical tables. To point the same SQL at a live system, run it
through the compatibility-view adapter in
[running on real ASYCUDA](../platform/running-on-real-asycuda.md) — the views
expose these table and column names over the real store, so the queries above
run unchanged.
## Test before you trust
Before adding a query here — or running one you did not write against a
production database — validate it. The [`customs-query`](../skills/index.md)
skill tests SQL through the customs-query-tester MCP, and the bundled
`skills/customs-query/scripts/test_query.sh` does the same from the shell. Both
are **privacy-preserving**: they check the query against schema metadata and a
read-only plan, never returning row data — safe against databases holding real
customs declarations.
---
# Extending the schema
The model is a starting point. When you add to it, keep two things intact: the
**conventions** (so it stays consistent) and the **provenance trail** (so your
fork stays as auditable as the original).
## The conventions, as a checklist
When adding a table or column:
- [ ] Live in the `asycuda` schema (the load already `SET search_path`).
- [ ] `snake_case` names; `ref_` prefix for code tables, `sys_` for system/RBAC.
- [ ] Surrogate PK: `bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY`.
- [ ] Keep real business codes as `UNIQUE NOT NULL` (don't rely on the surrogate).
- [ ] Coded columns are a **foreign key to a `ref_*` table**, not an inline
code+name pair.
- [ ] Money `numeric(18,4)`; mass/quantity `numeric(18,3)`; dates `date` /
`timestamptz`; flags real `boolean`.
- [ ] A status lifecycle → a `ref_*_status` table + a `*_status_history` child.
- [ ] `COMMENT ON` anything non-obvious.
- [ ] **Tag provenance** on the `CREATE TABLE` (see below).
## Provenance is the rule that makes this project trustworthy
Every `CREATE TABLE` carries exactly one provenance tag on the line above it:
```sql
-- src: S014, S003 (cite the SOURCES.md IDs the table is grounded in)
CREATE TABLE declaration_item ( ... );
-- inferred (introduced by your own modelling judgement)
CREATE TABLE trader_role ( ... );
```
The rule from the project goal is worth repeating:
!!! quote
A larger honest **inferred** set beats a fabricated **documented** one. If you
can't ground a table in a source you actually have, tag it `-- inferred` and
record it in `COVERAGE.md` — never invent a citation.
Individually inferred columns inside an otherwise-documented table get their own
inline `-- inferred` note too.
## Worked change — add a table and a column
Say you want to model **container gate movements** (a new concept) and add a
**customs-value-method note** column to items.
**1 · Add the column** (documented — it maps to SAD box 43):
```sql
-- src: S003 (SAD box 43 valuation method note)
ALTER TABLE declaration_item
ADD COLUMN valuation_method_note varchar(200);
COMMENT ON COLUMN declaration_item.valuation_method_note
IS 'Free-text note on the valuation method chosen (SAD box 43).';
```
**2 · Add the table** (inferred — no public source at this granularity):
```sql
-- inferred (gate in/out events are operational; not in the reference docs)
CREATE TABLE asycuda.container_gate_move (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
container_id bigint NOT NULL REFERENCES container(id) ON DELETE CASCADE,
direction varchar(3) NOT NULL, -- in / out
moved_at timestamptz NOT NULL DEFAULT now(),
office_id bigint REFERENCES ref_customs_office(id),
CONSTRAINT ck_gate_dir CHECK (direction IN ('in','out'))
);
COMMENT ON TABLE asycuda.container_gate_move
IS 'Container gate in/out events at a customs office; inferred.';
```
## Keep the docs in lock-step
Three files are the audit surface — update them in the same change:
| File | Update |
|------|--------|
| `SOURCES.md` | Add any **new** source ID you cited (with URL, note, and a cached copy under `sources/`). |
| `COVERAGE.md` | Add the new table under its module, marked `documented` / `partial` / `inferred`. |
| `DATA_DICTIONARY.md` | Regenerate from the catalog (it is generated, not hand-written). |
## Verify nothing broke
Re-run the load and the done-condition checks after any change:
```bash
createdb oct_check
psql -v ON_ERROR_STOP=1 -d oct_check -f Sydonia/schema/asycuda.sql
psql -v ON_ERROR_STOP=1 -d oct_check -f Sydonia/schema/seed_reference.sql
psql -v ON_ERROR_STOP=1 -d oct_check -f Sydonia/examples/e2e.sql
dropdb oct_check
# every CREATE TABLE must still carry a provenance tag:
grep -niE 'create[ \t]+table' Sydonia/schema/asycuda.sql | wc -l
```
!!! tip "Let an Agent Skill enforce this"
The [`customs-extend`](../skills/index.md) skill applies this whole checklist —
conventions, provenance tag, doc updates, and a re-load — for you, and
[`customs-validate`](../skills/index.md) confirms the schema still loads clean
and every table is grounded.
---
# Worked example — manifest to release
`Sydonia/examples/e2e.sql` inserts one complete import and prints it back. This
page narrates that script so you can see the domain and the schema working
together. The scenario: **consumer electronics and apparel shipped from Shanghai
to Pohnpei (FSM)**, cleared by a broker.
Load it (after the schema and seed):
```bash
psql -v ON_ERROR_STOP=1 -d customs_sandbox -f Sydonia/examples/e2e.sql
```
The whole script runs in **one transaction** and uses natural-key subselects
(`SELECT id FROM … WHERE code = …`) so it is order-independent and self-documenting.
## 0 · Parties
Five economic operators — carrier, shipping agent, exporter, importer/consignee
and broker — plus two system users (the broker and a customs officer).
```sql
INSERT INTO trader (tin, name, address, country_id) VALUES
('EXP001','Shenzhen Electronics Co', 'Shenzhen, China', (SELECT id FROM ref_country WHERE iso_alpha2='CN')),
('IMP001','Pohnpei Trading Ltd', 'Kolonia, Pohnpei',(SELECT id FROM ref_country WHERE iso_alpha2='FM')),
...;
```
## 1 · Manifest
The carrier's manifest: **MV Pacific Star**, voyage V2026-042, Shanghai →
Pohnpei, one bill of lading, one container, 250 packages. A house B/L
(`POL-BL-88231`, nature *Imports*) carries two goods lines — phones and T-shirts
— stuffed in container `OTEU1223808`.
```mermaid
flowchart LR
M[manifest
V2026-042] --> B[bill_of_lading
POL-BL-88231]
B --> C[container
OTEU1223808]
B --> G1[cargo line 1
851712 phones]
B --> G2[cargo line 2
610910 T-shirts]
```
## 2 · Declaration
The broker files an **IM4** (import for home use) declaration, regime `4000`,
against the manifest. Two items:
| Item | HS | Goods | FOB |
|:----:|----|-------|----:|
| 1 | 851712 | Mobile telephones | $40,000 |
| 2 | 610910 | Cotton T-shirts | $20,000 |
Invoice terms are **CIF**, currency USD, total FOB $60,000, freight $3,000,
insurance $300 → total CIF **$63,300**.
## 3 · Valuation note
Freight and insurance are apportioned to each item by FOB share (2:1):
| Item | FOB | + freight | + insurance | = item CIF |
|:----:|----:|----------:|------------:|-----------:|
| 1 | 40,000 | 2,000 | 200 | **42,200** |
| 2 | 20,000 | 1,000 | 100 | **21,100** |
Those item CIFs become the **customs value** — the tax base.
## 4 · Tax lines
Duty and VAT per item. Note VAT cascades — its base is *(customs value + import
duty)*:
| Item | Tax | Base | Rate | Amount |
|:----:|-----|-----:|:----:|-------:|
| 1 | IMP | 42,200 | 5% | 2,110.00 |
| 1 | VAT | 44,310 | 10% | 4,431.00 |
| 2 | IMP | 21,100 | 15% | 3,165.00 |
| 2 | VAT | 24,265 | 10% | 2,426.50 |
**Total assessed: $12,132.50.**
## 5 · Documents
An attached commercial invoice (`380`) and bill of lading (`705`), then each item
is **written off** against the manifest B/L via `declaration_previous_document`
(SAD box 40) — closing the loop between what the carrier manifested and what the
importer declared.
## 6 · Lifecycle, selectivity, payment, release
The declaration walks its lifecycle, is routed **RED** by the `HS-HIGHRISK`
criterion (electronics chapter), inspected and found conform, paid ($12,132.50,
receipt `RCPT-2026-0427`), and released:
```mermaid
stateDiagram-v2
direction LR
[*] --> stored
stored --> registered
registered --> assessed
assessed --> paid : selectivity RED → inspected conform
paid --> released
released --> [*]
```
An `audit_log` row records the release.
## The verification read-out
After `COMMIT`, the script prints four checks:
```text
--- Declaration summary ---
reg | type | status | lane | total_items | total_cif_value
-------+------+----------+------+-------------+-----------------
C 427 | IM4 | released | RED | 2 | 63300.0000
--- Items with tax totals ---
item_number | hs_code | customs_value | taxes
-------------+---------+---------------+-----------
1 | 851712 | 42200.0000 | 6541.0000
2 | 610910 | 21100.0000 | 5591.5000
--- Total assessed vs receipt ---
total_assessed | receipt_amount
----------------+----------------
12132.5000 | 12132.5000
--- Lifecycle trail ---
sort_order | code | note
------------+------------+-------------------------------------
1 | stored | Draft captured
2 | registered | Validated & registered (C 427)
3 | assessed | Assessed: duties & taxes calculated
4 | paid | Paid — receipt RCPT-2026-0427
5 | released | Release order issued
```
`total_assessed = receipt_amount` and every foreign key resolves — the model is
internally consistent end to end.
## Try changing it
- Add a **third item** and watch the apportionment and totals shift.
- Route it **GREEN** instead and drop the `inspection_act`.
- Pay against a deferred **`account`** instead of cash, and add the
`account_movement`.
The [querying guide](querying.md) has the reports to see your changes; the
[extending guide](extending.md) shows how to add new structure.
---
# ML on customs data
This toolbox was reconstructed with two payloads in mind: **(1)** doing
analytics / ML on customs declarations, and **(2)** plugging an external risk
engine into the clearance flow. This guide is the blueprint — distilled from the
public ASYCUDA record and public customs-ML research — and, crucially, a way to
**prototype the whole loop against this schema before you have access to a live
ASYCUDA system**.
!!! note "Read the platform behaviour first"
This guide assumes the clearance state machine and four-lane routing from
[Selectivity & clearance](../platform/selectivity-clearance.md). The lanes,
the criterion model, and the **random exploration slot** are the hooks
everything here plugs into.
## The canonical loop
Every documented ASYCUDA-plus-ML deployment follows the same four-step loop:
**read → score → inject → feedback**.
```mermaid
flowchart LR
A[("ASYCUDA
declarations")] -->|read: DB / API / stream| B[External model]
B -->|score: fraud + revenue| C{Inject}
C -->|criteria / trader profile| D[Selectivity lane]
C -.->|exploration| E[Random slot]
D --> F[Inspection Act]
E --> F
F -->|feedback: relabel| B
```
The pattern is proven at several levels of evidence:
- **Vendor risk-management plugins** on legacy AW — the real-world blueprint.
A typical plugin ingests ASYCUDA data *through either a database connection or
an API*, trains and scores externally, pushes high-risk flags back into
selectivity, and feeds inspection outcomes back in real time to retrain.
- **Live production deployments** — the deployable analogue. A separate but
integrated risk module within ASYCUDA World retrieves the declaration in real
time on submission, scores it (nomenclature, origin, importer/exporter history,
declaration patterns, valuation anomalies), and sends risk recommendations back
to officers through ASYCUDA World. This proves the architecture is deployable
**today**.
- **ASY5 first-class hook** — the new generation transforms *"signals from ML
analysis of customs data or from third-party AI engines … into clear risk
profiles"* via a **Risk Configuration → Signal Transformation → Risk Output**
pipeline. The intended injection point once a country is on ASY5 — but the
**payload format is not yet public**.
## The feature set
The empirically-standard feature schema used across public customs-ML research
maps directly onto ASYCUDA fields and onto our tables. The fourth column is what
you query in the [`asycuda`](../schema/data-dictionary.md) schema:
| Concept | SAD box | AW XML tag | Sydonia Toolkit column |
|---------|:-------:|------------|-----------------------------|
| Declaration ID | 7 | `Declarant/Reference/Number` | `declaration.id` (business ref: `declaration.trader_reference`) |
| Date | reg. | `Identification/Registration/Date` | `declaration.registration_date` |
| Importer TIN | 8 | `Consignee_code` | `trader.tin` via `declaration.consignee_id` |
| Declarant / broker | 14 | `Declarant_code` | `trader.tin` via `declaration.declarant_id` |
| Origin | 34a | `Country_of_origin_code` | `declaration_item.country_origin_id` → `ref_country` |
| Office / port | A/29 | `Customs_clearance_office_code` | `declaration.office_id` → `ref_customs_office` |
| HS / tariff | 33 | `Commodity_code` | `declaration_item.hs_code` |
| Quantity | 41 | `Supplementary_unit_quantity` | `declaration_item.supplementary_qty` |
| Gross weight | 35 | `Gross_weight_itm` | `declaration_item.gross_mass` |
| Invoice value (FOB) | 22/42 | `Invoice/Amount_foreign_currency` | `declaration_item.item_price` (hdr `declaration.total_invoice_amount`) |
| Customs value (CIF) | 46 | `Statistical_value` | `declaration_item.customs_value` |
| Total taxes | 47 | `Item_taxes_amount` / `Global_taxes` | `sum(declaration_tax_line.tax_amount)` |
| **Label** — illicit (0/1) | inspection | (server outcome) | `inspection_act.result` |
| **Target** — revenue | inspection | (server outcome) | `inspection_act.findings` (free text; not a structured amount) |
**Engineered signals** the literature relies on: unit price (`cif / quantity`),
weight-unit price (`cif / gross_mass`), tax ratio (`total_taxes / cif`),
FOB/CIF ratio, cross-features (**HS6 × origin**, office × importer), and
mean-target risk encodings per importer / HS / office.
## Labels & bias
The single hardest problem is not features — it is **labels**.
- **Labels only exist for inspected declarations.** `illicit` / `revenue` are
recorded only for Yellow/Red (and PCA) declarations; green-lane transactions
are unlabelled. This is **selection bias** baked into your training set.
- **The Inspection Act is the feedback signal** — in our model,
`inspection_act.result` (conform / discrepancy) and `inspection_act.findings`.
Read it back to relabel and retrain.
- **Exploration mitigates the bias.** The **random slot** (the 1–3% green→red
re-route) is your uniform sample of the unlabelled space — reserve it for
exploration rather than exploitation.
- **The field uses semi-supervised + active-learning methods** (e.g. GraphFC)
precisely because of this bias: inspected items get relabelled and added to
training (active learning), and unlabelled structure is exploited
semi-supervised.
## Prototype on this toolbox
You can build and test the entire read → feature → label loop against this
schema before touching a live system.
**1. Stand up the schema.** Use the
[`customs-schema-setup`](../skills/index.md) skill to load the `asycuda` schema,
then [`customs-seed`](../skills/index.md) to generate synthetic manifests,
declarations, items, tax lines and inspection acts. Set the search path once:
```sql
SET search_path TO asycuda, public;
```
**2. Extract a per-item feature vector.** One row per declared item, with the
engineered ratios computed in-query:
```sql
SELECT d.id AS declaration_id,
d.registration_date AS decl_date,
imp.tin AS importer_tin,
org.iso_alpha2 AS origin,
off.office_code AS office,
di.hs_code,
left(di.hs_code, 6) AS hs6,
di.gross_mass,
di.customs_value AS cif,
coalesce(sum(tl.tax_amount), 0) AS total_taxes,
di.customs_value / nullif(di.gross_mass, 0) AS unit_price_per_kg,
coalesce(sum(tl.tax_amount), 0)
/ nullif(di.customs_value, 0) AS tax_ratio
FROM declaration_item di
JOIN declaration d ON d.id = di.declaration_id
JOIN trader imp ON imp.id = d.consignee_id
LEFT JOIN ref_country org ON org.id = di.country_origin_id
JOIN ref_customs_office off ON off.id = d.office_id
LEFT JOIN declaration_tax_line tl ON tl.declaration_item_id = di.id
GROUP BY d.id, d.registration_date, imp.tin, org.iso_alpha2, off.office_code,
di.hs_code, di.gross_mass, di.customs_value;
```
**3. Pull the labels.** Only inspected declarations have them — the join is a
`LEFT JOIN`, and the `NULL` rows *are* the selection bias:
```sql
SELECT d.id AS declaration_id,
lane.code AS lane,
ia.result,
ia.findings,
CASE WHEN ia.result = 'discrepancy' THEN 1
WHEN ia.result = 'conform' THEN 0
ELSE NULL END AS illicit_label
FROM declaration d
JOIN ref_selectivity_lane lane ON lane.id = d.selectivity_lane_id
LEFT JOIN inspection_act ia ON ia.declaration_id = d.id;
```
**4. Aggregate a per-importer risk encoding.** A mean-target feature — the kind
the literature leans on hardest:
```sql
SELECT imp.tin AS importer_tin,
count(*) AS inspected,
count(*) FILTER (WHERE ia.result = 'discrepancy') AS discrepancies,
round(100.0 * count(*) FILTER (WHERE ia.result = 'discrepancy')
/ nullif(count(*), 0), 1) AS discrepancy_rate
FROM declaration d
JOIN trader imp ON imp.id = d.consignee_id
JOIN inspection_act ia ON ia.declaration_id = d.id
GROUP BY imp.tin
ORDER BY discrepancy_rate DESC NULLS LAST;
```
Adapt the join-path style from [Querying the model](querying.md); every column
above is verified against the schema.
!!! tip "External baselines to prototype against"
Before you have real ASYCUDA history, train against **public customs-ML
research and open customs datasets**. The field offers:
- **Open, downloadable customs declaration datasets** — some synthetic, with
fraud / critical-fraud labels — good enough to prototype the feature
pipeline and modelling against before you have real history.
- **Published dual-task scorers** that predict both illicitness and
recoverable revenue, reporting high precision and revenue recall while
inspecting only a small fraction of flows — a well-documented target to
benchmark against.
- **Reference open-source analytics notebooks** for customs fraud detection —
the standard starting point for feature engineering and baselines.
## Going live
Once you move from this sandbox to a real deployment, the four-step reference
architecture:
1. **Read** — a **DB read-replica** of the ASYCUDA schema for training data
(the proven path for vendor risk plugins), plus **ASYHUB** API / **Cargo-XML**
pre-arrival feed or **Kafka** (ASY5) for real-time scoring.
2. **Score** — output a per-declaration fraud/illicitness score **and** predicted
recoverable revenue (a dual-task scorer).
3. **Inject** — on legacy AW, write into selectivity criteria / trader profiles,
prioritising the **random / exploration slot**; on ASY5, emit "signals".
4. **Feedback** — read **Inspection Act / PCA** outcomes to relabel and retrain.
!!! warning "The deadline is set by when selectivity fires"
Whether selectivity fires **before or after assessment** is a per-country
switch (see
[the timing warning](../platform/selectivity-clearance.md#when-selectivity-fires-the-timing-switch)).
It sets the hard deadline by which your engine must have scored the
declaration. Confirm the mode before committing to real-time.
!!! note "What to request — these are not public"
No public customs-ML dataset or research codebase ships an ASYCUDA connector;
the ETL and write-back are bespoke in every deployment. Before building,
request from your national customs administration or the UNCTAD ASYCUDA
programme: the **physical DB schema**, the **ASYHUB API spec**, the **ASY5
risk-signal payload format**, the **Asysel** admin data model, and
**Inspection-Act read access** (illicit flag + recovered revenue). See
[Integration surfaces](../platform/integration.md) for the doors and the
request list.
---
# Provenance
The credibility of this project rests on one promise: **every table is either
grounded in a cited public source or honestly marked as inferred — nothing is
invented and dressed up as documented.** This section is the evidence.
49tables documented (-- src:)
6tables inferred (-- inferred)
100%cited, cached sourcing
0fabricated citations
- :material-compass:{ .lg .middle } **Methodology**
---
The evidence-first reconstruction loop, the source policy, and the
verification checks that gate "done".
[:octicons-arrow-right-24: Methodology](methodology.md)
- :material-bookshelf:{ .lg .middle } **Sources**
---
The public documentation consulted — official public technical tables, national
ASYCUDA World manuals, and open standards — cited as generic categories.
[:octicons-arrow-right-24: Sources](sources.md)
- :material-format-list-checks:{ .lg .middle } **Coverage**
---
Every module and table, marked documented / partial / inferred, with the
known gaps stated plainly.
[:octicons-arrow-right-24: Coverage](coverage.md)
- :material-vector-difference:{ .lg .middle } **Official fit & gap**
---
Our reconstruction mapped table-by-table against the official
ASYCUDA World tables — where it fits, and where it deliberately differs.
[:octicons-arrow-right-24: Fit & gap](fit.md)
## Read the provenance yourself
You don't have to take these pages on faith. The evidence is in the repository:
```bash
# every CREATE TABLE carries a -- src: or -- inferred tag
grep -niE 'create[ \t]+table' Sydonia/schema/asycuda.sql
# every cited ID resolves to a row in SOURCES.md and a cached file under sources/
grep -oiE '\-\- src: *S[0-9, ]+' Sydonia/schema/asycuda.sql | grep -oiE 'S[0-9]+' | sort -u
```
The [`customs-validate`](../skills/index.md) skill automates exactly this audit.
---
# Reconstruction methodology
How do you rebuild a proprietary system's data model **without touching the
proprietary system**? By treating public documentation as evidence and never
letting the schema get ahead of it. This page is that method — useful both to
audit this project and to apply the same discipline to any other reconstruction.
## The problem
ASYCUDA World (SYDONIA) runs customs in 100+ countries, but its internal database
schema is proprietary and unpublished. The goal was a **faithful, information-
equivalent reference model** for sandbox, analytics, integration and training
use — built entirely from what is public.
## The evidence-first loop
The core discipline: **never write DDL for a table or column until a source for
it is already fetched, cached and cited.** The order is always the same.
```mermaid
flowchart LR
F[fetch
public doc] --> S[save
under sources/]
S --> C[cite
in SOURCES.md]
C --> R[restate
in RESEARCH_LOG.md]
R --> M[model
the table/column]
M --> V[verify
re-load + checks]
V -->|next highest-value gap| F
```
Anything introduced by reasoning rather than a document is tagged `-- inferred`
and recorded as inferred in coverage — never quietly promoted to "documented".
## The provenance contract
Two tags, one on every `CREATE TABLE`, and the rule that governs them:
| Tag | Meaning |
|-----|---------|
| `-- src: ` | Grounded in the cited source(s) `` (each resolves in [Sources](sources.md)) |
| `-- inferred` | Introduced by modelling judgement; no public source at this granularity |
!!! quote "The governing principle"
A larger honest **inferred** set beats a fabricated **documented** one. If
something can't be sourced from a document actually fetched, it is tagged
`-- inferred` and marked in coverage — not given an invented citation.
## Source policy — non-negotiable
**Used freely (public):**
- Official ASYCUDA / UNCTAD programme material and the **official
technical table descriptions** (the real physical schema, published as
reference documentation).
- Full ASYCUDA World user and broker manuals published by national customs
administrations (declaration processing, manifest/cargo, valuation, suspense).
- Open standards: the **WCO Data Model**, the **SAD**, ISO 3166 / 4217 / 6346,
UN/LOCODE, UN/ECE Rec 21, the Harmonized System, Incoterms.
**Never (out of scope, by rule):**
- Downloading, cracking or decompiling the ASYCUDA software to dump its schema.
- Probing, scanning or logging into any live customs deployment beyond its
openly published docs.
- Using leaked credentials or bypassing access controls.
- Reproducing long verbatim copyrighted text — structure and field semantics are
restated in the project's own words; only short field labels are quoted, cited.
The public corpus proved more than sufficient.
## Verification gates "done"
The project is only "done" when these hold **and the checks were re-run** — not
asserted:
1. **Schema loads clean.** `asycuda.sql` → `seed_reference.sql` → `e2e.sql`
against a fresh PostgreSQL database completes with **zero errors**, and the
end-to-end example inserts with referential integrity intact.
2. **Every table is grounded.** Every `CREATE TABLE` carries a `-- src:` (ID in
`SOURCES.md`) or `-- inferred`. Zero untagged.
3. **Every source is cited and cached.** Each cited ID has a row in `SOURCES.md`
*and* a saved local copy under `sources/`.
4. **Supporting docs match the schema.** Data dictionary, ERD, coverage and the
research log are complete and consistent with the loaded model.
These map directly to the [`customs-validate`](../skills/index.md) skill.
## How the model was actually built
7research phases
100%sources fetched & cached
55tables, 100% tagged
1. **Orient** — a SAD overview, a WCO Data Model briefing, and a cargo-manifest
XML message description; confirmed the general + item segment model.
2. **Mine national manuals** — the highest-yield public source: full declaration
and manifest user guides, from which field names, code lists, segments and
lifecycles were extracted and restated.
3. **Code tables & standards** — grounded the `ref_*` tables in the ISO/UN/WCO
standards the forms reference.
4. **Draft the schema** module by module, loading into a scratch DB after each.
5. **Seed & validate** — representative reference data and the end-to-end example.
6. **Document & finalise** — data dictionary, ERD, coverage, fit/gap.
7. **Integrate official data** — later, the **official table
descriptions** were cited across the schema, upgrading 11 tables from
`inferred` to documented (49 / 6).
The full trail lives in `RESEARCH_LOG.md` (append-only findings with source IDs)
and `STATE/progress.md`.
## Why the shape differs from the official schema
The official model is a **wide, denormalised** physical schema tuned for the
ASYCUDA engine; this is a **normalised relational** reference model tuned for
sandboxes and analytics. They are information-equivalent for the modelled scope —
the [fit & gap analysis](fit.md) maps every official table to ours and states the
deliberate differences (FK vs inline code+name, surrogate keys, derived totals).
---
# Sources
Every `-- src: ` in the schema resolves to a row below, where each ID denotes
a **generic category of public documentation** consulted (specific titles,
publishers and URLs are intentionally omitted). Each cited category has a cached
local copy under `sources/` (or `docs/` for the official technical documents).
All material is public. This registry is the single source of truth for citations
— reproduced here from `SOURCES.md`.
Every `-- src: ` comment in `schema/*.sql` resolves to a row in this
registry, and each `` denotes a category of **public documentation**
consulted when modelling that table. A cached copy of the material is retained
locally under `sources/` (or `docs/`).
> Specific document titles, publishers, and URLs have been intentionally omitted
> from this registry. The model is grounded in public documentation only —
> restated in the project's own words — not in any proprietary schema or data
> from a live customs system.
## Categories consulted
- Public references on the **Single Administrative Document (SAD)** and Incoterms.
- **National ASYCUDA World user and broker manuals** published by customs
administrations (declaration processing, manifest/cargo, valuation, suspense).
- **Public ASYCUDA / UNCTAD programme documentation**, including official
technical table descriptions, processing manuals, and XML message descriptions.
- **Open international standards**: ISO 3166 (countries), ISO 4217 (currencies),
ISO 6346 (containers), UN/LOCODE, UN/ECE Rec 21 (packages), the Harmonized
System, the WCO Data Model, Incoterms, and the WTO valuation methods.
## Registry
| ID | Category | Cached |
|----|----------|:------:|
| S001 | Public reference on the SAD declaration | yes |
| S002 | National ASYCUDA World declaration-processing manual | yes |
| S003 | National ASYCUDA World declaration user guide | yes |
| S004 | National ASYCUDA World broker manual | yes |
| S005 | National ASYCUDA World declaration user guide | yes |
| S006 | National ASYCUDA World manifest user guide | yes |
| S007 | National ASYCUDA World manifest user guide | yes |
| S008 | Public ASYCUDA World cargo-manifest XML message description | yes |
| S009 | Public WCO Data Model overview | yes |
| S010 | National ASYCUDA World manifest manual | yes |
| S011 | National ASYCUDA World manifest manual | yes |
| S012 | Public Incoterms reference | yes |
| S013 | Official ASYCUDA World reference-tables description | yes |
| S014 | Official ASYCUDA World declaration-tables description | yes |
| S015 | Official ASYCUDA World manifest-tables description | yes |
| S016 | Official ASYCUDA World accounting-tables description | yes |
| S017 | Official ASYCUDA World SAD processing manual | yes |
| S018 | Official ASYCUDA World introductory manual | yes |
| S019 | ASYCUDA World suspense-declarations manual | yes |
| S020 | Public ASYCUDA World XML manifest message description | yes |
Field semantics were restated in the project's own words; only short field labels
were reused. Where a table could not be grounded in consulted documentation it is
tagged `-- inferred` in the schema and recorded in `COVERAGE.md`.
---
# Coverage
The provenance roll-up: each GOAL module and every table within it, marked
**documented**, **partial** or **inferred**, ending with the known gaps stated
plainly. Reproduced from `COVERAGE.md`.
Legend: **documented** = grounded in a cited source (`-- src:` in the DDL); **partial** =
core grounded, some columns/tables inferred; **inferred** = introduced by modelling judgement
(`-- inferred`), no public source at this granularity. IDs (S0xx) resolve in SOURCES.md.
Every `CREATE TABLE` in `schema/asycuda.sql` (55 tables) carries a `-- src:` or `-- inferred`
tag; this file rolls those up by GOAL §4 module.
> **Official-data update.** The official technical table descriptions cached under `docs/`
> (S013–S016) plus the suspense-declarations manual (S019) are now cited across the schema. This
> upgraded 11 tables from `inferred` to documented (exchange-rate, unit-of-measure, tax-rate,
> manifest-status, account-movement, guarantee, warehouse entry/exit, temporary-admission,
> risk-criterion, audit-log). See `FIT.md` for the full official-table-to-model mapping. Current
> tally: **49 documented / 6 inferred**.
---
## 4.1 Reference / configuration — **documented (partial)**
| Table | Status | Grounding |
|-------|--------|-----------|
| `ref_country` | documented | S008 (ISO 3166 2-alpha nationality) |
| `ref_currency` | documented | S008 (ISO 4217 freight currency), S003 box 22 |
| `ref_exchange_rate` | documented | S013 (official xxRATTAB), S003 box 23 |
| `ref_customs_office` | documented | S008 (customs_office_code AN5), S003 box 29 |
| `ref_location` (UN/LOCODE ports) | documented | S008 (place codes = UN/LOCODE) |
| `ref_transport_mode` | documented | S008 (full 1–9 code list) |
| `ref_package_type` | documented | S008 (UN/ECE Rec 21 alpha-2), S003 box 31 |
| `ref_container_type` | documented | S008 (ISO 6346:1995) |
| `ref_unit_of_measure` | documented | S013 (official xxUOMTAB statistical units), S003 box 41 |
| `ref_incoterm` | documented | S003 box 20, S012 |
| `ref_hs_tariff` | documented | S003 box 33 (HS), S008 (6-digit HS) |
| `ref_cpc_regime` | documented | S003 box 37 (CPC extended procedure) |
| `ref_tax_type` | documented | S003 box 47 (per-tax calculation) |
| `ref_tax_rate` | documented | S013 (official xxRULTAB taxation rules / xxTAXTAR) |
| `ref_document_type` | documented | S003 box 44, S008 attached_document |
| `ref_exemption_code` | documented | S003 box 37 national procedure (ANC) |
| `ref_declaration_type` | documented | S003 box 1 (IM4/EX1/SD4) |
| `ref_declaration_status` | documented | S002 (finder statuses) |
| `ref_manifest_status` | documented | S015 (official GEN_TAB STA status) |
| `ref_bl_nature` | documented | S008 (Bol_nature codes) |
| `ref_selectivity_lane` | documented | S002, S005 (four lanes) |
| `ref_warehouse` | documented | S003 box 49 |
| `trader` / `trader_role` | documented / inferred | S003 boxes 2/8/14, S008 traders; role-junction inferred |
| `sys_user` | documented | S002 (registered users) |
| `sys_role`,`sys_permission`,`sys_user_role`,`sys_role_permission` | inferred | RBAC implied; no public schema |
## 4.2 Manifest / cargo — **documented**
| Table | Status | Grounding |
|-------|--------|-----------|
| `manifest` | documented | S008, S006 (general segment) |
| `bill_of_lading` (master/house, self-ref) | documented | S008, S006, S010 (Bol segment, master/house, degroupage) |
| `container` | documented | S008 (ctn_segment, ISO 6346, seals, reefer) |
| `manifest_cargo_item` | documented | S008 (Goods/Commodity segment) |
| `manifest_status_history` | partial | S015 (GEN_TAB STA) grounds status; history shape inferred |
*Note:* the manifest XML also defines a vehicles sub-segment (chassis/VIN/engine) [S008]; it is
noted in RESEARCH_LOG but not modelled as a table (out of the e2e path). Marked as a known gap.
## 4.3 Declaration — the SAD (core) — **documented**
| Table | Status | Grounding |
|-------|--------|-----------|
| `declaration` (general segment) | documented | S003 (boxes 1–49/B), S001 |
| `declaration_item` (item segment) | documented | S003 (boxes 31–49) |
| `valuation_note` | documented | S003 (value build-up) |
| `item_value_note` | documented | S003 (per-item CIF apportionment), box 46 |
| `declaration_tax_line` | documented | S003 box 47 (type, base, rate, amount, mode) |
| `declaration_attached_document` | documented | S003 box 44, S008 |
| `declaration_previous_document` | documented | S003 box 40, S008 (write-off vs B/L) |
| `declaration_status_history` | documented | S002 (lifecycle) |
| `selectivity_result` | documented | S002, S005 |
## 4.4 Accounting — **documented**
| Table | Status | Grounding |
|-------|--------|-----------|
| `account` | documented | S003 box 48 (deferred payment account) |
| `payment` | documented | S003 box B, S005 |
| `receipt` | documented | S003 box B (receipt number) |
| `account_movement` | documented | S016 (official account transactions in/out) |
| `guarantee` | documented | S019 (suspense guarantees), S003 box 52 |
## 4.5 Transit & suspense — **documented** (S019)
| Table | Status | Grounding |
|-------|--------|-----------|
| `transit_declaration` | documented | S003 boxes 50–53 (principal, offices, guarantee) |
| `ref_warehouse` | documented | S003 box 49 |
| `warehouse_entry` / `warehouse_exit` | documented | S014 (SUS_WH_IN), S019 (suspense manual) |
| `temporary_admission` | documented | S019 (suspense temporary admission regime) |
## 4.6 Selectivity / risk — **documented**
| Table | Status | Grounding |
|-------|--------|-----------|
| `ref_selectivity_lane` | documented | S002, S005 |
| `selectivity_result` | documented | S002, S005 |
| `inspection_act` | documented | S002, S005 (examination) |
| `risk_criterion` | documented | S014 (official SEL_*_PARAM_TAB selectivity criteria) |
## 4.7 Audit / workflow (cross-cutting) — **documented** (LogTable)
| Table | Status | Grounding |
|-------|--------|-----------|
| `audit_log` | documented | S013 (official LogTable actions/procedures) |
| `*_status_history` tables | documented/inferred | declaration & manifest lifecycles (see above) |
---
## Roll-up
- **Documented (`-- src:`):** 49 tables — after citing the official table descriptions
(S013–S016) and the Suspense manual (S019), all reference/manifest/declaration/accounting/transit/
selectivity/audit tables are grounded in an official or public source.
- **Inferred (`-- inferred`):** 6 tables — `trader_role`, `sys_role`, `sys_permission`, `sys_user_role`,
`sys_role_permission` (RBAC + trader-role junction; no published ASYCUDA user/role schema).
- **Known gaps (not blocking the e2e):** manifest vehicle sub-segment; degroupage/split as its own
table (modelled via `bill_of_lading.master_bl_id`); full ISO/UN/WCO code-list *values* (seeded as
representative samples of the referenced standard, not exhaustive catalogues).
No table is left untagged; the honest inferred set is preferred over fabricated citations.
---
# Official fit & gap analysis
How the reconstructed model measures up against the **official public
ASYCUDA World technical table descriptions** (S013–S016). Verdict first, then the
table-by-table mapping and the deliberate structural differences. Reproduced from
`FIT.md`.
This checks our reconstructed PostgreSQL model (`schema/asycuda.sql`) against the **official public
ASYCUDA World technical table descriptions** cached under `docs/` (S013–S016). Verdict first, then the
table-by-table mapping and the deliberate structural differences.
## Verdict
**We fit the official model at the entity and field level.** Every core official operational table
(manifest general segment, bill of lading, containers, SAD general segment, SAD item, SAD taxes,
attached/previous documents, selectivity, accounting, suspense) has a corresponding table in our
schema, and the field semantics line up. After cross-checking, 49 of 55 tables now cite an official
or public source; the 6 remaining `inferred` tables are our own normalisation (RBAC + trader-role
junction), which the official model handles differently rather than "not at all".
**The difference is shape, not content.** The official schema is a *wide, denormalised* physical model
optimised for the ASYCUDA World engine; ours is a *normalised relational* reference model for sandbox,
analytics and integration use (which is exactly this project's stated purpose). The two are
information-equivalent for the modelled scope. Concretely:
| Aspect | Official ASYCUDA World (S013–S016) | Our model |
|--------|-----------------------------------|-----------|
| Table names | terse codes: `GEN_TAB`, `BOL_TAB`, `SAD_General_Segment`, `xxCTYTAB` | descriptive: `manifest`, `bill_of_lading`, `declaration`, `ref_country` |
| Coded fields | store **code _and_ name inline** (`GEN_CAR_COD` + `GEN_CAR_NAM`) | store an **FK to a `ref_*` table** (normalised) |
| General segment | **repeated** into every `BOL_TAB` / `SAD_Item` row | held once in the parent, referenced by FK |
| Keys | `INSTANCE_ID` / `InstanceId` engine keys | `bigint GENERATED ALWAYS AS IDENTITY` surrogate PKs |
| HS code | split across `TAR_HSC_NB1..NB5` (national precision) | single `hs_code` + `ref_hs_tariff` hierarchy |
| Reference validity | every `UN*` table has `VALID_FROM`/`VALID_TO` | `is_active` boolean (+ `valid_from/to` on rate tables) |
| Taxes | `SAD_Tax` + `SAD_Ask_Tax` + `SAD_Global_Taxes` + `SAD_Tax_Totals` | `declaration_tax_line` (+ `is_manual` flag); totals derived by query |
None of these change *what* is captured; they change *how many tables* and *whether a value is a
code+name pair or an FK*. For a reference/analytics model, the normalised form is the intended one.
## Manifest module — official S015 → our tables
| Official table | Our table | Fit |
|----------------|-----------|-----|
| `GEN_TAB` (Manifest General Segment) | `manifest` | ✅ office, voyage, dates (departure/arrival/last discharge), totals (BOL/pkg/ctn/gross), carrier, shipping agent, transporter+nationality+mode, place of loading/unloading, tonnage net/gross, registration year/nbr/date, status. |
| `BOL_TAB` (Bill of lading) | `bill_of_lading` | ✅ reference, line/sub-line, nature, type, previous master B/L ref → our `master_bl_id`, exporter/consignee/notify, loading/unloading, packages, gross, volume, freight/customs/insurance/transport value+currency, seals. |
| `CTN_TAB` / `BOL_CTN_TAB` (Containers) | `container` | ✅ reference, type, packages, empty/full, seals, weights, volume, dangerous goods, description. |
| (goods lines within BOL) | `manifest_cargo_item` | ✅ HS, description, packages, package type, gross, container link. |
| `HIS_WRITE_OFF_TAB` / `REM_WRITE_OFF_TAB` | `declaration_previous_document` (write-off) | ◑ we model write-off as the declaration→B/L link; the official standalone write-off/history tables are folded in. |
| `MAN_TRANSIT_TAB` / `MAN_TRANSH_TAB` | `transit_declaration` | ◑ transit/transhipment management captured at declaration level. |
| vehicle sub-segment | — | ✗ **gap**: RoRo vehicle details (chassis/VIN/engine/make) not modelled (noted in COVERAGE). |
## Declaration module — official S014 → our tables
| Official table | Our table | Fit |
|----------------|-----------|-----|
| `SAD_General_Segment` | `declaration` | ✅ office, model/type, regime, manifest ref, registration/assessment/receipt serials+numbers+dates, exporter/consignee/financial/declarant, countries (export/dest/origin/trading/first-dest), value details, CAP, transport (depart/border/inland MOT, incoterm+place, container flag, place of loading, border office), release fields, **selectivity colour flags (PTY_BLU/RED/YEL/GRE)** → our `selectivity_lane_id` + `selectivity_result`. |
| `SAD_Item` | `declaration_item` | ✅ item no., packages+marks, package type, HS (NB1..NB5→`hs_code`), preference, extended+national procedure, quota, item price, valuation method, value details, attached docs, country of origin, container refs, description, gross/net mass, `VIT_CIF`→`customs_value`, `VIT_STV`→`statistical_value`. |
| `SAD_Tax` | `declaration_tax_line` | ✅ exact: `COD`→tax_type, `BSE`→tax_base, `RAT`→rate, `AMT`→amount, `MOP`→mode_of_payment, `TYP`→`is_manual`. |
| `SAD_Ask_Tax` (manual taxes) | `declaration_tax_line` (`is_manual=true`) | ✅ folded into the flag. |
| `SAD_Global_Taxes`, `SAD_Tax_Totals` | (derived) | ◑ computed by query in the e2e read-out; not stored as summary tables. |
| `SAD_Attached_Documents` | `declaration_attached_document` | ✅ header/item, doc type, reference, date. |
| `SAD_Int` (Previous Documents) | `declaration_previous_document` | ✅ B/L / previous-declaration write-off. |
| `SAD_Supplementary_Unit` | `declaration_item.supplementary_qty/uom` | ✅ captured on the item. |
| `SAD_Relief` | `ref_exemption_code` + `declaration_item.exemption_id` | ✅ relief/exemption. |
| `SAD_Serial_Storage/Registration/Assessment` | `declaration` serial columns + `declaration_status_history` | ◑ serials stored on the header; lifecycle in the history table. |
| `Exit_Note_*` | `warehouse_exit` | ◑ ex-warehouse exit note modelled minimally. |
| `INSP_ACT_TAB` | `inspection_act` | ✅ inspection act. |
| `SEL_PARAM_TAB` / `SEL_*_PARAM_TAB` / `SEL_LISTS*` | `risk_criterion` (+ `ref_selectivity_lane`) | ◑ criteria catalogue simplified to one table. |
| `VAL_CTL_TAB` / `VAL_FOR_TAB` (valuation control) | `valuation_note` / `item_value_note` | ◑ we model the value build-up result, not the control formulas. |
## Reference module — official S013 (`UN*` tables) → our `ref_*` tables
Direct matches: `xxCTYTAB`→`ref_country`, `xxCURTAB`→`ref_currency`, `xxRATTAB`→`ref_exchange_rate`,
`xxCUOTAB`→`ref_customs_office`, `xxLOCTAB`→`ref_location`, `xxMOTTAB`→`ref_transport_mode`,
`xxPKGTAB`→`ref_package_type`, `xxCTNTAB`→`ref_container_type`, `xxUOMTAB`→`ref_unit_of_measure`,
`xxTODTAB`→`ref_incoterm`, `xxHS1-6TAB`/`xxTARTAB`→`ref_hs_tariff`, `xxCP1/3/4TAB`→`ref_cpc_regime`,
`xxTAXTAB`→`ref_tax_type`, `xxRULTAB`/`xxTAXTAR`→`ref_tax_rate`, `xxATDTAB`→`ref_document_type`,
`xxCP3TAB`→`ref_exemption_code`, `xxMODTAB`→`ref_declaration_type`, `xxNATTAB`→`ref_bl_nature`,
`xxWHSTAB`→`ref_warehouse`, `xxCARTAB/xxDECTAB/xxCMPTAB/xxPRPTAB`→`trader`(+`trader_role`),
`LogTable`→`audit_log`.
Official `UN*` tables we intentionally did **not** model as separate tables (folded inline or out of
scope): `xxPRFTAB` (preference → `declaration_item.preference_code`), `xxVAMTAB` (valuation method →
`valuation_method_code`), `xxTR1/TR2TAB` (nature of transaction), `xxTOPTAB` (terms of payment),
`xxMOPTAB` (means of payment → `mode_of_payment`), `xxINDTAB` (empty/full → `container.empty_full`),
`xxSEATAB` (seals parties), `xxQUOTAB` (quota → `declaration_item.quota`), `xxCAPTAB` (CAP),
`xxMUQTAB` (measurement qualifiers), `xxHOLTAB`/`xxLNGTAB`/`xxPRTTAB`/`xxKWD*` (system housekeeping),
and the many `*_link` tables (e.g. `xxCTYMOT`, `xxCUOMOT`, `xxPRFCTY`) which are M:N junctions our
FK design expresses differently. These are listed as known gaps in COVERAGE.md.
## Accounting module — official S016 → our tables
| Official | Our table | Fit |
|----------|-----------|-----|
| `xxATITAB`/`xxATOTAB` (account transactions in/out) | `account` + `account_movement` | ✅ accounts and ledger movements. |
| receipts + `TAX_TAB` (taxes per receipt) | `receipt` + `payment` + `declaration_tax_line` | ✅ payment/receipt with per-tax detail. |
| `SER_LETTERS_TAB` / `SER_NBERING_TAB` (serial mgmt) | serial columns on `declaration`/`receipt` | ◑ serials stored inline rather than in a dedicated numbering service table. |
| cashier/shift/daybook report tables (`RPT*`) | — | ✗ reporting/aggregation tables out of scope (analytics can be built as views). |
## Suspense / transit — official S014 + S019
`SUS_WH_IN` (warehouse entry/exit) → `warehouse_entry`/`warehouse_exit`; `WHS_DLY*` (extension of
delay) → `ref_warehouse` + item `warehouse_days`; temporary admission → `temporary_admission`;
transit → `transit_declaration`; guarantees → `guarantee`. The Suspense Declarations manual (S019)
now grounds these tables that were previously `inferred`.
## Remaining honestly-inferred (6 tables)
`trader_role`, `sys_role`, `sys_permission`, `sys_user_role`, `sys_role_permission` — RBAC and the
trader-role junction. ASYCUDA World has role-based access, but the reference docs don't publish a
user/role/permission schema, so this stays our normalisation. `manifest_status_history` is grounded
by `GEN_TAB.STA` but the *history* shape is ours.
## Bottom line for the user
Yes — the reconstruction fits your official data. Where the official docs are more authoritative than
the national manuals we started from, the schema now cites them (S013–S016, S019), which upgraded 11
tables from `inferred` to documented. The intentional divergences (normalised vs denormalised,
FK vs code+name, derived totals) are the right choice for a sandbox/analytics reference model and are
listed above and in COVERAGE.md so nothing is hidden.
---
# Agent Skills
The toolbox ships a suite of standard **[Agent Skills](https://code.claude.com/docs/en/skills)**
(the `SKILL.md` format) so you can drive the customs model in plain English,
right inside your own project. They live in
[`skills/`](https://github.com/FrancoisChastel/sydonia-toolkit/tree/master/skills)
and are focused on one thing: **using the model on your own codebase.**
They are harness-agnostic — installable into **Claude Code, Cursor, Codex,
opencode** and any other agent the [skills CLI](https://github.com/vercel-labs/skills)
supports.
## The suite
- :material-database-plus:{ .lg .middle } **`customs-schema-setup`**
---
Stand the model up in a database: create it, load schema + seed (+ optional
e2e) in order, and report a clean/failed result.
*"Set up the customs sandbox in a local Postgres database."*
- :material-magnify-scan:{ .lg .middle } **`customs-query`**
---
Generate correct SQL against the model — it knows the `asycuda` search path
and the join paths — and **verify it privacy-preservingly** through the
companion tester MCP (or a bundled script): metadata only, never row data.
*"Show me duty revenue by HS chapter last quarter — and test it."*
- :material-table-plus:{ .lg .middle } **`customs-seed`**
---
Add reference/code values or generate realistic sample declarations and
manifests that respect every foreign key.
*"Generate three more import declarations with 2–4 items each."*
- :material-source-branch-plus:{ .lg .middle } **`customs-extend`**
---
Add tables/columns while preserving the conventions and the provenance trail,
and update the coverage docs in lock-step.
*"Add a container gate-movement table to the schema."*
- :material-check-decagram:{ .lg .middle } **`customs-validate`**
---
Re-run the done-condition checks: clean load, every table tagged, every cited
source resolves. Your auditable green light.
*"Validate the customs schema still loads clean and is fully sourced."*
## Install into your codebase
Install the whole suite into your current project with the
[`skills`](https://skills.sh) CLI — it auto-detects your agent (Claude Code,
Cursor, Codex, opencode, …) and drops the skills where that harness discovers
them:
```bash
npx skills add FrancoisChastel/sydonia-toolkit
```
Add a single skill with `--skill`:
```bash
npx skills add FrancoisChastel/sydonia-toolkit --skill customs-query
```
Also copy the model itself (the `Sydonia/` folder, or at least
`Sydonia/schema/` and `Sydonia/examples/`) so the skills have SQL to load. The
skills accept a `--schema-dir` argument if you put it elsewhere.
Verify your agent sees them — in Claude Code, for example:
```text
/skills # lists available skills; customs-* should appear
```
## Use them
Just describe what you want — your agent matches the request to a skill:
```text
> set up the customs model in a throwaway database and run the example
> write a query for assessed-vs-paid across all released declarations
> add a "carrier rating" column to trader, keep it sourced
> validate the schema is still clean and fully grounded
```
Each skill is a self-contained `SKILL.md` with bundled scripts and reference
material, following Anthropic's progressive-disclosure guidance — small
front-matter, details loaded only when needed.
## The privacy-preserving query tester (optional MCP)
`customs-query` ships with a companion **MCP server**,
[`customs-query-tester`](https://github.com/FrancoisChastel/sydonia-toolkit/tree/master/mcp/customs-query-tester)
(pre-registered in the repo's `.mcp.json`), that closes the generate→verify
loop: the assistant can **prove a query is valid and runs** — against a
database that may contain *real, sensitive customs declarations* — without any
row ever reaching the model.
**The model gets an oracle, not a window.** Four guarantees, enforced
server-side on every call:
| Guarantee | How |
|-----------|-----|
| Nothing can be written | Sessions start with `default_transaction_read_only=on` — the PostgreSQL server itself refuses writes, even data-modifying CTEs |
| Only single SELECTs run | A comment/string-aware scanner rejects multi-statements and every write/DDL/`COPY`/`INTO` keyword before the DB is touched |
| No row data in responses | Result shape via `\gdesc` (describe **without executing**); volume via `SELECT count(*) FROM () __q` — one aggregate number |
| Bounded execution | `statement_timeout` (default 5 s) + a hard subprocess cap |
Three tools: `describe_schema` (structure), `validate_query` (EXPLAIN-only),
`test_query` (read-only run → columns, types, row count, duration). No MCP
connected? The skill falls back to `scripts/test_query.sh` with the identical
guarantees through plain `psql`. For defence in depth on real data, run it as a
`SELECT`-only database role — the server's README shows the three-line GRANT.
## What they deliberately don't do
These skills are for **using** the model. They will not decompile ASYCUDA, touch
a live customs system, or fabricate source citations — the same
[source policy](../provenance/methodology.md#source-policy-non-negotiable) that
governs the model governs the skills.