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.
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: .kapi/voice.yaml
acme:
channels: [docs, landing]
# no `voice:`; .kapi/profiles/acme/voice.yaml answers by convention
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
A collection's point is project.MergeCoordinates(defaults, derived, declared):
the project's defaults.coordinates, overlaid with what its channel: derives,
overlaid with its own coordinates:. Most specific wins, per axis. An empty
value never erases what a broader layer said: blanking a value is an incomplete
edit, and reading 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.
A profile's files mirror the recipe
.kapi/profiles/<name>/ holds what that profile overrides: voice.yaml, and
terms.json where the vocabulary differs too, with <name> the profile's key
under profiles:. The project default has no directory: its files are the flat
ones in .kapi/ itself. A profile that binds no voice:/termstore: of its
own is answered by its directory before defaults.voice is, so a profile
declaring only its channels is a complete profile.
This is the filesystem mirroring the recipe. A recipe states its default
governance under defaults: and its per-product governance under profiles:;
the default's files sit flat and each profile's sit in a directory of its own, so
"which voice governs this product's docs" is answerable by looking. 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 voice guide, kapi context 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 directory under .kapi/profiles/ to look in
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: .kapi/profiles/northsea-2025/voice.yaml
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.
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 point is edited through the one write verb
A kapi apply change-set with kind: "recipe" sets one recipe field per
entry, 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. The
gate runs when terms govern any unit in scope, whether the project defaults or
a profile bind them. A unit where no bound terms 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.
- 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.
- kapi.yaml project file: the schema reference.