C-02: Coordinates and governance
Summary
Content is written for a point in the context space. Two axes are
structural: the product the content belongs to and the channel it
ships on. Neither is a taxonomy a recipe invents. A key under profiles: is
the product-axis value, and the channels that profile declares are the
channel-axis values it can carry. The remaining axes are declared: a project
names the dimensions its content actually varies along (a brand, a document
mode, a market) once under defaults.coordinates, and a collection that sits
elsewhere overrides the one axis it differs on. A declared axis is an open
category unless the recipe types it under axes:. The typed kinds are a geo
axis whose values are places, so a country contains its cities; a range, so the
age range 13-17 contains 14-16; a closed list of values; and an ordered
list such as a lifecycle.
Governance (a voice profile, a vocabulary) binds to a point. One function,
KapiProject.ResolveGovernanceFor, answers what governs here, at this instant,
and every caller goes through it: a run, a check, a push, and the by-location
retrieval answer. Resolution walks the declared bindings from the finest point to
the coarsest, and a binding whose validity window excludes the instant is skipped
exactly as if the recipe had not declared it, with the transition reported
rather than applied silently.
The resolved point is what travels: as coordinates on the wire, and as the
coordinate a collection is governed_by in the context graph
(C-03).
Context
A project is not always one voice, and a voice does not read the same everywhere. One repository can ship more than one product, and a project-wide binding then governs the wrong one half the time. Which product a page belongs to and which channel it appears on are the two things that decide how it should read.
A free-form taxonomy declared alongside the collections that use it has to be
kept in step with them by hand, and drifts. Deriving the structural axes from
what the recipe already says removes both the taxonomy and the drift: the
profile that governs a product and the channels that product ships on are the
same declaration. The axes a project has to name for itself are stated in one
place and inherited, for the same reason defaults.voice is a default with a
per-collection override rather than a field repeated on every entry.
Decision
The space is structural
profiles:
northsea:
channels: [docs, app]
voice:
profile: northsea
acme:
channels: [docs, landing]
# no `voice:`; the profile the store holds under "acme" answers
collections:
- name: northsea-docs
channel: northsea/docs # both products ship `docs`, so qualify it
- name: acme-landing
channel: acme/landing
A channel: is always written profile/channel: a channel is a surface of a
product, and the binding reads as one. A bare channel name is a load error that
spells out the qualified form(s), so the fix is a copy-paste. A collection that
binds no channel is governed by the project's default point: defaults.voice
and the project's own terms.
The channel is what the framework interprets. Once a profile is selected, the channel selects the matching override inside that profile's voice (C-07). A landing-page register is therefore authored once, in the voice it varies; a channel the profile says nothing about leaves the base voice in place, which is the right answer for a voice that reads the same everywhere.
Profile names and channels are slugs (lowercase letters, digits and hyphens), and they are machine identifiers: stable, never translated, compared byte for byte. Each may carry a concept reference for display, and resolution never looks at it. Concepts are designed to be renamed and deprecated as vocabulary is revised, and governance that moved when someone edited a term would be governance nobody could rely on.
The two structural axes are named by the framework. They travel as product
and channel (project.ProductAxis, project.ChannelAxis), and
ChannelRef.Coordinates() renders a resolved point as that map, omitting the
axes it does not set.
Declared axes
The coordinate map is open. The wire carries it as map<string,string>, the
context entry's hash folds whatever it finds in sorted order, and the graph
writes each axis as a property, so a project names the dimensions its content
varies along:
defaults:
coordinates:
brand: northsea # every collection sits here unless it says otherwise
mode: reference
collections:
- name: northsea-tutorials
channel: northsea/docs
coordinates:
mode: tutorial # moves on one axis; inherits the brand
content:
- path: "tutorials/first-steps/**/*.md"
coordinates:
audience: new-customers # one item refines the collection's point
- path: "tutorials/**/*.md"
KapiProject.CoordinatesAt(pt, rc) returns the coordinates of a point in the
content. Every caller that works on a file or a collection uses it: a run, a
check, a push, the by-location answer, the review context and the recording of
a context operation. It overlays four layers with project.MergeCoordinates:
- the project's
defaults.coordinates; - the axes derived from the governance resolved at the point (
rc, fromResolveGovernanceForat the same point); - the coordinates of the collection that contains the point;
- the coordinates of the content item whose path matches it.
The item and the collection are the same ones governance resolution uses, so
an item's coordinates: refines its collection's point the way its channel:
refines the collection's channel. Per axis, the most specific layer wins. An
empty value leaves the broader layer's value in place. A blank value is an
incomplete edit, and treating it as an erasure would let a typo move content
off its point.
The structural axes are never written under coordinates:. They are derived
from channel:, and a declared value that could shadow them would let a recipe
contradict its own point; project.DeclarableAxis refuses product and
channel wherever a coordinate is edited.
Two declared axes are spelled by the framework so the spelling is one thing
rather than each recipe's own. project.BrandAxis (brand) is coarser than
product: a workspace has brands, a brand has products, a product ships channels.
Brand is an axis rather than a subsystem. Content sits at a brand the way it
sits at a product, and what governs it there (a voice profile, terms, gates) is
bound at the point rather than being part of it. project.ModeAxis (mode)
carries what kind of document sits at a point in the Diátaxis sense, with
tutorial, how-to, reference and explanation as conventional values rather
than an enum. Correct style is a function of mode: hedging is wrong in a tutorial
and right in an explanation, so one profile applied flatly across all four is
wrong for at least one of them.
Declared axes can be typed
An axis that the recipe only writes values for is an open category. Any value
is legal, and two values match when they are equal. Equality is enough for a
brand. A market or an audience age needs containment: a rule about the Nordic
market should apply to content written for Norway. The top-level axes: block
types the axes that need it:
places:
nordics:
name: Nordics
of: [no, se, dk, fi, is]
oslo:
name: Oslo
type: locality
within: no-03
axes:
market:
kind: geo
label: Market
audience:
values: [general, new-customers, specialists]
age:
kind: range
unit: years
lifecycle:
kind: ordered
values: [preview, current, sunset]
core/axis defines the four kinds and the comparison rules that every caller
uses (axis.Space, built from the block by KapiProject.AxisSpace()):
| Kind | Values | Contains(outer, inner) |
|---|---|---|
category (the default) | any slug; values: closes the axis | equality |
ordered | the declared values:, in order | equality |
geo | place ids from the project's gazetteer: world, UN M49 region slugs, ISO 3166-1 countries and ISO 3166-2 subdivisions in lower case, and the places the recipe declares under places: | the inner place sits within the outer one, as the gazetteer declares: a city in its county, a county in its country, a country in a territory made of it, everything in world |
range | an interval: 13-17, an open upper bound 65-, or one number | interval inclusion: 13-17 holds 14-16 and not 18- |
A geo axis uses the project's gazetteer (core/place,
C-12). The gazetteer holds the built-in world, M49 regions,
countries and subdivisions, and the places the recipe declares under
places:, each sitting within places or made of places. axis.Space carries
the gazetteer beside the typed axes, and its zero value uses the built-in
places. A geo axis takes no values: and no labels:; a place's name is
declared on the place. kapi context places looks up an id. A locale tag can
also identify a place. place.OfLocale returns the subdivision in a -u-sd-
extension (nb-NO-u-sd-no03 sits at no-03), else the place of the region
subtag (fr-CA at ca, es-419 at latin-america-and-the-caribbean). For
fr it returns no place.
A rule's scope is matched by containment (contextop.Scope.CoversIn,
terms.AtPointIn): a rule scoped to market: nordics applies at a point that
sits at market: no. A point's own coordinates are single values. Containment
is tested from the rule's scope to the point, in that direction only.
axis.Space.Overlaps reports whether two values can overlap at all: two places
that share ground, or two intervals that intersect.
The block adds types to axes the recipe already writes and declares no axis of
its own. product and channel are rejected under axes:, as they are under
coordinates:. An axis absent from the block is an open category, so recipes
without an axes: block load unchanged.
A profile's name is a binding
A profile that binds no voice: of its own is answered by the profile the
project's voice store holds under its own key from profiles:, before
defaults.voice is. So a profile declaring only its channels is a complete
profile, and a project that names its voice profiles after its products states
each binding once. The name selects out of the store and nothing else, so every
checkout holding the same store and recipe resolves the same voice.
A directory of context files takes the same shape: each profile's own voice and
terms under .kapi/profiles/<name>/, the project default flat in .kapi/, and
kapi store import reads it into the store
(C-11). The import scopes a profile's terms to
that profile. It stores a profile's voice under the id the file declares, and
when that id is not the profile's name it writes profiles.<name>.voice into
the recipe, so the binding travels with the recipe. Governance is
the only thing that splits this way. The content memory and the unit-state
record stay top-level (C-01), because a recycled
translation and an approval are facts about a unit, true wherever it is governed
from.
One resolver, one ladder
GovernancePoint names the place to resolve for and the instant to resolve at:
type GovernancePoint struct {
Profile string // a profile named directly, with no location under it
Collection string // a content collection, by name
Path string // a project-relative, slash-separated file path
Comments bool // the point the comments in the file at Path sit at
NoReader bool // no installed format reader parses the file at Path
At time.Time // the run's wall clock; the zero value is the as-declared view
}
func (p *KapiProject) ResolveGovernanceFor(pt GovernancePoint) (*ResolvedGovernance, error)
Resolution walks the declared bindings from the finest to the coarsest:
- A content item's own
channel:. A file is the finest declared point, so one file in a collection can ship on a different channel, or under a different profile, than its neighbours. A path is matched against every item in recipe order, and each layer of the file resolves at the first item that claims it (fileClaims), the same walk that assigns a file to its collection. An item that claims only the file's comments governs only those comments, wherever the recipe lists it. A point for the file's own content resolves at the first other item that claims the path (ContentItemForPath). A point for its comments resolves at the first item that claims only the comments, or at the values' item when that item declares the comments and comes first (CommentItemForPath). An item claims only the comments when it declarescomments: {only: true}, or when it declares the comments of a file no reader parses (NoReader) and names no format. - The collection's
channel:, for a path no item claims and for a caller that names a collection rather than a file. - The project's default point:
defaults.voiceand the project's own terms.
kapi context <path> and a check of files named on the command
line or through MCP check_file answer for a file's own content. Every block
the project reads from a file whose comments an item claims and whose values no
item claims is a comment (ClaimsOnlyComments), so a surface holding those
blocks resolves them at the
comments' point: a check of the project's declared content, the review of a
source unit, and the places context_search reports a term in use.
The comments in a file can sit at a point of their own. A point that names them
(Comments) puts two rungs above the first: the claiming item's
comments.channel, then defaults.comments.channel. Both take the same
qualified profile/channel form and the same validator as a collection's
channel:. The values a file's reader extracts stay at the item's point, so a
style written for code comments governs the comments of a YAML file and leaves
its values to the voice they ship under.
Profile outranks both location forms and does not fall through: a caller that
asked about a specific product is not served by the project default, so a name
the recipe does not declare is an error rather than a silent substitution. That
is the point an ad-hoc question occupies, where there is no content location to
refine the answer with.
The result is resolved already, so a caller applies it without knowing where it came from:
type ResolvedGovernance struct {
Channel string
Voice *VoiceBinding // the matched profile's, else defaults.voice
TermStore string // a standalone terms store bound by the profile
VoiceField string // the recipe key Voice came from, for error messages
Profile string // the profile's own key, a voice-store name
Validity *graph.Validity
Fallback *GovernanceFallback
}
VoiceField names the recipe line to fix when a profile cannot be loaded: the
difference between "your project has a broken voice" and "line 14 of your
recipe".
Governance expires
A profile bounds its governance in time with valid_from / valid_to, authored
as a bare date or an RFC3339 instant:
profiles:
northsea-2025:
channels: [landing]
voice:
profile: northsea-2025
valid_from: 2025-09-01
valid_to: 2026-03-01
The window is the same half-open model terms and graph edges carry
(valid_from inclusive, valid_to exclusive), parsed into the shared
graph.Validity vocabulary, so a profile's "from when until when" reads and
matches identically to a term's. A bare date is read as midnight UTC, which makes
a valid_to date exclude that whole day, the reading "until" invites.
At resolution, a binding whose profile is outside its window at pt.At is
skipped exactly as if the recipe did not declare it, and the ladder continues
to the next rung. The skip is not silent: the result carries a
GovernanceFallback naming the profile that stopped governing, which boundary
excluded the instant, and what governs in its place. kapi run and kapi check
print it (host/governance.go) and the by-location retrieval answer carries it
as a note (host/contextpoint.go). Governance changing on a date has to be
visible, because a governance change nobody is told about is indistinguishable
from a bug.
Two views of the same recipe follow:
| View | Call | Windows |
|---|---|---|
| As declared | ResolveGovernance(collection); At is the zero value | carried on the result, not applied |
| As of | ResolveGovernanceAt(collection, at), or ResolveGovernanceFor with a non-zero At | applied; an out-of-window binding does not govern |
A surface reporting what the recipe says wants the first. Every run takes the
second. ProfileWindows() lists the profiles that bound anything, for surfaces
that report which governance is in force and until when.
The resolved point is what travels
ResolvedGovernance.Ref() renders the resolution back as the point it names, so
a caller carrying coordinates (a push, a graph write) carries what actually
governed rather than what the recipe declared. A resolution that fell through to
the default point renders as the zero ref, which puts no structural coordinates
on the wire. The declared axes still travel: the entry a push carries for a
collection is the merged point, so a collection binding no channel in a project
that declares a brand sits at that brand.
That is what makes governance and retrieval agree by construction rather than by
convention: the coordinate a collection is governed_by in the graph, the
coordinates on the wire, and the answer kapi context <path> gives are the same
resolution.
It is also where a learned rule is scoped. A context operation records the
merged point its evidence was seen at, and its rule applies only where that
point is covered, with each axis compared as axes: types it
(C-11). A rule proposed from a
file at brand: northsea, mode: reference says nothing about a tutorial, and
widening it is a person naming the axis it should stop being specific about, or
asking for the whole workspace. A project with no declared axes resolves the
empty point, so everything it learns holds across the project, which is the
right answer for a project that has not yet split.
Validation happens at load
Every collection and every item is resolved once when the recipe loads, so a project that loaded cleanly has no channel reference left to fail on later. The errors are written to be fixed without reading this page: an unqualified channel lists the qualified forms that would work, a channel naming an undeclared profile lists the declared profiles, and a channel a profile does not declare lists the channels it does.
The places: and axes: blocks are checked in the same pass. The declared
places build the project's gazetteer (place.New). It rejects an id that a
built-in place uses, a place that sets both or neither of within: and of:,
a listed id that is not a place, and a place that contains itself through a
cycle (C-12). axis.Space.Check validates
the axes: each axis name is a slug, each kind is known, an ordered axis
declares its values, and a geo axis has neither values nor labels. Then
axis.Space.Validate checks every value under defaults.coordinates, a
collection's coordinates: and a content item's coordinates: against its
axis. The error for a closed axis lists the legal values. The error for a geo
value that is not a place id gives the lower-case or hyphenated spelling that
is one, or else the closest matches and the kapi context places command. The
error for a range written upside down gives it the right way round. SetField
runs the same check when a change set writes defaults.coordinates.<axis>.
The point is edited through the one write verb
A recipe operation in a kapi apply change set sets one recipe field, and
the coordinate surface is part of what it may set:
defaults.coordinates.<axis> declares or withdraws one axis of the default point
(an empty value withdraws it, so the operation stays total), and
collections.<name>.channel places a named collection. Each goes through
core/project.SetField, which refuses product and channel under
coordinates: and preserves the recipe's formatting. A collection is addressed
by name because a name is what a decision is about; an index moves when an
unrelated entry is added above it.
One run, one resolution per collection
The tool chain is assembled before any content is read and bakes the resolved profile into the steps that need it, so a run does not switch voice per file: it resolves governance per collection and executes once per distinct resolution. A recipe where no collection binds a channel runs unsplit, exactly as one that has never heard of the context space.
A gate is the exception: kapi check reads rather than writes, so it resolves
per file and holds each one to the voice and the vocabulary in force where
it sits. For a file whose comments sit apart it resolves per block: each comment
is held to its own point and each other block to the file's, and every finding
and every recorded context names the point its block was checked at. Both halves resolve through the same point, so a profile that binds its
own termstore: governs exactly the files its channels carry, which is how a
surface keeps a name the vocabulary retired.
The terminology gate of kapi check --ship, the loop checks and the coverage
they feed resolve terms at the same point for each unit and its language, and
add the term_rules: the recipe declares for that language
(C-08). The gate runs when terms govern any unit
in scope, whether the project defaults, a profile or the recipe's own rules bind
them. A unit where neither the bound terms nor the recipe's rules answer for its
language counts as not governed and holds no bar, and kapi status and
ship.json name a language as not governed only when terms govern none of its
content.
Not yet built: a point beneath the file
A content item's own channel: is the finest declared point for a file, and its
comments.channel for the comments in that file, so the finest governed units
are a file's reader content and its comments, for voice and for vocabulary alike. The case
that remains open is a passage (the retired name is permitted in these two
paragraphs of the migration guide), which needs a point beneath the file that
nothing declares yet.
Consequences
- A repository holding several products carries several profiles, and that is what tells their content apart.
- Adding a channel to a product is one line in the profile; nothing else has to learn about it.
- A project states its brand once and every collection inherits it; a collection that sits elsewhere moves on that one axis.
- A rule kept for a place holds in every place inside it, from a territory down to a neighbourhood, and one kept for an age range holds for the narrower ranges inside it, without a copy per value.
- Governance with an end date is expressible, and the day it ends is announced by every surface that resolves it rather than discovered by a reader.
- Because the structural axes are resolved from the recipe rather than declared beside it, a collection cannot claim a point that does not exist.
See also
- C-01: The project model: the recipe and the layout these bindings live in.
- C-03: The context store and graph:
coordinate nodes and the
governed_byedge. - C-06: Context retrieval: the by-location answer, which is this resolution rendered.
- C-07: Voice profiles: what a profile binds, and how a channel refines it.
- C-12: Places: the gazetteer a geo axis reads, and how places contain one another.
- kapi.yaml project file: the schema reference.