# Compatibility audit — DSH 0.1.7

> **Method: the tests are the audit.** The probes in `tests/config.spec.ts`,
> `tests/integration.spec.ts`, `tests/package-layout.spec.ts` and
> `scripts/verify-host.mjs` drive this plugin's real execution paths against the
> **real** `@deepseek-ai/*` packages — not against hand-written doubles that
> agree with whatever the plugin happens to call. `scripts/build.mjs` carries the
> artifact-side half of the same discipline.
>
> Verified against: `@deepseek-ai/*@0.1.7-rc.2` — the line's `latest`, and the
> version this release's `devDependencies` pin exactly. The peers declare the
> FLOOR `^0.1.7-rc.1` with `dsh.engines.dsh = ">=0.1.7-rc.1"`: same tuple, so the
> `rc.1 → rc.2` roll needed no peer change and both releases sit inside the
> declared window (`scripts/check-dsh-version.mjs` reports
> `covered: 2/27 … ceiling 0.1.7-rc.2`).
> Source references are to the upstream monorepo
> [github.com/deepseek-ai/deepseek-harness](https://github.com/deepseek-ai/deepseek-harness)
> at tag `dsh-v0.1.7-rc.2`, or to the published packages under
> `@deepseek-ai/dsh@0.1.7-rc.2/node_modules`.

## 1. Version alignment: one line at a time

`peerDependencies` declare **one** `^X.Y.Z-pre` tuple per harness package, and
`dsh.engines.dsh` repeats its floor. A second `||` term would re-declare a DSH
line this plugin does not track.

Two different semver rule sets are in play, and confusing them is easy:

| Rule set | `^0.1.5-rc.1` vs `0.1.7-rc.1` |
| --- | --- |
| npm's default prerelease matching | **false** — a prerelease candidate only matches when the range has a comparator on the *same* `[major, minor, patch]` tuple, so a new tuple needs a new `||` term |
| DSH's startup admission gate (`semver.satisfies(rt, range, { includePrerelease: true })`) | **true** — one caret term on any `0.1.x-pre` already covers the whole `0.1` line |

So the previous multi-tuple union (`^0.1.0-rc.6 || ^0.1.1-rc.2 || ^0.1.2-rc.1 ||
^0.1.5-rc.1`) was **admitted** on 0.1.7 and then failed at the API level — and no
peer spelling within `0.1.x` could have stopped it, because the gate cannot
distinguish "verified on 0.1.5" from "verified on 0.1.7". Narrowing to one tuple
is therefore a statement of what was actually verified (and it does make the gate
refuse *older* lines, which is the truthful outcome), not a way to make the gate
catch this class of break.

`node scripts/check-dsh-version.mjs` guards the declaration itself: identical
tuple sequence across every harness peer, engine floor in sync with the oldest
floor, and a report when the registry publishes above the declared window.

## 2. What actually broke (and why nobody saw it)

Three independent failures were in play. **Any one of them was sufficient to
make the plugin do nothing**, and all three were silent.

### 2.1 The host half mounted and then threw

`src/index.ts` called `scope.settings.register(...)` inside its
`ctx.inject(['settings','commands','fs'], …)` callback. DSH 0.1.7 removed
`SettingsForms.register` entirely. cordis catches an inject callback's throw,
logs it, and disposes the child fiber — so `apply()` itself did not throw.

Reproduced by mounting the built artifact against the real
`SettingsForms.prototype`:

```
real SettingsForms.prototype: constructor, importLegacyDocument, configure,
  invalidate, writable, documentPath, prepareDocument, describe, update,
  replace, mutate, write, schema
has .register? undefined
apply() threw synchronously: no
errors surfaced through ctx.logger: 1
  - TypeError: scope.settings.register is not a function
tools/pre-execute baseline reached: true; decision={"kind":"allow"}   ← no listener
```

The commands and the `tools/pre-execute` listener were registered *after* the
throw, so **neither ever mounted**: every edit ran unapproved, with one log line
as the only trace.

### 2.2 The client half never ran

`inject` named `settingsScope`, which exists in **0 of the 277 packages** of
0.1.7. cordis leaves a fiber with an unsatisfied inject inactive — no log, no
throw:

```
apply ran with an unsatisfiable inject present: false
apply ran when every inject is satisfiable:        true
```

So the browser half contributed nothing at all: no diff rendering, no collapse
button, no focus restore, no settings rows, not even the injected `<style>`.

### 2.3 The startup gate admitted it anyway

Verified by calling the harness's own exported
`evaluatePluginCompatibility(manifest, {}, '0.1.7-rc.1')` with this package's
real manifest: `ADMITTED (no incompatible peers)`. See §1 for why the previous
union satisfied it.

### 2.4 Why the test suite stayed green

`scripts/verify-host.mjs` hand-rolled a `settings` service implementing **only**
`register` — the single method 0.1.7 had just deleted — while the plugin under
test called `settings.register`. Suite and plugin agreed with each other and
with nothing real. (`devDependencies` were also pinned to 0.1.1-rc.2, so
`tsc` type-checked the plugin against the old surface too.)

The fix is structural, not a new assertion: the double is now anchored to the
**real** `SettingsForms.prototype` in both directions, so it can neither invent a
member the service lacks nor silently cover for a removal. Removing the anchor
demonstrably fails the suite:

```
FAIL the settings double implements only members the real service has — register
```

## 3. Seam-by-seam

`OK` = linked and behaved as before. Evidence paths are upstream unless stated.

| Consumed seam | 0.1.7 reality | Verdict | Resolution |
| --- | --- | --- | --- |
| `settings.register(ns, schema, {base})` | removed; one live `Config` per profile entry | **removed** | settings come from the plugin's own `Config` |
| `settingsNamespace()` brand helper | removed | removed | deleted |
| per-namespace scope object (`get`/`update`/`replace`/`watch`) | removed | removed | `Volatile` refs + `SettingsForms.update/mutate` |
| `tools/pre-execute` waterfall, `PreToolDecision`, `ToolExecution` | unchanged (`ask.reason` is now optional) | **OK** | none |
| `commands.register` / `CommandInvocation.rawInput` / `CommandResult` | unchanged | **OK** | none |
| `approval.overrideOf(session)` + `config.policy` | unchanged (`effectivePolicy` is private) | **OK** | none |
| `fs.resolve` / `stat` / `readText` | unchanged | **OK** | none |
| fs session-cwd rule | no longer canonicalizes a parent-traversing cwd | **changed** | the plugin's `canonicalPath` probe deleted; cwd is passed verbatim |
| `@deepseek-ai/dsh-sandbox` | still exists, no longer needed | unused | peer + import dropped |
| `str_replace_editor` as a default tool | dropped in 0.1.3 (package still ships) | **changed** | out of `DEFAULT_TOOLS`; guard branches kept and unit-tested |
| `@deepseek-ai/dsh-client-runtime` | package removed (split into connection / modules / resources / store) | **removed** | local structural faces; no import |
| `ctx.settingsScope` | exists in no 0.1.7 package | **removed** | `ctx.configForms.get(entryId)` |
| `SessionListState.current` | removed (0.1.6-alpha.2) | removed | not needed once the config page replaced the command round-trip |
| `SessionSnapshot.pending` / `.chat` | removed | removed | the `enhance()` gate that read them deleted |
| `uiConversation` + the `'chat'` view | unchanged | OK | none |
| `settings.general.item` slot | unchanged | OK | no longer used — the plugin has its own page |
| `plugins.bundle.config` slot | keyed by package name, `view: 'page'` only | OK | the config card registers here |
| `ctx.locale.register(ns, {zh,en})` / `bind(ns)` | unchanged | OK | one namespace now, not two |
| `ctx.effect` (both the returned-disposer and generator forms) | unchanged | OK | none |
| `window.__ModuleLoader__.load({id, factory})` | unchanged | OK | none |
| approval panel DOM (`data-approval-key`, `data-approval-scroll`, headline as the scroll body's first child, `panel > card > strip`) | unchanged | **OK** | untouched — this is why the panel features needed no rewrite |

### 3.1 The `0.1.7-rc.1 → 0.1.7-rc.2` delta

The line's own rc roll was verified the same way as the rest: `devDependencies`
pin `0.1.7-rc.2` exactly, `npm run check` is green against it, and the surfaces
`tsc` cannot see were re-probed against the rc.2 source and the installed rc.2
packages rather than assumed to be unchanged.

| Surface | Change | Effect here |
| --- | --- | --- |
| `PLATFORM_MODULES` (`packages/client/web/src/platform.ts`) | unchanged | the frozen list in `scripts/build.mjs` still holds |
| Approval panel DOM — `root[data-approval-key] > card > strip`, and `[data-approval-scroll] > div:first-child` as the headline | unchanged | the diff enhancer, the collapse button and focus restore keep working |
| `SettingsForms` prototype surface | unchanged (still no `register`) | the settings path is untouched |
| `PreToolDecision.ask` | **gained `displayReason`** | optional, unused here; `reason` still renders (see §7) |
| Approval panel keyboard handling | **added** — Enter allows, Escape rejects, skipped while focus is in a text field or on a button/link | our injected collapse button sits inside this handler. Enter on it still collapses (the handler skips buttons); Escape rejects, which is the panel's own global contract |
| `ui-primitives` exports | added several, dropped the `OnboardingSurface` export | none of them were used here |

## 4. The settings model on this line

- **One entry.** The `Config` schema lives on the `dsh-edit-approval` profile row
  — the row the bundle patch inserts, whose `id`, `name` and npm package name are
  the same string. The settings service keys entries by `entry.options.id` for
  direct children of the root include, and every composed row is one, so that id
  is exactly what a write addresses.
- **Two live fields, six ordinary ones.** Only `editEnabled` and `bashEnabled`
  are `.volatile()`. A volatile field is the only kind a form shows and the only
  kind a write may address, so marking one volatile *is* the decision to expose
  it. The rest are read as plain values when the entry loads; making them
  volatile would add live-update machinery they do not need.
- **Clear, don't pin.** A switch written back to its schema default is UNSET, not
  stored. Pinning a copy of today's default would silently opt the user out of a
  later default change, and it would put a value in the profile patch that the
  user never chose. The `/approval-*` commands and the config card apply the same
  rule, so the two write paths cannot leave the document in different shapes.
- **The page opts out of generation.** `configure({ auto: false }, ctx.fiber)` is
  registered as an effect inside a `ctx.inject(['settings'])` child, which is the
  documented gesture for a plugin that ships its own page. The owner MUST be the
  plugin's own fiber: the service looks the policy up by `entry.fiber`, so the
  inject child's default owner would store it where nothing reads it. Inert today
  (no shipped client builds pages from `autoGenerate`), pinned in `verify-host`.
- **The config page.** Registered into `plugins.bundle.config` with
  `key: 'dsh-edit-approval'`. The shell renders that section only when a
  contribution registered a `key` equal to the package name the Host reports
  (`ledger.bundles.has(pkg.name)`), so a wrong key makes the card silently
  absent. The shell passes **no** `form` prop for this slot — unlike
  `plugins.item` / `plugins.row.config` — so the card obtains the form itself
  through `ctx.configForms.get(entryId)`, which is structurally the
  `SettingsFormScope` the model wants (no cast).

## 5. Known behavior boundaries

- **Legacy settings do NOT migrate.** DSH's settings service imports each
  `settings.yaml` section into the entry of the **same name**. The old sections
  were `edit-approval` and `bash-approval`; the new entry is `dsh-edit-approval`.
  Both old sections are therefore logged and left in `settings.yaml.imported`. A
  user who had turned command approval on must restate `bashEnabled: true` in the
  profile patch or flip the switch once. This is a documented consequence, not a
  silent one — but it is silent *in the UI*, which is why it is called out in both
  READMEs and in the release note.
- **Ordinary fields are not live.** Changing `editTools` / `editMinDiffLines` /
  `editIncludeCreate` / `editIncludeDelete` / `bashTools` / `bashAllow` requires
  the entry to reload. Pinned in `verify-host` ("an ordinary field keeps its
  mount-time value").
- **The config page requires a loopback page.** `ctx.configForms` persistence is
  `host` only when `ctx.remote.$host.isLoopback`; otherwise it degrades to
  `memory` and every write returns `false`, so the card renders read-only. Not
  exercised here.
- **The approval panel DOM is an unsanctioned coupling.** `data-approval-key`,
  `data-approval-scroll` and the headline's position are not documented as a
  contract by the harness. They have now survived one line change unchanged,
  which is why the panel features were kept as a DOM enhancement. If a future
  line moves them, the failure is visible (the diff simply stops being colored)
  and the fallback is the harness's own native headline.
- **The panel is a composer takeover.** `ui-approval` registers into
  `conversation.composer` (a chain, `priority: 1`, `overlay: true`), so the panel
  renders in the composer seat rather than inline in the stream. Selectors are
  unaffected; the visual position moved.
- **Save from the card is transactional; the commands are immediate.** The card
  stages edits and writes them on Save; `/approval-edit on|off` writes at once.
  Both end up in the same document with the same clear-on-default rule.
- **Command copy is English-only, by decision.** The two `/approval-*`
  descriptors and every result line are literal English strings in the host half,
  while the client half's card and panel copy do follow the user's language. That
  split is deliberate rather than an oversight: localizing the host copy means
  reading the user's preference through the settings service on every registration
  and every render, which is ongoing maintenance for four sentences. The
  constraint that forces it is in §7 — the harness translates no third-party
  command copy, so a plugin that wants it must carry it.

## 6. Uncovered boundaries (need a live browser)

Settled by source and unit probes, but not yet observed in a running page:

1. The rendered panel tree — that nothing interposes between `card` and `strip`,
   and that `[data-approval-scroll] > div:first-child` really is the headline
   (class names are build-generated hashes).
2. That the plugin's headline rewrite survives the panel's `setAnswered(true)`
   re-render (React should not touch a string child's replacement nodes).
3. That resolving an approval **unmounts** the panel rather than hiding it —
   `refocus` observes a removal.
4. That the host's edit-approval reason still matches `isDiffReason`'s
   `/\n\s*\d+\| [+-]/` shape.
5. That the card appears on the bundle's page in the sidebar's Plugins panel, and that its form reports
   `writable` with `mode: 'host'` on a loopback page.
6. That `dsh-client-ui-settings-general` (which declares `settings.general.item`)
   is composed — no longer needed by this plugin, but its absence would have made
   the old rows wait forever.

## 7. Upstream observations

- The **`.check()` API** the settings cookbook recommends for cross-field Config
  validation does not exist in the vendored Schemastery (3.18.4). Full-Config
  validation before persistence happens through `resolveConfig` inside
  `ConfigEditor.edit`.
- The **cookbook says a custom plugin page receives `form.state` / `form.mutate`**
  from the Plugins page owner. That holds for `plugins.item` and
  `plugins.row.config`, but **not** for `plugins.bundle.config`, whose only owner
  prop is `{ view: 'page' }`.
- The **`SettingsForms` README's `autoGenerate` rule** ("a plugin that ships its
  own page registers `configure({ auto: false }, ctx.fiber)`") is followed by the
  first-party plugins that both own a namespace and ship UI (ui-chat, ui-theme,
  locale, ui-settings-general, …), but not by the first-party *companion page*
  packages — which is consistent, since a companion does not own the namespace it
  renders. It is inert today because no shipped client consumes the flag.
- The settings subsystem doc states consumers read the session policy through
  `ctx.approval.effectivePolicy(session)`; that method is **private** on
  `ApprovalService`. `overrideOf(session) ?? config.policy` remains the public
  route and is what this plugin uses.
- **Host command copy is not localizable by a third party.** The client
  translates a command's descriptor only for the first-party allowlist
  `HOST_FACES` (goal, plan, feedback, compact, permission, export); every other
  command falls through to `{ description: c.description }` — the host's raw
  string, verbatim. Command *output* is never localized for anyone: the
  execution notice renders `outcome.text` as-is. A plugin that wants either must
  carry its own dictionary and read the preference itself, which on this line
  means `ctx.settings.describe({ redactSecrets: true })` against the `locale`
  entry's `preference` field — the locale host half registers no settings
  section, only `configure({ auto: false })`. This plugin does not do that (see
  §5).
- **The APPROVAL PROMPT, unlike command copy, became localizable in `0.1.7-rc.2`.**
  `PreToolDecision.ask` gained
  `displayReason?: { readonly en: string; readonly [locale: string]: string }`,
  which `ToolRuntime` forwards to the approval request
  (`packages/core/tools/src/index.ts`, `packages/interaction/user-approval/src/types.ts`).
  The panel prefers it and resolves the map through the client's own locale
  runtime — `ctx.locale.resolveText` — falling back to `reason` when absent
  (`packages/client/ui-approval/src/client/ApprovalPanel.tsx`). No
  `settings.describe` read and no registration-time/render-time split is needed,
  which is what made command-copy localization expensive.
  This plugin sends `reason` only, so the panel renders the English headline; the
  client-side diff rebuild is keyed on the `NN| +`/`-` markers rather than on the
  header text, so a localized header would not disturb it. Adopting it is a
  user-visible copy decision left as a follow-up, not a compatibility gap.
