DeepSeek Harness · Tool System

File-Edit Engineering: Read Before Write

The read / edit / write trio — unread files can’t be edited. Core source:packages/fs/fs-observation-policy/src/index.ts

Course goalAfter this lesson you can explain three things: how DSH’s observation ledger remembers which files this session read and at which version; why edit is blocked by FS_NOT_OBSERVED when unread and by FS_STALE_VERSION after an external change; and why this defense is a removable plugin — while Claude Code and Grok answer the same problem at two concentrations: prompt rules vs. hint wording.
Interactive demo · Read-before-write challenge
File on disk: notes.mdExternal changeversion v7
The version is a freshness token issued by the backend — it changes whenever the file does
Observation ledger
Records from the observation-policy plugin: what this session has seen, and at which version
unseen = no ledger entry; present@vN = read version vN; absent = confirmed missing
edit intent verdict
Tool dispatches fs/edit-intent; the plugin decides against the ledger
Waiting for a tool call…
Pending
Pick a scenario and hit Play, or scroll here to auto-play Scenario A.
Teaching simulation: file contents and versions are course-adapted. Verdict logic matches writeIntent / editIntent in packages/fs/fs-observation-policy/src/index.ts and the error taxonomy in docs/subsystems/filesystem.zh.md. Watch the ledger: the verdict never looks at the file itself — only what this session has personally seen.
Mechanism · One ledger, three verdicts

Three classic ways Agents trash files: wrong spot, overwrite unread files, edit from stale content. DSH’s answer is a trio plus a ledger. The trio is the model-facing read/edit/write tools (docs/tool-catalog.zh.md): read does windowed, line-numbered text; edit does literal replace; write creates or overwrites whole files. The ledger is a weak-map inside the fs-observation-policy plugin, keyed by session, recording each file target’s observation state.

The ledger has three states. unseen: no entry for the file. present@vN: it was read, at version vN — an opaque freshness token from the filesystem backend. absent: the path was confirmed missing, e.g. a failed read. After every successful read/write/edit, the tool emits fs/observed and the plugin updates the books.

The verdict happens before any write. When a tool wants to write or edit, it dispatches fs/write-intent or fs/edit-intent — a single-slot cascade: the first listener that returns a decision owns it, and by deployment convention that’s this policy plugin. It sets guard conditions from the ledger; the real check runs in an atomic critical section on the backend: verify version, then match, then replace — nothing else can slip in.

write always has a path

Write without a prior read uses createIfAbsent: create if missing, refuse if present (FS_NOT_OBSERVED). Write after a read uses replaceIfVersion: replace only if versions match. New files don’t need a prior read; overwriting someone else’s file is blocked.

edit yields nothing

Unread → FS_NOT_OBSERVED; ledger says absent → FS_NOT_FOUND; after a read you proceed with a version guard. Version checks run before literal matching, so editing from stale content raises FS_STALE_VERSION — not a misleading match failure.

Errors carry structured identity

Every failure carries a stable FsError code; the tool registry keeps { name, code } on error results. Retries and UI branch on code — no parsing error prose (docs/subsystems/filesystem.zh.md, “错误分类体系”).

Core visual · Verdict flow
read succeeded emit fs/observed present@vN Observation ledger WeakMap: session → target → state edit call arrives dispatch fs/edit-intent single-slot cascade editIntent(target) check ledger, not disk unseen FS_NOT_OBSERVED absent FS_NOT_FOUND present@vN allow with version guard replaceIfVersion(vN) backend critical section verify version → match → atomic replace mismatch→STALE
Teaching diagram: verdict branches match editIntent in fs-observation-policy; critical-section semantics come from docs/subsystems/filesystem.zh.md “写入与编辑守卫”.
Key evidence · Two decisions on the ledger

The whole policy plugin is under 140 lines — two ledger lookups at the core. write’s verdict is a three-line choice: if the ledger shows present, return a replaceIfVersion guard with that version; if there’s no entry or confirmed absent, return createIfAbsent so creation is allowed only when missing. The header comment draws the decision table with two arrows. That’s “write always has a path”: new files need no prior read; overwriting someone else’s file is not allowed.

Source:packages/fs/fs-observation-policy/src/index.ts writeIntent, lines 61–71, verified on 2026-08-13。

edit’s verdict is stricter — unread means you don’t even get a guard; it throws. Worth reading whole: the two throws are this chapter’s title in code:

packages/fs/fs-observation-policy/src/index.tslines 78–88
  editIntent(target: FsTarget, actor: object | undefined): { version: FsVersion } {
    const owner = this.owner(actor)
    const prior = owner ? this.get(owner, target.targetKey) : undefined
    if (!owner || prior === undefined) {
      throw new FsError(`edit requires reading "${target.displayPath}" first`, 'FS_NOT_OBSERVED')
    }
    if (prior.kind === 'absent') {
      throw new FsError(`cannot edit "${target.displayPath}": not found`, 'FS_NOT_FOUND')
    }
    return { version: prior.version }
  }
Source snapshot note:Based on the local deepseek-harness-master repo; verified against packages/fs/fs-observation-policy/src/index.ts, verified on 2026-08-13. Code blocks keep the original source text.

The returned { version: prior.version } is that freshness token. Backend editText checks it against the current version first — mismatch → FS_STALE_VERSION; only then literal match. old_string must hit exactly once: many hits → FS_AMBIGUOUS_EDIT; none → FS_EDIT_NOT_FOUND unless replace_all was explicit. Match, EOL handling, staleness, and atomic replace all run in one critical section (docs/subsystems/filesystem.zh.md line 151).

Three more details worth keeping. First, the defense is removable: unload the plugin and write/edit fall back to unconditional bare-provider behavior — tool schemas don’t change a character, because tools only dispatch events and never call policy directly. Second, read authorization is freshness-only, whole-file or windowed alike: if the file hasn’t changed, reading 10 lines still authorizes a later whole-file edit. Third, read_image is classic conditional registration: no ctx.attachments capability → never register; registered but the routed model won’t take images → refuse at execute time (docs/tool-catalog.zh.md line 718). Evolution note: the contextual diff card on edit results first recomputed hunks at result time (archived Agent Note .agents/notes/archived/architecture/2026-07-02-result-time-applied-hunk-diffs.zh.md); later the backend returns full before/after, the tool computes hunks into meta, and replay skips recomputation — the channel covered in Tool Output Contract.

Side-by-side · One rule, three concentrations

Claude Code also forces read-before-write — the rule sits in the tool’s own prompt. FileEditTool prompt, original:

claude-code-sourcemap-main/study/chapters/14-all-prompts.md · line 1124 (citing restored-src/src/tools/FileEditTool/prompt.ts lines 14–28)
“You must use your ${FILE_READ_TOOL_NAME} tool at least once in the conversation before editing. This tool will error if you attempt an edit without reading the file.”

“This tool will error” shows CC’s runtime really enforces the check — not polite prompt theater. Non-unique old_string fails; add context or use replace_all — isomorphic to DSH. The difference is where the defense mounts: CC’s read-first check lives on FileEditTool itself; DSH extracts it into a plugin so read/edit/write/str_replace_editor share one ledger with zero permission code in the tools. How CC detects stale reads after external edits isn’t shown in reviewed study materials — kept provisional on public evidence.

Grok Build’s search_replace tool still shows the same fight. Config has skip_read_before_edit, commented as a deprecated runtime no-op that only gates the Read-tool dependency at config time — read-before-write was once a hard switch, later loosened. Stale-read handling shows the orientation gap even more clearly:

grok-build-main/crates/codegen/xai-grok-tools/src/implementations/grok_build/search_replace/mod.rs · lines 111–113 (include_user_edit_hint field comment)
“When true, append a hint that the user may have changed the file to NoMatchesFound error messages. This nudges the model to re-read instead of blindly retrying with the same stale content.”

In plain talk: when a human edit makes matching fail, Grok appends a hint in the error text nudging the model to re-read. That’s defense at hint concentration — relies on the model behaving. DSH is version-token concentration: mismatch → FS_STALE_VERSION, physically no write. Grok has strengths too: unicode_normalized_fallback retries when smart quotes or long dashes foil visual matching (same file lines 103–110); DSH edit currently matches exactly after EOL normalization. Hunk-level change tracking lives in Grok’s xai-hunk-tracker crate — see Implementation families, registry & dynamic MCP for the wider tool-system map.

Classroom Exercise
01

Walk through a three-hit combo

Session just started. The model does three things in order: write a missing draft.md, edit that draft.md, then you manually change one character in the editor and the model fires a second edit. For each call, write the guard (createIfAbsent / replaceIfVersion / version guard) and the outcome, and mark the ledger after each step. Especially step two: does a successful write emit fs/observed? If that entry isn’t booked, what happens to the next edit? (Hint: writeIntent’s table sends present down replaceIfVersion; unread edit is straight FS_NOT_OBSERVED.)

Takeaway:Read-before-write in DSH is an observation ledger plus a version token: unread edit → FS_NOT_OBSERVED; read-then-externally-changed → FS_STALE_VERSION; the verdict watches the ledger, not luck. The defense is a removable plugin with zero permission code in tools. Same rule: CC mounts it as the tool’s own runtime check; Grok softens it to a nudge in the error text — concentration gap, plain as day.