15 KiB
RFC: Split the filesystem seam — provider text mutations plus the dsh-fs-policy plugin
Status: implemented
Problem
The filesystem capability from filesystem-capability-seam currently makes one abstract FileSystem service own two different jobs:
- Provider operations — resolving targets, stat/version metadata, text reads/streams, atomic writes, and guarded literal edits.
- Agent-facing policy — line windows, literal edit semantics, and read-before-write/edit observed-state.
That makes every future backend reimplement model-facing read semantics and observation policy. readPage returns numbered lines and view metadata; the base service stores per-owner file state and distinguishes full from partial reads. Those are useful policies, but they are not filesystem-provider primitives. Literal text mutation is different: version guard, literal match, ambiguity detection, and atomic rewrite must stay together inside the provider mutation boundary, but the current applyEdit name and surrounding seam tie that provider operation to the old read-before-edit policy shape.
This also creates a real UX dead-end: a windowed read records view: partial, and partial views cannot authorize edit. A model that reads lines 100-150 of a large file therefore cannot edit line 120 unless it first gets a full read, which may be impossible for a file past the read cap. Literal edit only needs freshness: the bytes being matched must still be from the version the model read.
The old RFC already deferred a separate @deepseek-ai/dsh-fs-policy package. This RFC builds that layer and keeps ctx.fs close to fsspec-style storage primitives (info/cat/open), without turning it into full fsspec.
Decision
Split the stack into four layers:
tool dsh-tool-fs model-facing schemas + read windowing + text rendering; the EXECUTOR (reads/writes/edits via ctx.fs, dispatches the fs/* events)
policy dsh-fs-policy observed-state + read-before-edit + write/edit freshness, contributed through the fs/* event gate (no service)
provider seam dsh-fs ctx.fs: text IO + atomic mutation primitives (optional version guard)
provider dsh-fs-local local implementation of ctx.fs
dsh-tool-fs keeps the same model-facing read/write/edit schemas. It is the executor: it injects fs (not a policy service) and reaches ctx.fs directly, owns read windowing, and dispatches the fs/* events so dsh-fs-policy can gate and record.
This RFC decided the four-layer split, the provider contract, and the freshness policy. The tool↔policy COUPLING was then refined by the event-gate RFC: dsh-fs-policy is a gate PLUGIN that participates through the fs/* events rather than a ctx.fileContext method service, so the tool is not method-coupled to it and read windowing + the fs I/O live in dsh-tool-fs. This document describes that landed event-gate shape; the provider's version guard is optional (omit = unconditional bare provider).
Provider Contract
@deepseek-ai/dsh-fs shrinks to provider text IO plus guarded text mutation:
abstract resolve(path: string): Promise<FsTarget>
abstract stat(target: FsTarget, signal?: AbortSignal): Promise<FsInfo | undefined>
abstract readText(target: FsTarget, signal?: AbortSignal): Promise<string>
abstract streamText(target: FsTarget, signal?: AbortSignal): Promise<AsyncIterable<string>>
abstract writeText(target: FsTarget, content: string, expected: FsWriteIntent, signal?: AbortSignal): Promise<FsWriteOutcome>
abstract editText(target: FsTarget, edit: FsEditRequest, expected: { version: FsVersion }, signal?: AbortSignal): Promise<FsEditOutcome>
interface FsInfo {
version: FsVersion
type: 'file' | 'directory' | 'other'
size?: number
}
type FsWriteIntent =
| { kind: 'createIfAbsent' }
| { kind: 'replaceIfVersion'; version: FsVersion }
stat returns metadata, not content. version is the freshness token; type lets the executor reject directories/special files before reading; size lets the read tool choose readText vs streamText without probing by failure. undefined means absent.
readText reads the whole regular text file. streamText streams the same text semantics for large files. Both provider primitives own regular-file checks, UTF-8 decoding, binary/NUL rejection, and FS_NOT_TEXT; the policy layer never handles raw bytes or reimplements cross-chunk decoding. readText is the small-file/direct whole-file primitive, while large model-facing reads use streamText.
writeText is atomic temp-file + rename with an explicit write expectation. createIfAbsent creates a missing target and rejects an existing target with FS_NOT_OBSERVED; it is the path used when the owner has no prior read. replaceIfVersion replaces only when the target exists at the observed version; a missing target or version mismatch throws FS_STALE_VERSION.
editText is a provider-level guarded text mutation. When guarded it first verifies the target still exists at expected.version, then reads the current text, applies literal replacement, and writes atomically. The stale check must happen before literal matching so an edit based on an old read reports FS_STALE_VERSION, not FS_EDIT_NOT_FOUND or FS_AMBIGUOUS_EDIT from matching against newer content. Keeping this primitive on the provider seam preserves backend-local locking and lets a future remote backend implement native compare-and-edit without forcing the policy layer to pull the whole file through it.
This is a text-storage seam, deliberately half a level above byte-level fsspec (cat/open hand back raw bytes). UTF-8 decoding, binary/NUL rejection, guarded full-file writes, and guarded literal text edits live in the provider so the policy layer never touches raw bytes, reimplements cross-chunk decoding, or separates stale checks from the mutation critical section. Model-facing concepts still stay out of the provider: no line windows, numbered lines, rendered footers, or observed-state store leak down.
Deleted from dsh-fs: readPage, FsExpectation, FsView, FsStateSource, FsReadRequest, FsTextLine, line/window constants, formatReadBody, and the observed-state WeakMap. applyEdit is replaced by the narrower provider primitive editText, whose contract is version-guarded literal text mutation rather than policy-layer read authorization. The FS_PARTIAL_OBSERVATION code also leaves the FsErrorCode taxonomy: freshness authorization has no partial/full distinction, so nothing can raise it. FsTargetKey and FsVersion become branded opaque ids under the existing branded-ids RFC.
Policy Contract
@deepseek-ai/dsh-fs-policy is a plugin, not a service: it registers no ctx.* key and injects nothing. It owns the write/edit freshness policy and observed-state that do not belong on the FileSystem provider base class (where a sandboxed/remote backend would otherwise inherit model-facing observation policy it has no business carrying). It contributes that policy through the fs/* event gate the executor dispatches. (This RFC originally proposed a concrete ctx.fileContext service with read/write/edit methods; the event-gate RFC refined it into the plugin described here so the tool is never method-coupled to the policy.)
Observed state lives here as WeakMap<owner, Map<targetKey, FsVersion>>. An entry exists iff the owner has read, written, OR edited that target (every success emits fs/observed), so its presence is the prior-observation record — there is no separate hasRead flag. The owner is derived structurally from the opaque event actor ({ agent?: { session? } }), a shape that lives in dsh-fs-policy, not dsh-fs.
The plugin decides three fs/* events:
fs/write-intent— no prior observation ⇒{ kind: 'createIfAbsent' }(only new files can be created blindly); a prior observation ⇒{ kind: 'replaceIfVersion', version: vObserved }(existing files replaced only if unchanged since the observation). Single-slot decision; does not callnext().fs/edit-intent— requires a prior observation by the owner (elseFS_NOT_OBSERVED); returns{ version: vObserved }as the CAS basis. It does not implement literal replacement — it authorizes and supplies the version, and the provider's mutation critical section applies the guard, so concurrent edits based on the same observed version remain one-wins/one-stale.fs/observed— records{ version }for this owner+target after a successful read/write/edit. Synchronous, side-effect-onlyWeakMap.set.
The plugin does NO filesystem I/O: "have you observed this file?" is a WeakMap lookup, and "is the version you read still current?" is decided inside ctx.fs.editText/writeText in the same atomic lock that performs the mutation — the plugin only supplies vObserved as the basis.
Tool Contract
dsh-tool-fs keeps the same schemas and prompt surface. read still exposes file_path, offset, and limit; write and edit are unchanged. It is the executor: it validates model args, reads/writes/edits through ctx.fs directly, owns line windowing and result rendering (N: text, footer, <path>/<content> envelope), and dispatches the fs/* events.
Each mutation dispatches its intent waterfall with an undefined bare-provider default, then calls ctx.fs, then emits fs/observed: e.g. write does ctx.waterfall('fs/write-intent', target, exec, () => undefined) → ctx.fs.writeText(target, content, intent) → ctx.emit('fs/observed', …). A read stats once, reads/streams, builds the window, and emits fs/observed. Passing exec as the actor lets dsh-fs-policy derive the owner without the tool reaching into the policy.
Because the policy is contributed through events with an undefined default, dsh-tool-fs is not method-coupled to dsh-fs-policy: with the plugin absent, every intent waterfall falls through to undefined (unconditional bare-provider write/edit) and fs/observed has no listener. Loading the plugin back layers the read-before-write/edit policy on.
Concurrency Boundary
In-process updates are safe: the local backend keeps the existing per-target mutation lock, so version-check-then-rename is serialized and a losing update sees FS_STALE_VERSION.
In-process creates are guarded by the same per-target mutation lock: two callers racing with createIfAbsent serialize, one creates, and the next sees the target exists and receives FS_NOT_OBSERVED. Cross-process creates are best-effort only; a local stat-then-rename guard cannot make portable create-exclusive guarantees across all future backends.
Cross-process writes are best-effort freshness plus atomic replacement: mtime:size usually catches editor saves, but same-tick same-size writes can miss; atomic temp+rename prevents torn files but not every lost update.
Supersedes
This RFC reverses two decisions from filesystem-capability-seam and narrows a third:
- Read-before-write/edit policy moves out of
ctx.fsand into thedsh-fs-policyplugin (on thefs/*event gate). - Text reads no longer return backend-numbered line records or
full/partialviews; authorization is based on version freshness, so a windowed read can authorize edit when the file is unchanged. - Literal edit no longer sits behind the old
applyEditAPI that mixed backend mutation with seam-owned observation policy. It remains a provider primitive aseditText, because version guard + literal match + atomic rewrite must stay inside the provider's mutation critical section.
It keeps the interface/implementation/consumer discipline, consumer-never-imports-backend rule, backend-defined target/version/display metadata, atomic local writes, and the shared FsError taxonomy.
Acceptance Criteria
dsh-fsexposes exactlyresolve/stat/readText/streamText/writeText/editText;statreturnsFsInfo | undefined;writeTextusesFsWriteIntent(createIfAbsentorreplaceIfVersion); removed types/primitives are gone, and the oldapplyEditAPI is replaced byeditText.dsh-fs-policyadds the observed-state +read/write/editfreshness policy and has HMR/disposal coverage. (It does so as a gate PLUGIN on thefs/*events with noctx.fileContextservice, per the event-gate RFC — the original service form this RFC proposed was reworked.)dsh-tool-fsreaches the policy decisions and model-facing schemas stay byte-for-byte unchanged; the observation contract (a read records observed-state; a directctx.fsread does not) is documented and tested. (The tool injectsfsand dispatches thefs/*events rather than injecting afileContextservice, per the event-gate RFC.)- Windowed read authorizing edit is shown to fail on the pre-refit code and pass after the refit. Existing version-CAS behavior is preserved with a regression test; it is not claimed as a pre-refit failure. An edit based on a stale read must report
FS_STALE_VERSIONbefore attempting literal matching. dsh-fs-localcarries no line, view, orformatReadBodylogic; it does carry provider-leveleditTextlogic.- Docs and generated artifacts are updated:
docs/architecture.md,packages/README.md, fs package READMEs,docs/core-data-structures/filesystem.md, affectedtype-equivblocks andscripts/type-equiv.manifest.json, Cordis catalog, module graph, and doc references. - Gates stay green: normal
doc-sync,pnpm run knip, andpnpm run test:coveragewith 100% per-file coverage.
Later extension
The seam was later extended with direct directory listing by Add direct directory listing to the filesystem seam. That follow-up is tracked separately so this RFC's acceptance criteria continue to describe the fsspec-style refit that originally shipped.
Risks
- Adds a fourth fs package and a new service. This is intentional: it is the previously deferred policy layer, not a second abstract backend seam.
- Direct
ctx.fsuse bypasses the policy: a directctx.fs.readTextemits nofs/observed, so under the default policy a latereditrejects withFS_NOT_OBSERVEDuntil the file is read through thereadtool. The failure is explicit and documented. - Large-file line windowing moves from the backend to the
readtool indsh-tool-fs; text decoding and binary rejection stay inctx.fs.streamText, so this is relocation of windowing only, not a second text-IO implementation. - Keeping
editTextin the provider seam means every backend must implement the literal replacement contract. This is intentional: the operation is not pure storage, but stale guard + literal match + atomic rewrite is the unit that must stay together for correct error attribution and concurrency behavior. The contract should stay narrow and text-only so future backends can implement it natively or by whole-file rewrite. - Freshness permits full-file
writeafter a windowed read. That is weaker than the old view check, but avoids making large files impossible to edit; prompt guidance should still discourage blind full replaces.