Self-audited every factual claim against the shipped branch (hierarchy
tree, no group manifests, the dsh-* paths wildcard in both tsconfigs,
publint glob, explicit build references, the two new gates). Phrase the
paths-wildcard bullet as current state, and record the doc-typecheck
JSONC-parse subtlety the wildcard introduced so a future editor does not
reintroduce the regex comment-strip bug.
git mv it from proposed/ to rejected/architecture/ (preserving history)
and fold the supersede note into a one-line rejected Status. The
extract-example-app-packages RFC subsumes it: once the spine moves into
dsh-agent-core and the base*.yml files are deleted, there is no shared
base YAML left to rename. Move its README row to Rejected -> Architecture
and repoint the supersede cross-link to the new path.
Add a README to each group dir (core/llm/bash/session-persistence/ui/
support) stating its role and product-vs-support classification, and
rewrite packages/README.md around the hierarchy (group table, grouped
"what goes where", removed the package-hierarchy FIXME).
Move the package-hierarchy RFC to implemented/architecture/ and rewrite
it to describe what shipped (placement rationale, the paths-wildcard and
publint dedup, the two new guardrail gates). Fold the remaining
tsconfig.build.json references dedup into the discover-package-inventory
proposal and fix its cross-link.
Update AGENTS.md: regrouped repo-layout map, depth-2 globs, the new
verify-package-paths gate in the doc-sync listing, and a note that we
lean toward stricter lint in the agentic-coding era (machine-caught
errors and a consistent foundation outweigh the one-time cost).
Self-review against the actual source surfaced three imprecisions:
- agent-loop placement: the core bundle forwards agent-loop's `agents`
list as its own config (default []), matching AgentLoop.Config, rather
than hardcoding []. This is what lets a shared core coexist with stdio
pre-creating `main` and acp pre-creating none — and it directly rebuts
the reason base-core.yml gives today for keeping the loop out of core.
- timer is universal and stdout-safe, so it lives in the shared spine,
not the per-app front-door cluster (only logger + hmr are app-specific).
- model/systemPrompt land in different places per app (stdio onto the
pre-created agent, acp onto the bridge plugin), routed by the app
package's own Config — not a single uniform bundle entry.
Merge brought in the RFC-classification reorg and two new doc gates;
rewrite every drifted packages/<name> cross-link (Markdown link targets,
moved-README relative depths, and .ts comment paths) to the grouped paths.
Add two doc-sync/hygiene gates so the manual checks this restructure
needed become automated:
- verify-package-paths.ts: flags a packages/<path> reference (in Markdown
or a .ts comment/string) that does not resolve AND names a real package
in a segment — i.e. a stale path to a MOVED package. A path naming a
non-existent package (a forward-looking proposal) is left alone, so it
applies uniformly across proposed/implemented/rejected.
- check-workspace-constraints: assert the packages/<group>/<pkg> depth-2
shape (group dirs carry no package.json; no flat or over-nested
packages). Group names stay open; only the shape is fixed.
- Correct the SessionStore signature: `get(id: string)` is non-optional,
not `get(id?: string)` (only create/prepare take an optional id).
- Broaden the ACP brand-erosion description and the acceptance criterion
beyond `Map<string>`: the session-id surface also includes the
`bySession` WeakMap, the `loadingIds` Set, and the exported
`streamSessionEventUpdate(sessionId)` signature.
- Use bare inline code spans for code paths instead of markdown links,
matching the house style of the other architecture RFCs and removing the
link/bare-span inconsistency within this file (doc-to-doc cross-links
stay markdown links per docs/AGENTS.md).
Add a proposed architecture RFC to make the examples folder thin: each
example becomes mostly an invocation of an app package. A shared
dsh-agent-core bundle owns the providerless spine; dsh-stdio-agent and
dsh-acp-agent app packages bake in their coupled front-door cluster
(UI + logger/hmr policy + agent pre-creation), turning the ACP
stdout-purity footgun into a property of the artifact. Leaf cordis.yml
shrinks to backends + config; start.ts is dropped in favor of a package
bin.
Supersedes the providerless-example-base RFC (cross-linked) and indexes
the new RFC under Proposed -> Architecture.
Move the 18 flat packages/<name> packages into role-grouped dirs:
core/, llm/, bash/, session-persistence/, ui/, support/. Group dirs are
pure containers; each package keeps its @deepseek-ai/dsh-* name.
Collapse the per-package tsconfig paths maps (base + typecheck) into one
@deepseek-ai/dsh-* wildcard with a candidate per group, and derive the
publint list from the hierarchy. Update all depth-coupled globs/configs
(workspace, tsdown, vitest, eslint, knip, tsconfig includes/refs,
per-package tsconfigs, generators, doc-script scopes, type-equiv manifest)
and the cross-package/script relative imports in tests.
Fix doc-typecheck's workspacePaths() to parse tsconfig JSONC via the
TypeScript API instead of a regex comment-strip, which corrupted the
new wildcard `/*/` path candidates.
WIP: doc cross-links and package/RFC docs still to update.
Extend the existing Branded<B> machinery (CallId/SessionId/AgentId) to the
unbranded cross-boundary IDs that meet the brand.ts policy bar — chiefly the
model-facing bash task id (BashTask.id, the `bash-N` counter that shares
SessionId's `name-N` shape) and a distinct OwnerToken brand for the bash
owner token — and fix the brand erosion where existing brands decay back to
`string` at Map keys and method params.
Scoped focused per the "not every string needs a brand" policy: ModelId,
ToolName, numeric ordinals, and validated construction are listed as
deferred extensions, not in-scope work. Filed under proposed/architecture.
Add a second axis to every RFC — its class (feature, bug-fix,
simplification, architecture, process, testing) — encoded in the path
as docs/rfc/{lifecycle}/{class}/file.md. The folder is the label, so
the closed set is enforced by structure rather than a parsed field.
Two new doc-sync gates back it:
- verify-rfc-classification: every RFC sits in a valid class folder and
the README index lists it under the matching lifecycle→class heading.
- verify-doc-refs: every docs/*.md path cited in a packages|examples TS
comment resolves — closes a drift class verify-md-links can't see, and
catches the four comment refs this reorg moved.
The README gains a Classification section explaining the taxonomy and
per-class index sub-sections. A self-referential process RFC records why
the scheme is path-encoded and gated.
PR #71 shipped the core-data-structures catalog and the verify-type-equiv
drift gate without an RFC (judged small at the time). Add the retroactive
implemented RFC so the sibling pair is documented symmetrically: the
spine-vs-seam scoping rule (discovered by testing candidate definitions against
borderline types like BashExecRequest and ToolDefinition), the
verbatim-match-over-assignability choice for the gate, and the process —
including the Codex-caught scan-gap bug fixed in 6da7a0f. Cross-link the two
catalog RFCs to each other and index the new one.
- Exclude protected methods from the generated service interface: a protected
member (e.g. BashExecutor.notifyTaskDone) is a subclass hook, not part of the
public ctx.<key> surface a plugin author calls. The method filter now drops
private, protected, and static.
- Add BashTaskRead to the type cross-link map so readOutput()'s return type
links to its core-data-structures page.
- Reword the generator module comment and the AGENTS.md @mode rule to state the
current capability without narrating the retired event-taxonomy verifier
(that history lives in the RFC).
Add scripts/gen-cordis-catalog.ts: a fully-generated docs/cordis-catalog/
events-and-services.md cataloging every cordis event (exact signature + @mode)
and ctx.<key> service (exact interface), modeled on gen-module-graph's
--write/--check freshness gate. The harness tier renders in full from the
interface Events / interface Context declarations and their JSDoc; the inherited
cordis-core/loader/hmr/timer surface renders tersely from a curated table.
The generator hard-errors on a missing @mode tag and on a tag that contradicts
a conclusive signature shape (a trailing next param is structurally a
waterfall). Signature blocks use a ts cordis-catalog fence that doc-typecheck
skips. Type tokens cross-link to the core-data-structures catalog.
This supersedes the hand-maintained event-taxonomy table: verify-event-taxonomy
is deleted and verify-cordis-catalog joins doc-sync. architecture.md keeps the
Event taxonomy heading (TOC anchor) but points at the catalog; the Service-map
role table stays. RFC, AGENTS.md @mode authoring rule, and dependent doc/skill
references updated. Negative gate tests cover the missing-tag and
tag/shape-contradiction paths.
Review found verify-type-equiv only scanned docs the manifest already named, so
a type-equiv block in an unmanifested doc was silently skipped — defeating the
1:1 guarantee. Scan all docs in the markdown glob scope instead, so an orphan
block in any doc is caught. Also parse `abstract class` in blockSymbol (matches
sourceDeclaration's class support).
persistence.md listed the SessionPersistence surface as create/append/load/list;
the abstract service also exposes has/delete. AGENTS.md's doc-sync command
summary omitted verify-md-links and verify-type-equiv.
A new docs/core-data-structures/ folder: a self-contained core.md defining what
counts as a "core" data structure (the agent-loop spine) and covering the spine
vocabulary, plus per-seam sub-pages (llm-streaming, session, persistence, tools,
bash). Type definitions are pasted verbatim via `ts type-equiv` blocks and
drift-checked by verify-type-equiv. Cross-linked from architecture.md; the
`ts type-equiv` mechanics are documented in development.md.
Review follow-ups on the bash owner-token PR:
- packages/acp/README.md still described task isolation in object-identity terms
("records each background task's owning agent", "a different agent"). Rewrite
to the session-token model: ownership is by `session.header.id`, stored on the
executor's task, so a different Agent object on the same session may access it
and ownership survives a tool-bash HMR reload.
- The reviewer flagged that the notice routes by `session.header.id` while the
registry only enforces unique `agent.id`, so a programmatic caller could
register two agents sharing a session token and mis-route a notice (not
reachable via ACP). Rather than bolt a session-id invariant onto the generic
registry, add a proposed RFC (2026-06-20-unify-agent-and-session-id) to remove
the precondition by construction — an agent IS its session, one id — with a
full risks discussion (forecloses multi-session-actor / fork futures, makes the
config resume-or-create policy load-bearing, migration churn). The actual
unification ships as its own Codex-converged PR. Cross-linked from the
agent-lifecycle RFC's seam-precondition note.
- Reframe the tool-bash module-doc ownership paragraph to current-state (per the
new AGENTS.md doc convention): contrast storing the token on the executor vs
in the plugin as a standing rationale, not as "closing the old gap".
A reviewer found that window 2 (a cancel from a synchronous agent/status('running')
listener) had the same early-whenIdle() race that window 1 already guards: it
unconditionally `setStatus('idle')` + continue, which settles `whenIdle()`
waiters — so if the running listener cancels AND queues replacement work, the
waiter resolves while the replacement is still queued-and-unrun (the next
iteration runs it later, but the caller already observed quiescence).
Mirror window 1: after clearing the marker, only `setStatus('idle')` when
nothing new is queued; otherwise fall through to run the queued replacement
(status is already `running`), so `whenIdle()` resolves on that turn's
running→idle. Regression test reproduces the reviewer's interleaving (running
listener cancels A, sends B; whenIdle() resolves only after B ran).
Also syncs the cancellation contract in the two ACP RFCs that describe the live
behavior: `session/cancel` is the queue-aware `agent.cancel()` (drops an
about-to-start turn), not the old best-effort `agent.abort()` pre-step
limitation.
Delete the `taskOwner: Map<string, Agent>` entirely — it served two roles
(access control AND holding a live Agent for completion notices), both now
stateless:
- Access control: `bash_output`/`bash_kill` compare `ctx.bash.ownerOf(id)` to
the caller's token (`exec.agent?.session.header.id`) with `!== undefined`
semantics (an empty-string token is still a real owner). The owner is stamped
at spawn via `resolve({ …, owner })`. Ownership now lives on the task in the
executor, so it SURVIVES a tool-bash HMR reload — closing the old
XXX(tool-bash-owner-hmr) gap.
- Completion notice: `onTaskDone` reads `ctx.bash.ownerOf(task.id)` and finds
the live agent by scanning `ctx.get('agents')?.list()` for a matching
`session.header.id` (read via `ctx.get` — the listener runs on the bash
fiber, a foreign fiber, where the `ctx.agents` proxy would throw). No
registry / owner gone → drop the notice cleanly.
Token is `session.header.id` (NOT `session.id`): every other subsystem keys off
the header id, and the test fakes populate only `session.header.id`, so reading
`session.id` would make every fake unowned and pass the isolation tests for the
wrong reason.
Tests give A and B DISTINCT real session tokens (a same-token-different-Agent
case is now ALLOWED — identity no longer matters); the HMR test inverts to
assert ownership SURVIVES a tool-bash reload; a new test covers the
owner-agent-gone-before-completion drop. Migrates the agent-lifecycle RFC
proposed->implemented (recording all three seams + the session-id-uniqueness
precondition) and updates the tool-bash README + the now-implemented RFC's
cross-links.
Background-task ownership needs a stable home that survives a consumer HMR
reload. Add an optional `owner?: string` to `BashExecRequest` and a
required-but-nullable `owner: string | undefined` to the resolved
`BashExecSpec` (mirroring how `workdir`/`timeoutMs` are required on the spec —
a forgotten owner is a visible `undefined`, never a silently-absent property
that yields an unowned, cross-session-readable task). `resolve()` carries it
through.
Expose the stored token via a new `BashExecutor.ownerOf(id): string |
undefined` seam (ONE read path — not also on the public `BashTask`). The
executor stores and returns the token verbatim and NEVER interprets it: the
access POLICY lives in the consumer (`dsh-tool-bash`). `bash-local` stores
`owner` on its `TrackedTask` and implements `ownerOf`; unknown-id and
known-but-ownerless both read as `undefined`. Because ownership lives on the
task in the executor (disposed with the `dsh-bash` fiber), it survives a
`tool-bash` HMR reload.
Updates the StubExecutor seam test and the bash/bash-local READMEs.
The bridge now holds each session's `AgentHandle` disposer in its
`SessionRecord` and runs it on teardown (client disconnect or fiber dispose)
instead of the old `abort()` + `whenIdle()` drain that left agents
registered. A bare client disconnect now leaves NO registered agent and NO
session-store entry — not an idled-but-still-registered one. The queue-aware
`cancel()` inside the disposer also closes the former pre-step best-effort
window (a turn about to start is dropped), so teardown reaches true
quiescence.
The `session/load`-races-teardown leak is fixed: if the bridge closed while
`resume()` was pending, the just-resumed handle is disposed before throwing,
so it leaves no orphan (it has no SessionRecord, so quiesce() never sees it).
Tests: the disconnect test now asserts (through the SAME memoized teardown)
that the agent is unregistered AND its session removed; a durability test
re-loads the persisted log after dispose and asserts the closing turn/end is
on disk (guards the teardown-order contract); a sibling-isolation test proves
one handle's dispose() leaves other agents untouched. Docs: agent /
agent-loop / acp READMEs, architecture.md, and the stale in-code quiesce()
ownership comment updated to the per-agent disposal model; the now-resolved
TODO(rfc010-agent-disposal) / TODO(rfc010-cancel-prestep) teardown notes
removed.
abort() only kills the in-flight step, so a queued-but-not-yet-started prompt
ran to completion after a cancel and a prompt accepted right after could be
batched into the cancelled turn (the loop merges queued messages into one turn).
This closes TODO(rfc010-cancel-prestep) with a distinct cancel() verb.
cancel() clears the queued + steering FIFOs, aborts the in-flight step, and
drives a turn-scoped marker on the LoopHandle that the driver checks at EVERY
point a turn could start or continue:
- right after the idle wait (window 1): drop the about-to-run turn and settle
whenIdle() waiters directly (no running→idle transition fires, and no
agent/status is emitted, so an ACP listener can't see a spurious idle that
resolves a freshly-queued prompt as cancelled);
- after the synchronous setStatus('running') emit (window 2): a running listener
can cancel in the gap before runTurn;
- in the step-start window (before runStep, after setAbort): a synchronous
turn-start/step-start listener can cancel before any AbortController exists;
- at the continuation gate: a cancel during the continuation waterfall (the
finished step's controller already cleared) ends the turn aborted.
The marker is ARMED only when there is something to cancel (running, an
in-flight step, or queued/steering work) — an idle no-op cancel cannot leave it
set to drop a later prompt — and RESET unconditionally once per loop iteration,
so it governs exactly one turn and never leaks onto the next prompt (even when a
send() lands in the cancelled turn's flush window).
ACP session/cancel now maps to agent.cancel() (keeping the synchronous
settlePrompt). Teardown/disconnect still use abort('disposed') until PR D, so
the ACP README narrows the remaining best-effort window to teardown only.
Tests (agent-loop/cancel.spec.ts) cover every window unit-level (the F1 hang
guard: a whenIdle() waiter registered before a pre-step cancel resolves; the F2
leak guard: idle cancel then a prompt runs; mid-step, continuation, both
pre-step windows, turn-start-listener, steering-cleared, marker-reset). ACP
turns.spec.ts adds the through-bridge tests with NO intervening whenIdle (idle
cancel→prompt runs; mid-stream cancel→immediate next prompt runs) and updates
the stale pre-step test to the queue-aware guarantee. The existing cancel
snapshot golden is byte-identical (it drives the new cancel() path end-to-end
through the real subprocess), so no new golden is needed. 100% coverage.
The JSONL and SQLite backends were byte-identical (or same-algorithm) for ALL
of their write-path orchestration — the four maps (states/buffers/chains/inits),
installWritePath, initFor, onCreated's four adoption cases, flush, drain,
serialize, adopt/adoptLivePrefix, assertVersion, and the create/append/load/
has/delete skeletons. Only the storage primitives (write bytes vs INSERT rows)
differed, so every fix landed twice.
Extract that orchestration into a PersistenceCoordinator in the seam package.
Each backend composes one (new PersistenceCoordinator(ctx, this)), implements a
small PersistenceBackend hook interface (loadStored, loadLive, appendBatch,
commitRepair, deleteStored, list, optional close), and delegates its six public
service methods to it. Composition, not inheritance — a backend exposes only the
hooks, can't reach the coordinator's private state, and the public
SessionPersistence API is unchanged so a third-party backend may still implement
it directly.
The crash-repair torn-tail token is OPAQUE: the coordinator computes the
synthetic closers (it owns interruptedTurnClosers) but only tests
`tornMarker !== undefined` and round-trips it to commitRepair, never inspecting
it (JSONL = byte offset, SQLite = seq). loadStored vs loadLive stay distinct so
HMR adoption is cwd-scoped (a same-id log at a different cwd is a collision, not
a resume). appendBatch carries meta so lazy-materialize + first-batch commit
atomically (no separate materialize hook).
Tests: the duplicated orchestration tests (adoption, HMR, collision,
dispose-drain, crash-tail) move into one runCoordinatorContract suite run once
per backend (memory + jsonl + sqlite) via hook fixtures; per-backend specs keep
only storage mechanics. A through-coordinator torn-tail test per real backend
keeps the commitRepair-with-marker branch covered under the 100% gate.
Net -112 lines (the dedup outweighs the new coordinator + shared suite); 100%
coverage; backends shrank ~1200 lines of duplicated churn. Migrates the
write-coordinator RFC proposed -> implemented.
Codex's converge pass on PR A flagged three now-false references the deletion
left behind:
- the proposed write-coordinator RFC still listed an "update summary" backend
hook and "sidecar behavior" in its test focus;
- the JSONL README's format-version note still said a format change needs a
"version bump + migration" (contradicting the no-migration pre-release stance);
- a stale "sidecar pathing" comment in findLog's cwd-recovery branch.
All three corrected to current truth.
SessionSummary (updatedAt/title/firstPrompt) and SessionPersistence.update()
were dead state: zero production callers of update(), no production reader of
updatedAt/firstPrompt, and ACP's title comes from a tool-call presenter, not
storage. The live Session.header was already typed SessionHeader, so the
summary only ever existed in the persistence layer, written and read by nothing
but its own contract test.
Delete it entirely (no SessionMeta alias — SessionMeta collapses to
SessionHeader everywhere). This removes the JSONL .summary.json sidecar
machinery, the SQLite title/first_prompt/updated_at columns and per-append
updated_at bump, and the update() method from the abstract service and both
backends. SQLite SCHEMA_VERSION goes 1->2 and openDatabase now rejects any
non-current user_version (older or newer) — no migration, unreleased software.
Net -400 lines, and it erases the JSONL-sidecar-vs-SQLite-column durability
divergence that the upcoming write coordinator would otherwise have to model.
Records the decision in docs/rfc/implemented/2026-06-19-drop-mutable-session-summary.md
and migrates the 2026-06-14 session-persistence RFC's facts to current truth.
Adds a standalone AGENTS.md section "Tests document behavior, not golden truth"
(a passing test pins current behavior, not necessarily correct behavior) with
the summary-drop as its worked example, and reinforces the no-migration
pre-release stance.
Adds docs/rfc/implemented/2026-06-19-real-api-e2e-ci.md covering the rationale
for running the real-API e2e suite in a separate secret-consuming workflow, the
fork/Dependabot/secret threat model, the residual exposure of the pull_request
trigger, and what changes when the repo goes public. Indexes it in the RFC
README.
Also adds a SECURITY comment on the pull_request trigger forbidding a switch to
pull_request_target (an untrusted-code-with-secrets leak vector, especially once
public), pointing at the RFC.
- Rewrite the snapshot-test RFC's replay-plugin section to state current
reality directly (the plugin is the @deepseek-ai/dsh-llm-replay package,
under the coverage gate) instead of keeping the old example-local text with a
"superseded" note bolted on.
- Add docs/rfc/implemented/AGENTS.md (+ CLAUDE.md symlink): an implemented RFC
must be kept current with what actually shipped — update paths/names/structure
in the same change that moves the code, in place, not as an append-only
changelog of its own drift. A reversal of the DECISION is still a new RFC.
- Reconcile docs/rfc/README.md: the "never edited into a different decision"
rule now distinguishes tracking where a decision lives (required) from
flipping the decision (forbidden), and the implemented/ bullet points at the
new convention.
Round 1 of Codex review on the extraction PR.
- (B) EOF-exit race: the 200ms flush-then-exit setTimeout was untracked, so a
fiber/HMR dispose within that window could not cancel it and the process
would still exit. Track the handle and clear it in the disposer; coalesce
re-entrant maybeExit() calls onto the one pending timer. Regression tests for
both (dispose-within-window cancels; repeated idle schedules once).
- (B/doc) ui-stdio rendering is global, not scoped by config.agent (faithful to
the original copies — agent scopes only input + the EOF-exit gate). Corrected
the README + Config JSDoc, which overclaimed "drive and render".
- (C) createStdioChat is exported and driven directly by tests/programmatic
callers that bypass schemastery validation, so default welcome/agent in the
helper (?? 'ready.'/'main') instead of trusting the cast. Test for empty config.
- (A/doc) docs/rfc/.../acp-snapshot-tests.md asserted the replay plugin
deliberately stays in examples/ ("don't split preemptively") — now false since
this PR packages it. Added a superseding note with the why (coverage gate).
All gates green: typecheck, lint, test:coverage (891, 100%), doc-sync,
test:e2e (6 keyless pass), test:snapshot unaffected.
Logic that lived under examples/ was outside the per-file 100% coverage
gate (examples/ are not workspaces) and, in the stdio-UI case, duplicated
across two examples. Move it into packages/ so it is gated and de-duped.
- packages/ui-stdio (new): unify the two diverged stdio-chat.ts copies into
one @deepseek-ai/dsh-ui-stdio plugin (welcome/agent Config). A test-only
I/O seam (createStdioChat(ctx, config, runtime)) keeps process streams out
of the serializable config and makes every render/EOF/disposal branch
unit-testable. Per-file 100%. echo/coding cordis.yml now load the package;
both src/stdio-chat.ts deleted.
- packages/llm-replay (new): move examples/acp-agent/src/llm-replay.ts (+ its
spec) here so its derive/parse/replay branches fall under the coverage gate.
cordis.snapshot.yml + README rewired to the package name; added apply/env
/assertNever/abort tests to reach per-file 100%.
- examples/{echo,coding}-agent: keyless Loader-path e2e smokes that boot the
real cordis.yml (no key) — the guard a hand-mounted unit test cannot be for
the unwrapExports/export-shape class (postmortem 0001). examples/AGENTS.md
codifies the keyless+with-key smoke convention (keyless-by-nature exception
for echo-agent).
- AGENTS.md: a scoped, removal-triggered pre-release stance (foundation over
blast radius). packages/README.md: new rows + a FIXME to later regroup ALL
packages into a hierarchy. Wiring: tsconfig paths/refs, publint, knip,
module-graph.
Verified: typecheck, lint, test:coverage (887 tests, 100%), build, hygiene,
doc-sync, test:snapshot (10), test:e2e (6 keyless pass, with-key self-skip).
Rename the concrete Agent class to make its ReAct-style reasoning loop
explicit in the name. Package name, default-export plugin (`AgentLoop`),
and the `ctx.agentLoop` service key are unchanged.