Changelog
Opens the app with a prompt to read this page.
All notable changes to stetdoc are recorded here. The format follows Keep a Changelog (opens another site), and the project follows semantic versioning (opens another site) from 1.0.0. Releases before 1.0.0 may change the interface between minor versions.
0.9.1 - 2026-11-02
Fixed
- A replacement no longer cuts a bracket pair in half.
thirty (30)toforty-five (45)used to strikethirty (30and leave the)standing after the insertion; now the wholethirty (30)is struck and the wholeforty-five (45)inserted, however wide the search text. An edit that leaves(,[,{or“open takes the unchanged closer right after it, and the mirror for a closer, unless the bracket is in another author's pending change.opsandcompareboth get it, and glued%or'sstill stay outside the edit, as in Word. The compare noise baseline is re-taken for it: over the LAB pairs the redline has 0.05% more characters.
0.9.0 - 2026-11-02
The first release: the stetdoc CLI with its MCP server and the agent plugins, built for seven targets, from GitHub
Releases, npm, PyPI, Homebrew and crates.io (cargo install stetdoc --locked, which also publishes the engine as the
stetdoc-core library).
Added
-
Receipt::text_within(cap)composes a receipt's text in aboutcapbytes, astextis in about 11,000. The MCP tools that write (edit,replace,comment,pack) answer with a receipt of about 2 KB: the head line with its base, the first operations and blocks, what was left out, and the check line.detail: truereturns the whole receipt. -
stetdoc comment FILE ANCHOR TEXT [ANCHOR TEXT ...]adds several comments in one edit, all or none, asreplacetakes several pairs. The MCP comment tool takes them incomments, a list of{anchor, text}, besideanchorandtextfor one. -
stetdoc view --find TEXT --story STORYfinds only in that story, such as the headers and footers, where--findand--storyused to be refused together.Scope::Findhasstory(decision 0013 (on GitHub)). -
The text of a receipt is capped at about 11,000 bytes, so
replace --allorcomment --allover hundreds of places prints a receipt an agent can read whole: the first operations and blocks, how many it left out, and thestetdoc viewcommand that shows them. The head line, the base, the warnings and the check line stay whole, and the JSON is unchanged. -
--clausemay be given more than once onview,replaceandcomment, such asstetdoc view FILE --clause 4.01 --clause 4.02: the result is the union of the clauses' blocks in document order, each once.Scope::Clauseholds a list, and JSON takes one clause as a string or several as a list (decision 0012 (on GitHub)). -
A Python distribution of the CLI in
packaging/pypi/: maturin builds the nativestetdocbinary into a wheel per release target (bindings = "bin"), souv tool install stetdoc,pipx install stetdoc,pip install stetdocanduvx stetdocrun it with no Python at run time. The release workflow'spypiandpypi-sdistjobs attach the wheels and the sdist to the draft release, andcargo xtask release checkandstamp.mjs --checkkeep them in step. -
The npm package
stetdocreplaces its Node launcher with the platform's binary in apostinstallstep, as esbuild does, so a call throughnpm install -g stetdocstarts in about 6 ms instead of about 100 ms. It copies a file npm already installed and downloads nothing. With--ignore-scriptsand on Windows the launcher stays. -
The skill stub and
stetdoc skills get coreteach the default output (NAME.edited.docx, then--in-placeon it),--manualmarkup and when to use it, reporting tracked changes by others,--ignore-case,comment --alland--expect,view --revisions --context N, table hits, and the MCP edit tool's typedops. -
stetdoc init --agent antigravityinstalls the skill for Antigravity CLI (agy), which replaced Gemini CLI: in~/(read by its CLI, app and IDE) or a project's.gemini/ config/ skills/ stetdoc .agents/skills/stetdoc. It is detected byagyon the PATH or~/.gemini/antigravity-cli, and--mcpprintsagy mcp add stetdoc stetdoc mcp. Picking Gemini CLI now notes that Google retired it. -
The skill stub tells the agent to write each output next to its input in the user's folder, never in a temporary folder, and, when
stetdocis missing, not to offer another way into the .docx. -
stetdoc view --revisions --context Nshows the blocks that hold the changes whole, as the redline shows them, with N blocks on each side, so each change reads in its paragraph. It takes--author,--kindand--findas the list does.RevisionFilterhascontext.--contextwithout--findor--revisionsis refused with a next step. -
The Node bindings have
Document.occurrences(text, scope?), typed withOccurrencesandScope. -
Occurrencehasheading: the nearest heading when the paragraph is in no clause, which the CLI prints as(under "RECITALS"). -
Every receipt of
edit,replace,commentandcompare, and the head of every view but the ledger, says which tracked changes in the document authors other than you made, in one or two lines: how many by whom, the first three with their number, kind, ref and text, and "Tell the user about them." The JSON has them inothers. -
replaceandcommenttake--ignore-case(the MCP tools'ignore_case), and anot_foundrefusal lists the places where the text is in another case.Document::occurrences_ignoring_casefinds the same way. -
commenttakes--alland--expect N, one comment on each place, asreplacedoes. -
Manual markup:
stetdoc replace --manualandstetdoc edit --manualkeep the old text, struck through, and add the new text underlined, as plain formatting with no tracked changes, for a reader who asks for a redline without Track Changes. A deletion is struck text only and an insertion underlined text only.--manual-coloralso makes deleted text red and new text blue. The MCP replace and edit tools takemanualandmanual_color, the core hasEdit::markup(Markup::Manual), and the receipt says the markup is manual and lists what each operation struck and underlined (OpResult::marked).split,merge,rowand deleting a table are refused in a manual edit. -
The MCP
edittool takes typed operations inops: a list of objects, each withopand that operation's fields, with the schema generated from the same table ascapabilitiesand the operations reference.batchstill takes text, and the file the CLI's--opsnames is nowops_file. Every MCP tool's description carries the rules a model needs to call it right, for clients that never pass the server's instructions on, and no schema uses a union any more, so Gemini's function declarations take them as they are. -
A batch applied to a .docx may leave out its base, in the CLI and the MCP tool alike: the file is read and edited in one process, as
replacedoes, so its refs are read against the file as it is now. A bare JSON list of operations works there too. A working copy still needs the base, and a batch without one is refused with where to find it. -
The eval harness keeps each run's report and transcript, counts only check findings that Word would reject as invalid, and its graders accept an edit made as a longer phrase and judge the stale case on any mention of the other author's change.
-
An eval suite for the Claude Code plugin in
evals/, run withevals/run.shon Claude Code'sclaude plugin eval: nine cases on real contracts, graded from stetdoc's own reading of the documents the agent wrote, with turns, tokens, cost and time per case, and arms to compare with no plugin or another plugin. Seedocs/evals.md. -
stetdoc replace FILE OLD NEW [OLD NEW ...]changes text in one step, on a .docx (-o OUTor--in-place) or a working copy: each OLD, found once in the text as the final view reads it, becomes NEW as tracked changes, all pairs in one edit, all or nothing. An empty NEW deletes OLD. Text that is not there, or is there more than once, is refused with every place listed (ref, clause, text around it).--allreplaces every occurrence and says how many,--expect Nonly when there are exactly N,--rangeand--clauselimit the search, and--dry-runwrites nothing. -
stetdoc comment FILE ANCHOR TEXTcomments on text found the same way, in one step. -
The MCP server has
replace(its pairs an array of{old, new}) andcommenttools, with the JSON receipts ofeditand the places found infound. -
Document::occurrenceslists where a text is, asfindmatches it, in the whole document, a story, a range or a clause, and the new error codecount_mismatch(exit 3) refuses a request whose expected count does not match. -
stetdoc view --findtakes several texts (--find payment --find "Due Date") and finds blocks that hold any of them. Each line says which texts it holds and how often, and the position line counts each text. The MCP view tool'sfindtakes one string or a list, andScope::Findhasneedles(JSON still readsneedleand one string). -
The Claude Code plugin carries a guard hook,
plugins/, that blocks tool calls reading or writing a .docx another way: an archive tool, a script or a .docx library on a Word file, a conversion, an in-place edit of an unzipped part, and the Write and Edit tools on a Word file. It allows everyclaude/ hooks/ docx-guard.sh stetdoccommand, needs onlyshandawk, and tells the agent to use stetdoc or say it is not installed.docs/agents.mdlists what it blocks and shows how to add it to a skill install, andstetdoc init --agent claude-codepoints there. -
The skill stub carries the everyday commands (
view --find,replace,commentandcompare), so a simple task needs nostetdoc skills get core. In Claude Code it sends the agent straight to the command.stetdoc initreplaces the previous stub as one it wrote. The core skill makesreplacethe default way to change text andcommentthe way to comment, with batches for the rest, and the README shows the same flow. -
The skill stub and the core skill tell the agent to report tracked changes it did not make, such as a colleague's edit made since its last view, with who made them and what they change. Its comment example writes a new file, so an agent copying it leaves the user's original untouched.
-
stetdoc view --find TEXT --range p40..p60looks only in that range, in the CLI and the MCP view tool. A find pages like a range, and its position line counts the matches:find "days": 44 matches in 38 blocks, showing 1..9. -
stetdoc view --revisions --find TEXTlists only the changes whose inserted or deleted text contains it, and--revisions --fullshows the whole text of each change (up to 1,000 characters) and not 80, still paged. -
stetdoc view --allis the same as--budget 0. A view of a body too long for one page shows its outline with anoutline_shownwarning that names the range of the whole body and--all, and the-- more:line of a page says where it stops, how much follows and the command for the next page. -
stetdoc inspect FILE p42lists the block's tracked changes (ids, kind, author, text) and comments, or says it has none, so text such as[DELETED: ...]in a document can be told from a real change. -
The MCP view tool's schema names, in each property's description, the arguments it cannot be used with, and a call that sends such a pair gets a next step that names both, in the MCP server and in the CLI's
--jsonerrors. -
The agent guidance picks the surface and keeps agents away from the package. The skill stub sends an agent with stetdoc's MCP tools to
skills_get, and otherwise to the CLI, with a quick reference that covers a simple task. With neither, the agent tells the user stetdoc is not installed and reads nothing another way. The core skill says how paging works, that[DELETED: ...]without braces and an id is document text and not a revision, never to read the XML, to state or ask which reading of an ambiguous rule it takes before resolving revisions in bulk (judging each change from--revisions --fullor--find), and to name the output and remove the working copy at the end withpack --done. It documents--all,--findwith--range,inspect's list of a block's revisions,edit --in-placeand the one-line summary of problems already in the input.stetdoc init --mcp,docs/agents.mdand the MCP server's instructions say to install the skill with the server. The README gives the one-line Claude Code install, and the Claude Code plugin has an icon. -
The CLI is published as the
stetdoccrate, socargo install stetdocinstalls thestetdocbinary. The library is published asstetdoc-core. -
stetdoc view FILE --revisionslists the tracked changes as a ledger, one line each: the ids, the kind (insert,delete,moveorformat), the author, the day, the block and the text.--authorand--kindfilter it, and--after change:Ncontinues a long list. It lists whatDocument::revisionsreturns. It wasunsupportedbefore. -
The agent skill says where the author of a revision comes from:
--author, thenSTETDOC_AUTHOR, then the name saved bystetdoc init, so an agent no longer asks for a name the config already holds. -
Docs for users and builders:
docs/agents.md(setup for each agent),docs/self-hosting-mcp.md(servingstetdoc mcpto cloud apps) anddocs/build-on-stetdoc.md(the crate, the Node package, the CLI and MCP). -
Packaging for the releases:
packaging/install.sh(checks the SHA-256 of the release archive), a Homebrew formula template, npm packages for the CLI (packaging/npm/stetdoc), plugin and listing manifests for Claude Code, Cursor, OpenAI and the MCP Registry, and a draft release that carries all of them. Seepackaging/README.md. -
White space at the ends of a
w:twithoutxml:space="preserve"is text, as Word for the web shows it (hand check 37), where LibreOffice drops it, a documented deviation;xml:spaceis read as inherited from an ancestor, and what stetdoc writes or copies says it where needed; aw16se:symExemoji shows its character (or its fallback's text); the text of a vertically merged continuation cell is flagged hidden; theFinalview of the notes leaves out a note whose reference is a pending deletion, and theOriginalview one whose reference is a pending insertion, each giving a note the mark Word shows once its changes are resolved (mark=), while its id stays the same in every view; and a text edit never deletes a note's own number. Found by the LibreOffice oracle (cargo xtask oracle libreoffice), which reads a seeded sample of 500 outputs. -
stetdoc packoverwrites an existing output only when it is the working copy's original or a .docx it packed; anything else is the new error codestale_target(exit 4), unless--force. The default date of a revision honoursTZ, with the time zone database built in as a fallback, and a zone that cannot be read gives UTC with a warning. An error from a readable file names its file and line once. -
The
stetdoccommand surface: all twelve commands with their flags,--jsonon every command, exit codes from the error table, and the stamp from--authororSTETDOC_AUTHORwith the local clock as the default date.view,inspect,lookup,check,edit -o,compareandcapabilitieswork on a.docx. -
The agent skill, embedded in the binary:
stetdoc skills get [core|operations|errors]and--full. The operation and error tables are generated fromcapabilities()and the command list from the CLI's own definitions, so they cannot drift. -
stetdoc initpicks agents: Claude Code, Codex, Cursor, OpenCode, OpenClaw, Gemini CLI, Copilot, Amp, Hermes, Grok Build, cmx2 and Factory. It installs into the home folder by default and into a project with--project, which also keeps a short block inAGENTS.md. It asks in a terminal, installs for the detected agents otherwise, and takes--agent a,borall; a folder several agents share is written once.--listshows each agent's detection, folder and installed version,--removedeletes only folders that hold nothing but an unchanged stetdoc stub,--mcpprints how to registerstetdoc mcpwith each agent without editing its config, and--dry-runchanges nothing. The stub records the version that wrote it.stetdoc init --author NAME, or the answer to its question in a terminal, saves the author tostetdoc/config.tomlin the config folder, and--listshows it.skills/stetdoc/SKILL.mdis the same stub, and a test fails when the two drift. -
The author of a revision comes from
--author, thenSTETDOC_AUTHOR, then the name saved withstetdoc init; without any, the command says to runstetdoc init. It is never a name the user did not confirm. -
stetdoc mcp: a Model Context Protocol server on stdio, speaking revision 2026-07-28 and the earlier handshake revisions. Every command butinitis a tool whose schema comes from the CLI's own definitions, a call returns the JSON the command prints with--json, and a failure is a tool error with its message and next step.--root DIRconfines every path argument toDIR, refusing..and links that lead out of it.--author NAMEstamps the calls that name no author. -
docs/reference/cli.md, generated from the CLI's own definitions bycargo xtask docs, with examples that run as tests. -
FIDELITY.md: per feature, what is kept exactly, what is normalized, what is read-only and what is left out, with the design behind each settled detail. The README shows the working CLI. -
Working copies on disk:
unpack,pack,edit,check,view,inspectandlookupon a directory. Every write follows the core's write plan with atomic renames under an advisory lock on.stet/lock, and a command that finds an interrupted write recovers it first. -
The workspace:
stetdoc-core,stetdoc-cli,stetdoc-napi,stetdoc-editor,stetdoc-testkitandxtask, on a pinned toolchain. -
The
stetdoc-coremodule tree, with a contract in each module. -
ErrorCode,ExitClassandStetError: the error vocabulary, with a generated error reference. -
The
internalerror code and exit class 7: a bug in stetdoc, which callers report and never retry around. -
cargo xtask checkandcargo xtask check --quick, and CI that runs the same check. -
The entry-point docs, the licence files (
MIT OR Apache-2.0) andNOTICE. -
cargo xtask corpus fetch: downloads the corpus manifest's documents from their pinned commits, verifies every SHA-256 and caches them, offline once cached.cargo xtask checkruns it and setsSTETDOC_CORPUS_DIRfor the tests. -
stetdoc_testkit::fixtures: 85 edge, 7 Word and 87 hostile synthetic documents built in memory, each with the outcome stetdoc must give and, for a hostile one, why. They include the package layer's own 117 cases, ported without duplicates: 53 are new fixtures and the rest are the fixtures the testkit already had. A snapshot pins the SHA-256 of every fixture, so a change to the builder or to the deflate library shows up as a reviewed diff. -
stetdoc_testkit::expect:ExpectandStage, shared by the corpus and the fixtures. A refusal is an error code at a stage (open,read,xmlordocument), and a layer sees only the refusals it reaches. A refusal atreadorxmlalso names the parts that carry it (Entry::parts,Fixture::parts), andExpect::seen_by_readinggivesOkto a layer that never reads them. Every refusal incorpus/manifest/has a stage, a part where that applies and a reason fromcorpus/reasons.tsv, andcargo xtask checkenforces it. -
Fuzz targets for the container and the span tree, run with
cargo xtask fuzz <target>: a libFuzzer target that opens, reads, replaces a part of and writes a package, and one that parses, serializes clean and with every tag dirty, and edits a part, each aborting when a law of the module breaks. They are seeded from the fixtures and the curated corpus, built with a nightly toolchain pinned for fuzzing alone, and run for 60 seconds per target in CI. The core reaches them through the off-by-defaultfuzzingfeature (decision 0002 (on GitHub)), andcargo xtask checknow runs clippy with the default features and with all of them, the tests and rustdoc with all features, and cargo-deny on the fuzz crate under its own policy (fuzz/deny.toml, the root policy and the NCSA licence of libFuzzer). -
cargo xtask benchand the benchmark harness,stetdoc_testkit::bench: the time and the peak memory of package open and save, span tree parse and serialize, schema validation, the state (open, a batch of fifty writes, save, export, resume),wordml(scan, field pass, text pass, refresh, fifty text edits, field edits and paragraph insertions) and the style cascade and clause numbers (the gate of 25 ms on a 20,000-paragraph body), on a 300-page contract and on four more inputs (the densest real document and three synthetic worst bodies), held to the budgets ofARCHITECTURE.mdand to a baseline for each platform. The memory is counted by an allocator that the core's test binary installs, so it is the same on every run. The only exemption from a budget is a named pair of a benchmark and an input that is a known memory gap, which ends by itself when the pair fits (there is none today). A time regression against the baseline is a warning (--strict-timefails it), and the other failures are the budgets, a memory regression and a closed gap.--cihas the margins of a shared runner,--out DIRkeeps the table and the rows, and a nightly workflow runs it with the budgets as written and uploads both. A benchmark is a small file instetdoc-core/src/bench(decision 0003 (on GitHub)). -
projectionandindexin the core: what an agent reads and edits.viewgives a page of a document within a token budget (the estimate is ceil(ascii / 4) plus the other characters): the whole body when it fits, otherwise its outline, or a range, a clause, the results of a find or one story, with a cursor that is the ref of the last block shown, margin notes (what a find matched, what a citation such asclause 9.1came to, what cites a clause), and the notes and comments a page cites.render_fileswrites the readable files (document.md,notes.md,headers.md,comments.md) in thestet/1format or asjson/1, andfiles::to_batchturns an edit of them into operations by comparing two drafts, in no syntax, so the alignment is written once: a changed slot is onereplace, a line removed is adelete, a line with no ref is aninsertafter the paragraph it follows, and a paragraph cut into lines or lines joined are recognized as exact splits and merges only when the text is conserved. Everything else is applied as written or refused with the file, the line and the way out (readonly_region,unsupported,cross_block,stale_files), and the files of an unedited document give no operation.render_blocksshows the blocks of a receipt as the files hold them, with warnings (looks_hidden,duplicate_numberand the rest).lookupfinds where a term is defined and used, which block isclause 4.2(a)(computed or typed, composed across lists), and what refers to a block, and returns every candidate when it is not unique. Laws M1 to M7, F1 to F8, T1 to T8, P1 to P8 and I1 to I6 run over the fetched corpus incargo xtask check, and the benchmarks time the first view, a page with notes, the readable files,to_batchand the first lookup on a 300-page contract. See the design notes (on GitHub). -
Every testkit fixture is opened with
Package::openand held to the container laws and to its expectation by a test of the core, with the row to add forState::openwhen it merges. The laws now also check that the parts a refusal atreadorxmldoes not name read and parse cleanly, whichExpect::seen_by_readingrelies on, and a part refused with another code than the index gives is named in the message, where it used to be reported as a clean one. -
The corpus cache is safe to share between checkouts and between concurrent runs: fetching holds a lock, work happens in a per-process directory, and a set is built once, under a name that carries the manifest hash, and never changed. A repair of a damaged blob builds a new generation of the set behind the stable link, so a reader of the old one sees no change. A repository that was deleted or made private fails its source alone, with a message that asks for a new pin.
-
cargo xtask codegen, which generates Word's element order and value types from the Open XML SDK's schema data (pinned, MIT), and theschemamodule that uses them: where a new child element goes (insert_in_order,reorder) and the violation set behind the no-new-violations rule (validate_part). The schema code reads the span tree (SpanTreeimplementsSchemaTree, andRefslets code that holdsNodeRefs call the rules), finds the context of an element (ctx_at), and is the one place that says which branch of anmc:AlternateContentWord reads and whatmc:ProcessContentkeeps (taken_branch,disposition). -
Mode(Final,Original,Redline) inapi::view, re-exported from the crate root: how tracked changes appear when a paragraph's text is read.Finalapplies every revision,Originalrejects every one, andRedlinekeeps both. -
wordmlin the core: the stories of a document and the blocks in them, each paragraph's text in the three modes with a map from every character to the XML it came from, and the definitions of styles and numbering. The map is the base that edits, revisions and projections build on. See the design notes (on GitHub). -
packageandxmlin the core: the ZIP container and the XML span tree. A package opens lazily, checks a part's size and CRC-32 when it is read, and writes back the input bytes exactly when nothing changed. Replacing one part rewrites that entry and moves the offsets after it, and an edit through the span tree changes only the bytes of the nodes it touched (the root start tag may gain the namespace declarations new content needs). A package is read as Python'szipfile,unzipandbsdtarread the same file, or it is refused: the central directory is found from the end record, entries lie end to end, and a ZIP64 record has to agree with the end record.Limitsbounds the archive, the entries, the compression ratio and the XML (depth, attributes, nodes, namespace declarations in scope). The laws L1 and a one-edit L2 run over the fetched corpus incargo xtask check, against a second ZIP reader and against the stage and part the index gives for each refusal. See the design notes (on GitHub). -
The span tree stores its nodes and attributes in chunks that a clone shares until it changes one, so cloning a tree for copy-on-write staging copies a few pointers and an edit copies the chunks it changes, and a tree holds what it uses where a growing vector held up to twice that. The densest benchmark inputs went from 103% of the memory budget to 70% (writing a part while its tree is held), a clone of the densest input with 50 paragraphs changed from 207 MiB to 97 MiB, and the four known memory gaps closed.
SpanTreerefuses]]>in text (malformed_input, reasonxml.cdata_end_in_text; none of 51,831 documents has one), an element holds at most 4,096 attributes, and a text node that an edit makes meet another text node reportsis_dirty, so a node that is not dirty is written exactly as it was read. -
checkin the core: the findings and the report of a check (FindingCode,Finding,CheckReportinapi::check, with stable codes and a sentence for each),check::document(every part: the schema, the Word rules for deletions,xml:space, nested revisions, unique ids, paired ranges, cells, tables, notes, empty revisions and properties, and fields cut by a deletion, and the comment audit) andcheck::edit, the check of a batch under the no-new-violations rule.editfinds the paragraphs, tables, notes and frames that a transaction changed from the span trees, with an equality test against the base so that a long session does not pay for its history, and compares each with its origin, so a problem the input had never blocks an edit, and an element the compiler renames keeps the key of its problems.schemagainedwalk_unit,read_children,is_unit_boundaryandin_taken_branch, so the Word rules read a unit as the validator does. See the check design (on GitHub). -
Under a scope,
acceptandrejectnever take a change of the body's last section properties, which name no paragraph, and the scope guard's second pass refuses any change to what lies between blocks or after the last one unless the block after it is in scope. Typed quotes read the character before the text, so'styped after a word becomes an apostrophe. Thedocument_*benchmarks time an edit, its dry run, an edit of the files, the first view andexportthroughDocument. -
The comment operations through
Document::edit:comment,reply,resolve,edit-commentanddelete-comment, anddelete-note(a note named as the view shows it,fn3). The receipt lists what each did to which comment, andreconcileruns after the operations. Under a scope a comment operation is checked by the paragraphs it is anchored to. -
The operations on properties through
Document::edit:set(any property of a paragraph, run, mark, section, table, row or cell), andformat,paragraph,styleandlistover it, with values in human units (12pt,0.5in,1.5cm). Each is a tracked property change. None of them writes or removes revision markup, and a section break throughsetis untracked only. -
The
Commentsscope ofview: one entry per comment thread, with the root's anchor, every comment's author, date and text and whether the thread is resolved, filtered by author and by resolved, and paged by the root of the last thread shown (c7). The comments story says which comment a reply answers and that its thread is resolved, and the summary line counts resolved threads. -
A JSON view gives each paragraph its
final_text, the text that the offsets of an operation count in. -
A relative cross-reference lists the candidates it was chosen over after its target, in a margin note (
relative to this block, or else p3 (...)) and in a lookup. -
comparethroughDocument: an original and a revised document into a redline whose transactioncheckverifies before it commits, with the original's content, style and numbering definitions and copied parts declared as foreign origins, so a problem they already had is carried and not refused. The receipt has the revisions by author, the moves as refs, and compare's warnings, with a warning naming each block it could not show as changes (compare_not_redlined) or that rejecting the changes does not give back (compare_not_restored), by a block of the redline or, when there is none, by the original's first words, and one naming each copy whose paragraphs and tables did not pair with the original's and so may keep any of their problems (compare_keys_merged).stetdoc comparenow writes the redline. -
laws::comparein the testkit: N5 and N7 throughDocument. A redline must passcheckwhencomparewrites it, and once saved it has no violation that neither input has after its revisions are accepted. It runs over the contract, over the compare pairs of the corpus (corpus/pairs.tsv, the 241 curated ones incargo xtask check), and over forty curated documents against an edited form of themselves, and it counts thecompare_keys_mergedwarnings. -
The benchmarks
document_open,document_save(after a fifty-operation edit),document_resume(the working copy of an edited document) anddocument_files(the readable files alone). -
The fuzz targets
batch(a batch parsed as JSON or block text, written as JSON and parsed back, then applied to a small document tracked, untracked or as a dry run) andfiles(an edit ofdocument.md), with their dictionaries and seeds. Both run in CI's fuzz job. -
The TypeScript library over Node-API (
crates/stetdoc-napi, package@clauseo/stetdocin itsnpm/directory):Documentwithopen,resume,view,edit,save,inspect,revisions,lookup,check,checkPending,filesandexport, andcompare,capabilitiesandjsonSchema. Calls are synchronous, data crosses as the core's JSON, offsets count UTF-16 code units unless an edit asks for"scalar", and every failure is a thrownStetdocErrorwith itscode,next, and theopIndex,fileandlinethe core set. A smoke test runs the same file under Node and Bun. -
stetdoc-editor: the backend of Claude's text editor tool (text_editor_20250728) over the readable files.viewshows a file with line numbers or lists the files,str_replaceneeds a unique match (otherwiseambiguous_match, listing each match with context, ornot_found),str_replaceandinsertcommit at once as one tracked edit and show the touched blocks where they now are, andcreateandundo_editareunsupported. The library offers it asDocument.textEditor(input, { author, date, pathPrefix }), which takes the input exactly as the model sends it and returns thetool_resulttext, itsisErrorflag, whether it committed and the receipt. -
The library's data types in
npm/types.d.tsare generated from the core's JSON Schema bycargo xtask codegen, and a test fails when they are stale. The library exportsversionandformatVersion, and loads in Node and Bun worker threads. -
Packaging and release: one npm package per target (
@clauseo/stetdoc-<target>, seven of them) as exact optional dependencies of@clauseo/stetdoc, with no install script;.github/, which builds the CLI and the addon for the seven targets, smoke-tests them on Bun 1.3.13, Bun 1.3.14 and Node 24, and attaches the archives, their SHA-256 files and the npm tarballs to a draft GitHub release without publishing anything; aworkflows/ release.yml napijob in CI; andcargo xtask release check, which replaces the placeholdercargo xtask releaseand keeps the packages and the workflow in step with the workspace. -
The library's documentation,
crates/, shipped in the package, and its sections instetdoc-napi/ npm/ README.md README.mdandCONTRIBUTING.md. -
stetdoc edit FILE.docx --in-place(andin_placeon the MCP edit tool) writes the result back to the .docx it read, atomically: a temporary file beside it, readable by its owner only until it gets the file's permissions, an fsync and a rename (and a sync of the folder on Unix). A link is written through, a hard link is broken, the owner is not kept, and a read-only file is refused. If someone wrote the file during the edit, the write is refused withstale_targetand the file is untouched. The meaning ofstale_targetnow covers this case; its code and exit class are unchanged. It is refused with--dry-run, with-o, and on a working copy, which an edit always writes.-onaming the input is still refused, and itsnextnow names--in-place. -
stetdoc pack DIR --done(anddoneon the MCP pack tool) removes the working copy once the .docx is written and validated. It removes only what stetdoc writes (the manifest first), so files it did not write, in the folder or in.stet/, stay with their folders and are named. A working copy named through a link has the folder it links to removed, then the link. When the pack fails, the working copy stays. -
stetdoc check FILE --alllists every finding the report keeps (the first 200), not only the first 20.
Changed
-
The MCP tools' list is 11 tools and about 4,100 tokens (16,419 characters), from 12 and about 7,200: each description says the few words a model needs, steers to one
replace,commentoreditcall on the .docx with nounpack,pack,checkor dry run, and leaves the detail toskills_getand the refusals.capabilitiesis no longer an MCP tool (skills_getteaches the operations and the error codes; the CLI keeps it), and the edit tool leaves outoffsets(decision 0013 (on GitHub)). -
stetdoc mcpanswers a call with the text the CLI prints, which is what the skill teaches and a tenth of the size of the JSON for a typical receipt;structuredContentstill carries the JSON whole. Next steps and refusals name the tool's properties (all: true,range,output), not the CLI's flags. -
stetdoc mcptakes"true"and"false"as text for a boolean argument and for a typed operation's boolean field, and a number as text for a typed operation's number field, since some clients (Gemini's among them) send them so. -
stetdoc mcpfinds a working copy or a document passed by a relative name that is not there as given in the folders of the documents the session has named or written, most recent first, socontract.stetworks afterunpackwrote it beside a document in another folder. A path to write is never moved, and--rootstill confines every path. -
Every receipt's check line reads as the completed validation it is:
validated 3 units: no problems, and this file needs no separate check.The core skill no longer carries the table of refusal codes (skills get errorshas it), andcheckis described, in the MCP tool and the skill, as for files stetdoc did not write and for hand edits. -
An
ambiguous_matchrefusal ofreplaceorcommentsays how to retry in one call:--allfor every place, or--rangewith the ref of one place, such as--range p12..p12, and that no dry run is needed. -
The npm packages move under the Clauseo organisation: the Node library is
@clauseo/stetdocwith platform packages@clauseo/stetdoc-<target>, and the CLI's platform packages are@clauseo/. The CLI itself staysstetdoc-cli-<target> stetdoc, and its release tarball isstetdoc-<version>.tgz. The Homebrew tap isclauseo-ai/homebrew-tap, so the install line isbrew install clauseo-ai/tap/stetdoc. -
Publishing a release on GitHub uploads its wheels and sdist to PyPI by trusted publishing (the
publish-pypijob ofrelease.yml, in thepypienvironment). The tag build still only makes the draft. -
packaging/README.mdand the npm package's README say how Bun users get the fast install:bun add -g --trust stetdocorbun pm -g trust stetdoc, andbunx --bun stetdocfor one-off runs. -
The skill says to use
--manualwhen the user, or a document or note they point to (such as a client email), asks for manual markup or no Track Changes, and neverview --allon a long document: page with-- more:or narrow with--findor--range.skills get coreis shorter, so every part of the skill fits in one tool output (under 25 KB), and the stub stays near 450 tokens. The previous stub isskill/earlier/stub-8.md. -
The released binaries, the npm addon and the wheels are built with a new
distprofile (fat LTO, one codegen unit, stripped), and the CLI allocates through mimalloc: the macOS binary is 7.4 MB instead of 9.9 MB, the engine benchmarks run about 8% faster, and CLI calls about 9% faster.cargo build --releaseis unchanged. -
docs/agents.mdandpackaging/README.mdrecommend a binary on the PATH (Homebrew, the install script,uv tool install) for agents that callstetdocmany times, andnpx stetdoc mcpfor the MCP server. -
A find hit or an occurrence names a clause only when its paragraph is that clause or its own text. A recital after the parties "(1)" and "(2)" names no clause and sits under its heading (
{in p15 "RECITALS"}), and a paragraph after a list item such as (g) names the clause the list is in ({§14.1}, not{§14.1(g)}). A bare label keeps its noun:{§Schedule 1}. -
A find hit in a table cell shows the other cells of its row (
{row: p36 "Greater than 4.25 to 1.00", p38 "2.25%"}), and a compact find pages by hit line, so a table with many matching cells no longer comes on one page. -
edit,replaceandcommenton a .docx with neither-onor--in-placewriteNAME.edited.docxbeside it, the namepackuses, orNAME.edited-2.docxand so on when that exists, and say so. Before, they refused. -
An empty
w:rProrw:pPr(empty_properties) is a note, not a violation: the schema allows it.checkandcheck --strictpass a document that has them, and a receipt no longer counts them as problems. -
The MCP
editandreplacetools' descriptions say to callskills_getfirst for the batch syntax and the rules. -
stetdoc edit --dry-runon a .docx with-oor--in-placewrites nothing, and says so on its first line, instead of refusing the flag. A dry run needs neither flag, asreplaceandcommentalready allowed. -
stetdoc view --findlists one line for each paragraph that matches: its ref, its clause number, and about 100 characters of its exact text around the match, under a line for each top-level clause, with a tip that names the clause to view whole when a clause heading matches.--context N(N of 1 or more) shows the blocks whole, as before. On a 75,000-character contract,--find dayswent from 11.5 KB for the first 9 of 38 hits to 5.3 KB for all 38. -
The Claude Code plugin no longer registers the MCP server. In Claude Code the skill and the
stetdoccommand are the route: Claude Code keeps MCP tools behind its tool search, so with both an agent switched between surfaces, and the server is the same binary, so it was no fallback. The Cursor and Codex plugins keep it. -
The receipt of a command that writes a .docx (
edit -o,edit --in-place,compare) carries the base of the written file, which opens in a new ref namespace, so the next batch on it can start from the receipt. When the edit added or removed blocks, the written file numbers them again: the receipt then has no base (nullin JSON) and anext:line that says to view the file. Before, the receipt's base was refused asstale_refon the written file. -
The
nextof astale_reffor a batch from another namespace says that a file rewritten since the view or receipt has new refs, and to view it again. -
compareends its receipt withnext: stetdoc view OUT --revisions, which lists the redline's changes without unpacking. -
A batch's
idsreads[57, 58]and#57, #58as well as57, 58, and a value that is not a number is refused with a message that saysidsis a comma-separated list of whole numbers. -
The agent skill says to view a .docx directly rather than unpack it to read, to use
--in-placeonly on a file the user asked to update or that the task wrote, and how to take the next base after writing a .docx. -
The default page of a view is 3,000 tokens (about 12 KB), not 8,000, in the CLI, the MCP view tool and
Budget::default(), so that one view, or two or three in one call, stays under what an agent's tool result holds. -
A receipt of
edit,packorcomparecounts the problems that were already in the input on one line, such as "66 problems were already in the input, and no new one was added:stetdoc check --alllists them.", instead of listing ten of them. The JSON still has the first ten invalidation.carried. A problem the edit would add still refuses it withvalidation_failed, naming the problems. -
The pack receipt says that the working copy stays until the folder is deleted, and that
pack --doneremoves it. The JSON hasworking_copy_removed. -
The README said that any batch written against an older state is refused. What is refused is a batch from another document or history, or one that names a block changed since its base; a batch from an older state of the same history that touches only unchanged blocks applies, as the design says (
docs/).design/ refs-and-state.md -
The first view of a large document, and the first range with its margin notes, take about half the time they did: the text model is built only for the paragraphs that hold a note reference when the notes are numbered, a UTF-8 part's names and text are not checked again on every read, a revision is looked for by its name before its namespace, and mentions of clauses are found in one pass over the words. Views, receipts and peak memory are unchanged.
Fixed
-
stetdoc checklistsmc_prefix_undeclaredfirst, on a .docx, a working copy and the MCP check tool, so a prefix thatmc:Ignorablenames and nothing declares is the first thing the check says about the input. It used to come after the part's schema findings, past the 20 that the text lists without--all, and past the 200 that a report keeps when the part had more. Its message names the part and the prefixes, says that Word refuses the file or offers to repair it, and says which prefixes stetdoc declares and which it removes when it writes the document (decision 0014 (on GitHub)). -
An input whose
mc:Ignorablenames prefixes it never declares, as python-docx leaves some templates' main part, no longer gives an output that Word refuses to open ("Word found unreadable content"). Every edit, compare, accept and reject now declares the prefixes Word knows (w14,wp14,w15, thew16family and the rest Word writes) on the element that names them and drops the ones nobody knows, in every part the check reads, touched or not, and changes nothing else. The receipt says what it repaired in a new warning,repaired, andstetdoc checkreports the defect on any document asmc_prefix_undeclared, a problem already in the input (decision 0014 (on GitHub)). The Harvey indenture of the long markup task opened in Word only after this fix. -
A run that an edit creates or cuts off (new text, the rest of a split run, a comment's anchor, a new row, compare's runs) gets its run properties in the order Word writes, even when the run it copies from has them out of order. Before, each copy repeated the defect, and the Open XML SDK validator counted every copy as a new schema error (33 new errors in one counter-redline of a credit support annex). The runs the document had keep their bytes, including the part of a split run that stays where it was.
checknow counts a property out of the order Word writes (schema_non_canonical_order) as a problem the edit added when a block has more of them than before; the ones the input had are still reported as already there. -
stetdoc edit FILE.docx --dry-runworks with neither-onor--in-place, so the two refusals no longer send an agent round in a loop. The MCPedittool'sdry_runtakes the same path. -
A missing input path is
not_foundwith the messageviewgives, for every command that takes one (replace,comment,edit,pack,check,inspect,lookup,view,unpackandcompare). Before,replaceandcommentsaid the file was a .docx that needed-o, andpacksaid a folder was not a working copy. -
The random law L2 allows a table next to a removed block to gain, in its first or last paragraph, the comment markers that the removal hoists there (design
revisions.md, 3.6.5). It failed onfloattable-anchorpos.docx, where deleting a table moved its comment into the first cell of the table after it. -
Rejecting the inserted paragraphs of a footnote, an endnote or a comment whose reference stays keeps the note's last paragraph, emptied, instead of leaving a note with no paragraph, which the check refused as
validation_failed(Revision footnotes.docx). When the reference is rejected too, the note goes with it, as before. -
The random law L2 reads
<w:p a="1" />opened as<w:p a="1" >to hold the comment markers that a deletion hoists into it as the same start tag. It took the space before the>for a changed paragraph and failed ontdf112450_vml_polyline.docx. -
Resolving a move removes the range markers of both its sides, as the design says (resolution is by move), also when an earlier rejection had emptied one side. The emptied side's markers stayed under the move's name, and
checkrefused the edit for a move with a source and no destination (Tika-792.docx). -
Rejecting a paragraph inserted after an empty last paragraph no longer fails with
validation_failedwhen the original paragraph had a problem in its attributes, such as a Word 2007 betaparaId: the paragraph that stays takes the original's attributes back, and the reject now declares that it came from the original, socheckcarries the problem. -
An operation's
addedin a receipt lists only refs the edit minted. When a block a split made was then joined into an older block by anacceptorrejectof the same batch, the joined block keeps the older ref, and the split listed that ref as added, in a dry run too, which must issue none (H14). -
An accept that leaves a range marker between blocks (the paragraph that held only a move's range start goes) no longer fails with
validation_failedwhen the marker already had a problem, such as aw:moveFromRangeStartwithout itsw:date:checkgives the body the problems of the paragraph the marker came from, as it does for any other unit. -
Accepting the deletion of a table keeps a comment's range start before its end when the markers stand at different depths (the end inside another author's insertion). The end came first, and
checkrefused the accept. -
Accepting or rejecting a move removes a deletion that held nothing but the move's range marker, instead of leaving an empty
w:delthat the compiler refused asinternalandcheckas a new violation. -
A table of contents, or another field whose result spans paragraphs, no longer collapses in a view when that hides something the view shows. In
Redlinea tracked change on the mark of the paragraph where the field ends keeps it open, so a tracked split there is shown. InFinalthat paragraph joins the next one when its mark is deleted, so the field stays open when the next paragraph has text, and takes in an empty one, as it does once the deletion is accepted. A paragraph that joins the one where the field begins is read the same way. -
The random law L5 compares each paragraph's style against its own view's common style, so a reject that changes which style is commonest in the document no longer reads as a change on every line. It tolerates exactly one empty paragraph that a reject adds as the last block of a cell whose last block was a table (W9), where it used to drop every such paragraph from both sides (
tdf153891.docx). -
A split before a paragraph's text (
at: 0, orbeforeits first word) keeps the paragraph's ref, element and attributes on the text and adds the new empty paragraph, with the new ref, before it. Rejecting the split gives back the very paragraph under its old ref; before, the text came back under the new ref and lost the paragraph'sw14:paraIdand rsids. -
A deleted paragraph is one
ParagraphDeletedchange with every piece of its deletion, a piece that holds nothing with width (a field'sbeginand code) included. Such a piece was a change of its own, so rejecting the paragraph's deletion gave back a field's result without itsbegin, and the edit was refused withvalidation_failed. -
A tracked
mergeof two list paragraphs that each hold a legacyw:numberingChangeis applied. Their change ids made their properties look different, so the second took the first's, and the copy carried the first's numbering change and its id: two tracked changes had the same id and the edit was refused withvalidation_failed. The copy now follows the copy rule and leaves out every change-tracking element. -
A tracked
seton a table, row, cell or section whose properties already break the schema (a secondw:tblLook) is applied. The copy of the old properties in the change's record carried the same problem, and the check counted it as new and refused the edit withvalidation_failed. -
compareplaces the deleted blocks at the end of a story after the last inserted table, before the paragraphs that end the story, so that rejecting everything restores the original's last paragraph. Placed before the table, the paragraph that took the inserted end mark could not join across the table that rejecting removes, and an empty paragraph was left (DB010). -
The random law L3 expects a whole-paragraph
replaceof a note's paragraph to keep the note's own number (U+0003), as every text edit does (designrevisions.md, 3.6.4). It expected the bare text and failed on a note oftdf129659.docx. -
A paragraph inserted after the last paragraph of a cell, a note, a comment or the body is written in Word's Enter shape: the anchor's mark is inserted and the new paragraph keeps the container's own, so Word's Reject All gives the original back. With an inserted mark of its own, Word left the cell an empty paragraph (hand check C11), and stetdoc's own reject now does the same for such a paragraph, as Word and LibreOffice do. Where that shape cannot go (the last paragraph's mark is a pending deletion or move) the insertion is
readonly_region.comparewrites a run of new paragraphs at the end of a container the same way. A paragraph that holds only a field and joins the next one in a view keeps its place there, with the field. -
comparenever leaves a deleted table directly against an inserted one, which Word showed with a gap that stayed after Accept All and after Reject All: a table replaced by another is one table of deleted rows and inserted rows, whatever their grids: the rows take one grid, with every column edge of both when the deleted rows would not fit the revised grid as they are. -
Deleting a whole note with
delete-notedeletes the note's own number with its text, as Word writes it (RP050): with the number left live, Word for the web kept an empty note after Accept All. A text edit of a note still keeps its number. -
A field that spans paragraphs, such as an index, collapses in the
Finalview when the only text beside it is a pending deletion, as it does once the deletion is accepted, so accepting no longer changes that view (law L5, release seed 303).Redlinestill shows the paragraphs, with the deletion. -
A text or property edit of a paragraph inside a body-level move away (
w:moveFrom) or deletion (w:del) isreadonly_region, as an edit of deleted text is, where it used to write text that the Final view does not show. -
A paragraph inserted next to a block inside a body-level move, insertion or deletion goes outside that change, before its first block or after its last, where it used to land inside it and read as empty; anywhere else inside it is
readonly_region. -
An
insertof many paragraphs costs time in proportion to them, where each new paragraph used to scan the story again (4,000 empty paragraphs took 2.2 s, now about 0.1 s). Each paragraph of aninsertnow counts as one operation towardmax_batch_ops, as each new paragraph already did on the files path, and the paragraphs of oneinsertfollow the quotes of their anchor as it was before the operation. -
A style that does not exist, in
style,format'schar_styleor an inserted paragraph, isinvalid_payloadwith the nearest names of that kind, as the error table says, and no longernot_found. -
setrefuses a property its container does not allow wherever the paragraph sits, including a paragraph inside a body-level move, where it used to be written. -
An operation that names a block of a header or a footer that an earlier operation of the batch took out of the document (an accepted deletion of the section break that named it) is refused as
stale_ref, and an edit of that footer earlier in the batch is applied and its refs retired. Both used to fail asinternal. -
A receipt's
retiredlists the ref of every block a merge or a join absorbed, asmerged into p4(or the survivor's reason, when the same edit removed the survivor too), as the design says. It used to list only blocks that left the document. -
A
deletefromrefthroughtocompares its two points in document order:startin the first paragraph may be greater thanendin the last, andtonaming the same paragraph deletes the range between the two offsets. -
checkreads a paragraph, run, table, row or cell by its own content model wherever it stands (inside a body-level move, under an unknown element), and the survivor of a join carries the problems the joined paragraphs already had. -
Accepting a merge, or any move, in a document that binds one prefix to different namespaces in different places declares the prefix on what moves, where it used to be refused as
unsupported. -
A block that a dry run creates shows its tags (
{¶+ ...}and the others) in the receipt, so it reads as the real edit's line without the ref. -
A refusal of readable files (a ref on two lines, a syntax error, a read-only line) carries its file and line in
StetError::fileandStetError::line, not only in its message, as a scope violation does. -
A merge across an imported document part (
w:altChunk) is refused, and a paragraph deleted right before one keeps its mark, so accepting all reads asFinal. -
Text put into an empty paragraph whose mark is hidden is not hidden.
-
A
w:tin aw:moveFromis no longer reported as text in a deletion. -
Paragraph ids in the
w15namespace of Word 2013's preview are read and validated asw15. -
A row inserted by one author and deleted by another is gone in
Finaland inOriginal. -
Section 9.2 of This AgreementandPARAGRAPH (a) OF THIS SECTIONcite this document, not another: the word afterofis compared in any case withthis,theseandhereof. -
The readable files are never written or read in
json/1:render_filesandto_batchrefuse it withunsupported. -
Rejecting a tracked format on a run whose properties hold a property twice (tdf104649) puts both back instead of failing with
invalid_payload. -
A paragraph added after one with a hidden paragraph mark is not hidden text, a copied content control loses its
w:dataBinding, and new paragraphs getw14:paraIdandw14:textIdonly in the Word 2010 namespace. -
A tracked deletion of an equation that already holds a tracked deletion is refused with
readonly_regioninstead of writing a deletion inside a deletion. -
The quote policy shapes only the quotes an edit types: a paragraph that mixes curly and straight quotes, rewritten with one word changed, keeps the quotes it carried over.
-
Text put into the empty paragraph after an equation does not copy the mark's
w:oMathinto its run. -
Rejecting a table whose rows were all inserted keeps a bookmark that starts in it and ends after it.
-
The old run properties in a
w:rPrChangeare checked for Word's order, so rejecting a format that removed a duplicate property (tdf168070) is not refused as adding it. -
Clearing the last property of a run removes its empty
w:rPr. -
checkcarries a copy of a defect in aw:rPror aw:pPr(a mark's properties copied onto a run, a paragraph's into aw:pPrChange) instead of refusing the edit for it. -
An untracked edit never rewrites or hides another author's pending change: text inside their insertion, properties under their formatting change, a run they inserted, or a paragraph or row that holds their changes is
readonly_region, and the message says to accept or reject that change first. Untracked still means your own changes go untracked. -
The Claude Code guard hook reads the tool call to the end when
STETDOC_GUARD=offturns it off, so Claude Code never writes into a closed pipe. Its test failed now and then in CI for that reason. -
A comment added to a package that binds
w15to the namespace of Word 2013's preview (http://) and has noschemas.microsoft.com/ office/ word/ 2010/ 11/ wordml commentsExtended.xmlno longer creates that part, which made desktop Word report unreadable content. A reply, resolve or reopen there is refused asunsupported, since a thread and its done state live only in that part. -
An insert at the very start of a paragraph goes after
w:pPrwhen an XML comment or processing instruction stands before it. It used to land beforew:pPr, and the edit was refused withvalidation_failed. -
Deleting a table that is the first or last block keeps a comment whose range runs from inside the table to a marker between blocks, as an empty-range comment like the others. It used to remove that comment.
-
The Final and Original views collapse a long field, such as a table of contents, exactly when the accepted or rejected file would: a pending paragraph mark that joins two of its paragraphs is counted as joined.
-
After a tracked split and a delete of the same paragraph, the view keeps the fields that come back into the paragraph. It used to drop them until the document was opened again.
-
A delete from the middle of one paragraph to the middle of another keeps a comment whose range starts or ends in a paragraph the delete takes whole, or at a marker between paragraphs, as an empty-range comment if all its text goes. The edit used to be refused with
validation_failed, and accepting such a tracked delete failed the same way.