C-01: The project model
Summary
A kapi project is a folder containing a kapi.yaml recipe at its root and a
sibling .kapi/ directory. The recipe captures the user's declarative intent
(identity, content collections, flows, governance bindings) plus any extension
blocks a plugin has registered a schema for.
.kapi/ is the one directory kapi owns, and it is committed: it holds the
project's context (terms, content memory, voice profiles, the unit-state record)
alongside the manifest and the reader configuration. Machine state is confined
to one internal directory, .kapi/work/, which is ignored. This is the model
git itself uses: .git is the tool's directory and its index lives inside it.
A ProjectContext resolves the recipe into a runtime configuration, and a
Store interface with pluggable providers gives tools random-access storage
beyond the streaming pipeline.
Context
Real work needs to persist more than an in-flight stream of parts. Translators add targets over time, per locale. Several tools contribute independent annotation layers. Re-running a flow must not re-translate blocks whose source has not changed. Content collections group heterogeneous source files with different formats, writer outputs and language targets.
The channel-based Part → Tool → Part model
(E-01) is a forward-only transform. It
does not cover random-access reads, incremental work, or parallel tools writing
independent annotation layers. A declarative recipe captures intent; a local
store captures the working state.
The project folder is the day-to-day working unit and the default unit users
share, back up and commit. It is discovered by a git-style upward walk, never
named on a command. Sharing a project usually means sharing the folder. When a
folder cannot travel (a hand-off to a translator, an archive, an air-gapped
transfer), the single-file .kpz parcel carries the same state losslessly
(M-06), and a task-scoped bilingual
.kpz is what goes to a reviewer
(M-01). The parcel is for
boundaries, not a competing working model: you open it into a project, work in
the folder, and pack to ship.
Decision
Project layout
Ownership zones at the project root:
my-app/
├── kapi.yaml ← RECIPE (user edits; a conventional YAML config file)
├── .kapi/ ← COMMITTED (authored, reviewed in a pull request)
│ ├── .gitignore ← the two-line ignore rule, written by kapi init
│ ├── manifest.yaml ← bookkeeping written by kapi init
│ ├── filters.json ← shared reader/writer configuration
│ ├── filters.local.json ← personal overrides (ignored)
│ ├── terms.json ← the terms source (C-08)
│ ├── voice.yaml ← the voice profile (C-07)
│ ├── memory/ ← content-memory bundles (C-09)
│ │ └── <surface>.memory.json
│ ├── profiles/ ← per-profile governance overrides (C-02)
│ │ └── <profile>/
│ │ ├── voice.yaml
│ │ └── terms.json
│ ├── state/ ← the committed unit-state record (C-04)
│ │ └── <document>.jsonl
│ └── work/ ← ALL MACHINE STATE (ignored)
│ ├── store.db ← the one local database (C-03)
│ ├── vault/ ← withheld originals (C-10), local-only
│ └── cache/ ← free to delete, always
│ ├── extractions/ ← per-extract batch state (M-01)
│ ├── redaction/ ← per-batch vault sidecars (C-10)
│ ├── refs.json ← the observed freshness refs (C-05)
│ └── collections/ ← overlay layers per collection
├── src/ ← authored sources (user-owned)
└── i18n/ ← writer output (generated)
Ownership, zone by zone:
-
kapi.yamlis the user's. Hand-edited YAML, committed. A fixed, conventional config filename in the family ofpackage.jsonor a CI workflow file: because it ends in.yaml, every editor and every code host's preview and diff apply YAML highlighting with no custom file-type registration. Wide, zero-config recognition was chosen over a branded document type.The same reasoning governs every other committed artifact: each ends in the suffix of the serialization it actually is. Where a file also needs to say which document of that serialization it is, the marker goes in a segment ahead of the suffix (
.kbf.json,.memory.json,.overlays.jsonl), sojq, diffs and highlighting keep working while the name stays self-describing. Only.kpz, a binary zip nobody hand-edits, keeps a dedicated extension. -
.kapi/is the project's context, authored and reviewed in a pull request, and it sits flat: everything committed here is context, so an umbrella directory saying so would appear in every path and distinguish none of them.terms.jsonis the terms source (C-08) bound bydefaults.terms_source;memory/holds the content-memory bundles (C-09);voice.yamlis the voice profile (C-07). All three recipe keys bind any path. These are the conventional homes, not the only ones. -
.kapi/profiles/<profile>/holds what one profile overrides, and nothing else. Governance binds to a point (C-02), so the flat files are the project default and a profile's differences sit in a directory named for it. The directory name is the profile's key underprofiles:. Only governance splits this way: the content memory and the state record stay top-level, because a recycled translation and an approval are facts about a unit, true wherever it is governed from. -
.kapi/state/is the committed record of per-unit state: where each unit stands on the review ladder, which a plain target file cannot hold. JSON Lines, one shard per document (C-04). Written bykapi commit, not hand-edited, so the record travels with the project. -
.kapi/work/is everything the machine derives, and the only thing kapi keeps out of version control. One database,store.db, holds every subsystem's tables (C-03). Beside it sit the caches and the redaction vault (C-10). -
src/**is user-authored content. Referenced by the recipe; never moved into.kapi/. -
Writer outputs are produced by the format writers the recipe declares. The application runtime consumes these; kapi does not.
One directory, one ignore rule
.kapi/ is committed and .kapi/work/ is not, which makes the ignore contract
two lines with no negation in it. The framework owns the whole rule as one
constant, core/project.StateGitignore, and its home is .kapi/.gitignore, so
it travels with the directory it governs. A repository that would rather state
the same rule at its root writes the two paths there instead:
work/
filters.local.json
The second line is the personal reader overlay: a developer's own settings,
which are theirs and not the project's. Nothing else in .kapi/ is ignored, and
nothing ignored has to be excepted back in. Confining derivation to one
subdirectory removes the question a scattered layout raises, where a committed
path has to be rescued by a negation that version control only honours if the
parent was never ignored.
What deleting costs, stated exactly:
rm -rf .kapi/work/cacheis always free. Everything under it is rebuilt on the next run.rm -rf .kapi/workcosts two things. Unit state staged since the lastkapi commitlives only instore.db, and the redaction vault under.kapi/work/vault/holds withheld originals that are local-only and not regenerable (C-10): never committed, never synced, so nothing anywhere else has a copy.
Recipe schema
The recipe is a YAML document parsed into core/project.KapiProject:
# kapi.yaml
version: v1
id: prj_k3m7xq2vw9ta5r8ybn4dcp
name: Northsea App
profiles:
northsea:
channels: [app, docs]
voice: .kapi/voice.yaml
collections:
- name: ui
channel: northsea/app
content:
- path: "src/**/*.{tsx,jsx}"
format:
name: exec
config:
command: "vp neokapi-i18n extract --stream"
target: "i18n/{lang}.json"
plugins:
okapi: "^1.47.0"
flows:
translate:
steps:
- tool: recycle
config:
fuzzyThreshold: 75
- tool: translate
- tool: qa
defaults:
source_language: en
target_languages: [fr, de, ja]
concurrency: 4
parallel_blocks: 3
encoding: utf-8
tools:
redact:
detectors: [rules]
defaults.tools holds project-level tool presets: per-tool config defaults
applied wherever that tool runs in a project flow. A flow step's own config
overrides the preset per key, so a project pins its redaction rules or a
pseudo-translation prefix once while an individual flow refines them. Resolution
happens at tool construction, and the data-flow and placement gates
(E-03) validate against the same merged config
the runtime uses: a preset that enables the redact tool's entity detection makes
the upstream entity port required exactly as an inline config would. A tool's
config keys are its JSON field names, in camelCase.
Required fields: version: v1, which must equal the current schema version, and
a non-empty path on each content item. Every flow contains at least one step
with a non-empty tool, unless the step uses parallel, in which case the
parallel branches carry the tools. name is the project's human label. Since
the recipe filename is fixed, it is the only place the label lives; kapi init
defaults it to the current directory's basename.
The other recipe families each have an AD or a reference section of their own:
defaults.coordinatesand a collection'scoordinates:place content on the declared axes of the context space;profiles:andchannel:derive the structural ones (C-02).source_only: trueon a collection asserts that it has no target language: a run reads and checks it and writes nothing back. A collection that sets it and also carries a target is rejected at load.defaults.flownames the flowkapi upruns;defaults.source_gate,ship_gate,ship_gates,verified_gateandgatesare the convergence gates (Convergence).defaults.materializegoverns delivery of target-language files. Withmanual, the default, a pass writes where the recipe points as it produces each unit, and delivery is an explicitkapi mergeorkapi up --materialize. Withon-convergethe run owns delivery under the ship gate: its passes draft into a run-local tree, and only a locale whose gated scopes are all shippable has its files written to the collection'starget:path.defaults.annotationsdecides which stand-off annotations a writer draws inline;defaults.localesholds per-target-language tool presets;defaults.formatsholds per-format reader configuration and detection priorities (below).
The project file reference lists every key.
The recipe holds provider names only. Credentials live in the OS keychain (S-01) or the environment. Nothing in the recipe is secret; it is safe to commit.
Discovery is git-style: kapi walks up from the current directory until it finds a
file named exactly kapi.yaml (core/project.ResolveLayout). A directory holds
at most one, so discovery is unambiguous; an explicit -p <path> overrides it,
and KAPI_NO_PROJECT=1 opts out of discovery entirely.
Project identity
id: is the project's identity and name: is its label. They are separate
fields because they answer different questions, and because the label changes.
The id is minted once, by kapi init, out of crypto/rand and nothing else.
No part of it is derived from the name, the folder or the repository's origin,
so editing any of those leaves it alone. The form is prj_ followed by
lowercase RFC 4648 base32 of 128 random bits, which leaves the value
case-insensitive and free of every character that needs quoting in YAML, a URL
or a shell word (core/project.NewID, checked on load by ValidateID).
Everything kapi records about a project locally is keyed on
KapiProject.Identity(), the id when the recipe carries one. The context
graph's scope tuple (C-03) is the first such
key: host.ProjectScope writes every node and edge id under it. What a project
can do without disturbing that key follows directly:
- Rename it. The label is one line in the recipe, and editing it leaves every derived key where it stands.
- Move the folder, or clone the repository somewhere else. No key is derived from a path.
- Work in two checkouts at once. Both carry the same recipe and therefore the same id, so a per-user store keyed by project reads them as one project.
Identity() falls back to name: for a recipe that carries no id:. Such a
project keeps working with the name as its key and no warning about it, and
kapi init --mint-id writes an id into it through the ordinary recipe setter
(project.SetField, saved over core/yamledit), so the file keeps its
comments, its blank lines and its key order. A recipe stating neither leaves
the scope's project dimension empty, which is the honest answer where there is
no identity to state.
Setting the id is one-way. The setter refuses to clear it, and refuses to write
a second one over the first, because either strands everything already recorded
under it. Turning a copied recipe into a genuinely different project means
deleting the id: line and minting a fresh one.
Unknown top-level keys are preserved rather than rejected (see Recipe extension
mechanism), so a kapi release built before this
field reads a recipe carrying an id: and identifies the project by its name.
The near-miss reporter stays quiet about it as well, since id resembles no
field of KapiProject.
Content paths
Each content item's path is a
doublestar glob: ** matches across
directories and {a,b,c} matches alternatives, so a single glob covers a
directory of mixed content with the format detected per file.
A collection's base is the directory it lives in: every path, target and
item base below it is written relative to that directory and joined onto it, so
a collection reads as the tree it governs rather than as a prefix repeated on
every line. An item's own base is the narrower thing, the directory a matched
file's path is made relative to, which drives the path tokens. It defaults to the
glob's fixed prefix.
target is a path template expanded per file and language. The common case is
directory-mirror: when the target names a directory, the source path relative
to base is reproduced under it, so target: output/{lang} turns
input/docs/api.md into output/fr/docs/api.md. For custom layouts, tokens
({lang}, {relpath}, {path}, {dir}, {filename}, {name}/{basename},
{ext}) reshape the path explicitly. The resolver is
core/project.ResolveTargetPath; the token reference lives in the
project file reference.
Recipe extension mechanism
KapiProject carries an Extras map[string]yaml.Node field with yaml:",inline"
on KapiProject, Defaults, Collection and ContentItem. Unknown YAML keys
are captured as raw nodes; a layer built on the framework declares its own typed
schema and decodes from Extras at load time. The framework knows nothing about
those extensions and round-trips them verbatim.
A package registers schemas at init():
coreproj.RegisterExtensionGroup("myplugin", []coreproj.Extension{
{Name: "myplugin", Scope: coreproj.ScopeProject, Decoder: venueDecoder, Venue: true},
{Name: "hooks", Scope: coreproj.ScopeProject, Decoder: hooksDecoder},
})
Scope distinguishes which Extras map a key belongs to: ScopeProject,
ScopeDefaults, ScopeCollection or ScopeItem. Each (Scope, Name) binds to
one decoder. KapiProject.Validate() walks every Extras map and runs the
matching decoder; keys with no decoder registered round-trip without error, so
binaries with different plugin sets linked in stay forward-compatible. An
unknown key that is one edit away from a known field of the same struct is a
likely typo rather than an extension, and KapiProject.KeyWarnings reports it
with the field it resembles.
A recipe declares a hard dependency with requires:, a map of plugin name to
version constraint ("*" for any version; semver forms such as ^1.0 are also
accepted). Validation fails when no extension under the named group has been
registered:
version: v1
requires:
myplugin: "*"
myplugin:
url: https://platform.example.com/team/proj
A binary that does not link the myplugin extensions rejects this recipe with an
actionable error rather than silently ignoring the block. A recipe without
requires: loads in any binary and the extras pass through.
The venue client in host/venue registers one collection-scoped extension key
of its own, preview: where a collection's strings can be read in place (a
component explorer or a running site), as a kind and a URL. It is decoded only
when the recipe also carries the venue key, it travels on the collection's
context entry with the coordinates and the governance, and it is folded into
that entry's hash so that re-pointing a collection reconciles. A binary without
the venue client round-trips it like any other extra.
Implementation detail (the Scope enum, the decoder helpers and a worked
example) lives in Note: Plugin model.
Converging somewhere else
The framework defines the sync wire (core/venue: the context content type,
refs and tree shapes both sides hash the same way) and a client for it
(host/venue), but no venue key of its own. Server, account and venue are not
recipe fields. A layer that adds one builds it on the generic mechanism above: it
registers a ScopeProject extension under its own name and gates it with
requires:, so a recipe carrying the key is meaningful only where that plugin
is installed.
One thing the framework does need from such a key is whether the project
converges somewhere other than this machine. It asks the registry rather than
looking for a name. An extension registered with Venue: true claims that role
(a plugin manifest declares it as "venue": true on a schema extension), and the
framework then reads exactly two fields out of the block, url: and converge:,
through KapiProject.Venue(). Which layer provides the venue, and what its key
is called, stays that layer's business: a recipe key kapi cannot name is a key it
cannot grow an opinion about. An unregistered key of the same name reports no
venue and no opinion.
Content collections
A Collection lists the source patterns kapi extracts from and the reader used
for each. Extracted blocks flow through the project's executor; persistent block
state (hashes, per-locale targets, annotations) lives in the project store.
For subprocess-based extractors, the format is exec: kapi runs the declared
command once per collection with every matched file path streamed on stdin
(NUL-separated) and reads NDJSON block records from stdout. The developer picks
the package manager or binary path. kapi runs whatever command says verbatim,
which is why the exec class is gated by explicit consent
(E-06).
The store interface
Flows and tools read and write blocks and overlays through the Store and
Session interfaces (core/blockstore), not through raw channels. The streaming
contract is preserved as one capability among several.
type Store interface {
Begin(ctx context.Context) (Session, error)
Capabilities() Capabilities
Close() error
}
type Session interface {
Capabilities() Capabilities
Blocks(filter BlockFilter) iter.Seq2[*Block, error]
GetBlock(hash string) (*Block, error)
PutBlock(collection string, b *Block) error
GetOverlay(kind, blockHash string) (Overlay, error)
PutOverlay(s Overlay) error
ListOverlays(kind string) iter.Seq2[Overlay, error]
Commit() error
Rollback() error
Close() error
}
type Capabilities struct {
RandomAccess bool
Concurrent bool
Remote bool
Writable bool
Persistent bool
}
Two providers back it: an ephemeral map-based one for tests and ad-hoc CLI
invocations, and the block tables inside .kapi/work/store.db for long-lived
project work. Tools never open the database directly; they operate on a session.
In the browser the block store is a path-keyed in-memory implementation
(C-03); nothing above the Store interface
notices.
A tool that needs random access implements the optional SessionTool extension
(core/tool/session.go), which adds a session handle alongside the same
streaming channels:
type SessionTool interface {
Tool
SessionProcess(
ctx context.Context,
sess blockstore.Session,
in <-chan *model.Part,
out chan<- *model.Part,
) error
}
The executor opens one session per run, dispatches each stage through
SessionProcess when the tool implements it, and owns the transaction boundary:
tools must not call Commit/Rollback themselves. SessionTool is the path for
term enforcement, multi-pass statistics and checks that read the whole store.
ProjectContext
A ProjectContext (core/project) bridges the static recipe and the live
runtime. Every consumer that runs in project mode constructs one:
type ProjectContext struct {
Project *KapiProject
ProjectDir string
SourceLocale model.LocaleID
TargetLocales []model.LocaleID
AllowedSources []string
Encoding string
Concurrency int
ParallelBlocks int
LocaleFormat string
FormatDefaults map[string]FormatDefaults
}
AllowedSources derives from the plugins section. It always includes
"built-in" plus each declared plugin name; a project without a plugins
section sees built-in formats only.
Project-scoped detection. ProjectContext.DetectFormat delegates to
FormatRegistry.Detect(path, DetectOptions{AllowedSources, PriorityOverrides}).
Detection is content-aware, since the file head disambiguates a shared
extension, and takes per-call priority overrides. When a plugin is installed
globally but the project does not declare it, its formats are excluded and
built-ins are used instead. Explicitly declared formats on a content item bypass
detection entirely.
The overrides come from defaults.formats.<format>.priority: where an extension
is claimed by several formats at equal priority, a recipe steers detection by
bumping the preferred engine. This lets a single wildcard content item
auto-detect the right engine per file instead of pinning one format per
extension. The override is applied per detection call rather than by mutating the
registry, so concurrently open projects with different priorities do not race.
Content resolution. ProjectContext.ResolveContent matches content patterns
against the filesystem, applies ignore rules, detects formats and returns
[]ResolvedFile. Both the CLI and the desktop app use this one implementation.
Reader and writer configuration. ConfigureReader applies a format's
FormatDefaults.Config overrides from defaults.formats.<format> onto the
reader's config; it takes the Configurable interface (any component exposing
Config() format.DataFormatConfig) and is a no-op when the project declares no
defaults for that format. ConfigureWriter sets the writer's encoding from the
project defaults. Preset selection is resolved separately, through
resolver.ResolveFormatConfig, which merges the named preset's config before the
reader is opened.
Reader config decides which text a reader emits at all, so it is part of a
content unit's identity rather than a detail of one code path: the config a
collection declares is defaults.formats.<format>.config overlaid by the
content item's own format.config, and every path that reads a declared file
reads it under that merged config. That covers the flow run, extract, merge, and
equally the measurement paths (coverage, status, the review queue, the bilingual
checks), which resolve it per unit as they resolve content. A measurement blind
to the config counts a different set of units than the run produced, so the
percentage is a fraction over two denominators.
Plugin scoping generalizes beyond detection: AllowedTools() filters the tool
registry to tools from declared plugins plus built-ins, presets from undeclared
plugins are excluded from preset selectors, and flows referencing tools from
undeclared plugins produce warnings during validation.
Bookkeeping
.kapi/manifest.yaml is core/project.StateManifest (kind: kapi-state):
the generator that scaffolded the project, a reference to the recipe, and room
for per-collection block counts and per-source fingerprints. kapi init writes
it. Nothing authoritative lives in it, so deleting it costs nothing.
.kapi/work/cache/extractions/<batch-id>/manifest.yaml records each kapi extract run (M-01): the emitted
source→output pairs, per-file source SHA-256, leverage counts, the bilingual
format version and the skeleton filenames. The batch id is stamped in each
emitted bilingual file so kapi merge resolves a returning file back to the
right extraction without guessing from its name.
Consequences
- Incremental work: re-running a flow translates only blocks whose source hash is not already recorded for the target locale.
- Concurrent tools: term match and memory lookup run in parallel, each writing an independent overlay layer.
- Transaction semantics vary per provider, and tools calling
GetBlockper block are slow against a remote store. - The recipe is always free of credentials, so it is safe to commit and to share.
- The recipe binds sources (
defaults.terms_source,defaults.memory_source,defaults.voice) and never a derived artifact; the state record and the database that stages it are both fixed by the layout.
See also
- C-02: Coordinates and governance: the point a collection sits at and what governs it.
- C-03: The context store and graph:
.kapi/work/store.db. - C-04: Unit state and the decision record:
.kapi/state/. - E-01: Processing Engine: flow execution.
- E-03: Tool System: the
ToolandSessionToolinterfaces. - Flow steps format: the shared flow syntax.
- kapi.yaml project file: the schema reference.