soul.demarkus.io:6309/adr/0037-contract-is-the-protocol-two-doors.mdsoul.demarkus.io:6309/plans/memory-knowledge-layering/file-worlds.md draft reader metaFile worlds in the knowledge server
First move of step 7 of Memory and knowledge layering, under the delivery bar, and the "self-host without GCS" item of The engine, not the wire. For demarkus maintainers: how knowledgeserver opens a world from a directory with the file store the soul already runs, what the config looks like, what a file world cannot do, the tests, and the two PRs. Drafted 2026-10-19 from main at bb24d871 (server 0.70.0). The shape it serves is ADR 0037 decision 6: one runtime, two thin mains; this is the half that gives the knowledge path a file backend, so the thin local main can follow.
Today
Soul (cmd/demarkus-server) |
Knowledge (knowledgeserver) |
|
|---|---|---|
| Store | store.New(dir), BuildIndex, catalog, filestore.Open with a change ring: openFileStore in store.go, with buildCatalog and logPartialWalk in main.go |
bucketstore.Open over a blob.Store; the only storeFactory, bucketStores in run.go |
| World identity | none (one world at the root) | Bucket.WorldID, the key peer hints carry and the WATCH cursor epoch |
| Config | config.Config (-store file, ContentDir) |
knowledgeconfig.WorldConfig with a required bucket: section; validateWorlds requires bucket.url and bucket.worldID on every world |
| GCS client | none | Server.open builds storage.NewClient whenever Options.Buckets is nil, before any world opens |
| Runtime | worldruntime.New wired by hand in main.go |
worldManager.serve: policy seed, style guide on the floor world, writepolicy.Enforce, worldruntime.New |
filestore.Store already satisfies worldStore (backend.Store, backend.ChangeSource, io.Closer) and passes the same storetest conformance, lookup, handler and differential suites bucketstore does. Nothing in the runtime knows which store it holds. The gap is wiring and config.
The change
-
One file-store constructor.
filestore.OpenDir(dir string, opts Options) (*Store, error):store.New,BuildIndex(a partial walk logged per entry, a broken walk an error), the catalog,Openwith the change ring.openFileStore,buildCatalogandlogPartialWalkleavecmd/demarkus-serverforfilestore; the soul callsOpenDir. Pure move, no behavior change; the soul's on-disk layout is unchanged by construction, since the knowledge path will call the same function. -
A world has a
diror abucket.WorldConfiggainsDir string yaml:"dir". Exactly one ofdirandbucket.urlis set; a world with both or neither is refused by name. A file world'sdiris cleaned and absolute; two worlds cannot share one (same rule as duplicate bucket URLs).bucket.worldIDis not required on a file world andID()returns""for one. A file world is one replica: a config withpeersand a file world is refused, because a directory cannot be shared across replicas and the hint it would carry has no epoch.readOnly,auth,policy,limitsandauthoritiesapply as today. -
The factory dispatches on the world.
storeFactoryinrun.gobecomesworldStores(objects, role): a world withDirreturnsfilestore.OpenDir(world.Dir, filestore.Options{ChangeRing: changefeed.DefaultRingSize, Logger: hooks.logger}); otherwise the bucket path as today. A writable file world's directory is created on open (MkdirAll); a read-only one must exist.hooks.committedis not wired for a file world: no peers, nothing to hint. -
The GCS client is lazy.
Server.openbuildsstorage.NewClienton the first bucket world, not at startup, so a server whose worlds are all directories runs with no credentials and no network.Options.Bucketskeeps its test seam. -
Limits.
limits.maxDocumentsis enforced bybucketstoreonly;filestorehas no count. For a file world the field is accepted and logged as not enforced, with a follow-up to enforce it infilestoreor inworldruntimefor both. Not a blocker: the soul has never had the limit.
Nothing else moves: policy seed, floor merge, style guide, token files, SNI routing, the gateway's in-process exchange and WATCH all run against the store interface and gain file worlds for free.
Config, the whole of it for a self-hosted system:
version: 1
listen: { address: ":6309" }
tls: { certFile: /etc/demarkus/tls.crt, keyFile: /etc/demarkus/tls.key }
worlds:
- name: team
authorities: [team.example.com]
dir: /var/lib/demarkus/worlds/team
auth: { tokensFile: /etc/demarkus/team-tokens.toml }
floorWorld: team
What it does not do
- Broker state on disk.
demarkus-knowledge(worlds plus the in-process broker) still keeps its registry, OAuth state and tenant provisioning in a GCS state bucket, and provisions tenants as bucket worlds. A self-hosted knowledge system with SSO needs that state off GCS too; it is the next item under "self-host without GCS", its own design. This step's proof isdemarkus-knowledge-serveralone. - Dynamic file worlds. The
worldsFileseam and the broker's provisioner stay bucket-only. A file world is operator config. - Replicated file worlds. Refused by validation, above.
- The thin main.
demarkus-serverkeeps its own wiring in this step. It becomes alocalmain overknowledgeserverin the step after, once a file world is proven; that is where the layer guard learns the forbidden imports.
Tests that fail without each change
| Change | Test |
|---|---|
| 1 | filestore: TestOpenDirServesAnExistingSoul opens the upgrade capture's content directory (testdata/upgrade/home/.demarkus/content) and reads it, the same documents the soul server test reads; cmd/demarkus-server tests unchanged and green (TestSoulWritesAreGated, the upgrade capture) |
| 2 | knowledgeconfig: a world with dir and no bucket loads; both set, neither set, a relative dir, two worlds on one dir, and peers with a file world are refused by name; ID() is empty for a file world; the chart-rendered fixture is untouched (the chart knows only buckets) |
| 3 | knowledgeserver: TestFileWorldServes opens a server from a config with one file world under t.TempDir(), publishes through Exchange, reopens the server on the same directory and fetches the document and its version; a WATCH cursor taken before the restart resumes after it; the policy document is seeded on first open; a read-only file world on a missing directory fails the open by name |
| 3 | knowledgeserver: TestFileWorldReadsASoul opens the upgrade capture's content directory as a world and fetches a document the soul wrote; the proof that the merged binary reads a soul's files without migration |
| 4 | knowledgeserver: the file-world tests pass with Options.Buckets nil and no GCS credentials in the environment, which today fails in Server.open |
| 5 | knowledgeconfig: maxDocuments on a file world loads; knowledgeserver logs it as not enforced once at open |
| all | storetest suites unchanged; gateway goldens unchanged (no surface change); scripts/check-chart-fixtures.sh unchanged |
Release
Two PRs, both in the server module, each its own release (a pure move or a behavior change, never both):
refactor(server): file store opener lives in filestore. The move. Server tag, pin bump, no user-visible change; the plugin's managed server upgrades through the launcher as always.feat(server): knowledge worlds open from a directory. Changes 2 to 5 with their tests. Server tag, pin bump. No prompt, tool, instruction or chart change; nothing for the plugins.
Ride-along on PR 2, its own commit: TestSeveralWritersModel's 70 percent closed-loop ratio becomes a log line; the starvation, slot-loss, recovery and p50 assertions stay (the flake recorded in debugging).
Skew
| Pair | Effect |
|---|---|
| Old config, new server | a bucket: world is unchanged; nothing to migrate |
New config with dir, old server |
refused at load: bucket.url required. A config is deployed with its server, so this is the only mixed pair and it fails loud |
| Soul directory, knowledge server | readable as a file world (the capture test); the soul keeps writing it with demarkus-server until the thin main lands. Both write through filestore, so a handover either way is a config change, not a migration |
| Chart | unchanged; the chart renders bucket worlds only until a self-host values path exists |
Risks
ID()empty for a file world reaches two places: the committed hook (not wired) andHint(compares IDs, then requiresbackend.Follower, whichfilestoreis not). A peer hint with an empty world ID is already malformed at the parser. Covered by the validation that refuses peers with file worlds.- A file world and the soul server on the same directory at once would corrupt the change journal. Not prevented by code today for two soul servers either; the lock is the launcher's. Documented, and the thin-main step removes the second writer.
- The lazy GCS client moves a startup failure (no credentials) to the first bucket world's open; in static mode that is still fatal at startup, in dynamic mode it goes to pending with a logged reason. Same contract as a bucket that cannot be reached.
Progress
- Drafted 2026-10-19. Waiting on Fritz to approve the shape before any branch.
- Approved by Fritz 2026-10-19, one feature branch for the whole feature (move and behavior together, his call):
feat/file-worldsfrombb24d871. Shipped as designed with four corrections found in the code: the capture's soul directory istestdata/upgrade/home/.demarkus/soul, notcontent; a read-only file world needs a store-level guard (readOnlyStoreinknowledgeserveranswersbackend.ErrReadOnly, since the file store has no read-only mode and the policy seed writes straight to the store) and opens without a change journal, so an empty directory stays empty and WATCH is off on it; the WATCH-cursor-across-restart case stays with the file store's own journal tests rather than a server-level test; the config's seen-store maps are onestoreKeysstruct (argument-limit lint). Tests:TestOpenDirServesAnExistingSoul,TestOpenDirRefusesAMissingDirectory,TestParseFileWorld,TestValidateFileWorld(five refusals),TestFileWorldAcceptsLimits,TestFileWorldServesAcrossARestart,TestFileWorldReadsACapturedSoul,TestReadOnlyFileWorldWritesNothing,TestOpenServesFileWorldsWithoutGCS(credentials pointed at a missing file; the server opens and routes). The flake fix rode along. Server module green with race, pre-commit green. Uncommitted for Fritz. - Simplify pass 2026-10-19 (four reviewers) moved three things to their right depth:
filestore.OptionsownsReadOnly(no journal, writes answerbackend.ErrReadOnly; the soul's--read-onlystops rewriting its journal too) andMaxDocuments(a create past the cap isErrDocumentQuotawrappingbackend.ErrQuota, a new version always lands), so thereadOnlyStorewrapper and the "not enforced" log went; the lazy GCS client isgcs.Lazyinserver/blob/gcswithOpen,Opened,Close, soknowledgeserverno longer imports the storage client;OpenDircreates a writable directory itself. Also: the soul'scmdlost its one-entrystoreOpenersregistry andbackendstruct (openStorereturns the file store,ValidateStoreBackendrefuses any other name now);knowledgeconfigshares onecanonicalPathrule betweendirandpolicy.fileand onestoreKeys.claimfor every duplicate check;Hintignores a world with no ID. Tests:testCertFilestakes the DNS name,backendtest.Directreads the capture, the boundary test usesOpenDir; newTestOpenDirCreatesOrRequiresTheDirectoryandTestOpenDirCapsDocuments. Skipped: the double walk at open (index then catalog; pre-existing, aBuildIndexobserver would halve startup I/O, follow-up), a shared capture locator across three test packages, droppingTestFileWorldReadsACapturedSoul(it is the design's proof at the world level). Lint ratchet improved by one and was updated. Server module green with race, pre-commit green. - PR 631 review, CodeRabbit round one: two security findings, both real and fixed. The document cap keyed on
expected-version: 0missed the absent header (-1, an unconditional write) and so let a client create paths past the cap;protocol/store.Indexednow tells a new path from an existing one and the cap applies to every create. Two file worlds could nest and serve each other's files;validateStorerefuses an ancestor or descendant (strictwithin), symlink aliases named as not resolved. Rolled in after approval: operator docs fordir(server guide and config reference), and one walk at open:BuildIndexObservedfills the index and the catalog together, halving startup reads for every soul and file world (the follow-up from the simplify pass). - Simplify pass on the single-walk round (four reviewers):
WalkCurrentdeleted (no production caller once the observed build existed; demarkus-library does not importprotocol/store), its test retargeted atBuildIndexObservedand renamed; the index is now built aside and swapped in whole, so the observer runs with no lock held and a broken walk keeps the previous index; the partial-walk helper lost its one-valued parameter and the log its derivable catalog count; docs say each thing once and the file-world note sits beside the bucket note. Skipped:filestore.New/Openowning the catalog (eleven callers across test packages, a separate tidy), the body-to-string copies incatalog.Putand the repeated frontmatter split per document (pre-existing, follow-ups), one review claim that peers with a file world is not refused (it is;TestValidateFileWorld/peers). - Smoke 2026-10-19 on built binaries over real QUIC:
scripts/smoke.sh file-worldadded (the knowledge server on adirworld, credentials pointed at a missing file, the same verbs, policy, watch and restart checks as the GCS mode) at 77 of 77; the soul mode at 105 of 105 after its restart check moved to the newdocument index builtline; the GCS mode against fake-gcs at 86 of 86. The one failure found was the smoke script's own expectation of the removedlookup catalog builtlog line. - Merged 2026-10-19 as PR 631 (
9154affc) after three CodeRabbit rounds; server 0.71.0, protocol 0.28.0, client 0.63.0, knowledge 0.73.0, tools 0.69.1; pins by PRs 632 and 633, plugin 0.13.201. Done. Next move of step 7: the thinlocalmain.