fix(cfl): INT-732 detect formatting loss a lossy read hides - #477
Conversation
The verification added in INT-725 compares what the caller sent against what Confluence stored. When the caller read their content with --body-format adf, both sides carry the same loss and the check passes over a damaged page. Confluence's ADF representation does not always carry marks its storage representation does: em and strong wrapping a code span inside a table cell are dropped. Reading a page as ADF and writing the identical bytes back therefore destroys them. Reproduced on a scratch page, where a no-op round trip took the storage body from three em and three strong elements to one of each while the command reported success. This is not hypothetical. It happened to a real page during onboarding work: three table rows lost the bold italic that the page's own legend describes as marking rows changed in a migration, and every ADF-based check agreed the write was clean because the baseline came through the same lossy read. The storage body survives the round trip, so it is what gets watched. It is captured before the write and compared after, and element types whose counts fell are reported. A text edit moves no element counts and stays silent; losing emphasis, a code span or a link is collateral a caller rarely intends. A missing baseline reports nothing rather than reporting no loss, so a failed read never manufactures a clean result. Markdown writes are unaffected: they are not verified, so no extra read is paid for. [INT-732]
monit-reviewer
left a comment
There was a problem hiding this comment.
Automated PR Review
Reviewed commit: a55ba0020e05
Profile: claude-monit-reviewer - Posting as: monit-reviewer
Summary
| Reviewer | Findings |
|---|---|
| go:implementation-tests | 0 |
| policies:conventions | 1 |
| architecture:solid-reviewer-agnostic | 5 |
| structure:harness-engineering | 1 |
policies:conventions (1 finding)
Major - tools/cfl/internal/present/mutation.go:197
tools/cfl/internal/cmd/OUTPUT_SPEC.md calls itself "the authoritative declaration of the target cfl output contract" and its
page edit/page createsections spell out every stderr block those commands emit (normalization, content-loss, offsets, exit codes). This PR adds a new stderr block — "The stored page lost formatting that was present before this write: ..." plus the follow-up explanation line — but OUTPUT_SPEC.md is untouched. A caller or agent relying on the spec to know whatpage edit/page createcan print won't learn this warning exists, when it fires (storage-element-count regression, independent of the existing sent-vs-stored drift check), or that it is stderr-only and exits 0 (unlike the adjacent content-loss block, which exits non-zero). Add a short subsection to OUTPUT_SPEC.md'spage edit/page createentries describing this warning shape and its exit-code behavior.
architecture:solid-reviewer-agnostic (5 findings)
Major - tools/cfl/internal/cmd/page/verify.go:496
U-L1 (contracts are behavioral):
storageProfileis documented as "counts elements in a storage-format body", but<(\w[\w-]*)[\s/>]cannot match a namespaced element — afteraccomes:, which is not in[\s/>], so the match fails entirely. I ran the regex against representative storage XHTML to confirm:input: <p>x</p><ac:structured-macro ac:name="code">…<ac:plain-text-body><![CDATA[<div><strong>hi</strong></div>]]></ac:plain-text-body></ac:structured-macro><ri:page ri:content-title="T"/><!-- <em>c</em> --> result: map[div:1 em:1 p:1 strong:1]Two consequences, in opposite directions:
- Silent miss.
ac:structured-macro,ac:parameter,ri:page,ri:attachment— i.e. most Confluence-specific storage markup — are invisible to the profile. Dropping an entire macro or attachment reference across a write reports nothing, while the presenter's wording ("lost formatting that was present before this write") reads as a general verdict on the write.- False positive. Element names inside CDATA and HTML comments are counted, so editing prose inside a code block that quotes
<div>/<strong>reports invented "formatting loss".Suggested fix: widen to something like
<([\w-]+(?::[\w-]+)?)[\s/>], and — following the precedentadfContentsets at verify.go:142-145 — state the heuristic's limits in the comment (comments/CDATA are counted; this is a signal, not a parse). If quoted markup false positives matter, strip<!-- -->and<![CDATA[…]]>spans before profiling.
Major - tools/cfl/internal/cmd/page/edit.go:222
U-L2 (errors are contracts): the baseline read's error is discarded (
storageBefore, _ = ...), and a failed read then makesverifyStorageLossreturn nil, so the operator sees exactly the same output as a clean write. The check silently stops existing.That is the failure mode this PR was written to fix: the original page damage went unnoticed precisely because every signal said "clean".
readStorageBody's doc comment says callers treat "" as "cannot compare" — but nothing downstream ever tells the caller a comparison could not be made, so "cannot compare" is indistinguishable from "nothing lost" at the only place it matters, the terminal.Suggested fix: keep the write path non-fatal, but emit a warning when the baseline (or the post-write) storage read fails — e.g. carry a
baselineUnavailable bool(or an error) onverifyRequestand add a line toPresentWriteDriftsuch as "formatting-loss check skipped: could not read the page's storage body before the write". The same applies to the swallowed error at verify.go:528.
Minor - tools/cfl/internal/present/mutation.go:201
U-S1 (responsibility): the presenter asserts a cause it has not established.
verificationAppliesenables the storage-loss check for XHTML writes too, so an operator piping storage markup throughsed(the documented workflow in edit.go's own--body-format xhtmlexample) who loses an element is told "Reading a page as ADF and writing it back does not always preserve marks its storage form carries" — an explanation of a round trip they did not perform. The model already carriesd.BodyFormat, and this branch ignores it.Diagnosing why counts fell is core knowledge (it is stated once already in verify.go:484-494); the presenter's job is wording the finding. Suggested fix: word the shared part on the observation ("these element types are less frequent in the stored page than before the write"), and gate the ADF explanation on
d.BodyFormat == "adf"— or have the command supply the cause hint as a model field rather than the presenter inferring it.
Minor - tools/cfl/internal/cmd/page/verify.go:368
U-S1 (one reason to change):
verificationApplieswas introduced to own the "will this write be verified" decision, but it does not — the same policy is still encoded twice more.verifyStoredBodyre-derives it fromreq.enabledplus its own format check (verify.go:383-388), and each call site computesenabledby hand (hasNewContent && !opts.noVerifyin edit.go:247,!opts.noVerifyin create.go:192). The new helper has exactly one production caller.The drift this invites is asymmetric and silent: add a body format to
verificationAppliesand the baseline read starts happening whileverifyStoredBodystill returns early — a paid-for read with no comparison. Reverse the omission and the loss check goes quiet with no signal (see the edit.go:222 finding).Suggested fix: let
verifyStoredBodygate onverificationApplies(req.bodyFormat, req.hasNewContent, req.noVerify)and haveverifyRequestcarry those inputs instead of a pre-computedenabled, so the predicate has one home.createpasseshasNewContent: true, which reads as the fact it is.
Minor - tools/cfl/internal/cmd/page/verify.go:4
U-L2 (errors are contracts) — answering the judgement call in the PR description. I would keep exit 0: the write did land,
TextChangedalready owns "the content is wrong", and the existing normalization branch sets exactly this precedent (report, tolerate, exit 0). Flipping this one to non-zero would make the exit code mean two different things depending on which check fired.The gap worth closing is not the exit code, it is that loss has no machine-readable channel:
PresentWriteDriftemits prose to stderr, so a pipeline can only branch by string-matching the warning text, which U-L2 rules out. Suggested fix: surfaceLostElementsas data in the structured output alongside the warning, or add an opt-in--fail-on-lossso callers who want a hard stop can ask for one — rather than making every caller's exit code carry the ambiguity.Defensible either way; what would change my view is evidence that the primary consumers are non-interactive scripts that never read stderr, in which case warn-only is the same silence this PR is fixing.
structure:harness-engineering (1 finding)
Major - tools/cfl/internal/cmd/page/verify.go:527
verifyStorageLoss fetches the storage (XHTML) body unconditionally via readStorageBody, but for a --body-format xhtml edit that body was already fetched moments earlier: verifyStoredBody's own readback (getPageWithBodyFormat at line 390, called with req.bodyFormat==bodyFormatXHTML) already returns bodyValue(...,bodyFormatXHTML), i.e. Storage.Value — the exact same representation verifyStorageLoss re-fetches at line 527. So every non-no-verify edit with --body-format xhtml issues two GETs for the identical body instead of reusing
storedContentcomputed in verifyStoredBody. (For --body-format adf the two fetches are legitimately different representations, so this only applies to the xhtml path.) This directly undercuts the PR's own framing that reads are only paid for when needed (markdown skips the read entirely), and it's invisible to the added tests: verify_test.go only pins the GET count for the --no-verify and markdown-skip paths (TestRunEditNoVerifySkipsReadback, TestRunEditSkipsVerificationForMarkdown), never for the normal xhtml verify path where the duplicate actually fires. Fix: thread the already-fetched storedContent into verifyStorageLoss (or have verifyStoredBody pass it in as the 'after' body) when req.bodyFormat == bodyFormatXHTML, and add a GET-count assertion for the xhtml verify path so a future change can't silently reintroduce the duplicate.
Reviewer Coverage
go:implementation-tests— complete (broad); skipped: none; constraints: nonepolicies:conventions— complete (broad); inspected 4 assigned files (5 inspected across reviewers):tools/cfl/internal/cmd/page/create.go,tools/cfl/internal/cmd/page/edit.go,tools/cfl/internal/cmd/page/verify.go,tools/cfl/internal/present/mutation.go; skipped: none; constraints: nonearchitecture:solid-reviewer-agnostic— complete (broad); inspected 4 assigned files (5 inspected across reviewers):tools/cfl/internal/cmd/page/create.go,tools/cfl/internal/cmd/page/edit.go,tools/cfl/internal/cmd/page/verify.go,tools/cfl/internal/present/mutation.go; skipped: none; constraints: Confluence's real storage output was not available, so claims about namespaced macro markup were verified by running the new regex against representative storage XHTML locally, not against a live page. Rango test ./internal/cmd/page/... ./internal/present/...in tools/cfl with CGO_ENABLED=0 (the sandbox's inherited cgo cache path is stale); both packages pass. Reviewed the four assigned files; verify_test.go and fetch.go were read as context only and are not reported on.structure:harness-engineering— complete (broad); inspected 2 assigned files (5 inspected across reviewers):tools/cfl/internal/cmd/page/verify.go,tools/cfl/internal/present/mutation.go; skipped: none; constraints: none
Inspected files (5)
tools/cfl/internal/cmd/page/create.gotools/cfl/internal/cmd/page/edit.gotools/cfl/internal/cmd/page/verify.gotools/cfl/internal/cmd/page/verify_test.gotools/cfl/internal/present/mutation.go
0 PR discussion threads considered. 0 summarized; 0 resolved.
Completed in 7m 04s | ~$5.56 (est.) | claude-sonnet-5, claude-opus-5 | cr 0.10.286
| Field | Value |
|---|---|
| Model | claude-sonnet-5, claude-opus-5 |
| Reviewers | go:implementation-tests, policies:conventions, architecture:solid-reviewer-agnostic, structure:harness-engineering |
| Engine | claude_cli · claude-sonnet-5, claude-opus-5 |
| Reviewed by | cr · monit-reviewer |
| Duration | 7m 04s wall · 12m 13s compute |
| Cost | ~$5.56 (est.) |
| Tokens | 136 in / 49.0k out |
Per-workstream usage
orchestrator-selection— claude-sonnet-5- In: 6
- Out: 3.7k
- Cache read: 101.8k
- Cache create: 81.8k
- Cost: ~$0.39 (est.)
- Duration: 45s
go:implementation-tests— claude-sonnet-5- In: 42
- Out: 12.2k
- Cache read: 1.7M
- Cache create: 105.0k
- Cost: ~$1.08 (est.)
- Duration: 2m 43s
policies:conventions— claude-sonnet-5- In: 16
- Out: 4.2k
- Cache read: 514.8k
- Cache create: 93.1k
- Cost: ~$0.57 (est.)
- Duration: 1m 11s
architecture:solid-reviewer-agnostic— claude-opus-5- In: 50
- Out: 17.9k
- Cache read: 2.2M
- Cache create: 122.4k
- Cost: ~$2.29 (est.)
- Duration: 5m 08s
structure:harness-engineering— claude-sonnet-5- In: 16
- Out: 6.0k
- Cache read: 583.8k
- Cache create: 115.9k
- Cost: ~$0.70 (est.)
- Duration: 1m 25s
orchestrator-rollup— claude-sonnet-5- In: 6
- Out: 5.0k
- Cache read: 114.4k
- Cache create: 110.3k
- Cost: ~$0.52 (est.)
- Duration: 58s
Review round. The baseline read discarded its error, so a failed read produced exactly the output of a clean write and the check silently stopped existing — the shape of failure this change was written to remove. The comparison now reports whether it ran, and says why when it could not. Storage keeps macro bodies verbatim inside CDATA and carries comments, where angle brackets are content rather than markup; both are removed before profiling so they cannot invent losses. The cause is chosen by the format actually used. Blaming an ADF round trip for a storage write was asserting something not established: an operator piping storage markup through sed lost those elements themselves. An xhtml write already reads the stored body back, so that body is reused instead of fetching it a second time. verificationApplies now owns the decision at both call sites rather than being one of three places encoding it. Documents the new stderr blocks in OUTPUT_SPEC.md. [INT-732]
|
Six taken, one agreed-with-no-change, in The discarded baseline error was the sharpest, and you're right that it was this PR's own failure mode. CDATA/comments — correct, and it would have invented losses: storage carries macro bodies verbatim inside CDATA where angle brackets are content, not markup. Both stripped before profiling. Cause asserted without establishing it — also correct, and it's the same class as the counts finding on #474: blaming an ADF round trip for an XHTML write tells an operator who piped storage through Redundant fetch — an XHTML write already read the stored body back; it's reused rather than fetched twice.
OUTPUT_SPEC — added, both new blocks. Exit code — agreed, keeping exit 0, and your reasoning is better than mine: Re-dogfooded after the changesNot repeating #474's mistake of shipping review edits on unit tests alone. On fresh scratch pages, created and deleted:
|
monit-reviewer
left a comment
There was a problem hiding this comment.
Automated PR Review
Reviewed commit: c1da2f0ba335
Profile: claude-monit-reviewer - Posting as: monit-reviewer
Summary
| Reviewer | Findings |
|---|---|
| go:implementation-tests | 0 |
| policies:conventions | 0 |
| architecture:solid-reviewer-agnostic | 3 |
| structure:harness-engineering | 0 |
architecture:solid-reviewer-agnostic (3 findings)
Blocking - tools/cfl/internal/cmd/page/create.go:192
U-L1 (contracts are behavioral): the new "could not compare" path fires on every
page createwith--body-format adf|xhtml, including a perfectly clean one.
createpassesstorageBefore: ""(line 191) with nostorageBeforeErr, andcompareStoragetreats an empty baseline as a failed comparison (verify.go:556-557) rather than as "a create has no before-state".verifyStoredBody's early return is now gated on!storage.Unavailable(verify.go:410), so the warning block always renders. Confirmed by runningrunCreateagainst the existingcreateDriftServerwith the stored body identical to what was posted:STDERR>>>⚠ Could not compare the stored page against its state before the write, so formatting loss would not have been noticed. <<<The claim is false — nothing could have been lost, because there was nothing there before — and the reason parenthetical is empty, so the operator gets an unexplained warning on the happy path of a routine command. The cost compounds: a warning that appears on every create is one operators learn to ignore, which is precisely the desensitization this check exists to prevent. OUTPUT_SPEC.md documents this block as meaning the comparison could not be made, and says nothing about
createemitting it unconditionally.Suggested fix: make "no baseline was expected" a distinct state from "the baseline read failed" — e.g. a
storageBaselineApplies boolonverifyRequest, set byeditand left false bycreate, withcompareStoragereturning a zerostorageComparisonwhen it is false. Cover it where it broke: assert onopts.Stderrin a clean-create test (the existingTestRunCreateToleratesNormalizationonly asserts no error, which is why this shipped), and mirror it with a clean-edit assertion.(Not a regression in the fix for the settled baseline-error thread — that part is right for
edit, wherestorageBeforeErris now captured and surfaced. Only the create path needs distinguishing.)
Major - tools/cfl/internal/cmd/page/verify.go:505
U-L1 (contracts are behavioral): the CDATA/comment half of the earlier regex finding is fixed (
storageInertRE, verify.go:507), but the namespaced-element half is unchanged and now undocumented — the new doc comment explains the CDATA handling and is silent about what the profile cannot see at all.
<(\w[\w-]*)[\s/>]still cannot match<ac:…>or<ri:…>: afteraccomes:, which is not in[\s/>], so the match fails outright. Run against a page that loses an info macro and an attachment reference:before: <p>x</p><ac:structured-macro ac:name="info"><ac:rich-text-body><p>hi</p></ac:rich-text-body></ac:structured-macro><ri:attachment ri:filename="a.png"/> after: <p>x</p> profile(before) = map[p:2] lost = [p (2→1)]The macro and the attachment reference vanish and the report names neither; it names
p, a paragraph the caller lost inside the macro. So the output is not merely incomplete — for Confluence-specific markup, which is most of what distinguishes storage format from plain XHTML, it points at the wrong thing while the presenter's wording reads as a general verdict on the write.Suggested fix: widen to
<([\w-]+(?::[\w-]+)?)[\s/>]soac:structured-macro,ac:parameter,ri:pageandri:attachmentare counted. If they are deliberately out of scope, say so in the comment the wayadfContentstates its own limits (verify.go:142-145) — this heuristic is currently the only place the boundary could be recorded, and it isn't.
Minor - tools/cfl/internal/cmd/page/edit.go:222
U-S1 (one reason to change): the post-write duplicate read is gone (the
reusethread at verify.go:404-408 is correctly settled), but the pre-write baseline still re-fetches a body the command already has.
getPageWithBodyFallback(edit.go:174) fetches withBodyFormat: "storage", soexistingPage.Body.Storage.Valueis already the baseline for both the adf and xhtml non-editor paths;readStorageBodyhere asks the server for the same representation of the same version again. Measured on a clean xhtml edit through the existingdriftServer:GETS=3(pre-write fallback, baseline, post-write readback) where 2 carry all the information.Suggested fix: use
bodyValue(existingPage, bodyFormatXHTML)when it is non-empty and fall back toreadStorageBodyonly when it is not (the--editor+ adf path, where the pre-write fetch asked foratlas_doc_format). Keeping thestorageBeforeErrstates as they are, since the fallback can still fail.While you are in the tests: the reuse wiring itself is still unpinned end to end.
TestStorageComparisonReusesSuppliedBodyexercisescompareStoragedirectly, so a change to theif req.bodyFormat == bodyFormatXHTMLline at verify.go:406 would reintroduce the duplicate GET with every test still green. A GET-count assertion on the normal xhtml verify path (thedriftServeralready returns the counter) closes that and would also pin whatever this fix lands at.
Reviewer Coverage
go:implementation-tests— complete (constrained); inspected 5 assigned files (6 inspected across reviewers):tools/cfl/internal/cmd/page/create.go,tools/cfl/internal/cmd/page/edit.go,tools/cfl/internal/cmd/page/verify.go,tools/cfl/internal/cmd/page/verify_test.go,tools/cfl/internal/present/mutation.go; skipped: none; constraints: nonepolicies:conventions— complete (constrained); inspected 4 assigned files (6 inspected across reviewers):tools/cfl/internal/cmd/page/create.go,tools/cfl/internal/cmd/page/edit.go,tools/cfl/internal/cmd/page/verify.go,tools/cfl/internal/present/mutation.go; skipped: none; constraints: nonearchitecture:solid-reviewer-agnostic— complete (constrained); inspected 5 assigned files (6 inspected across reviewers):tools/cfl/internal/cmd/OUTPUT_SPEC.md,tools/cfl/internal/cmd/page/create.go,tools/cfl/internal/cmd/page/edit.go,tools/cfl/internal/cmd/page/verify.go,tools/cfl/internal/present/mutation.go; skipped: none; constraints: Findings below were confirmed by running the package under test in a scratch copy of the checkout (the review checkout was left untouched);go test ./internal/cmd/page/... ./internal/present/...passes on head with CGO_ENABLED=0. No live Confluence instance was available; storage-markup claims were verified by running the shipped regex against representative storage XHTML. Re-review of c1da2f0 against base 51de27e; the earlier round's threads are marked settled, so I verified each fix in the current code rather than restating settled points. verify_test.go was read as context but is not an assigned file, so test-only observations are attached to the production code they cover.structure:harness-engineering— complete (constrained); inspected 2 assigned files (6 inspected across reviewers):tools/cfl/internal/cmd/page/verify.go,tools/cfl/internal/present/mutation.go; skipped: none; constraints: none
Inspected files (6)
tools/cfl/internal/cmd/OUTPUT_SPEC.mdtools/cfl/internal/cmd/page/create.gotools/cfl/internal/cmd/page/edit.gotools/cfl/internal/cmd/page/verify.gotools/cfl/internal/cmd/page/verify_test.gotools/cfl/internal/present/mutation.go
0 PR discussion threads considered. 0 summarized; 0 resolved.
Completed in 5m 14s | ~$6.48 (est.) | claude-sonnet-5, claude-opus-5 | cr 0.10.286
| Field | Value |
|---|---|
| Model | claude-sonnet-5, claude-opus-5 |
| Reviewers | go:implementation-tests, policies:conventions, architecture:solid-reviewer-agnostic, structure:harness-engineering |
| Engine | claude_cli · claude-sonnet-5, claude-opus-5 |
| Reviewed by | cr · monit-reviewer |
| Duration | 5m 14s wall · 9m 59s compute |
| Cost | ~$6.48 (est.) |
| Tokens | 106 in / 36.3k out |
Per-workstream usage
go:implementation-tests— claude-sonnet-5- In: 26
- Out: 6.7k
- Cache read: 1.3M
- Cache create: 168.9k
- Cost: ~$1.14 (est.)
- Duration: 1m 52s
policies:conventions— claude-sonnet-5- In: 18
- Out: 4.4k
- Cache read: 754.0k
- Cache create: 137.3k
- Cost: ~$0.81 (est.)
- Duration: 1m 11s
architecture:solid-reviewer-agnostic— claude-opus-5- In: 28
- Out: 16.2k
- Cache read: 1.7M
- Cache create: 217.7k
- Cost: ~$2.62 (est.)
- Duration: 4m 22s
structure:harness-engineering— claude-sonnet-5- In: 30
- Out: 7.7k
- Cache read: 1.8M
- Cache create: 197.3k
- Cost: ~$1.40 (est.)
- Duration: 2m 07s
orchestrator-rollup— claude-sonnet-5- In: 4
- Out: 1.2k
- Cache read: 31.8k
- Cache create: 131.8k
- Cost: ~$0.52 (est.)
- Duration: 25s
Review round. A create has no prior state, but an empty baseline was read as a failed comparison, so every clean create with an exact body format announced that formatting loss would not have been noticed. A warning that fires on the happy path teaches the operator to ignore it, which costs more than the warning was worth. The element pattern could not match a namespaced name, so macros and attachment references were not merely miscounted but invisible: removing an ac:structured-macro moved nothing the profile could see. Namespace prefixes are matched now, and the comment says what the profile does and does not cover rather than describing only the CDATA handling. The pre-write baseline re-fetched a body the command already held. The non-editor path fetches the page with the storage representation, so that value is used and the extra read only happens when it is genuinely absent. [INT-732]
|
All three taken, in The create false-positive was the worst of the three — every clean Namespaced elements weren't miscounted — they were invisible. Pre-write re-fetch — correct, Re-dogfooded live again
|
monit-reviewer
left a comment
There was a problem hiding this comment.
Automated PR Review
Reviewed commit: 44bc3fb34fb2
Profile: claude-monit-reviewer - Posting as: monit-reviewer
Summary
| Reviewer | Findings |
|---|---|
| go:implementation-tests | 0 |
| policies:conventions | 0 |
| architecture:solid-reviewer-agnostic | 1 |
| structure:harness-engineering | 1 |
architecture:solid-reviewer-agnostic (1 finding)
Nits - tools/cfl/internal/cmd/OUTPUT_SPEC.md:270
U-G1 (documented contracts): the
page createsection forwards the reader topage edit— "exactly aspage editdoes" — and that section now describes two blocks a create can never emit. WithcomparePriorStatefalse for creates (verify.go:564), neither "The stored page lost formatting…" nor "Could not compare the stored page…" is reachable frompage create; I confirmed a clean create prints nothing on stderr.The forwarding sentence does scope itself ("the stderr blocks emitted on normalization and on content loss"), so this is not wrong so much as easy to over-read — and the distinction it elides is exactly the one that took a round to get right in the code.
Suggested fix: one clause in the create section, e.g. "a create has no prior state, so the prior-state comparison blocks in that section do not apply."
structure:harness-engineering (1 finding)
Minor - tools/cfl/internal/cmd/page/verify.go:413
The two request-dedup fixes from prior review rounds are both correctly implemented now: edit.go:224 reuses existingPage's storage body for the pre-write baseline instead of re-fetching, and verify.go:409-413 reuses the post-write storedContent for the xhtml path instead of a second readStorageBody call. Both are real, verified fixes. But neither is protected by an end-to-end regression test through the command path: TestStorageComparisonReusesSuppliedBody only calls compareStorage directly (a unit test of the dedup logic), and the runEdit-level tests that exercise the normal (verified, non-no-verify) xhtml path — TestRunEditFailsWhenStoredContentDiffers and TestRunEditToleratesNormalization — discard driftServer's *int GET counter entirely (
srv, _ := driftServer(...)). Only the no-verify and markdown-skip paths pin a GET count (verify_test.go:303, :336). So a future change to edit.go'sbodyValue(existingPage, bodyFormatXHTML)reuse or verify.go'sreq.bodyFormat == bodyFormatXHTMLreuse condition can silently reintroduce either duplicate GET (this happened once already: the post-write duplicate was the reviewed-and-fixed finding two rounds ago) with the full test suite staying green. Fix: add a GET-count assertion on TestRunEditToleratesNormalization (or a new test) asserting the normal xhtml verify path issues exactly 2 GETs (the pre-write fetch and the post-write readback, no baseline duplicate) — mirroring the pattern already used for the no-verify and markdown cases.
Reviewer Coverage
go:implementation-tests— complete (constrained); inspected 5 assigned files (6 inspected across reviewers):tools/cfl/internal/cmd/page/create.go,tools/cfl/internal/cmd/page/edit.go,tools/cfl/internal/cmd/page/verify.go,tools/cfl/internal/cmd/page/verify_test.go,tools/cfl/internal/present/mutation.go; skipped: none; constraints: nonepolicies:conventions— complete (constrained); inspected 4 assigned files (6 inspected across reviewers):tools/cfl/internal/cmd/page/create.go,tools/cfl/internal/cmd/page/edit.go,tools/cfl/internal/cmd/page/verify.go,tools/cfl/internal/present/mutation.go; skipped: none; constraints: nonearchitecture:solid-reviewer-agnostic— complete (constrained); inspected 5 assigned files (6 inspected across reviewers):tools/cfl/internal/cmd/OUTPUT_SPEC.md,tools/cfl/internal/cmd/page/create.go,tools/cfl/internal/cmd/page/edit.go,tools/cfl/internal/cmd/page/verify.go,tools/cfl/internal/present/mutation.go; skipped: none; constraints: Re-review of 44bc3fb against base 51de27e, focused on the c1da2f0→44bc3fb changes and on verifying the settled threads in the current code rather than restating them. Verified by running the package in a scratch copy of the checkout (review checkout untouched): clean create is now silent at 1 GET, clean xhtml edit is silent at 2 GETs, and the profile counts ac:/ri: elements.go test ./internal/cmd/page/... ./internal/present/...passes on head with CGO_ENABLED=0 (the sandbox's inherited cgo cache path is stale). verify_test.go was read as context but is not an assigned file, so test observations are attached to the production code whose behavior they leave unpinned.structure:harness-engineering— complete (constrained); inspected 2 assigned files (6 inspected across reviewers):tools/cfl/internal/cmd/page/verify.go,tools/cfl/internal/present/mutation.go; skipped: none; constraints: none
Inspected files (6)
tools/cfl/internal/cmd/OUTPUT_SPEC.mdtools/cfl/internal/cmd/page/create.gotools/cfl/internal/cmd/page/edit.gotools/cfl/internal/cmd/page/verify.gotools/cfl/internal/cmd/page/verify_test.gotools/cfl/internal/present/mutation.go
0 PR discussion threads considered. 0 summarized; 0 resolved.
Completed in 3m 38s | ~$7.56 (est.) | claude-sonnet-5, claude-opus-5 | cr 0.10.286
| Field | Value |
|---|---|
| Model | claude-sonnet-5, claude-opus-5 |
| Reviewers | go:implementation-tests, policies:conventions, architecture:solid-reviewer-agnostic, structure:harness-engineering |
| Engine | claude_cli · claude-sonnet-5, claude-opus-5 |
| Reviewed by | cr · monit-reviewer |
| Duration | 3m 38s wall · 8m 27s compute |
| Cost | ~$7.56 (est.) |
| Tokens | 94 in / 31.4k out |
Per-workstream usage
go:implementation-tests— claude-sonnet-5- In: 28
- Out: 8.6k
- Cache read: 1.8M
- Cache create: 225.3k
- Cost: ~$1.52 (est.)
- Duration: 2m 01s
policies:conventions— claude-sonnet-5- In: 14
- Out: 4.6k
- Cache read: 654.4k
- Cache create: 178.4k
- Cost: ~$0.93 (est.)
- Duration: 1m 13s
architecture:solid-reviewer-agnostic— claude-opus-5- In: 20
- Out: 9.1k
- Cache read: 1.4M
- Cache create: 291.2k
- Cost: ~$2.76 (est.)
- Duration: 2m 34s
structure:harness-engineering— claude-sonnet-5- In: 26
- Out: 7.7k
- Cache read: 1.9M
- Cache create: 273.5k
- Cost: ~$1.72 (est.)
- Duration: 2m 12s
orchestrator-rollup— claude-sonnet-5- In: 6
- Out: 1.4k
- Cache read: 134.0k
- Cache create: 149.1k
- Cost: ~$0.62 (est.)
- Duration: 24s
Both request-dedup fixes were unprotected: the unit test exercises compareStorage directly, and the runEdit tests on the verified xhtml path discarded the request counter, so only the skip paths pinned a count. A future change could have reintroduced either extra read without a test noticing. Also corrects the page create section of OUTPUT_SPEC, which forwarded the reader to page edit for blocks a create cannot emit now that it is not treated as a failed comparison. [INT-732]
|
Both taken, in The dedup fixes were unprotected — fair, and the gap is exactly where a regression would hide: only the skip paths pinned a request count, so the verified path could have quietly regained either extra read. OUTPUT_SPEC's create section forwarded to
|
monit-reviewer
left a comment
There was a problem hiding this comment.
Automated PR Review
Reviewed commit: 5e32dda77dc8
Profile: claude-monit-reviewer - Posting as: monit-reviewer
Summary
| Reviewer | Findings |
|---|---|
| go:implementation-tests | 0 |
| policies:conventions | 0 |
| architecture:solid-reviewer-agnostic | 0 |
| structure:harness-engineering | 0 |
Reviewer Coverage
go:implementation-tests— complete (constrained); inspected 5 assigned files (6 inspected across reviewers):tools/cfl/internal/cmd/page/create.go,tools/cfl/internal/cmd/page/edit.go,tools/cfl/internal/cmd/page/verify.go,tools/cfl/internal/cmd/page/verify_test.go,tools/cfl/internal/present/mutation.go; skipped: none; constraints: Only tools/cfl/internal/cmd/page/verify_test.go changed in this commit among the assigned files (create.go, edit.go, verify.go, and mutation.go are unchanged since the prior review round and were re-inspected for regressions, not re-reviewed from scratch).policies:conventions— complete (constrained); inspected 4 assigned files (6 inspected across reviewers):tools/cfl/internal/cmd/page/create.go,tools/cfl/internal/cmd/page/edit.go,tools/cfl/internal/cmd/page/verify.go,tools/cfl/internal/present/mutation.go; skipped: none; constraints: This round's diff only touches tools/cfl/internal/cmd/OUTPUT_SPEC.md and verify_test.go, neither of which is in this reviewer's assigned file set; the assigned Go files are unchanged since the prior round.architecture:solid-reviewer-agnostic— complete (constrained); inspected 5 assigned files (6 inspected across reviewers):tools/cfl/internal/cmd/OUTPUT_SPEC.md,tools/cfl/internal/cmd/page/create.go,tools/cfl/internal/cmd/page/edit.go,tools/cfl/internal/cmd/page/verify.go,tools/cfl/internal/present/mutation.go; skipped: none; constraints: Confirmed the new read-count test is load-bearing by reverting each dedup in a scratch copy: removing the edit.go baseline reuse and disabling the verify.go xhtml reuse each fail it at 3 GETs. Review checkout untouched. No production code changed this round — only verify_test.go and the OUTPUT_SPEC create section — so the assigned .go files were re-read for regressions rather than re-reviewed from scratch. Re-review of 5e32dda against base 51de27e, concentrated on the 44bc3fb→5e32dda changes; earlier rounds' threads were re-checked in the current code rather than restated. The read-count test pins the xhtml verified path; the adf path legitimately makes a third read for the storage body and is intentionally not covered by that assertion.go test ./internal/cmd/page/... ./internal/present/...passes on head with CGO_ENABLED=0 (the sandbox's inherited cgo cache path is stale).structure:harness-engineering— complete (constrained); inspected 2 assigned files (6 inspected across reviewers):tools/cfl/internal/cmd/page/verify.go,tools/cfl/internal/present/mutation.go; skipped: none; constraints: none
Inspected files (6)
tools/cfl/internal/cmd/OUTPUT_SPEC.mdtools/cfl/internal/cmd/page/create.gotools/cfl/internal/cmd/page/edit.gotools/cfl/internal/cmd/page/verify.gotools/cfl/internal/cmd/page/verify_test.gotools/cfl/internal/present/mutation.go
0 PR discussion threads considered. 0 summarized; 0 resolved.
Completed in 2m 01s | ~$7.36 (est.) | claude-sonnet-5, claude-opus-5 | cr 0.10.286
| Field | Value |
|---|---|
| Model | claude-sonnet-5, claude-opus-5 |
| Reviewers | go:implementation-tests, policies:conventions, architecture:solid-reviewer-agnostic, structure:harness-engineering |
| Engine | claude_cli · claude-sonnet-5, claude-opus-5 |
| Reviewed by | cr · monit-reviewer |
| Duration | 2m 01s wall · 4m 09s compute |
| Cost | ~$7.36 (est.) |
| Tokens | 58 in / 12.8k out |
Per-workstream usage
go:implementation-tests— claude-sonnet-5- In: 16
- Out: 4.8k
- Cache read: 1.1M
- Cache create: 276.8k
- Cost: ~$1.43 (est.)
- Duration: 1m 13s
policies:conventions— claude-sonnet-5- In: 8
- Out: 2.2k
- Cache read: 325.4k
- Cache create: 215.3k
- Cost: ~$0.94 (est.)
- Duration: 37s
architecture:solid-reviewer-agnostic— claude-opus-5- In: 14
- Out: 3.2k
- Cache read: 1.0M
- Cache create: 341.3k
- Cost: ~$2.73 (est.)
- Duration: 1m 15s
structure:harness-engineering— claude-sonnet-5- In: 14
- Out: 2.0k
- Cache read: 1.0M
- Cache create: 337.5k
- Cost: ~$1.61 (est.)
- Duration: 46s
orchestrator-rollup— claude-sonnet-5- In: 6
- Out: 478
- Cache read: 138.8k
- Cache create: 160.2k
- Cost: ~$0.65 (est.)
- Duration: 16s
[INT-732]
The verification from #474 compares what the caller sent against what Confluence stored. If the caller got their content from
cfl page view --body-format adf, both sides carry the same loss — so the check passes over a damaged page.The loss is real and reproducible
Confluence's ADF representation doesn't always carry marks its storage representation does.
em/strongwrapping acodespan inside a table cell are dropped. So reading a page as ADF and writing the identical bytes back destroys them.Reproduced on a scratch page — a no-op round trip, nothing changed:
<em><strong>page editreported success with no warning.This already happened to a real page: three table rows lost the bold-italic that the page's own legend describes as "Changed-from-Lambda rows are bold italic and marked — CHANGED". Every ADF-based check agreed the write was clean, because the baseline came through the same lossy read. It was only caught by comparing the storage body.
Fix
The storage body is what survives the round trip, so that's what gets watched: captured before the write, compared after, and element types whose counts fell are reported.
verificationAppliesgates it, and a test pins the request count).Dogfood — against real Confluence, with the built binary
I did not do this properly for #474: I shipped on unit tests plus one no-op live call, and the very first real use exposed this blind spot. So this time, on a scratch page created and deleted for the purpose:
Detects the real loss:
No false positive — an ADF text edit on a page with no nested emphasis completed silently, and the intended edit landed in both cases.
make test(61 packages) andmake lint(3 modules) clean.One judgement call for you
This warns and exits 0 rather than failing. Rationale: the write succeeded and the content is intact, so failing would misrepresent what happened. The counter-argument is that a pipeline checking only the exit code would corrupt formatting across many pages silently — which is exactly how the original damage went unnoticed. Happy to flip it to non-zero if you'd rather;
--no-verifyalready exists for callers who don't want the check.