AX notes · Incident

We built an edit feature and shipped it switched off: the HWP save gate that caught a form-field bug

The pre-save gate blocked both form-field saves, so we shipped 1.1.0 with editing switched off by a build flag and turned it on nine days later in 1.3.0, without form fields.

By SugarMount Testing and release 10 min read Last updated

PARAGRAPHS control record tag 71 changed. Every time someone filled a form field in an HWP document and saved it in HWP Viewer SugarDoc, the pre-save gate refused the save with that reason. On October 9, 2026, an end-to-end run on a Galaxy S20 showed it on both form-field test documents. The record diff showed the engine rewriting paragraphs the user never edited. That day we shipped 1.1.0 to the internal track with editing turned off by a build flag, and nine days later 1.3.0 turned it on without form fields.

Sources: the SugarMount app repository (sugar-utils): the device E2E report (2026-10-09), the 1.1.0 and 1.3.0 release records, the save-gate section of the design document, commits d9cd29b, 43046e0 and 1b7c328, and the work session logs. Checked on October 23, 2026.

Contents (8)
  1. Filling a field made rhwp rewrite paragraphs nobody edited
  2. The first plan trusted the engine's verify function, which only counts pages
  3. None of the 2,899 corpus edits filled a form field
  4. We shrank the feature instead of widening the gate
  5. When this approach does not fit
  6. A save-gate checklist for features that write files
  7. What to check next
  8. FAQ

Filling a field made rhwp rewrite paragraphs nobody edited

HWP is the file format of the Hangul word processor. SugarDoc edits it by running the open-source rhwp 0.8.6 engine in a WebView, and it only ever writes the result to a new file; the received original is never written. Before saving, a Kotlin verifier compares the exported bytes with the original, record by record. The set of changed paragraphs must equal the set of paragraphs the user edited.

Replaying the same edit on a PC and diffing the records showed three changes. In the filled field's control header record (CTRL_HEADER, tag 71), an attribute byte went from 0x01 to 0x80. One tag-69 record in that paragraph disappeared. Third, field-end character ids were rewritten in other paragraphs the user never touched (clk+0x0109 became %clk+0). The gate saw that and blocked the save. (The device runs typed with adb input text, so the markers were ASCII strings like ZQ…; Korean input was not checked.)

Comparison: the gate allows changes only in edited paragraphs, but the file written after filling a form field also changed field-end ids in unedited paragraphs, so both form-field documents failed to save.
The gate stopped the save at the moment the engine rewrote paragraphs nobody had touched.

On screen the filled text looked fine. Without the gate, filling one blank would have produced a file whose other paragraphs had different field data.

The first plan trusted the engine's verify function, which only counts pages

The product spec's line for release R1.1 said: save only if exportHwpVerify passes. The edit spike broke that assumption. The function compares page counts and nothing else. It returned "recovered":true when an old HWP3 document lost content and when a distribution-protected document silently lost its protection. Its loss report (contentLoss) said zero in every case.

So we wrote our own verifier. The core of the paragraph verdict, condensed from the repository code:

// HwpExportVerifier, per section (condensed from the repo)
paragraph key = (section, paragraph index[, table control / cell / cell paragraph path])
original and result paragraph counts differ        → GATE_PARAGRAPHS
for each pair a (original), b (result) at the same position:
    key or nesting level differs                    → fail
    control record differs outside the allow-list   → "control record tag N changed"
    all paragraph records identical                 → next
    a.key in edited paragraphs (ops)                → add to changed, run edited-paragraph checks
    within the edited section's re-serialization allow-list → next
    otherwise                                       → "unedited paragraph changed"
edited paragraphs - changed is not empty            → fail

// EditWiringTest (1.1.0): release builds ship with edit off
if (!BuildConfig.DEBUG) assertEquals(false, BuildConfig.FLAG_EDIT)

The allow-list came from round trips with no edits at all. One document differed in 43 LIST_HEADER (tag 72) records even when nothing was edited.

One of our own assumptions was wrong too. The first size check rejected results under half the original size. Fifteen unedited saves came out at 0.05 to 0.48 times the original with identical records and images; the engine had simply dropped empty sectors in Hancom-written files. We removed the lower bound. The check is cheap: saving one edit to a 77-page document took 3.6 s on the S20 (4.9 s in a fresh process) and 5.1 s on the S21, against a 10 s limit.

None of the 2,899 corpus edits filled a form field

Before the device run, the PC corpus looked good. In a corpus of 164 HWP5 documents, twenty random edits on each editable document passed 2,662 of 2,899 times, and no passing result hid a difference outside the allow-list. The corpus generator (edit_corpus.mjs) came in with the R1.1 implementation from the agent work session, and it produced only two kinds of edit: body text runs and table cell text. The E2E report put it plainly: the edit corpus (random run edits) had no field fills, so it missed this.

The agent that ran the edit spike had recommended "filling body form fields" as part of the scope, because the setFieldValue API existed and filled values survived a save on a PC. The device run reversed that recommendation, 2 failures out of 2.

The same hole showed up again. In the E2E before 1.3.0, typing into an empty paragraph with no controls always failed with GATE_CONTROLS, at all 7 empty paragraphs of one document. Empty paragraphs have no text runs, so random run edits could never pick them. Both times the generator picked edit positions from the text runs the engine rendered, not from the list of edit kinds the UI offers.

We shrank the feature instead of widening the gate

Do not ship a feature the gate blocks by loosening the gate. Cut the scope, or ship it behind a flag that is off. For F1, the form-field defect, we had three options:

Blocking fields did not make edit ready. Three decisions were still open: whether free users see the menu item, the price of Pro with editing, and whether Hancom Office, the suite that makes HWP, opens our files. Finding an edit target with TalkBack (edit_target_a11y) was not built yet either. Holding the small items (source-app chip, hiding other apps' overlays, a low-storage banner) until edit was ready was rejected too: the design document estimated about 3 days for those small items (R1.1a) against about 19 days for edit (R1.1b).

That left flagEdit=false in app-config.properties. With it off, the overflow menu has no Edit item, there is no edit mode, and the Pro panel drops its edit line. EditWiringTest pins the release value to false, and all 12 new guard rows failed on a tree copy with the guard deleted (the method is in 3,066 green tests hid a line that breaks opening the database: checking tests by deleting the guard). 1.1.0 passed 446 release unit tests and went to the internal track on October 9 (commit 43046e0). Minified release builds on the S20 and S21 had no Edit item.

Step diagram: Oct 9 field-fill saves fail 2 of 2, 1.1.0 ships the same day with edit off, Oct 18 1.2.0 to production, the 1.3.0 E2E finds the empty-paragraph defect, 1.3.0 turns edit on with 1,254 unit tests and no failures, and two Hancom re-saves pass after release.
Edit shipped switched off twice over nine days and was turned on in 1.3.0 with a smaller scope.

On October 18 the operator picked the three open answers: free users see Edit with a Pro badge, the price stays, and the agent checks Hancom compatibility on the S21. That evening commit 1b7c328 (39 files, +924/-72) set flagEdit=true. All 1,254 unit tests passed with no failures, and the same commit added one automatic retry when the gate fails: reopen the original, replay the edits, check again.

A person was needed in two places. Re-saving in Hancom Docs needs an account login, and the agent's rules forbid entering passwords, so the two files were checked only after the operator signed in on the S21 and turned on file access. TalkBack double-tap could not be automated because adb taps bypass TalkBack, so the operator tried it by hand.

When this approach does not fit

Without a parser that reads the output independently of the engine, there is nothing to compare records with. SugarDoc already had an HWP container parser from the viewer; the record verifier was new.

If the engine changes files even when nothing is edited, the gate blocks the whole feature, not one defect. For HWPX, 0 of 34 unedited round trips passed (rhwp dropped image references and some attributes), so HWPX editing was cut entirely. In the corpus, 8.2% of HWP5 edits were blocked, concentrated in 12 documents where the engine dropped paragraphs or reordered controls; how many of those were not real damage was never sorted out. The single retry added in 1.3.0 targets something else: failures left by undo and snapshot state, recovered by reopening the original. How often it helps has not been counted yet.

Matching structure does not prove another app will open the file. Before release we only saw the 7 output files open in Hancom Office Viewer, which cannot save; the Hancom Docs re-save was checked after the production release, for 2 files. A release build cannot turn on Pro, so "flag off plus a Pro user" was never seen on a device and is covered by tests only. The disabled edit code and strings still ship in the APK.

A save-gate checklist for features that write files

  1. Test the engine's own verify function first. Feed it inputs that lose data (old formats, protected files) and see whether it reports failure.
  2. Measure no-edit round trips across the whole corpus before anything else, and allow only the differences found there. Check "obvious" limits such as size ratios at this step.
  3. Make the rule "set of changed units = set of user edits", not "no more than the number of edits".
  4. Generate the corpus from the list of edit kinds the UI offers, and add a test that each kind appears at least once.
  5. Run device E2E for each edit kind on real form documents, and set and measure a save-time limit on the largest document.
  6. When the gate blocks, reproduce it on a PC with a record diff, then block that edit's entry point instead of widening the gate.
  7. Ship anything still blocked behind a build flag, pin the release value and "no menu, no entry point" with tests, and delete the guard once to watch those tests fail.
  8. When turning it on, change the flag line and the pinned test in the same commit.

What to check next

We have not yet counted how often reason=field fired. Whether to reopen form-field filling waits on that number. If it reopens, field-end normalization goes into the comparison and field fills plus empty-paragraph edits go into the corpus first. Re-saving the other 5 output files in Hancom, and the desktop Hangul word processor, are still open. Engine measurements for the same app are in Building an Android HWP viewer: rhwp in a WebView, measured on 222 documents.

FAQ

Can editing an HWP file in an Android app corrupt it?

SugarDoc compares the result with the original record by record before saving and refuses to save if anything changed outside the edited paragraphs. It never writes the received original; edits always go to a new file. Re-saving edited files in Hancom Docs was checked with two files on October 18, 2026.

Can SugarDoc fill in HWP form fields?

Not as of October 23, 2026. Version 1.3.0 edits body text and table cell text in HWP 5 documents; tapping a form field shows a notice that it cannot be filled yet. HWPX documents cannot be edited yet either.

How do you prove a feature flag really hides a feature in the release build?

Pin the release value with a test, add tests that the menu item and entry points are absent, then delete the guard and confirm those tests fail. SugarDoc also installed minified release builds on an S20 and an S21 and checked by eye that the Edit item was missing.

This note is written from values SugarMount measured itself; an AI coding agent was used to draft it.

All notes on Testing and release · All AX notes