Corpus Store#
@kontourai/corpus-store owns transactional, file-backed mutable records. Its
purpose is to give hosts one implementation of record identity, links,
provenance, mutations and recoverable publication. Immutable docs bundles are
separate. The docs CLI does not depend on this package and needs no build.
The extraction preserved Station semantics at
2479f38b1ab062e2d9cd6df9f8fa2cdbeee1d136; its only codec change preserves text
scalars from old Knowledge Kit writers. Later changes implement the owner's
decisions one at a time; see Owner decisions. Version
0.1.0-next.0 is the first npm candidate, published only after owner bootstrap
and trusted-publisher setup through the
workspace publish workflow.
Owner decisions#
The conformance vectors encode the owner's 2026-10-13 decisions. A decision not yet implemented is an explicit entry in the expected-fail list, never silent behavior.
Proposals may target any record type.
propose,applyandrejectaccept a target of every type, not onlyconcept. The target and proposer must exist (NOT_FOUND), evidence fields are still required (MISSING_EVIDENCE), andapplyandrejectstill require the proposer'sproposeslink to the target. Mutation-log evidence keeps its storedconcept_idkey.Lists return the working set.
listByTypeandlistByCategoryomit retired and superseded records unlessincludeHistory: trueis passed. Both profiles share one predicate: a record is historical when its status isretired, when it carries asuperseded-bymutation-log entry, or (vault) when its note is archived.implementedrecords stay in the working set. The vault's history walks archived notes.includeRetiredis a deprecated alias, translated toincludeHistory: true, so it now also returns superseded records; removing the alias is a separate, owner-approved change.getandgetLinksalways reach history.One expiry rule and an expiry-only stale filter. A record expires at its explicit
expires_at, otherwisettl_secondsaftercreated_at; a record with neither never expires. The TTL anchor is creation, so an update never extends it, and a record is expired from its expiry instant onward. List optionstale: truekeeps only expired records andstale: falseonly unexpired ones; withoutstalethere is no expiry filter.now(epoch milliseconds) defaults to the injectedclock. A non-booleanstaleor a non-finitenowis refused withMISSING_EVIDENCE. The filter composes with the working set. This is record expiry only: nostalestatus is stored, and maintenance age and trust freshness are separate signals.A compiled record must link a raw source when it is created.
createwithtype: 'compiled'requires at least one link of kindsourceto an existingrawrecord; otherwise it throwsMISSING_EVIDENCEand writes nothing.provenance.source_ids,relatedlinks and body wikilinks do not count. Only creation is checked: existing compiled records without a source stay readable, listable and updatable.
Every decision is implemented, and the expected-fail list is empty.
Host composition#
import { KitDefaultStoreAdapter, KitObsidianStoreAdapter } from '@kontourai/corpus-store';
const store = new KitDefaultStoreAdapter({
storeRoot: '/private/records',
coordinationDirectory: '/private/host/coordination/knowledge-file-transactions',
lock: hostFileMutationLock,
coordinationIdentity: hostCoordinationIdentity,
clock: () => Date.now(),
ids: () => crypto.randomUUID(),
diagnostics: { warn(message, fields) { console.warn(message, fields); } },
transactionObserver(operation, outcome) { /* host metrics */ },
});
const id = await store.create({
type: 'raw', title: 'Source', body: 'Observed content', category: 'engineering',
provenance: { agent: 'operator' },
});
await store.get(id);
Vault construction uses KitObsidianStoreAdapter with the same options plus
optional sourcesDir (default sources). Constructors prepare directories;
locked reads may repair derived indexes or recover a journal. Lists return the
working set; includeHistory: true adds retired and superseded records, and
category lists also take prefix (see Owner decisions).
Retired and superseded records are always gettable. Vault superseded files move
to archive/. Lists also take an expiry filter, {stale, now}. No search,
capabilities object or public storage API is claimed by this package.
The lock function has Station's FileMutationLock shape:
(lockPath: string) => release | Promise<release>, where release returns void or
Promise. Station keeps its own implementation. All three of lock, coordinationDirectory and coordinationIdentity are required. Opening without them, or with an identity factory that does not return a recheck, throws before filesystem I/O. Every cooperating writer
must inject the same lock protocol and coordination directory. Pass the final
private directory, not a host home path. The package preserves the hashed
realpath/device/inode .lock name and .station-knowledge-transaction.json v1
journal, including recovery and publication ordering.
The explicitly selected standaloneFileMutationLock is standalone only and does not
interoperate with Station's lock. It uses a filesystem bakery directory at the
lock path, unique PID/UUID participants and atomic ticket publication, waiting
for live owners and ignoring dead processes. Every host supplies its own absolute private coordination directory; omission is rejected. It is intended for
same-host processes on a local filesystem. PID reuse can conservatively delay
acquisition until timeout. Inject the host services for shared stores; no mixed
Station/standalone co-writer qualification is claimed.
Diagnostics and transaction observer exceptions cannot change storage outcomes.
Host roots, tenancy, authorization, notifications, HTTP/MCP and retrieval remain
host responsibilities. provenance.agent records attribution, not authority.
Codec and format#
The reader preserves Station's known string fields and reads its numeric fields
(ttl_seconds, supersede evidence superseded_count) as numbers only when the
source scalar is unquoted. Quoted numeric TTL remains corruption, matching
Station. Unknown fields use core-schema types when conversion is lossless: 3,
true and null become number, boolean and null; 1.10 and a bare - remain
text. Unknown quoted strings stay strings.
The codec retains original tokens only for unchanged, top-level, single-line
plain scalars whose core-schema conversion would be lossy (1.10, 1e3,
0x10, ~). Every retained token must re-parse as the same string under the
package's failsafe scalar reader. Invalid legacy text such as a bare - is
quoted. Block scalars, flow collections, sequences, mappings, nested fields and
multiline text go through normal serialization from their parsed values. No
collection fragments are copied into a different indentation context. Station's
serializer remains the writer for changed fields and new records. Station-written
fixtures include all mutation evidence shapes and round-trip byte for byte.
Default layout: records/<id>.md, graph-index.json, alias-index.json.
Vault layout: category-routed notes, people/, archives, path-index.json,
graph-index.json, alias-index.json, and collision-safe body sentinels.
Graph and alias indexes can be rebuilt; vault path mappings remain authoritative.
Vault index and migration#
path-index.json is the canonical vault path index. The vault adapter selects
the index on every operation:
path-index.jsonalone: the vault is read and written normally..graph-index.jsonalone (kit-v1, written by the Knowledge Kit's vault adapter, with the same payload): the vault opens read-only. Reads work; every mutation, includingreindex, throwsREAD_ONLYand namescorpus-store migrate.- Both files: refused as a conflict (
KnowledgeStoreCorruptionError); the package does not choose one. - Neither file: an empty root, or one with only plain notes, opens as a new
vault. A root that already holds store data (a
graph-index.jsonoralias-index.json, or a markdown note whose frontmatter has anidor atype) is refused rather than read as empty. Unreadable frontmatter or an exceeded scan bound also refuses.
The package's corpus-store bin converts a kit-v1 vault offline:
corpus-store migrate <vault-root> --check # validate and report; changes no vault file
corpus-store migrate <vault-root> # convert, then verify
corpus-store migrate <vault-root> --reverse # restore the Kit index
Stop every writer first. The migration's lock (by default under
<vault-root>/.corpus-store-migration/coordination, or
--coordination-directory <dir>) only excludes other migration runs. --check
creates only that lock directory. It
validates the Kit index as exact mappings (consistent by_id/by_path,
contained paths, archived matching an archive/ location, every note present
with that frontmatter id) and refuses with every problem listed if any check
fails. In one journaled transaction of the store's own transaction engine it
hashes every other file under the root, saves the original index as
.corpus-store-migration/kit-v1-path-index.json, writes
.corpus-store-migration/manifest.json, writes path-index.json with the same
mappings, and removes .graph-index.json. It then re-hashes every file, opens
every indexed record through the vault adapter, re-hashes again, and marks the
manifest verified.
If the process stops after committing the conversion, rerunning migrate
resumes verification against the recorded hashes and leaves the manifest
migrated on refusal. --check refuses an incomplete verification and directs
the caller to rerun the conversion command. A verified migration remains
already-migrated even after ordinary store writes.
Only the index file changes; markdown files the Kit index
did not list (for example a default store's records/ sharing the root) are
listed in the manifest and left alone. --reverse restores the saved index only
while path-index.json and every hashed file are byte-for-byte as the manifest
recorded, so it never overwrites a later edit. stdout is one JSON result,
including the manifest's sha256; exit 0 means success or nothing to do, 1 a
refusal, 2 a usage error.
Public exports#
The vault constructor accepts dimensions, an ordered list of names for category
segments after the domain. For example, ['territory', 'customer', 'initiative']
projects sales.east.acme.renewal as those three frontmatter fields. Each write
recomputes them from category; shortening a category removes obsolete fields.
They stay out of returned records. Names must be unique identifiers and may not
replace record fields. Omit the option to preserve ordinary unknown frontmatter.
@kontourai/corpus-store: constructors, record/evidence/interface types, service option types, reindex result type and standalone lock.@kontourai/corpus-store/errors: the unchanged domain and persistence error constructors/classifier. Persistence errors do not gain new codes here.@kontourai/corpus-store/observation: construction-free bounded filesystem observation, includingobserveExactKnowledgeRecordFile(root, coordinationDirectory, exactId). It refuses journal/lock artifacts and returns a recheck; hosts must retain non-atomic/unknown-transaction semantics and enforce authorization separately.@kontourai/corpus-store/package.json: metadata.corpus-storebin:migrate, described in Vault index and migration.
Deep source/dist imports and /storage are not exports. ESM only, Node >=22.
Runtime dependency: js-yaml, retained for Station-identical writing.
Development and evidence#
The development harness pins Vitest 4.1.11. Vitest and its mock/worker tooling are development dependencies; the published store runtime does not depend on them. The lockfile retains the existing Vite and Rollup versions. Build the store before testing or packing so consumers exercise compiled entry points.
npm ci
npm run build --workspace=@kontourai/corpus-store
npm run test --workspace=@kontourai/corpus-store
npm run conformance --workspace=@kontourai/corpus-store
npm run verify:pack --workspace=@kontourai/corpus-store
Builds emit package-local ESM JS and declarations. No root prepare/build step is added. Pack verification installs tarballs in isolated JS and TS consumers, checks exports and declarations, executes every store conformance vector on both profiles against installed code, and runs the installed docs CLI without a build. CI runs this on Node 22 and 24.
The expected-fail list is keyed by public vector ID,
profile, deferred owner decision and either an exact failing assertion or a specific error pattern. Unknown failures,
missing probes and listed vectors that pass fail the run. Vectors with
"scope": "host" (today only search-working-set) need search, which this
package does not implement: the runner skips them, refuses to select them, and
fails if the expected-fail list names one. Every host that mounts this store
must run hostVectors from @kontourai/corpus-conformance and pass them. The extraction record
contains source mapping and executed evidence. The ported transaction suite
retains internal crash hooks; the conformance crash subprocesses use those
existing internal hooks without adding a public storage seam.
Coordination identity#
Every host supplies coordinationIdentity, a factory that
captures that home and returns a recheck. It must validate the home with
observeDirectoryChain(home) (including strict realpath/no-symlink checks),
then reject if the current runtime-home resolver returns a different absolute
path. The package calls the captured recheck before transaction I/O. Pass the
same factory as the fourth argument to
observeExactKnowledgeRecordFile(root, coordinationDirectory, id, coordinationIdentity);
its returned recheck repeats the home/namespace check before returning source.
Filesystem identity checks alone cannot detect a host resolver selecting another
home, so hosts with that policy must inject the resolver check. Standalone hosts
with a fixed directory must explicitly provide a factory that captures and
rechecks their selected private parent directory (which must exist before opening). No Station home resolver is imported.
For example, a standalone identity can use the public observation helper:
import { observeDirectoryChain } from '@kontourai/corpus-store/observation';
const coordinationIdentity = () => observeDirectoryChain('/private/host');
The same identity is required on the construction-free observation port. Hosts with a runtime-home resolver must additionally check that resolver against the captured home. Record data remains identical on re-read; whitespace/style may normalize except for the approved top-level lossy plain tokens.