> ## Documentation Index
> Fetch the complete documentation index at: https://docs.mirage.strukto.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Adding a New VFS

> Ship your own backend with BaseVFS, or contribute a builtin VFS using Mirage's VFS, command, and snapshot conventions.

A VFS maps an external system to Mirage's filesystem operations and shell commands. There are two paths:

* **Ship your own backend**: a single TypeScript file in your own project or package, built on `BaseVFS`. No Mirage fork, no edits to Mirage source.
* **Contribute a builtin**: the four-layer layout inside the Mirage repo, mirrored in Python.

## Ship Your Own Backend

Write the core functions over your data source, group the three required reads in a `VFSAdapter`, and `BaseVFS` wires the full generic command set (`ls`, `cat`, `grep`, `find`, `head`, `wc`, ...) plus glob resolution and the VFS/FUSE ops:

```ts theme={null}
import {
  Accessor,
  BaseVFS,
  FileStat,
  MountMode,
  type PathSpec,
  VFSAdapter,
  Workspace,
} from '@struktoai/mirage-node'
import { parseConfigWithSchema, secretStr, z } from '@struktoai/mirage-core/vfs/secrets'

const JiraConfigSchema = z.object({ site: z.string(), token: secretStr() })
type JiraConfig = z.infer<typeof JiraConfigSchema>

class JiraAccessor extends Accessor {
  constructor(readonly client: JiraClient) {
    super()
  }
}

declare function readdir(accessor: JiraAccessor, path: PathSpec): Promise<string[]>
declare function readBytes(accessor: JiraAccessor, path: PathSpec): Promise<Uint8Array>
declare function stat(accessor: JiraAccessor, path: PathSpec): Promise<FileStat>

class JiraVFS extends BaseVFS<JiraAccessor> {
  constructor(input: Record<string, unknown>) {
    const config = parseConfigWithSchema(JiraConfigSchema, input)
    super({
      name: 'jira',
      accessor: new JiraAccessor(makeClient(config)),
      io: new VFSAdapter({ read: { readdir, readBytes, stat } }),
      prompt: 'Issues rendered as .json files.',
    })
  }

  override async close(): Promise<void> {
    if (this.isClosed) return
    try {
      await this.accessor.client.close()
    } finally {
      await super.close()
    }
  }
}

const ws = new Workspace({ '/jira/': new JiraVFS(cfg) }, { mode: MountMode.READ })
```

The accessor is a type parameter, so the table is checked against the accessor your core functions actually take: wiring `readdir` where `stat` belongs, or an accessor from another backend, is a compile error rather than a runtime one.

For a smaller runnable example, see `examples/typescript/other/basevfs_views.ts`.
Its read-only `NotesVFS` connects to a nested mount, a namespace symlink,
a CLI using namespace and session views, and the filesystem and runtime APIs.

The driver never sees the mount it runs under. The index store, the registered tables and the `vfs:` reference it was built from all live on the mount, built when the driver is placed and shared with any alias of the same instance. The loader recognizes a driver by the `VFS_BRAND` symbol `BaseVFS` stamps, so a script file that loaded its own copy of the package still mounts.

### Add capabilities as the resource grows

Only `readdir`, `readBytes`, and `stat` are required. The adapter derives a
stream from `readBytes`, existence checks from `stat`, and defaults to a remote
resource that is available. Range reads fall back to reading and slicing.
A derived stream still fetches the entire file; use a native stream for large files.

```ts theme={null}
import {
  type NativeReadOps,
  type ReadOps,
  type WriteOps,
  VFSAdapter,
} from '@struktoai/mirage-node'

const read: ReadOps<JiraAccessor> = { readdir, readBytes, stat }
const native: NativeReadOps<JiraAccessor> = { readStream, readRange }
const writes: WriteOps<JiraAccessor> = { write, unlink }
const adapter = new VFSAdapter({ read, native, writes })
```

Each callback above is implemented by your backend. Leave out `native` and
`writes` for a minimal read-only adapter. These groups are independent:

| Group | Operations | Purpose |
| - | - | - |
| `ReadOps` | `readdir`, `readBytes`, `stat` | Required filesystem behavior. |
| `NativeReadOps` | `readStream`, `readRange`, `exists`, `find`, `du` | Optional equivalent fast paths. |
| `WriteOps` | `write`, `append`, `pwrite`, `create`, `mkdir`, `unlink`, `rmdir`, `rmR`, `rename`, `copy`, `dirCopy`, `truncate`, `setAttrs` | Individually supported mutations. |

A native range receives `(accessor, path, index, offset, size)`; `size=null`
means through EOF. Its write twin, `pwrite`, receives `(accessor, path, data,
offset)` and keeps every byte outside the window, as pwrite(2) does. A table
with `write` and no `append` or `pwrite` gets both built from `readBytes` and
`write`, so supply them only when the backend writes a range natively. Use `FileStat.size=null` when rendered size is unknown.
`du` supplies both size and entry enumeration. All operations enforce the same
resource scope, including direct reads of known IDs. A partial listing cannot
prove an omitted resource absent.

Supplying a write callback does not enable deletion or rename. Every generic
command is registered either way: `gzip -c` and `tar -t` run as readers, a line
that needs a missing operation answers `Operation not supported` at that
operation, and mount mode still controls whether a supported mutation may run. Set `local: true` only for host-local data;
`isMounted` can override the default availability check.

`adapter.toCommandIO()` assembles the single `CommandIO` used by commands and
filesystem ops. Builtins compile `VFSAdapter` into this table; advanced integrations can also
supply `CommandIO` directly. The escape hatches remain:

* `overrides` drops a generic command you replace, and `commands` supplies the replacement (or any bespoke verb) from `command({...})`.
* `ops` layers an irregular VFS/FUSE handler over the derived set; one carrying no `filetype` shadows the derived op of the same name. `autoOps: false` opts out of deriving any. A verb the op table does not carry is not served: the mount answers `Operation not supported` for it, so a backend can be partial.
* `sizesAlwaysKnown` declares that `stat` sizes every file without fetching it, which is also what makes the mount legal on FSKit. `supportsSnapshot` declares that `stat` fills `FileStat.fingerprint`; setting it without that is not drift detection.

### How the driver connects to a workspace

`BaseVFS` is the backend authoring API. Mount it once with
`new Workspace({ "/jira": new JiraVFS(config) })`; consumers use workspace paths.

| Consumer | Connection |
| - | - |
| Shell and coreutils | `commands()` derives generic commands from the adapter; the shell supplies expansion, pipes, redirects and session policy. |
| Filesystem calls | `ops()` supplies the dispatcher's operations used by `ws.vfs`. |
| CLIs | The host registers a `CLISpec` with `registerCli`. File-oriented handlers use `inv.doors.dispatch` and `inv.doors.ns`; mounting a driver does not install an account CLI. |
| Runtimes | Runtimes wired to `RuntimeVFS` reach the same dispatcher. A process or remote runtime needs its configured mount bridge. |
| Namespace | The workspace owns symlinks, nested mounts and metadata overlays; the backend implements its own tree. |

Use `path.vfsPath` to address your backend and `path.virtual` for workspace
paths. Use the index passed to callbacks: the mount scopes it for ownership
and freshness. Put shared behavior in the adapter callbacks; an explicit
`ops` override changes that operation, while a `commands` override changes
that shell command. Optional mutations remain optional, and unsupported
operations report `Operation not supported`.

### Native search and backend-specific core functions

Builtin backends use the same `VFSAdapter` groups. Operation types live in
`vfs/types`; `CommandIO` extends them with command context. Wire compatible core
functions directly, and wrap incompatible client functions to resolve `PathSpec`
and normalize their results. All callbacks are checked against the adapter's
accessor type.

```ts theme={null}
import { type SearchOps, type SearchQuery, type DuOps } from '@struktoai/mirage-node'

declare function search(
  accessor: JiraAccessor,
  path: PathSpec,
  query: SearchQuery,
): Promise<string[] | null>

const searchOps: SearchOps<JiraAccessor> = { search }
const adapter = new VFSAdapter({ read, search: searchOps })

// Native size queries require both total size and leaf enumeration.
const du: DuOps<JiraAccessor> = { size, entries }
```

`SearchQuery` contains `query` and optional `options`, for example
`{ query: 'recent deployments', options: { limit: 20 } }`. Options can hold
filters, limits, or other JSON values. `SearchOps.meta` is optional static
capability metadata. Regex support and grep compatibility are not required.
Omitting `search` entirely also works: MIRAGE implements grep and rg by reading
files.

A resource search is not automatically used by grep or rg. Opt into that
integration with `{ search, meta: { grep: { mode: 'literal' } } }`, or use
`mode: 'regex'` when the backend honors regex semantics. Only that integration
interprets the `grep` namespace. Requests put `ignore_case`, `fixed_string`,
`whole_word`, and `basic` booleans in `query.options.grep`; these JSON keys are
snake\_case in both languages. Other options and metadata belong to your resource.

Return text records: `[]` means no matches and `null` declines the request.
Errors propagate. Under the grep integration, records must be complete rendered
output lines, including path prefixes. A new filesystem search accelerator must
match scanning rendered files. Existing backends retain their declared output
semantics, including Langfuse's summary search. Never report truncated results
as complete.

MIRAGE scans for unsupported flags, multiple operands, declined requests, and
visibility restrictions. Set `meta: { grep: { mode: 'regex', stream: true } }`
to enable native streaming for fallback scans. The hierarchy kit's
`makeSearchOp(detectScope, SEARCHERS, stat?)` adapts scope-specific callbacks;
PostgreSQL, MongoDB and Langfuse use it. Custom resources can implement `search`
directly. Expose semantic or service-specific queries through a custom command
that calls the same callback with its own options. Native traversal and size
enumeration remain independent capabilities.

`examples/typescript/other/basevfs_views.ts` wires the same literal search callback
to both commands. Its counters verify that `grep -F` and `rg -F` avoid file reads,
line numbers and regexes scan, and `-i` scans after the callback returns `null`.
The example accelerates single-page searches; directory searches also decline.

To make the backend constructible by name (workspace config, snapshots, the daemon), register a factory:

```ts theme={null}
import { registerVfsFactory } from '@struktoai/mirage-node'

registerVfsFactory('jira', (config) => Promise.resolve(new JiraVFS(config)))
```

The constructor uses `parseConfigWithSchema` to validate raw configuration at runtime and refuse unknown keys; a type assertion does not validate input. Keep the same validation when loading through a static `create` method.

The registry takes a factory rather than a class because a browser backend is often reached through a dynamic import; `buildVfs('jira', config)` then works exactly as it does for a builtin.

Validate at the constructor or `static create` boundary so direct construction, factories, and file references enforce the same config. A type assertion does not validate user input. There is no open step: a backend reaches its service lazily, on the first request, or does its setup in a `static async create`. Close clients owned by this VFS in `override close()` and call `super.close()`. The index store is the mount's, and the mount closes it. Shared clients belong to the embedding program's lifecycle.

Registering is not needed to mount from config. A `vfs` value carrying a colon names the class directly, the same way a `clis` entry's `cli` value names a spec tree, so a deployment can point at a file next to the config or at a class inside an installed package:

```yaml theme={null}
mounts:
  /jira:
    vfs: ./jira.mjs:JiraVFS
  /wiki:
    vfs: my-backends:WikiVFS
```

A relative path resolves against the config file's directory, not the server's working directory; a bare specifier is Node's to resolve, so a package name is left alone. A registry name always wins over a reference, so a name can never be reread as code. A `static async create` is honored ahead of the constructor, which is how a backend whose setup needs I/O is spelled here.

When mounting a driver built with `buildVfs` in code, preserve its loader name on the placement: `new Mount(await buildVfs('jira', config), { vfsRef: 'jira' })`. Config loading does this automatically. Snapshots also preserve the mount’s effective index settings. An index URL containing credentials is redacted; pass a `new Mount(vfs, { index })` override with fresh credentials when loading it.

Snapshots and versions reach the registry too: `Workspace.load` rebuilds a saved mount through the registered name (or the `./jira.mjs:JiraVFS` reference config named), the same way Python's loader does. What comes back depends on what the VFS owns. Content the VFS holds itself (an in-memory store) is mirage-owned state: override `getState` and `loadState` to carry it, and a snapshot or a version restores the mount with that content and no override. Content that lives in a remote service is only observed: keep the default state, which says `needs_override`, set `supportsSnapshot` and fill `FileStat.fingerprint`, and a snapshot pins what it read while `Workspace.load` asks for the live VFS back through its overrides (`Workspace.load(state, {}, { '/jira/': new JiraVFS(cfg) })`). A forgotten override is a refusal to load, never a mount that comes back empty. `Workspace.copy` needs nothing, since it passes the live VFS through. `examples/typescript/other/custom_vfs.ts` shows both halves: a wiki page is written, the workspace is snapshotted, the page is changed, and the loaded workspace serves the page as it was, while a feed mount that keeps the default state is refused until the load hands it back through its overrides.

See `examples/typescript/other/custom_vfs.ts` for a complete runnable backend in one file, and `examples/python/other/custom_vfs.py` for its Python twin. Both are asserted against the same truth file, so the two SDKs cannot drift.

## Contribute a Builtin VFS

Builtins live inside the Mirage repo: one backend is four layers with one name (accessor, core, ops, VFS) plus its commands, and every layer has a Python twin. Change both languages in the same PR; where they disagree, the more correct side wins.

Pick the package by runtime, not by preference: `packages/core` for a backend that works in both the browser and Node, `packages/node` for one that needs Node APIs, `packages/browser` for one that needs a browser transport. Paths are always `PathSpec` values inside the VFS; never pass a path as a raw string.

Most of a backend is already written as a kit. Reach for one before writing a layer by hand:

| Kit | Module (`packages/core/src/`) | For |
| - | - | - |
| API client | `core/api/` (`apiRequest`, `cursorItems`, `TokenManager`, `RetryPolicy`) | every HTTP call: one status-to-error mapping, retry, pagination, OAuth refresh |
| Hierarchy | `core/hierarchy/` (`Scope`, `Slot`, `makeDetectScope`, `makeReaddir`, `makeStat`, `entryStat`, `makeRead`, `DirListing`) | an API tree of `<label>__<id>` directories: one scope table classifies every path for readdir, stat, read and search |
| Object store | `core/object_store/` | a flat key space (S3-style buckets, GridFS) |
| Render | `core/render/json.ts` (`jsonBytes`, `jsonlBytes`) | records rendered as the bytes the tree's files hold |

Two builtins are the references. Trello is the hierarchy kit end to end: a scope table, listers, id-addressed readers, and nested `trello <noun> <verb>` commands. Jaeger is the client shape to copy: every call takes the mount's transport, the one handle the accessor owns.

## File Structure

```text theme={null}
typescript/packages/<core|node|browser>/src/
  accessor/<name>.ts           # the client handle: transport and config
  core/<name>/
    client.ts                  # API calls, through core/api
    scope.ts                   # the scope table and detectScope (hierarchy kit)
    pathing.ts                 # <label>__<id> names (sanitizeName, makeIdName)
    normalize.ts               # the JSON a file renders
    readdir.ts stat.ts read.ts
  ops/<name>/index.ts          # ops derived from the CommandIO table
  commands/builtin/<name>/
    io.ts                      # VFSAdapter compiled to the shared IO table
    index.ts                   # <NAME>_COMMANDS
    <bespoke commands>.ts      # push-downs and verbs
  vfs/<name>/
    config.ts prompt.ts <name>.ts
```

Tests are colocated: `<name>.test.ts` beside the source it covers.

## 1. Config, Accessor, and Registry

Define the config as a zod schema and parse it through `parseConfigWithSchema`, which refuses an unknown key by name, the way Python's `extra="forbid"` does; mark credentials with `secretStr()` so the redactor masks them. Create an `Accessor` subclass that owns the transport. Add the VFS name to `VFSName`, and add a factory to the runtime package's `vfs/registry.ts`; that entry is what workspace config, snapshots, and the daemon construct through.

Config keys arrive snake\_case from YAML shared with Python and are mapped by `normalizeFields`, which already sends every unlisted key through `snakeToCamel`. Add a rename entry only for a key that mapping gets wrong.

Keep every import at module scope. If that would create a cycle, change the dependency direction instead of adding a lazy import inside a function.

## 2. Core VFS Operations

Implement only the operations the backend supports. A read-only API-backed VFS usually starts with:

* `readdir(accessor, path, index?)` returning child paths.
* `read(accessor, path, index?)` returning bytes.
* `stat(accessor, path, index?)` returning a `FileStat`.

A hierarchy backend writes its tree once, as the scope table in `core/<name>/scope.ts`, and builds the three operations from it: `makeReaddir(detectScope, { listers })`, `makeStat(detectScope, readdir, { entryStats })` and `makeRead(detectScope, readers, { stat })`. The kit holds these rules, and a new backend keeps them:

* A reader that reaches the API by the ids in the path slots passes `stat` so the kit proves the file's parent through the listing first. Without it, a path outside the configured scope (Trello's `workspaceId` and `boardIds`) reads while `ls` and `stat` say it does not exist.
* A listing that is a filtered or truncated view (one page of a bounded query, a time window, a glob-scoped span) returns `{ entries, seeds: {}, partial: true }`. Cached as the whole directory, it would prove every entry it left out absent.
* `entryStat('<idKey>', ...)` names the id under the key its path slot declares.
* Id-addressed commands honor the same scope knobs the listing does (Trello's `commands/builtin/trello/_scope.ts`).

`FileStat.size` must be the rendered content's byte length or `null`, never a storage-side number: a confidently wrong size makes `wc -c` and `ls -l` lie over FUSE, while `null` rides the unknown-size machinery. Put the storage number in `extra` if it is worth reporting. Set `sizesAlwaysKnown` only when every listed size is computed from the same payload a read renders.

Glob resolution is not a per-backend file: `makeGenericOps` derives a `glob` op from the table's `readdir`, capped by its `maxGlobMatches`, and the mount expands patterns through it.

## 3. Ops Layer

Build `IO = new VFSAdapter({ read, native, writes }).toCommandIO()` in `commands/builtin/<name>/io.ts`, omitting groups the backend does not implement.

Ops are the workspace dispatcher's typed adapters onto the core functions, and they are generated, not hand-written:

```ts theme={null}
import { IO } from '../../commands/builtin/qdrant/io.ts'
import { VFSName } from '../../types.ts'
import { makeGenericOps } from '../generic/factory.ts'
import type { RegisteredOp } from '../registry.ts'

export const QDRANT_OPS: readonly RegisteredOp[] = makeGenericOps(VFSName.QDRANT, IO)
```

Write an op by hand only for an irregular handler, and pass its name through `overrides` so the derived set skips it. Mark every mutation `write: true`.

## 4. Commands

Build the standard set with `makeGenericCommands(VFSName.X, X_IO, { overrides })` over the same table; the generic command owns flag interpretation, so a backend wrapper is wiring only. `overrides` names the builders a bespoke command replaces, and a name no builder has is refused. Export the result as `<NAME>_COMMANDS` from `commands/builtin/<name>/index.ts`.

* Declare native text search in `new VFSAdapter({ read, search })`. Generic grep/rg builders consume it only when `search.meta.grep` opts in; semantic search does not opt in. The hierarchy kit supplies `makeSearchOp` for scope-based core functions; unsupported requests fall back to scanning. Existing wrappers may call `runSearch(X_IO, name, ...)` when preserving a custom registration.
* A verb (`trello card create`) declares its own `new CommandSpec({...})` with every id as a flag, reads flags through `new FlagView(opts.flags, SPEC)` (never `flags.get(...)`), registers `write: true` for a mutation, and calls `requireMountWritable(...)` before the client. Specs declared inline are dumped to `.cache/spec/typescript/<node|browser>/vfs_commands/` and compared with Python's, and a prompt that teaches the verbs is pinned against their specs (`vfs/trello/prompt.test.ts`).
* A handler that reads its `accessor` directly is trusted host code. Admission judges the paths it is given, but no policy sees what it reads below them, so keep such a handler to its operands.

## 5. VFS Class

Extend `BaseVFS`, declare the facts as members, and return the command and op arrays from `commands()` and `ops()`. Core functions stay independent of the VFS class:

```ts theme={null}
import { BaseVFS } from '../base.ts'

export class MyVFS extends BaseVFS {
  override readonly name: string = VFSName.MY_VFS
  override readonly cachesReads: boolean = true
  override readonly prompt: string = MY_PROMPT
  override readonly accessor: MyAccessor

  constructor(config: MyConfig) {
    super()
    this.config = resolveMyConfig(config)
    this.accessor = new MyAccessor(this.config)
  }

  override ops(): readonly RegisteredOp[] {
    return MY_VFS_OPS
  }

  override commands(): readonly RegisteredCommand[] {
    return MY_VFS_COMMANDS
  }
}
```

The mount registers both arrays when the driver is placed; a driver never registers anything onto itself.

Set `cachesReads` true only for stable, read-mostly content. `BaseVFS` supplies a no-op `loadState()` and a 600 s `indexTtl`, so declare one only to change it. A mount's read `ttl` caps listing lifetimes. Override `getState()` to carry the (redacted) config, and `close()` to release any client handles, calling `super.close()`. The index store is the mount's, built when the driver is placed, so a driver never sees or closes one.

A backend whose content lives in a remote service keeps `needs_override: true` in that state. Both loaders refuse a missing live override; neither substitutes an empty RAM mount. A backend that owns portable content can implement `getState` and `loadState` and register a factory so snapshots rebuild it, as shown above.

### Point lookups under `fresh`

A `read: fresh` mount re-stats a cached file through a throwaway store that
starts with none of the mount's rows (`ListingCheckStore`), so no cached
row answers the check.
A backend with a path lookup answers with one request for that path (Dropbox).
A backend that addresses items only by id may, after checking that the index
is a `ListingCheckStore`, read the mount's last row for
the path with `await index.hint(key)` and address one request by its id (Box);
it must then confirm from that answer alone that the item still sits at
exactly this path, and otherwise resolve the path as it always does. The hint
is a lead, never an answer: a stat built from its fields would let a stale row
pass a freshness check.

### Listing versions

Under `read: fresh`, a cached listing the running command did not write is listed again, unless the backend declares what to check it against. Declare `listingVersion` as a literal member, like `readRevalidatable`, so the spec generator can read it: `override readonly listingVersion: ListingVersion = ListingVersion.MOUNT`.

* `ListingVersion.NONE` (the default): no version; every command re-lists.
* `ListingVersion.MOUNT`: one version covers every listing of the mount (GitHub and the Hub use the head commit). A stat of the mount root through the gate's empty check store (`ListingCheckStore`, `cache/index/ram`) asks the backend and returns it as `fingerprint`; through any other index it names none and reads neither the index nor the backend, since nothing reads a root fingerprint off a mount-view stat. The tree fill stores the same value with every listing it writes (`seed(..., version)` or `setDir(..., { version })`).
* `ListingVersion.FOLDER`: each listing carries its own folder's version (disk). A stat of the folder returns it as the fingerprint, and `readdir` reads it before listing and passes it to `setDir(path, entries, expiresAt, { version })`, so a change during the scan leaves the stored version behind.

Take the version from a backend response, the same kind of token the stat answers, since the gate compares the two with `===`. Store `null` when there is nothing reliable to store; that listing re-lists.

`listingsPin` (default `null`) is set per instance in the constructor when the config pins the mount to something that cannot move: the lowercased ref when it is a full 40- or 64-hex commit sha. A stored listing whose version equals the pin is served with no request. The stored value still comes from a response, never from config, so a full-sha ref is served unchecked only when its listing was fetched at that sha. github.com refuses a branch or tag named with 40 or 64 hex characters, so a full-sha GitHub ref always names a commit (a GitHub Enterprise host is assumed to do the same). The Hugging Face Hub repos set no pin: a Hub revision named like a sha is checked every command.

`node/src/vfs/listing_version.test.ts` holds every declarer to this, as `tests/vfs/test_listing_version.py` does in Python: the fill stores a version, a stat through an empty index answers the stored value, a second command sends only the expected checks, and an outside change moves the version. Live declaration parity is checked by `scripts/check_spec_parity.py` using the temporary manifests produced by both generators. A new declarer adds a harness to the test and adds its name to the pinned roster, or the roster tests fail.

## 6. Snapshot Support

Leave `supportsSnapshot` unset unless the complete drift contract is implemented:

1. `stat()` returns a stable `FileStat.fingerprint`.
2. Every read record includes the fingerprint that produced those bytes.
3. If the backend supports immutable revisions, reads consult the resolved revision and record it.

Setting the flag without recording fingerprints does not provide drift detection.

## 7. Verification

Exercise a custom backend at a nested prefix, with globs, unknown file sizes, a read-only mount, and a deliberately omitted mutation. Assert stdout, stderr, and exit status together. Use a small bounded page (three entries with five available) and request counters to check both cold and warm traversal costs. A partial `DirListing` caches positive membership until expiry; it never proves an omitted entry absent, and a subsequent directory read fetches a new page. Custom index stores may override `setPartialDir`; the default conservatively refreshes on lookup.

* Tests for config parsing (an unknown key refused), path layout, every VFS op, command behavior, read-only enforcement, scope enforcement, state redaction, and cleanup.
* Rebuild the dists (core, then node, then browser), regenerate `spec/` with `typescript/scripts/gen-specs.ts` and `scripts/gen_specs.py`, then run `scripts/check_spec_parity.py`, `scripts/check_layout_parity.py --strict` and `pnpm typecheck`.
* An integration target in `integ/targets.json` with cases under `integ/vfs/<name>/`, run by both hosts' runners against the same goldens; a SaaS backend gets a fake under `integ/server/`. Any change in observable shell behavior adds a case.

## Updating Existing Backends

### From `GenericVFS` and the `VFS` interface

`BaseVFS` is now the one driver contract, and a driver serves only through
the tables `ops()` and `commands()` return. Nothing keeps the old spelling
alive, so a custom backend written against it changes in these places:

| Before | Now |
| - | - |
| `new GenericVFS({ name, accessor, io, index })` | `new BaseVFS({ name, accessor, io })`; `io` takes a `VFSAdapter` or a `CommandIO`, and the index moves to `Workspace` or `Mount` `index` |
| `implements VFS`, `readonly kind` | `extends BaseVFS`, `override readonly name` |
| `open()` | gone: reach the service lazily on the first request, or set up in a `static async create` |
| `readFile`, `readdir`, `stat`, `glob` and the other per-driver methods, `opsMap` | `new DriverOps(vfs)` outside a workspace, `ws.dispatch(...)` inside one |
| `vfs.index`, `setIndex(...)` | the mount's store, `ws.mount(prefix).indexStore` |
| `storageId()`, `statfs()` | `storageLocation()`, which may return `null`, and `capacity()` |
| `recordVfsRef(vfs, ref)`, `vfsRefOf(vfs)` | the mount's `vfsRef` |
| `cachesReads(vfs)`, `sizesAlwaysKnown(vfs)`, `readRevalidatable(vfs)` | read the flag off the driver |

Mount configs now reject unknown fields. Remove PostgreSQL's `default_search_limit` and MongoDB's `default_doc_limit` and `default_search_limit` from YAML (or `defaultSearchLimit` and `defaultDocLimit` from TypeScript). The remaining read ceilings are `max_read_rows` / `maxReadRows` and `max_doc_limit` / `maxDocLimit`. PostgreSQL `head`/`tail` and MongoDB `tail` report a clipped window through stderr and a nonzero status; consumers must check status before treating captured output as complete. PostgreSQL refuses whole reads over its thresholds. MongoDB streams and search no longer apply a silent result cap.

`BaseVFS` provides `loadState` and `indexTtl` defaults, and has no `open` step. Subclasses that replace a member need `override` when `noImplicitOverride` is enabled. The hierarchy name helpers sanitize empty and dot-leading labels to reachable names; regenerate stored paths from the listing and keep the provider id as the stable identifier. Enforce read and mutation scope on every entry point, including direct ids, rather than relying on a previous listing.

PostgreSQL whole-file reads check a bounded result’s database JSON byte size before transferring rows, then check the rendered JSONL size. Database formatting can make the first check conservative; use an explicit row window when that guard refuses a read.

### Check the adapter contract

The read-contract helper accepts either an adapter or its compiled I/O table.
Supply a small known file, its parent directory, an absent sibling, and the
expected bytes. It checks listing, metadata, reads, streams, native ranges,
existence, and missing-path errors without mutating the resource.

```ts theme={null}
import { checkReadContract, type ReadFixture } from '@struktoai/mirage-node'

const fixture: ReadFixture = {
  file: filePath,
  directory: parentPath,
  missing: missingPath,
  content: new TextEncoder().encode('known fixture bytes'),
}
await checkReadContract(adapter, accessor, fixture)
```

`checkDriverContract(vfs, fixture)` runs the same read checks against a whole
driver, through the op table `vfs.ops()` serves, the one channel a mount
dispatches to. It checks a builtin-shaped subclass as readily as a driver built
from an adapter, and always probes the `read` op's byte window. `DriverOps` is
the table it drives: it calls a driver's ops the way a mount does, with the
accessor bound and one index store per instance, which is also how to script a
driver outside a workspace.

```ts theme={null}
import { checkDriverContract, DriverOps } from '@struktoai/mirage-node'

await new DriverOps(vfs).write(filePath, new TextEncoder().encode('known fixture bytes'))
await checkDriverContract(vfs, fixture)
```

Use disposable fixtures to test supported writes and verify read-only mounts
refuse mutations. Resource-specific tests should cover pagination, scope,
authorization failures, and query options.

Builtin VFS classes and a `BaseVFS` built from an adapter serve through the
same two tables: commands, filesystem ops, and glob expansion all come from the
backend's I/O table, and a driver carries no direct verb methods beside them.
Backend classes keep configuration, client lifecycle, storage location, watch
hooks, and snapshot behavior.

### Batch resource search

`{ search: searchOne, searchMany }` optionally supplies a batch callback with
`(accessor, paths, query, index)`. Use it when ranking and `top_k` must apply once
across several paths. The single-path callback remains required. The shared
`searchResources` helper uses the batch callback when provided and otherwise
concatenates single-path results; a declined request reports an error rather
than no matches.

Chroma, Dify, Qdrant, LanceDB, and Mem0 use this capability for their existing
resource search commands. Their options remain backend-specific: for example,
`top_k`, `threshold`, and `method`. These declarations do not opt into grep.

### Start from a packaged example

The `mirage-vfs-authoring` skill in the Mirage plugin includes self-contained
Python and TypeScript starters and `scripts/new_adapter.py`. It creates an
adapter file in your project, refuses to overwrite existing files, and includes
the contract check plus a mounted shell smoke test. Replace its fixture client
with your resource API, then expand capabilities as needed.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.