Set up hub-and-spoke domain governance
Hub-and-spoke is how a Kirimana project stays governable as it grows.
A central hub — the platform team — publishes the standards every
domain must meet: classification defaults, approval counts, ownership
rules. Each spoke — a domain like sales, hr, or finance — owns
its own contracts and moves at its own pace, inside those standards.
Nothing is centrally bottlenecked, and nothing is ungoverned.
Git is the single authority. Domains live in kiri.yml; contracts
live in files; every catalog is a materialisation of that library,
never an independent source of truth. Get the structure right once
and the rest of the governance surface — CODEOWNERS routing, PR-time
gates, approval counts — falls out of it mechanically.
Domains are the unit of ownership
A domain is a named business area with an owner and a policy. Declare
them in kiri.yml:
domains:
sales:
owners: [sales-team, alice@acme.com]
default_classification: internal
hr:
owners: [hr-team]
default_classification: confidential
finance:
owners: [finance-team]
default_classification: internal
Every domain must have at least one owner — that is a hard
invariant, not a convention. Ownership is what drives CODEOWNERS
auto-routing and the owner-validity lint (see the next two pages).
A domain with a placeholder owner (owner@example.com, TODO) is a
lint failure, so you cannot ship a domain nobody is accountable for.
The domain name is load-bearing. It is the parent directory of the
domain’s config, the prefix of its contract URNs
(kirimana:contract:sales.customer:1), and the key CODEOWNERS routes
on. Rename a domain and you rename all three — treat the name as
stable once contracts reference it.
Per-(domain, layer) contracts
Inside each domain, contracts are organised by medallion layer. The
canonical shape is one contract file per (domain, layer) pair —
01-config/<domain>/bronze.yml, silver.yml, gold.yml, and
optionally pit.yml. Each file is a single ODCS v3 DataContract whose
schema:[] array lists every table in that layer. A three-layer
domain is three files, not eighty.
01-config/
finance/
bronze.yml # raw ingested tables
silver.yml # deduped, typed, business-ready
gold.yml # roll-ups and marts
Adding a table is a schema:[] append inside the existing file — no
new file, no new directory. This keeps the promotion story simple:
the catalog binding lives in exactly one place per file (the typed
targets: block), so promoting dev → prod is a target switch, not a
sweep across dozens of contracts.
Two rules the validator enforces hard:
- Never split a (domain, layer) across files. One file per pair.
- Never put per-table catalog/schema bindings in a layer file.
Catalog binding has exactly one home — the
targets:block.
Run kiri layer validate -p . to check both. A freshly scaffolded
project returns zero findings by design.
Layer-wide defaults, per-table overrides
Defaults cascade through a precedence ladder so you set the common
case once and override only where a table genuinely differs. When a
kiri.* property is resolved for one column on one table, Kirimana
looks in order:
- Column-level —
schema[t].properties[c].customProperties - Table-level —
schema[t].customProperties - Layer-wide — the file’s top-level
customProperties - Built-in default — closed-menu, fail-closed
So a silver.yml that declares kiri.classification: internal at
the top makes every table in that layer internal unless a specific
table or column raises it to confidential. You write the exception,
not the rule. This is the mechanism the hub uses to make a standard
stick: a hub-published default sits at the layer level, and a spoke
that needs to deviate does so explicitly and visibly in the diff.
The hub publishes, the spokes own
Put the pieces together and the topology is clear:
- The hub owns
kiri.yml. Domain definitions, default classifications, per-domain approval counts, the auth block, and the RBAC bindings all live here. Editing this file is a platform-admin-only capability — it is the standards document. The hub publishes a standard by setting a layer-wide or domain-level default; spokes inherit it automatically. - The spokes own their contracts. A domain team edits the files
under
01-config/<their-domain>/, adds tables, and moves data through the medallion layers on its own cadence. They cannot touch another domain’s contracts — CODEOWNERS routing and the PR gate see to that.
Cross-domain edges are the one place spokes must coordinate, and the
validator makes the coordination symmetric: if a consumer in one
domain declares a kiri.upstream reference to a producer in another,
the producer must declare a matching kiri.downstream_consumers
entry. Neither side can quietly depend on — or expose to — the other.
An unmatched edge is a hard validator failure, so a cross-domain
dependency is always a two-sided, reviewed decision.
Where this leaves you
Once the structure is in place, governance is emergent rather than
bolted on. The hub changes a default in kiri.yml; every spoke that
did not override it picks the change up on its next apply. A spoke
adds a table; the owner is already known, the classification default
is already set, the CODEOWNERS line already routes the PR. You did
not configure any of that per-table — it followed from the domain and
layer the table lives in.
The next pages build directly on this: Domain owners + CODEOWNERS turns domain ownership into review routing, RBAC roles + capabilities scopes who may act in which env and domain, and PR-time governance gates enforces the whole thing in CI.