Skip to content

Record what markfluence published, so a later run has a merge base - #160

Merged
willkg merged 9 commits into
mainfrom
page-moved-detection
Sep 14, 2026
Merged

willkg merged 9 commits into
mainfrom
page-moved-detection

Conversation

@willkg

@willkg willkg commented Sep 14, 2026

Copy link
Copy Markdown
Member

The plan for #149, plus its first half: internal/actionlog, written by create/update/export and read by nothing yet. The checks that consume it are the next PR — shipping the writer first means bases start accruing while that one is written, which matters because protection here accrues rather than migrating.

The problem, in one paragraph

update cannot tell "the page differs from my file because I have edits to publish" from "the page differs because somebody published first". Distinguishing those needs a merge base — what this copy was derived from — and no page-side state can hold it, because the page cannot know what a given local copy came from. The mtime skip looks like it protects against this and does not: git does not preserve mtimes, so a clone, pull, checkout or touch all look exactly like an edit, and it compares the local filesystem's clock against Atlassian's. _plans/041_page-moved-detection.md has the full argument, the scenario table, and the decisions.

Two things settled the design, and the second follows from the first

update --force means "always PUT", full stop. No logic may suppress the request; CI narrows its own file list from git state, which docs/github-actions.md already documents. That reverses a comment on #149 which had split the skip into a divergence check --force overrides and an idempotence check it does not — the latter made stateless, so a fresh CI checkout could use it. Statelessness was a requirement only because CI has no local state, so with CI on --force it buys nothing, and both checks can sit behind one if !force.

Which is fortunate, because the stateless comparison does not work. Measured against a live page: Confluence injects a fresh ac:macro-id uuid into every macro on write, and a code block is a macro — so a sent-vs-stored byte comparison fails for most real pages, not just for the HTML-comment edge case storage-format.md records. Making it work would need comments, whitespace collapse, ac:macro-id/ac:local-id and self-closing spacing, and a case it misses fails toward "differs forever", silently republishing everything.

What a line records

{"time":"...","action":"update","status":"ok","file":"docs/some-page.md","page_id":"123456789","page_version":44,"publish_sha256":"b800cc4f…","markfluence":"1.2.3"}

page_version answers divergence, publish_sha256 answers idempotence, and they are read independently so a line carrying one still answers half the question. Nothing ever compares time — deciding by clock is the defect being removed.

Sum covers exactly what the body PUT sends: the resolved title and the rendered body. The title is in because convert.ConfluencePage carries none — update passes it to UpdatePage separately — so a render-only hash would skip the publish of a file whose only change was its title. Page width, labels and attachments are deliberately out: each has its own pass that runs whether or not the body is republished, so folding them in would bump the page version for a change that never touched the body.

Rules worth knowing

Not committed, and the directory ignores itself. A shared repository is itself a declaration that the repository is the source of truth — the arrangement where --force is the answer and no base is consulted — so the log serves a local copy with the source of truth in Confluence, where per-checkout state is the right shape. A planted .markfluence/.gitignore holding * keeps it out of git without editing a .gitignore markfluence does not own.

Nothing in it may fail a command. A missing, unreadable, corrupt or half-written log degrades the check that reads it and never the run. A root with no markfluence.yaml gets no log at all, matching #139's rule that a root with no project file refuses rather than creating one.

A skip records nothing. The skip that exists today is the mtime check, which establishes only that two timestamps are ordered a certain way — a line would claim a base nothing verified and would then silence divergence detection for a page edited in the UI, which is precisely the failure #149 exists to fix. The content-based skip that replaces it does record one, because matching the page is exactly what it verified.

export takes two passes, and the split is forced. A sha taken during the walk would be wrong — a file's render depends on the link index over the whole tree, and its siblings are not on disk yet — but waiting for the end would break #139's D10. So the walk writes a version-only line per page, and a pass afterwards appends a fuller line with the sha.

Verified live

Against the personal space: create, update, then export into a subdirectory, page purged. The export's recorded sha came out byte-identical to the update's, which is the property the whole design rests on — the base export records is exactly what a later update recomputes from the same file. That was not a given; it means the round trip is a fixed point through the hash, not only through the markdown.

Reviews

A code review found three findings, all in cmd/export/actionlog.go and all the same class: the log failing invisibly. The --json one was real — ui.Hint is a no-op in JSON mode, so an unwritable .markfluence let export --json report every page as a clean success while recording no base.

A security review found nothing above the bar, but writing the question first turned up an S1 (no-write-outside-root) break in the new package, confirmed by experiment: os.OpenFile with O_APPEND|O_CREATE follows a symlink, so a symlinked log.jsonl had markfluence append JSON to a file outside the root. This was the only write in the tree not going through the root's os.Root. Fixed, with a test that plants a link out of the root and asserts the target is byte-unchanged.

That also raised the boundary the next PR must not cross, now recorded in the plan: the log is never a source of a page id, a path, or anything a request is built from. It is read-only input to two comparisons, and the page a run writes to always comes from frontmatter or a pages: entry — so a planted log can suppress a publish or provoke a refusal, and cannot redirect one.

Not in this PR

The checks themselves, deleting the mtime skip, the CONFLICT code, S8, and correcting L4 (publish-is-idempotent), which is marked Holds and is not — F2 is a counterexample and mtime is what implements it. Also unfixed and to be filed separately: client.updateLanded's body comparison can never match for a page holding a code block, for the same ac:macro-id reason — narrow, since it only runs when a PUT errors, but total when it does.

Closes-plan-for: #149.

`update` overwrites a page that has moved on since the local copy was made,
with no warning and nothing in --json a consumer could branch on. The mtime
skip looks like it protects against this and does not. Telling "I changed it"
from "they changed it" needs a merge base -- what *this copy* was derived from
-- which is a per-copy fact no page-side state can hold, so the fix is an
append-only JSONL log at the project root, uncommitted.

Two things settled the shape, and the second follows from the first.

**"update --force" means "always PUT", full stop.** No logic may suppress the
request; CI narrows its own file list from git state, which
docs/github-actions.md already documents. That reverses #149's 12:35 comment,
which split the skip into a divergence check --force overrides and an
idempotence check it does not, the latter made *stateless* so a fresh CI
checkout could use it. Statelessness was a requirement only because CI has no
local state, so with CI on --force it buys nothing and both checks sit behind
one "if !force".

**Which is fortunate, because the stateless comparison does not work.**
Measured: Confluence injects a fresh ac:macro-id uuid into every macro on write,
and a code block is a macro -- so a sent-vs-stored byte comparison fails for
most real pages rather than for the HTML-comment edge case storage-format.md
records. Making it work needs comments, whitespace collapse, macro-id/local-id
and self-closing spacing, and a case it misses fails toward "differs forever",
silently republishing everything.

So the log carries two fields answering two orthogonal questions, both checked
only without --force: page_version decides divergence and refuses the file,
publish_sha256 decides idempotence and skips the body PUT.

**The sha covers exactly what the body PUT would send** -- the resolved title
and the rendered body. A source sha misses a sibling gaining a page_id and a
converter change; a render-only sha misses a title change, since
convert.ConfluencePage carries no title. Out of the sha: page_width, labels,
attachment bytes and paths (each has its own pass, and none of those writes
bumps the page version), the version message (hashing it would make --message
"typo fix" republish the tree), page_id (its own field, so a retarget discards
the base), space/parent, and the ConfluencePage diagnostic fields.

Decisions #149 left open, resolved here:

- **The mtime check is deleted with no fallback.** Keeping it would preserve
  exactly the failure modes this issue exists to fix, for exactly the population
  that has no base -- which at first is everyone.
- **Divergence is a per-file failure** with a new CONFLICT code, not a warning
  that publishes anyway.
- **A body-unchanged skip writes a log line too**, which is load-bearing: once
  the sha does the skipping most runs skip, and a publish-only log would never
  accrue a base in a tree already published.
- **export computes its shas in a post-walk pass**, since a render taken during
  the walk would be wrong -- siblings are not on disk yet. A page it skipped
  records no line: that file is somebody's, possibly edited.
- **An unknown base warns once per run, not per file**, and the two situations
  that never self-heal -- no project root, an unreadable log -- get their own
  warning naming a remedy.
- **No command sets the base.** A set-base verb is a way to make markfluence
  believe a copy is current when it is not, which is the failure this plan
  exists to fix reached through a supported verb. --force is the honest form.

Two findings that are not this plan's to fix, both to be filed separately. L4
(publish-is-idempotent) is marked Holds and does not: F2 is a counterexample,
mtime is what implements it, and git does not preserve mtimes. And
client.updateLanded's body comparison can never match for a page holding a code
block, for the macro-id reason above -- narrow, since it only runs when a PUT
errors, but total when it does.
One log per project root, at <root>/.markfluence/log.jsonl. Nothing reads it
yet; this is the half of #149 that accrues bases so the checks that consume
them have something to work with the day they land.

It exists because nothing else can tell "the page differs because I have edits
to publish" from "the page differs because somebody published first". That
needs a merge base -- what *this copy* was derived from -- and no page-side
state can hold it, since the page cannot know what a given local copy came
from. So a publish records the version it left the page at and a hash of what
it sent, and the last successful line for a file is that copy's base.

**Sum covers exactly what the body PUT sends**, the resolved title and the
rendered body. The title is in because convert.ConfluencePage carries none --
update passes it to UpdatePage separately -- so a render-only hash would skip
the publish of a file whose only change was its title. Page width, labels and
attachments are deliberately out: each has its own pass that runs whether or
not the body is republished, so folding them in would bump the page version for
a change that never touched the body. The parts are length-prefixed so a title
cannot run into a body; that framing is part of the persisted format, and
changing it invalidates every recorded base.

**Nothing here may fail a command.** It is advisory bookkeeping: read skips a
line it cannot parse and returns what it has, an absent log is not an error,
and an unreadable one leaves every file with no base -- which means publish,
exactly what markfluence did before any of this existed. A line from a newer
markfluence carrying an unknown field is accepted rather than refused, which is
what lets a field be added later with no format version.

**Not committed**, and the directory ignores itself: a planted .gitignore
holding "*", never overwritten. A shared repository is itself a declaration
that the repository is the source of truth, which is the arrangement where
--force is the answer and no base is consulted -- so the log serves a local
copy with the source of truth in Confluence, where per-checkout state is the
right shape. Committing it would buy nothing and would conflict on every
concurrent publish.

For returns nil for a root with no markfluence.yaml and every method is
nil-safe, which is the whole of the "no root, no log" rule: Root.Dir is a
fallback there -- the file's own directory rather than a root anybody declared
-- so planting state would scatter a .markfluence per directory. Cache hands
out one Log per root so a batch reads each log once rather than once per file.
A line per file, appended from the batch loop as that file completes rather
than once at the end -- #139 D10's rule, since a run that dies partway must
leave every already-published page recorded.

The sha is taken from the same title and body handed to UpdatePage, at the
call site, so the recorded value and the published one cannot drift apart.

**A skip records nothing**, and that is the one decision here worth arguing.
The skip in this change is the mtime check, which establishes only that two
timestamps are ordered a certain way -- not that this copy matches the page.
A line would claim a base nothing verified, and would then silence divergence
detection for a page somebody edited in the UI, which is precisely the failure
#149 exists to fix. The content-based skip that replaces it does record one,
because matching the page is exactly what it verified.

Also recording nothing: --dry-run, since a preview that logged would claim a
publish happened; a file nothing claims, since there is no page for a base to
be against; and a file with no manifest key, since a key is what a line is
looked up by.

A failure records a failed line, which Base skips. It is history for
debugging, gated on having got far enough to know the page id -- a line naming
only a filename says nothing worth keeping.

A failed write is a warning and never a failure: the page is published by the
time this runs, so failing the result would report that it did not happen --
the non-fatal shape pagewidth.Apply and labels.Apply already have. The cost
lands on the next run, which sees a base trailing the live page by this very
publish and reports a divergence that --force resolves.

The three new result fields stay out of --json deliberately: they are
bookkeeping about the run, not a report about the page.
A created page is as much a merge base as an updated one: the file and the
page are in step the moment create finishes, which is exactly what a base
records.

Inside the publish loop rather than after it, for the same reason create
already writes each file's metadata as that page is published -- #139 D10, a
run that dies partway must leave every finished page recorded.

Only a successful publish records. A create that failed either left no page at
all, so there is nothing for a base to describe, or left the stub S7 names --
and that stub is finished by `markfluence update`, which records its own line
when it does. A failure line here would name a page that may have been rolled
back.

One rule from #149 turns out not to apply. It required a source sha to be
taken *after* create's frontmatter write-back, since that changes the file's
bytes. A publish sha needs no such ordering: the write-back adds a page_id,
which moves neither the title nor the rendered body.

The version recorded is version+1, where the page lands: create reserves a
content-less stub and publishes into it, so a page is at v2 when create
finishes and a base naming v1 would read as a divergence on the next run.
export is the other end of the merge base. Arrangement 2 -- Confluence is the
source of truth, a local copy is an export that gets edited and published back
-- begins here, so without a line written now the first update of an exported
file has nothing to compare against and overwrites whatever the page has
become in the meantime.

**Two passes, and the split is forced rather than chosen.** A sha taken during
the walk would be wrong: a file's render depends on the link index over the
whole tree, and its siblings are not on disk yet, so it would not be the value
a later update recomputes. But waiting for the end would break D10. So the
walk writes a version-only line as each page is written, and a second pass
after it -- index built over the finished tree -- appends a fuller line
carrying the sha. The reader takes the last successful line, and the
degrade-per-field rule covers a run that died between the two.

The second pass is self-consistent in the way that matters: it hashes our own
render rather than comparing against the page, so it does not depend on
round-trip fidelity at all and L5/L6 being Partial is irrelevant. What it does
have to match is update's inputs, which is why the title comes from the file's
frontmatter and the space key from the live page's webui link.

**A skipped page records nothing.** export skips a page whose file already
exists (S3), and that file is somebody's -- possibly edited. A base claiming
it was derived from this version is a claim export has no grounds for, and it
would silence divergence detection for exactly the file most likely to need it.

The recorder is built after writeProjectFile, deliberately: for a multi-page
export that marker is what makes dest a root at all, so resolving the log
before it would walk past dest and key every line against whatever project
happens to be above. A single-page export has no marker of its own and
correctly keys against the root it landed in, verified live.

Verified live end to end against the personal space: create, update, then
export into a subdirectory. The export's recorded sha came out byte-identical
to the update's, which is the property the whole design rests on -- the base
export records is exactly what a later update computes from the same file.
Page purged.
CLAUDE.md gains internal/actionlog: what a line is, that Sum covers exactly
what the body PUT sends and nothing else, that nothing in the log may fail a
command, and that a root with no project file gets none.

docs/root-model.md gains a .markfluence/ section beside markfluence.yaml's,
since a reader finding that directory in their tree needs to know what it is
before they need to know what it is for. The uncommitted argument is the
structural one rather than the ergonomic one: a shared repository is itself a
declaration that the repository is the source of truth, which is the
arrangement that uses --force and consults no base -- so the log serves a
local copy with the source of truth in Confluence, where another person's sync
point is irrelevant to mine.
…sible

**A failed append was silent under --json.** It went through ui.Hint, and every
ui helper is a no-op in JSON mode -- so an unwritable .markfluence (a read-only
checkout, a full disk, a file shadowing the directory) let `export --json`
report every page as a clean success while recording no base anywhere. It now
warns on the page's own result, which is a schema field and is what update and
create already do for the identical failure. That needed recordWalk and
recordShas to take the result by pointer; both are called before the result is
appended, so the warning travels. A test plants a regular file where the state
directory goes, which is the one way to make MkdirAll fail portably.

**recordShas built the link index before checking whether anything would use
it.** linkindex.Build walks and parses every .md under the *discovered* root,
not under what this run wrote -- so re-running an export over an
already-exported tree (every page skipped, so no line to write) paid for a
full-tree walk, and a single-page export into a large docs repo walked the
whole ancestor project for one file. anyRecordable gates it.

**project.Discover's error vanished.** A malformed markfluence.yaml above the
destination is fatal to update and inert here, so an export would record no
bases at all and report a clean run, leaving no hint that the project file is
why divergence detection will later find nothing. A ui.Debug line keeps
"nothing may fail the export" while making it diagnosable.
An S1 (no-write-outside-root) break in the package I just added, and the only
write in the tree that did not go through the os.Root every other path uses.

os.OpenFile with O_APPEND|O_CREATE follows a symlink. So a
`.markfluence/log.jsonl` symlinked anywhere -- planted by whoever can write the
project directory -- had markfluence append JSON to a file outside the root.
Measured rather than reasoned: the bare call wrote straight through such a link
into a file in another directory.

The attacker needs write access to the project already, which is why this is
low rather than alarming. It still matters: appending to an arbitrary path
outside the repository is a privilege they do not otherwise have, and S1 is
stated as Holds. internal/convert/images.go already refuses a symlinked image
leaf and every read is scoped by root.FS; this was the gap.

Log now holds the *project.Root and every read, write, mkdir and stat goes
through root.FS, which refuses an escape even through a symlinked intermediate
directory -- what a lexical containment check cannot see. Path() survives as
the absolute path for a message naming the file, and nothing does IO with it.

Refusing the *escape* rather than refusing symlinks outright is the right
reading of S1: a link that stays inside the root is fine, and os.Root's own
semantics are exactly that. The test plants a link pointing out of the root,
asserts Append fails, and asserts the target is byte-unchanged.
Writing the S1 test raised the question the plan had not answered: what can a
hostile or corrupt log actually do? The answer is a boundary worth stating
before PR 2 makes the log a decision input at all.

The log is read-only input to two comparisons -- a version against a version, a
sha against a sha -- and the page a run writes to always comes from frontmatter
or a pages: entry. So a planted log can suppress a publish or provoke a
refusal, and cannot redirect one. The page_id a line carries is compared, never
followed.

Also notes that the log's own file handling obeys S1 through the root's
os.Root, since the plan describes the writes and did not say what scopes them.
@willkg
willkg merged commit 4ef76cd into main Sep 14, 2026
1 check passed
@willkg
willkg deleted the page-moved-detection branch September 14, 2026 02:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant