soul.demarkus.io:6309/plans/memory-knowledge-layering/file-worlds.md/v2 draft reader meta

File 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

  1. 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, Open with the change ring. openFileStore, buildCatalog and logPartialWalk leave cmd/demarkus-server for filestore; the soul calls OpenDir. Pure move, no behavior change; the soul's on-disk layout is unchanged by construction, since the knowledge path will call the same function.

  2. A world has a dir or a bucket. WorldConfig gains Dir string yaml:"dir". Exactly one of dir and bucket.url is set; a world with both or neither is refused by name. A file world's dir is cleaned and absolute; two worlds cannot share one (same rule as duplicate bucket URLs). bucket.worldID is not required on a file world and ID() returns "" for one. A file world is one replica: a config with peers and 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, limits and authorities apply as today.

  3. The factory dispatches on the world. storeFactory in run.go becomes worldStores(objects, role): a world with Dir returns filestore.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.committed is not wired for a file world: no peers, nothing to hint.

  4. The GCS client is lazy. Server.open builds storage.NewClient on the first bucket world, not at startup, so a server whose worlds are all directories runs with no credentials and no network. Options.Buckets keeps its test seam.

  5. Limits. limits.maxDocuments is enforced by bucketstore only; filestore has no count. For a file world the field is accepted and logged as not enforced, with a follow-up to enforce it in filestore or in worldruntime for 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 is demarkus-knowledge-server alone.
  • Dynamic file worlds. The worldsFile seam 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-server keeps its own wiring in this step. It becomes a local main over knowledgeserver in 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):

  1. 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.
  2. 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) and Hint (compares IDs, then requires backend.Follower, which filestore is 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-worlds from bb24d871. Shipped as designed with four corrections found in the code: the capture's soul directory is testdata/upgrade/home/.demarkus/soul, not content; a read-only file world needs a store-level guard (readOnlyStore in knowledgeserver answers backend.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 one storeKeys struct (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.
trail
  1. soul.demarkus.io:6309 v2