From c11594747a5b0e11a63ba961ff9ce20cea5f0cd6 Mon Sep 17 00:00:00 2001 From: Will Kahn-Greene Date: Fri, 25 Sep 2026 10:30:29 -0400 Subject: [PATCH 1/3] docs: the update-moves-pages plan Plan for #10: update refuses a page in another space and moves a page to its declared parent. --- _plans/053_update-moves-pages.md | 214 +++++++++++++++++++++++++++++++ 1 file changed, 214 insertions(+) create mode 100644 _plans/053_update-moves-pages.md diff --git a/_plans/053_update-moves-pages.md b/_plans/053_update-moves-pages.md new file mode 100644 index 0000000..ee0bff0 --- /dev/null +++ b/_plans/053_update-moves-pages.md @@ -0,0 +1,214 @@ +# 053: update moves a page to its declared parent + +Answers #10. `update` publishes a file's body, title, width, labels and status, +but ignores its `space` and `parent`: it takes the space from the live page and +never looks at the parent. That breaks **L9** (`declared-metadata-is-asserted`) +for the two coordinates, and `diff` has to say so in its help ("update does not +move a page to a different space or parent"). This plan makes `update` assert a +declared `parent` by moving the page, and refuse a declared `space` that the +page is not in. + +Two parts of the issue are obsolete: `fix` was removed in #151, and +`update --dry-run` exists. + +## What the probe established + +**Verified 2026-09-25** on scratch pages under `markfluence roots smoke test` +in the personal space, through the gateway, with a personal token. Every probe +page was trashed afterwards. + +| probe | result | +|---|---| +| v2 `PUT /pages/{id}`, version+1, same body, new `parentId` | 200. The page moves, **and its version goes up by one**. It lands last among the new parent's children. | +| the same, version unchanged | 409 `Version must be incremented`. A v2 move always bumps the version. | +| v2 `PUT`, version+1, `parentId: null` | 200, and nothing changes. **v2 cannot move a page to the top of a space.** | +| v2 `PUT`, version+1, body and `parentId` both changed | 200, one version bump, both applied. | +| v2 `PUT`, version+1, nothing changed | 200, and the version does **not** go up. | +| v1 `PUT /rest/api/content/{id}/move/append/{pageId}` | 200 `{"pageId": ...}`. Moves under a page, last. **Version unchanged.** | +| v1 `move/append/{folderId}` | 200. Moves into a folder. Version unchanged. | +| v1 `move/after/{rootPageId}` | 200. Moves to the top of the space (`parentId` null). Version unchanged. | +| a page with a child, moved by either route | The child moves with it. | +| target is the page's own descendant (v2) | 400 `Cannot move the content here as it creates a parent-child loop.` Nothing changes. | +| target does not exist (v2) | 404 `Cannot find content with id [1]`. Nothing changes. | + +**Not verified:** whether a move in the Confluence UI bumps the version (it +probably does not, like the v1 route), and whether v1 `move` needs a scope +beyond `write:confluence-content`. + +## Decisions + +**D1. A space mismatch fails the file, and nothing moves between spaces.** The +declared space is the file's `space:` (frontmatter or `pages:` entry), **or +the project's `space:` default** in `markfluence.yaml` when neither declares +one. If the page is in another space, the file fails before any write, with +`CodeValidation`. The error names the page's space, the declared space, and +which location declared it (`pagemeta.Origin`, or the project default), and +says that markfluence does not move pages between spaces: move the page in +Confluence, or correct the declaration. + +The project default counts because a project that says `space: ENG` and a page +somewhere else is a mistake whose intent markfluence cannot know. A move across +spaces is out of scope: it needs write access to both spaces (possibly admin), +it moves the whole subtree, it can change permissions, and the one workflow +raised (drafts in a personal space, then overwriting pages in a team space) +changes the page ids and so is not a move at all. + +**D2. A declared `parent` is asserted by moving the page.** The comparison is +of resolved ids, as `diff` already does: + +- an id is used as given (a page or a folder); +- a `.md` path resolves to that file's `page_id`, read through the root the way + `create` reads it; an unpublished parent file fails the file; +- `parent: null` (and every null spelling, `~` and an empty value included) + means **the top of the space**; +- an **absent** `parent` leaves the page where it is (L9). + +A key that is present with a blank value is a declaration. `pagemeta.Resolve` +already keeps it in `Fields` as `""`, so the test is whether the key is +present, not whether it is non-blank. `create` already reads a blank `parent` +as the top level, and `create` writes `parent: null` back for a top-level +page, so the two verbs agree. When the frontmatter says `parent: null` and the +`pages:` entry names an id, the id wins, since `pagemeta` treats a blank as +silence when two locations are compared. + +The risk accepted here: a half-typed `parent:` moves the page and its subtree +to the top of the space. A move is easy to undo, unlike the status write L9's +Accepts refuses a blank for. + +**D3. Every move uses the v1 move route.** It is a separate write that never +changes the page version, so: + +- the moved-page check (S8) and the log are unaffected: the version the body + `PUT` returns is still where the page ends up; +- the "body unchanged, skip the `PUT`" path (L4) is unaffected: a move-only + change sends no body; +- it is the only route that reaches the top of the space. + +The target: + +- a page or folder parent: `move/append/{parentId}`; +- the top of the space: `move/after/{lastRootPage}`, the last of + `ListSpaceRootPages` in position order. The homepage is a root page, so the + list is never empty for a page that is not already a root. + +**D4. A moved page goes last among its new siblings.** That is what `create` +already does and what the UI does for a new child page, it needs no extra +request for a parent, and it keeps a batch in its own order. markfluence never +reorders siblings after that. The help of `create` and `update`, and the +`parent` section of `docs/markdown-file.md`, say so and point at the +Confluence UI for reordering. + +**D5. Order within `update`.** After the page fetch: + +1. the space check (D1), before the divergence check, since a page in the + wrong space is the more basic error; +2. the divergence check (S8), unchanged; +3. resolve the parent (D2) and, when it differs from the live one, check the + target: it exists, it is a page or folder in the page's space (`create`'s + `checkParentInSpace`), and it is not the page itself or one of its + descendants (the target's ancestors, one request, only when moving); +4. the render and the idempotence check, unchanged; +5. `--dry-run` stops here and reports the move; +6. **the move**, before attachments and the body. A failed move fails the + file, unlike a failed width or label write: those run after the page is + published, while the move runs first and nothing else has been written. + +A parent that cannot be resolved or checked fails the file before any write, +also under `--dry-run`. + +**D6. Output.** A human line when a move happens: +`moved under "Runbooks" (placed last; reorder in Confluence if needed)`, or +`moved to the top of the space (placed last; ...)`, with "would move" under +`--dry-run`. `--json` gains a `moved` field on each result: `null`, or +`{"from": id|null, "to": id|null}`, with `null` meaning the top of the space. +The schema gets the field and conformance tests cover both shapes. A new key +is a compatible change since #200 (`_plans/054`, docs/json-output.md), so +`schema_version` stays 1. + +**D7. `diff` follows.** + +- `parent: null` becomes a declaration it compares (top of the space against + the live parent), since `update` now acts on it. +- The space comparison includes the project default, to match D1. +- The notes "update does not move a page between spaces / to a new parent" go. + The space note becomes "update refuses a page in another space". The help + paragraph about neither field being changed is rewritten. + +## Implementation + +**`internal/client`:** `MovePage(pageID, position, targetID string) error`, a +v1 `PUT /wiki/rest/api/content/{id}/move/{position}/{targetID}` through +`send`. The positions used are `append` and `after`; the method takes the +position rather than having two methods, and refuses any other value. The +request is safe to retry, since a repeated move to the same place is a no-op. +Plus `Ancestors(pageID)` if nothing already reads them (v2 +`/pages/{id}/ancestors`). + +**A shared parent resolver.** `create`'s `resolveParent`/`checkParentInSpace` +and `diff`'s `parentPageID` already hold two copies of ".md parent → page id +through the root", and `update` would make a third. Move the shared part into +`internal/parentref`: + +- `PageID(root, fileDir, ref) (string, error)`: read a `.md` parent through + `root.FS` via `pagemeta`, refusing an escape and a symlink, and returning `""` + for an unpublished parent; +- `Kind(c, id, spaceID) (string, error)`: `checkParentInSpace`, answering + `"page"` or `"folder"`. + +`create` keeps its own in-set and `--parent` handling on top. `diff` keeps its +lenient reporting by turning the errors into notes, as it does now. + +**`cmd/update`:** D1, D5, D6. `updateResult` gains the move; `json.go` reports +it. + +## Files + +| file | change | +|---|---| +| `internal/client/client.go` | `MovePage`; `Ancestors` if needed | +| `internal/parentref/` | new: `PageID`, `Kind` | +| `cmd/create/create.go` | use `parentref`; help note on positioning (D4) | +| `cmd/update/update.go`, `json.go` | space check, parent resolution, move, output; help | +| `cmd/diff/frontmatter.go`, `diff.go` | D7 | +| `schema/json-output/v1.json` | `moved` on update results | +| `docs/confluence/api.md` | the probe table; replace the "Not verified" line about moves; the scope row for `move` | +| `docs/markdown-file.md` | `space` and `parent` rows and the `parent` section: what `update` does, and positioning | +| `docs/json-output.md` | `moved`, if it needs more than the schema says | +| `docs/commands/` | `make docs` | +| `CLAUDE.md` | `internal/parentref` bullet; `MovePage` in the client bullet; "Frontmatter-driven publishing" says where the move sits in `update`'s order | + +## Tests + +- **Space (D1):** a file declaring another space fails with no write; the same + for a project default with no file declaration; a file declaring the page's + own space passes; the error names the declaring location. +- **Parent (D2):** an absent `parent` makes no move request and no parent + lookup; a matching parent makes no move request; a differing id, a `.md` + path, a folder, and `null` each make one move request with the right + position and target; `null` on a page already at the top makes none; an + unpublished `.md` parent, a parent in another space, a missing parent, and a + descendant each fail with no write. +- **Order (D3, D5):** a move-only change moves and skips the body `PUT` with a + base present; the recorded version is unchanged by a move; a failed move + fails the file with no attachment or body write; `--force` still moves. +- **Dry run:** reports the move, sends no move request. +- **`--json`:** `moved` null and set, validated against the schema. +- **`diff`:** `parent: null` against a page with a parent is a difference; the + project default space is compared. +- **`parentref`:** the escape, symlink, unpublished and entry-held-`page_id` + cases that `create` and `diff` each test today, once. + +## Not in scope + +- **Moving between spaces** (D1). It waits for a use case, and the one raised + is not a move. +- **Managing sibling order.** It would need a declared order, since L8 rules + out inferring one from file names or directories, and under L9 every + declared order is asserted, so a reorder done by hand in the UI would be + undone by the next publish of any sibling. Nobody has asked for it. +- **Detecting a move done by hand in the UI.** A file with a stale `parent:` + moves the page back without refusal, because a move (probably) does not bump + the version the divergence check reads. That is L9 working as written, and + it is how labels already behave. +- **Moves in `create`.** `create` places a new page where the file says, which + is already correct. From 045ffe79207ca846354a87d5d416a071a6317931 Mon Sep 17 00:00:00 2001 From: Will Kahn-Greene Date: Fri, 25 Sep 2026 10:42:18 -0400 Subject: [PATCH 2/3] feat(update): move a page to its declared parent, refuse another space update published the body, title, width, labels and status but ignored space and parent, which left the two coordinates outside L9. A declared parent is now asserted by moving the page: an id or a .md path moves it under that page or folder, parent: null (or ~, or an empty value) moves it to the top of the space, and an absent parent leaves it alone. A moved page goes last among its new siblings; markfluence does not manage sibling order. Every move uses the v1 move route, which leaves the page version alone where a v2 parentId change bumps it, so a move stays out of the moved-page check and the unchanged-body skip, and v1 is the only route that reaches the top of a space. Every read the move needs happens before any write, so --dry-run reports a move that would fail: the parent resolves, is a page or folder in the page's space, and is not the page itself or below it. The loop check walks the target's parentId chain, since v2's ancestors route needs a scope nothing else here needs. The move is the first write, and a failed move fails the file. A space is a refusal, never a move: a page outside the file's space, or outside markfluence.yaml's space: default when the file names none, fails before any write. --json gains moved on update results, null or {from, to}; a new key is compatible since #200. diff compares parent: null and the project default space, to match. The ".md parent -> page id" code create and diff each had is now internal/parentref, which update uses too. Fixes #10. --- CLAUDE.md | 11 +- _plans/053_update-moves-pages.md | 22 ++ cmd/create/create.go | 127 ++------ cmd/create/create_test.go | 4 +- cmd/create/parent_test.go | 2 +- cmd/diff/diff.go | 9 +- cmd/diff/diff_test.go | 29 ++ cmd/diff/frontmatter.go | 95 +++--- cmd/update/json.go | 26 +- cmd/update/json_test.go | 1 + cmd/update/move.go | 133 +++++++++ cmd/update/move_test.go | 430 +++++++++++++++++++++++++++ cmd/update/update.go | 41 ++- docs/commands/markfluence_create.md | 4 +- docs/commands/markfluence_diff.md | 10 +- docs/commands/markfluence_update.md | 16 +- docs/confluence/api.md | 48 ++- docs/json-output.md | 6 + docs/markdown-file.md | 20 +- internal/client/move.go | 36 +++ internal/client/move_test.go | 45 +++ internal/pagemeta/pagemeta.go | 28 ++ internal/pagemeta/pagemeta_test.go | 55 ++++ internal/parentref/parentref.go | 219 ++++++++++++++ internal/parentref/parentref_test.go | 164 ++++++++++ schema/json-output/v1.json | 16 +- 26 files changed, 1405 insertions(+), 192 deletions(-) create mode 100644 cmd/update/move.go create mode 100644 cmd/update/move_test.go create mode 100644 internal/client/move.go create mode 100644 internal/client/move_test.go create mode 100644 internal/parentref/parentref.go create mode 100644 internal/parentref/parentref_test.go diff --git a/CLAUDE.md b/CLAUDE.md index eea05b8..6be08ac 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -54,7 +54,7 @@ Module `github.com/mozilla/markfluence` (`go 1.25`). `main.go` is a shim to `cmd ### Layout - `cmd/root.go` — the cobra root: `--env-file`/`--root`/`--debug`/`--no-color`/`--json` persistent flags, version from `internal/buildinfo`, and registration of every subcommand. `Execute()` prints cobra-generated errors (bad args/flags) but not `ui.ErrSilent`, which marks a failure a command already reported. -- `cmd/{update,create,check,diff,pageinfo,spaceinfo,read,export,children,find,search,userfind,userinfo}/` — one package per command (each exports `Cmd`), orchestrating the `internal` packages and `internal/ui` output. `create` is three-phase and transactional (preflight all, reserve parents-first in topological order, then publish); `check` is read-only on both the server and disk and touches neither, since it never constructs one (see its own bullet below). `create` accepts a **page or a folder** as `parent`: `checkParentInSpace` asks the page route, then `/folders/{id}`, and finding nothing as a page proves nothing until both have been asked — that single missing fallback was #68, since Confluence itself accepts a folder `parentId` with no other accommodation. It also validates a frontmatter `page_id` **first** (before space/parent/title lookups: most specific error, three fewer API calls) and treats all three outcomes as failures — non-numeric, resolves to nothing, or already taken. The middle one is the bug from #18: publishing anyway would create a second page and overwrite the id, so an id that can't be explained is never "create a new page". Both "already exists" errors (page_id taken, title clash) link the page in the way; the `pageIDFailure` typed error carries `page_id`/`url` so `--json` reports them as fields, which is the only case where a failed result names a page. Preflight also **converts every file and throws the page away**, keeping only the error (#127/S7): a defect the converter refuses is a property of the file on disk, so asking before the reserve phase is what keeps it from leaving a content-less page and a `page_id` the author has to undo by hand. The result cannot be reused by `publishOne` — reserve seeds the batch's ids into the shared link index in between, so an in-set link renders unresolved in preflight and resolves in publish, and phase 1's `Broken`/`Warnings` are discarded for that reason. The *error* is identical across the two, for a narrower reason than "the converter ignores the index": whether `renderImage` runs at all does depend on it (`renderLink` skips a broken link's children, and `Broken` is decided by `FileExists`), but reserve only calls `SetPage`, which writes `idx.pages` alone — nothing there can raise an error or change one's text, and `FileExists`/`Anchor` read `idx.anchors`, fixed at `Build` time. Making `SetPage` also mark a file as existing would break it; pinned by `TestErrorDoesNotDependOnTheIndex`. It is called **last**, after every server check, so `page_id`-first precedence is untouched, and its failure carries `CodeConvert` on the `failure` struct rather than `abort()`'s old hardcoded `VALIDATION`. +- `cmd/{update,create,check,diff,pageinfo,spaceinfo,read,export,children,find,search,userfind,userinfo}/` — one package per command (each exports `Cmd`), orchestrating the `internal` packages and `internal/ui` output. `create` is three-phase and transactional (preflight all, reserve parents-first in topological order, then publish); `check` is read-only on both the server and disk and touches neither, since it never constructs one (see its own bullet below). `create` accepts a **page or a folder** as `parent`: `parentref.Lookup` asks the page route, then `/folders/{id}`, and finding nothing as a page proves nothing until both have been asked — that single missing fallback was #68, since Confluence itself accepts a folder `parentId` with no other accommodation. It also validates a frontmatter `page_id` **first** (before space/parent/title lookups: most specific error, three fewer API calls) and treats all three outcomes as failures — non-numeric, resolves to nothing, or already taken. The middle one is the bug from #18: publishing anyway would create a second page and overwrite the id, so an id that can't be explained is never "create a new page". Both "already exists" errors (page_id taken, title clash) link the page in the way; the `pageIDFailure` typed error carries `page_id`/`url` so `--json` reports them as fields, which is the only case where a failed result names a page. Preflight also **converts every file and throws the page away**, keeping only the error (#127/S7): a defect the converter refuses is a property of the file on disk, so asking before the reserve phase is what keeps it from leaving a content-less page and a `page_id` the author has to undo by hand. The result cannot be reused by `publishOne` — reserve seeds the batch's ids into the shared link index in between, so an in-set link renders unresolved in preflight and resolves in publish, and phase 1's `Broken`/`Warnings` are discarded for that reason. The *error* is identical across the two, for a narrower reason than "the converter ignores the index": whether `renderImage` runs at all does depend on it (`renderLink` skips a broken link's children, and `Broken` is decided by `FileExists`), but reserve only calls `SetPage`, which writes `idx.pages` alone — nothing there can raise an error or change one's text, and `FileExists`/`Anchor` read `idx.anchors`, fixed at `Build` time. Making `SetPage` also mark a file as existing would break it; pinned by `TestErrorDoesNotDependOnTheIndex`. It is called **last**, after every server check, so `page_id`-first precedence is untouched, and its failure carries `CodeConvert` on the `failure` struct rather than `abort()`'s old hardcoded `VALIDATION`. - `cmd/credentialsinit/` — `credentials-init` (#193, `_plans/051`): prompt for the URL, username and token, check them, and write the credentials file. It writes **only** that file, never anything in the working directory — project setup is #5's `init`, and a person fixing auth from `~` must not get a `~/markfluence.yaml` that becomes the root for every Markdown file under their home. Four things decide its shape. **A failed check is classified by the response's shape, never its status**: a rejected credential, a site-domain HTML 401 (a scoped token with no cloud ID), any other 401/403, and a non-credential 404 refuse and write nothing, while a scope-mismatch 401 (the token *authenticated* but lacks `read:confluence-user`), a 5xx, or no response at all asks `Save anyway?` — the shapes are `HTTPError`'s own predicates, so the check and the error hint cannot disagree. **The cloud ID is fetched, never asked for**, and only for `*.atlassian.net` (see Configuration). **It refuses rather than degrades**: no terminal on stdin *or* stderr (the prompts go to stderr), `--json`, `--env-file`, and no credentials path are each a plain returned error, because `Execute` turns that into an `errorObject` under `--json`, where a `ui.Error` would print nothing; it is on `noJSONEnvelope`. And **the terminal is the only code touching a tty** (`terminal.go`): `runInit` takes a `prompter` and a `deps` of stubs, so every decision is tested with a scripted prompter. `terminal.go` reads lines a byte at a time (a `bufio.Reader` would swallow typed-ahead input `ReadPassword` then never sees) and restores the terminal on SIGINT/SIGTERM/SIGHUP during the token read, since `ReadPassword` leaves `ISIG` set and its own `defer` never runs on a signal. An existing file prefills every prompt (never showing the token), but **only for the same site**: the current token and cloud ID are offered only when the normalized URL is unchanged, since keeping them for a new URL pairs one site's token with another's — the pairing the same-source rule forbids. The file is rewritten whole, and the first line says so and what will be dropped (comments, non-settings), with `Ctrl-C to exit`, from one read (`client.ReadCredentials` returns a `CredentialsFile`: values, counts, mode). A scope-mismatch 401 counts as *checked* (the token authenticated). The environment is reported before the first question. All of its output goes to stderr beside the prompts (`ui.InfoStderr`/`SuccessStderr`), so `> log` cannot hide a status line while the prompts still show. - `cmd/check/` — `check` (#42): validate one or more Markdown FILEs against the converter and frontmatter rules with **no network access, no credentials, and no writes** — the first command whose `run()` never constructs a `client.ConfluenceClient` (`root.go`'s `PersistentPreRunE` doesn't force one into existence either, so nothing upstream requires it). It builds `root`/`index` per file exactly like `update`/`create` (`internal/project.Cache`/`internal/linkindex.Cache`), against hardcoded `baseURL`/`spaceKey` (the regression suite's own `https://wiki.example.net`/`ENG`) rather than flags — both are read only to build a rewritten doc-link's *text*, and nothing in `Broken`/`Warnings` reads either, so hardcoding them costs nothing and makes `check` byte-identical across machines. Frontmatter validation is deliberately narrow: an unparseable/unterminated block (`frontmatter.ErrUnterminatedFrontmatter`, plus everything real YAML now refuses — a nested value, a `|` block, a duplicate key, a tab indent, a reserved indicator), an invalid `page_width` (`pagewidth.Declared`), a present-but-non-numeric `page_id` (`pageref.IsDigits`), a **present-but-empty `title`**, and a **present-but-empty `page_status`** — never whether `page_id`/`space`/`parent` are set at all, since `check` cannot know whether the caller is about to `create` or `update`, and a false positive there is worse than a miss. `title` is the one exception to that reasoning and only in its present-but-empty form: `create` and `update` both reject it, so no verb makes it valid and there is no false positive to have. An *absent* title stays unreported, since `update` accepts one and keeps the live page's title. `page_status` is the first field whose *vocabulary* `check` cannot reach at all — a space's statuses are that space's own configuration, read from Confluence — so it checks the shape and its `Long` says so, because a green `check` otherwise reads as a promise the status will publish. A `broken` result is `ok: false` with `error`/`code` both left `null`: unlike every other failure, `broken`/`warnings` already say everything there is to say, so `code: VALIDATION` is reserved for `status: failed` (a file that never reached the converter at all). `--show-html` surfaces `ConfluencePage.HTML`/`Attachments` — nothing else in the CLI ever prints either — as `debug: {html, attachments} | null`; `html` stays compact/unindented in `--json` (matching what `update`/`create` would literally publish) while human output indents it by nesting depth (`indentHTML`, a per-line indent based on tag-open/close counting, not a whitespace-normalizing reformat, since the renderer already breaks lines at every structural boundary and reformatting within a line could alter meaningful inline text). - `cmd/diff/` — `diff` (#154): what differs between one file and its page, fetching the page and rendering it to Markdown the way `export` does. Writes nothing, to disk or to Confluence. **One file**, deliberately the opposite of `update`/`check` — a diff over a tree is output nobody reads, and #148's `status` is the tree-wide view. Four things decide its shape. **The output is two things on two streams**: stdout carries the body as a unified diff and *nothing else*, so `diff FILE > my.diff` is a patch — applied with `patch -R -p1`, since the file on disk is the `+++` side, or a plain `patch -p1` from `diff --reverse` — while stderr carries the frontmatter half as a per-field report (`frontmatterReport`). The split is not presentational — a value difference is a one-line fact rather than a hunk, and only a report can carry the **provenance** (`pagemeta.Resolved.Origin`, via `sourceLabel`) that makes "the title differs" say which file to edit; keeping stdout to one document is `children --space`'s hint rule with a stronger case. **The frontmatter block is shared between the two diffed documents byte for byte** rather than diffed (`documents`), which is what makes hunk line numbers file-relative rather than body-relative (a `patch` "succeeded at 23 (offset 18 lines)" fudge otherwise), keeps the local side the file's *exact bytes* so a patch can apply to it — never normalized, so a missing final newline stays the `\ No newline at end of file` marker unified diff has for it — and means no patch can rewrite a `page_id`; the author's blank line after the delimiter belongs to that shared prefix, or a file with two diffs against a rendered side with one. A **label is recognised by its position and never by its prefix** (`bodyLines`/`headerLines`): a removed body line is `-` plus its content, so `--json is a flag` arrives as `---json is a flag` and a prefix test read it as a file label — dropping it from the counts and colouring it as one, which Markdown thematic breaks and flag-heavy documentation hit constantly. An **empty page body is compared as an empty body** rather than refused the way `read` refuses one: a folder cannot reach that point (v2 answers a folder id with 404), so the reachable case is a genuinely empty page — what `create` leaves for a body-less file — and the whole local file is simply an addition. **Only fields the file declares are compared**, because those are the ones publishing asserts (an absent `labels`/`page_width` leaves the page's alone per L9, an absent `title` keeps the live one), which is the single rule that removes most of what #154 accepted as unavoidable frontmatter noise; a page-side read that *fails* is reported `comparable: false` and does **not** count as a difference, since `read`/`export`'s omit-on-failure would here claim publishing adds a label that may already be there. `parent` is compared as a **resolved id**, not a spelling — a file from an export tree names its parent by relative `.md` path — and `page_status` for the same reason, since the match is case-insensitive and only an id ever travels, so two spellings of one status are not a difference; and `page_width` consults the project file's own setting too, since that is a real declaration (#100) `update` acts on, labelled distinctly (`markfluence.yaml (project default)`) because it is a different place in the file than a `pages:` entry. And **exit codes are `diff(1)`'s**: `0` identical, `1` differs, `2` *any* trouble, including the operational failures every other command reports as `1`. A deliberate departure from `docs/json-output.md`'s contract, and the reason the command is worth having in CI; `--reverse` swaps the sides so the patch applies Confluence→file without `patch -R`. The diff labels are root-relative, **except when no `markfluence.yaml` exists anywhere**: `project.Discover` then falls back to the starting directory, which here is the file's own, so a root-relative path would be the bare base name and drop the `docs/` a reader typed — `reportPath` gates on `root.File != ""` and uses the path as typed otherwise. Colour goes through `internal/ui`'s `DiffAdded`/`DiffRemoved`/`DiffHunk` applied line by line to the library's own output (`renderDiff`) rather than re-rendering its structured hunks, since the `@@ -a,b +c,d` arithmetic is exactly what a library was chosen to get right; the styler is a parameter for `cmd/search`'s reason — lipgloss emits nothing when stdout is not a terminal, so a test wired to the real style proves nothing. @@ -69,7 +69,8 @@ Module `github.com/mozilla/markfluence` (`go 1.25`). `main.go` is a shim to `cmd - `cmd/schema/` — `schema`: print the embedded `--json` schema to stdout verbatim (no args, no credentials, no Confluence call). `--json` is deliberately a no-op — the output is already the schema document, not an envelope — which is also why `schema` is absent from the schema's own `command` enum. - `schema/` — the published JSON Schema (`json-output/v1.json`) *and* the `schema` Go package that embeds it (`V1`). The Go file lives beside the schema because `go:embed` cannot reach outside its own directory, and the schema stays at a top-level path a non-Go consumer can browse, mirroring its own `$id`. `internal/schematest` validates against the embed rather than reading the file, which is what makes "what ships" and "what the tests checked" the same bytes — do not reintroduce a disk read or a second copy. **The published schema is open** (#200, `_plans/054`): no `additionalProperties: false` anywhere, because v0.1.0 has consumers (`markfluence-action`'s `results-json`) and a key the file closes is a key no later release can add without breaking whoever validates against the copy they hold. [docs/json-output.md](docs/json-output.md#compatibility) holds the rule — a new key, a new command, or a new `diff` `field` value is compatible (`field` is a plain string for that reason); a removed/renamed/retyped key, a newly nullable key, or a new value in any other enum bumps `schema_version`. The schema's own `description` only points there, so the rule has one copy. Opening the objects is also why every result-or-failure union is `anyOf`, not `oneOf`: `exportResult` declares every key `singleOpFailure` requires, so an open failed export row matches both, and `oneOf` refuses a row matching two. The version number is **not** restated here: `jsonout.SchemaVersion` and the document's own `schema_version` const are the two copies, tied together by a test in `cmd/schema`. The envelope's and the error object's top-level **`warnings`** are the one field no command fills: `jsonout.NewEnvelope`/`EmitError` drain a package-level collector (`AddWarning`), because the only thing in it is raised during credential resolution — below any command, before either document exists. - `internal/project` — the documentation root: `Discover` (walk up from a directory looking for `markfluence.yaml`), `FromPath` (`--root`), `Resolve`, and `Cache`, which consults itself at every level of the walk so a batch spanning a subtree pays for the walk — and `os.OpenRoot` — once rather than per directory (the quadratic cost `_plans/025` measured). A `Root` carries `Dir`, `File`, `Config` and an `os.Root` that refuses an escape even through a symlink partway down. `Discover` starts from a Markdown file's own directory, to bound its reads and name its attachments, which is why it returns a type rather than a string; nothing discovers a root from the working directory any more (that walk located `.env` until #188). Files in different projects have different roots, so a multi-root batch is allowed and nothing refuses it ([docs/root-model.md](docs/root-model.md)). `config.go` reads the project file's **settings** (#100): `space` and `page_width`, resolving **flag > frontmatter > project file**, which is *not* the credentials chain and must never be conflated with it. Three things about it are load-bearing. It is read through `frontmatter.Dialect.ReadMapping` rather than a second parser, since every rule there was found by probing goccy and a second copy would be a second set of the same bugs. An **unknown top-level key is fatal**, and that is the point rather than a cost — a silently ignored `spce: ENG` is wrong for every file at once, and a file written for a newer markfluence holds keys this binary would ignore, so there is no schema version and this must not be loosened; an empty or comment-only file stays valid, being what ships and what `export` plants. And loading happens in `open()`, the single place a `Root` is built from a marker hit, so `Discover`/`Cache`/`FromPath` cannot disagree that a file which cannot be understood **is not a valid marker**: the walk does not continue upward and does not fall back to the starting directory, because the root decides every attachment name and guessing at it is worse than stopping. `ConfigError`/`IsConfigError`/`RootError` exist so a caller reports that as a local defect (`VALIDATION`) rather than under `resolving the documentation root` as I/O. `pages.go` holds the **`pages:`** key (#139) and `SetPageEntry`, which records one file's metadata there: read-modify-write **once per page, not once per run**, because `create` writes each file's frontmatter as that page is published and a run that dies partway has to leave every already-created page recorded (D10). It **retries once** on a concurrent write, because the read-modify-write is not serialized and this file is shared by every page in the project: before the manifest each page's metadata went into its own file, so two concurrent `create`s could not collide, and they now can — A reads, B reads, A writes, B writes, and A's entry is gone while A's page exists. Optimistic rather than locked, matching `client.SetContentProperty`'s retry-once, since a lock file brings stale-lock handling for a verb a person invokes by hand. `beforeReplace` is a test hook for the give-up path, the `SetRetryLogger` arrangement. It also verifies twice — `frontmatter.SetNested` re-reads its own output, then `parseConfig` (split out of `loadConfig` for this) re-runs the *loader's* rules — since a tool that corrupts the file it is recording success in is the worst version of the feature; a test pins that a file which would not load afterwards is left byte-identical. A root with no project file refuses rather than creating one (#5's question, not a `create`'s to answer silently). An `Entry` is `{Fields, Lists}`, the same two maps `frontmatter.MarkdownFile` carries, which is the design rather than a convenience — `pagewidth` and `labels` both reach `client`, which holds a `*Cache`, so a typed validated entry would need a broken cycle or a second copy of every field's rules, and with two maps `labels.Declared(e.Lists, e.Fields)` works unchanged. `entryFields` is the manifest's schema and the only place it is written down; it mirrors frontmatter's fields deliberately, since adding one there and not here would make a field expressible in a file and not in an entry. Load checks **structure** — a mapping of mappings, legal paths, known field *names*, right shapes — and never a field's **value**, because #139 requires a semantically bad entry to be reported only when its file is one of the arguments, and this package has no idea which files the command was given. An unknown field *name* is the exception and is fatal at load, being the same typo class as an unknown setting. `NormalizePageKey` is lexical (L2 forbids a key whose meaning depends on the checkout's layout), and an escaping key or two keys normalizing to one are load-time errors naming both spellings. `Config.Pages` is **nil when there is no `pages:` key and empty-non-nil for `pages: {}`**, which is how a command tells "has not chosen the manifest" from "has, and has registered nothing". What it validates is **structure only**: `internal/pagewidth` cannot be imported here (`pagewidth` → `client` → `project`), so a width's vocabulary is checked by `pagewidth.Declared` where it already runs and by `check`'s offline lint. `Config` deliberately holds **no `url` or token**, and the reason is sharper than "those are credentials": basic auth goes to whatever host the resolved URL names, so a committed, walked-up file naming one would decide where `CONFLUENCE_TOKEN` is sent — a worse version of the walked-up `.env` hole #188 closed. -- `internal/pagemeta` — `Resolve`, the one merge of a file's page metadata from the two places it may live: its own frontmatter and a `pages:` entry in the project file (#139). A package because `update`, `create`, `check` **and `internal/linkindex`** all need it, and a per-command copy is how two commands come to publish one file to two different pages. It imports `frontmatter` and `project` and nothing else, which is also why it validates no *value*: `pagewidth` and `labels` are unreachable from here, and the commands that need them already call them on the maps it returns. **Frontmatter and an entry are two spellings of one level, not two levels of a precedence chain** — when both speak the rule is not "the higher wins" but a grading: `page_id`/`space`/`parent` are coordinates and a disagreement fails the file (for `create`, the batch, since it preflights everything), while `title`/`page_width`/`labels` are visible and recoverable, so they warn and frontmatter wins. Agreement is silent, which is what makes migration incremental. Three things took a second pass and should not be flattened: `Source` (what `--json` reports as `metadata_source`) and `Managed` are computed from **different predicates** — the first answers "who contributed metadata", the second "should `update` act on this file", which is true when an entry exists or a `page_id` is named, so `a.md: {}` is a claim that must fail for want of an id rather than be skipped; contributed metadata is counted only over fields `project.IsPageField` knows, or a file carrying only keys markfluence *preserves but does not understand* (`reviewers:`, pinned by a frontmatter test) reads as claimed and a whole tree of them fails; and `labels` is compared as a **set**, since Confluence has no label order and a reordering cannot reach the page. A blank value is not a disagreement — every null spelling already reads as `""`. `KeyFor` is the one place a file's path becomes a manifest key, used on both sides because a mismatch is a silent skip rather than an error. `Origin` is the same question per field — which location supplied *this* value — recorded as `Resolve` grades them, because `diff` reports it beside every frontmatter difference and "the title differs" is otherwise ambiguous about which file to edit; unlike `MetadataSource()` it does **not** collapse `FromBoth`, which is the most useful of the three there (correcting a field two locations supply means editing two files). Computed here rather than by the caller for the reason the package exists: a second copy of the precedence rules is a second copy whatever it is used for. +- `internal/pagemeta` — `Resolve`, the one merge of a file's page metadata from the two places it may live: its own frontmatter and a `pages:` entry in the project file (#139). A package because `update`, `create`, `check` **and `internal/linkindex`** all need it, and a per-command copy is how two commands come to publish one file to two different pages. It imports `frontmatter` and `project` and nothing else, which is also why it validates no *value*: `pagewidth` and `labels` are unreachable from here, and the commands that need them already call them on the maps it returns. **Frontmatter and an entry are two spellings of one level, not two levels of a precedence chain** — when both speak the rule is not "the higher wins" but a grading: `page_id`/`space`/`parent` are coordinates and a disagreement fails the file (for `create`, the batch, since it preflights everything), while `title`/`page_width`/`labels` are visible and recoverable, so they warn and frontmatter wins. Agreement is silent, which is what makes migration incremental. Three things took a second pass and should not be flattened: `Source` (what `--json` reports as `metadata_source`) and `Managed` are computed from **different predicates** — the first answers "who contributed metadata", the second "should `update` act on this file", which is true when an entry exists or a `page_id` is named, so `a.md: {}` is a claim that must fail for want of an id rather than be skipped; contributed metadata is counted only over fields `project.IsPageField` knows, or a file carrying only keys markfluence *preserves but does not understand* (`reviewers:`, pinned by a frontmatter test) reads as claimed and a whole tree of them fails; and `labels` is compared as a **set**, since Confluence has no label order and a reordering cannot reach the page. A blank value is not a disagreement — every null spelling already reads as `""`. `KeyFor` is the one place a file's path becomes a manifest key, used on both sides because a mismatch is a silent skip rather than an error. `Origin` is the same question per field — which location supplied *this* value — recorded as `Resolve` grades them, because `diff` reports it beside every frontmatter difference and "the title differs" is otherwise ambiguous about which file to edit; unlike `MetadataSource()` it does **not** collapse `FromBoth`, which is the most useful of the three there (correcting a field two locations supply means editing two files). Computed here rather than by the caller for the reason the package exists: a second copy of the precedence rules is a second copy whatever it is used for. `Space(root)` and `Parent()` answer the two coordinate questions `update` and `diff` both ask: `Space` falls back to the project's `space:` default (a declaration for `update`'s refusal, #10), and `Parent` separates an **absent** key (leave the page alone) from a **blank** one (`null`/`~`/empty: the top of the space), which is why it tests presence rather than non-blankness. +- `internal/parentref` — a file's `parent:` reference, the part `create`, `update` and `diff` share (#10, `_plans/053`): `Locate` a `.md` parent through the root's `os.Root` (refusing an escape, including one through a symlinked directory, and a parent that is itself a symlink, S2), `PageID` it through `pagemeta` so a `pages:` entry counts, `Resolve` either spelling to an id, `Lookup` what an id names in a space (a page or a folder: the two are separate v2 route families, so a page 404 proves nothing until the folder route has answered), and `Within`, whether a target is a page or below it. `Within` walks the target's `parentId` chain rather than asking v2's ancestors route, which answers in one request but needs `read:content.metadata:confluence`, a scope nothing else here needs. A package because three copies of ".md parent → id" existed or were about to, and two had already drifted (one refused symlinks, one only read). What differs stays with the caller: `create`'s in-set parents and `--parent`, and `diff` turning an error into a note. - `internal/actionlog` — the append-only record of what markfluence published, one log per project root at `/.markfluence/log.jsonl` (#149). `create`, `update` and `export` each append a line as a page completes, carrying the page version they left behind and `Sum`'s hash of what the body `PUT` sent; the last successful line for a file is that copy's **merge base**. It exists because nothing else can tell "the page differs because I have edits" from "the page differs because somebody published first" — that needs what *this copy* was derived from, which is a per-copy fact no page-side state can hold. Three things about it are load-bearing. **It is not committed**, and the reason is structural: a shared repository is itself a declaration that the repository is the source of truth, which is the arrangement where `update --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. The directory ignores itself (a planted `.gitignore` holding `*`, never overwritten) rather than 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, which is why `read` skips a line it cannot parse instead of erroring and why a failed `Append` is a warning at the call site — the page is published by then, so failing would report that it was not. And **`Sum` covers exactly what the body `PUT` sends**, the resolved title and the rendered body: the title because `convert.ConfluencePage` carries none (a render-only hash would skip the publish of a file whose only change was its title), and *not* page width, labels or attachments, each of which has its own pass that runs whether or not the body is republished — folding them in would bump the page version for a change that never touched the body. Its parts are length-prefixed, which is part of the persisted format: changing the framing invalidates every recorded base. A root with `File == ""` gets no log at all (`For` returns nil, and every method is nil-safe), matching #139's rule that a root with no project file refuses rather than creating one. `Cache` hands out one `Log` per root so a batch reads each log once, and a batch spanning roots writes to several. `update` reads it for **two orthogonal checks, both only when `--force` is absent** (`--force` means always PUT, and no logic may suppress the request): the logged **`page_version`** against the live one decides **divergence** and *refuses* the file with `CodeConflict` — deliberately not qualified by the sha, since the case where you have no local edits is the worse one, publishing their work away with the bytes they started from — and the logged **`publish_sha256`** against this run's decides **idempotence** and skips the *body* `PUT` alone, leaving the attachment, width and label passes to run. An entry naming a different `page_id` is discarded rather than compared (a retarget would otherwise read as "the page moved 40 versions"), and the two fields degrade independently, so an `export` line written before its sha pass still refuses a moved page. A body-unchanged skip **records a line too**, which is load-bearing: once the sha does the skipping most runs skip, and a publish-only log would never keep a base current in a tree that is already published. Principles: **S8** (`no-overwrite-of-a-moved-page`), decided by the logged page version, and **L4** (`publish-is-idempotent`), decided by the publish sha rather than by mtime; both accept that a file with no base, or a root with no project file, gets neither. - `internal/pageslug` — `Slug`/`For`/`Filename`: a title to a filename-safe slug. A package rather than a helper because `export`, `read` and `attachment-download` all place attachments under a page's own directory and must agree. It lowercases (so case-variant titles collide and can be caught) and drops `/` (so no title can inject a path separator); it is lossy, and no readable slug can avoid being, so the caller decides what a collision means. Known limit: NFD and NFC spellings of one title are different Go strings but one filename on APFS, so that pair is not disambiguated. - `internal/pagedoc` — a fetched page as a Markdown document: `Render` (frontmatter + converted body), `Frontmatter` (which since #168 also emits `page_status`, widening `RenderFrontmatter`'s positional signature and adding an unconditional `GET /content/{id}/state` per page — a third best-effort read beside the width and label ones, so `export --space --depth all` pays it for every page in the space), and the lookups the converter can't do for itself — `Sources`/`SourcesFrom` (attachment name → recorded source path) and `PageLinks` (the page an `` points at → its URL). **One conversion, parameterized by a `Placement`**: where the page's file sits, where its unrecorded attachments go, what `parent:` says, and the attachment listing the caller already has. `read`, `export` and `attachment-download` all go through `Options`/`AttachmentDirFor` rather than assembling their own, so they cannot drift by accident — only by argument. For a page at the top level of what is being written, which is what `read` prints and what a single-page export writes, `read` and `export` are byte-identical; deeper in a tree they differ in exactly the position-dependent parts (a sourced attachment's `../` prefix, a `-` suffix a sibling forced, and `parent:`), because `read` has no tree to be positioned in. It needs a client (page width, attachment list, title lookups), which is why it isn't in `internal/convert` — that package is deliberately client-free, and it's why `StorageToMarkdown` takes those maps rather than fetching them. Every one of them is best-effort in the same shape: no references in the body means no request at all, and a lookup that fails is omitted rather than fatal (an omitted page link renders as raw storage, not as a link with no destination). It also owns **`UserCache`**, the user-name lookup both mention directions share (#91): a per-run, **cross-page** cache, threaded in from the caller the way `project.Cache`/`linkindex.Cache` are, because the obvious structure is wrong — `PageLinks` builds its space-id map per page and `Options` is built per page, so a user map written that way would re-resolve the same twelve people on every page of a 200-page export. It **remembers misses**, or a page mentioning deactivated people costs a request each, every page, to learn the same failures. Not persisted to disk, and the reason is **L2**: output must depend only on the files on disk, not on what a cache happens to hold. `MentionWarnings` is the forward direction's use of it, and sharing the cache is what makes that warning affordable — publishing needs no display names at all, so that lookup exists purely to report an id that names nobody. `PageLinks` resolves a space id **once per space key**, not once per link, and refuses to search site-wide when it can't scope a title to a space — a same-titled page in the wrong space is a wrong answer, which is worse than the passthrough a miss produces. @@ -77,7 +78,7 @@ Module `github.com/mozilla/markfluence` (`go 1.25`). `main.go` is a shim to `cmd - `internal/attachfile` — `Resolve` (where an attachment goes under a destination root, **including the traversal clamp**) and `Write` (download it there, honoring force/dry-run). Shared by `attachment-download` and `export`; the clamp must never exist in two copies. - `internal/pagetree` — `Walk`, the traversal of pages *and folders* under a node, plus `WalkSpace` (the same traversal seeded from a space's root pages, via `client.ListSpaceRootPages`) and `AllDepths`. Both go through one `walker`, so the depth rule and the visited guard exist in a single copy. It is a package rather than command-local because listing a subtree and exporting one (#59) need the identical walk, and its rules must not exist in two copies: siblings arrive from two requests (`/child/page`, `/child/folder`) and are **merged by `extensions.position`**, or the output loses the order Confluence displays; a folder **counts as a level** like a page, which is only reasonable because folders are reported rather than silently traversed; and the walk descends folders even when only pages matter, since a folder may hold the only pages in a subtree. `nodeURL` uses `SiteURL()` — a v1 child row carries `webui` but no `base`. A visited set guards the unbounded case. - `internal/pageref` — `Resolve`, the single page-argument resolver: a numeric id, a Confluence page **or folder** URL (`pagePathRE` matches both `/pages/` and `/folder/`, since `children` takes a folder and a folder URL is what a browser hands you — the id is all it returns, so a command that can only use a page reports its own not-found), or a `.md` file that **declares** a `page_id` — in its own frontmatter or in a `pages:` entry for it (#139), resolved through `pagemeta` after discovering the root from the file's own directory (stat'd first, so `123.md` is a file). Every command taking a page uses it, which is why the manifest lookup lives here rather than at the seven call sites: without it the page argument meant one thing to `update` and another to `page-info`/`read`/`children`/`export`/`attachment-*`, so a file `update` could publish could not be named to any of them. A **disagreement** between the two locations is fatal here — it is the question being asked — while a **malformed** `markfluence.yaml` is not: a project file this resolver never consults must not make `page-info 123` fail, and the commands that bound reads by the root report it themselves. `message.go` also owns the wording for the two ways a *frontmatter* `page_id` is wrong — `NotFoundMessage` (caller supplies the remedy, which differs per command) and `NotNumericMessage` — because `create` and `update` report both and `check` reports the non-numeric one, and a reader should recognize the same problem across all of them. They return strings, not errors: `create` wraps the text in its typed `pageIDFailure` (which also carries the `--json` fields), the others want a plain error. Anything checking a `page_id` before a request uses `IsDigits`, since the API answers a non-numeric id with a 400 whose body says nothing useful. -- `internal/client` — `ConfluenceClient` over `net/http` with basic auth. Built from a `Config` (site URL, cloud ID, username, token) via `New`; it carries **two bases**: `BaseURL()` is where requests go (the gateway when a cloud ID is set) and `SiteURL()` is always the site. Anything a reader sees uses `SiteURL()` — printed page URLs and, critically, the `baseURL` handed to `convert.MdToConfluence`, since rewritten links are published *into* the page. Pages are Confluence **v2**; attachment writes and the user lookup are **v1** (`/wiki/rest/api/...`). A **folder** — the Cloud content type that can parent a page — has its own v2 route, `GetFolderOrNil` against `/wiki/api/v2/folders/{id}`, because every v2 *page* route answers a folder id with 404; enumerating children, if it is ever added, must be v1, since v2 cannot list inside a folder at all and its page-children route silently omits folders ([docs/confluence/folders.md](docs/confluence/folders.md)). Page **status** — the title lozenge — is v1 only and lives in `state.go` (`PageState`/`AvailableStates`/`SetPageState`, plus `StateVocabulary`, which keeps the space's statuses and the caller's own custom ones in separate fields because only the first are valid for a file); v2 carries no state field on a page in any form and there is no expansion that adds one, so a lozenge is one extra request per page, always. `AvailableStates` must be asked about the page the status is going on — its answer varies by caller *and* page, and it needs edit permission on that page. There is deliberately **no `ClearPageState`**: the `DELETE` route exists, but nothing can reach it until a clearing spelling does, and an unused write method is a loaded gun. Typed `HTTPError`, per-attempt context timeouts, centralized retry/backoff in `send`. `HTTPError.Error()` appends a **hint** for the three auth failures whose status misleads, matched on the response *body* rather than deduced from the status and always **appended** to it, never replacing it. The one that matters: **a rejected credential is a 404 on every v2 route**, so a revoked token used to make `read` answer `page ... not found` about a page that exists. `RejectedCredential` tells it apart by the fact that every genuine v2 404 *names* what it could not find and the auth one does not, `notFound` gates the three `…OrNil` helpers on it so they stop reading it as "absent", and `jsonout.CodeFor` checks it before the status switch so `--json` reports `AUTH` rather than `NOT_FOUND`. **Two error types on the request path, and one predicate for them**: an `*HTTPError` once a response has a status, an unexported `requestError` when there is none (a transport failure, a request that would not build, a body that would not decode), and `FromRequest` answers whether an error is either. That is what lets a caller tell a server failure from a local one — `jsonout.CodeOr(err, fallback)` is the whole point of it, since `CodeFor` alone reports every non-`HTTPError` as `NETWORK` and so turns `no title given` into a network problem (#133). The rule is deliberately scoped to the request: `DownloadAttachment` writing to the caller's writer, `uploadAttachment` opening the caller's file, and `Resolve` reading the environment stay untyped, because tagging them would misreport an unreadable file as a network failure. The wrapper carries no message of its own, so `Error()` is the inner text verbatim and nothing a reader sees changed. A 403 that is not one of the two measured credential phrasings gets no hint, because that is what a genuine permission denial looks like ([docs/confluence/api.md](docs/confluence/api.md#scopes)). **Retry rules**: 429 for any method; 502/503/504 for idempotent methods; **any other 5xx only when the response carries `Retry-After`** — that is how a 500 becomes retryable, and it is why `parseRetryAfter` reports the header's *presence* apart from its delay (`Retry-After: 0` means "retry now", not "no header"). The exponential delay is jittered, a server-supplied `Retry-After` never is. Decisions go to a package-level hook (`SetRetryLogger`, set once in `root.go` beside `ui.SetDebug`) and fire whichever way they went, because `internal/client` prints nothing and a silent twelve-minute retry storm is otherwise indistinguishable from a hang. **A versioned PUT is not as idempotent as its method**: `SetContentProperty` retry-once on top (recovers a lost create-POST response) and `UpdatePage`'s `updateLanded` both exist for the same reason — a write whose response was lost gets re-sent, and the re-sent version is refused. `updateLanded` requires version *and* title *and* body to match what was sent, since a concurrent edit could have produced the version alone and claiming success over someone else's content is worse than a false failure ([docs/confluence/api.md](docs/confluence/api.md)). `SyncAttachments` (skip/update by a SHA-256 recorded in the attachment's comment, alongside the source path so `read` recovers image paths exactly; only the current comment form is parsed — an attachment stamped by a markfluence predating a comment-format change reads as unmanaged and is re-uploaded once, the same as any hand-uploaded file — except that a *recorded path disagreeing with the local source* is an update even when the checksum matches, so a mangled path repairs itself instead of surviving every later publish; a comment with no source recorded at all is not a disagreement. Every text part of the upload form must go through `writeTextField`, never `multipart.Writer.WriteField`, which emits no charset and gets decoded as Latin-1), `_links.next` pagination. Every attachment file is read through `LocalAttachment.Open`, twice — once for `planAttachments`' checksum, once by `uploadAttachment`, which reads the file whole and takes the comment's checksum from *those* bytes, since the file may have changed in between and a comment misdescribing its content reads as up to date on the next publish. **Four pagination schemes, and picking the wrong one truncates silently.** v1 *child/attachment* collections page through the generic `listV1` helper by `start`/`limit` offset, never `_links.next` (absent when the results fit one page, so it cannot terminate a loop); `ListAttachments`, `ListChildPages`, and `ListChildFolders` all go through it. v2 collections page through `listV2` by the cursor in `_links.next` (whose loop is `walkV2`, shared rather than copied so a counting caller can stream — a second implementation of v2 paging is how one of them comes to terminate on a short page), which is a `/wiki`-prefixed absolute path `resolveNext` handles unchanged; `ListContentProperties` and `SearchPagesByTitle` share it. **`/wiki/rest/api/search` is neither**: it ignores `start` outright, its `next` is context-relative so it needs the `/wiki` prefix `resolveNext` does not add, a short page does *not* mean the end, and `totalSize` can be nonzero against an empty `results` — so `searchCQL` terminates only on a missing `next` and nothing may branch on `totalSize` ([docs/confluence/search.md](docs/confluence/search.md)). `searchCQLBounded` adds a row bound under it (`SearchCQL` is that call with no bound, which is why `find` is unaffected): it asks for `max+1` and reports the surplus as `more`, since `totalSize` cannot supply a count. **`/wiki/rest/api/space` is a fifth, and the one that punishes the obvious choice**: it pages by `start`/`limit` offset exactly as the child collections do, and **a short page is not the end** — asked for 250 from `start=0` it answered 200, and `start=200` then answered 250 more, against 525 spaces. `listV1` stops on that short page, so `WalkSpaceOperations` has its own loop terminating on an **empty** page, advancing by rows *returned* rather than by the limit asked for, bounded by `maxSpacePages` since an empty page is the only end signal offset paging has here. The first version of the probe that found this trusted the short page and reported 200 spaces with total confidence ([users.md](docs/confluence/users.md)). **`/wiki/rest/api/search/user` is a fourth**, and the one that looks most like an existing scheme while not being it: it pages by `start`/`limit` offset exactly as `listV1` does, so `listV1` is the obvious home for it and is a trap — the route **caps a page at 100 rows while echoing back whatever limit was requested** (101, 250 and 500 all answer 100), and `listV1` asks for `v1PageSize = 250` and reads a short page as the end of the collection, so it would truncate every result set past 100 with no error at all. `SearchUsers` lives in its own `users.go` with `userPageSize = 100` and the measurement beside it for that reason, and `TestPageCapDoesNotTruncate` is the regression. It also carries `maxUserPages`, `searchCQLBounded`'s guard for the same hazard reached a different way: a short page is the *only* end signal offset paging here has, so a server that clamped `start` — or ignored it the way `/wiki/rest/api/search` ignores it outright — would return a full page forever and an unbounded walk would collect rows until it ran out of memory. Its `totalSize` is a *third* kind of wrong: not absent like v1's and not an estimate like `/search`'s, but the row count of the page just fetched, so `limit=3` answers 3 and `limit=500` answers 100 against 304 real matches. `user.go` holds the two identity routes (`CurrentUser`, `UserInfo` — both `read:confluence-user`, both seeing a deactivated account the directory cannot) and `WalkSpaceOperations`; `space.go` holds `GetSpace` (one v1 request answering identity, the caller's own operations, description, labels and the homepage *with its title*), `SpaceStateSettings` (space-admin only, so a 403 that is not a rejected credential is `(nil, nil)` rather than an error) and `WalkSpacePages`. Both space routes decode the space `id` as a `json.Number`: v1 reports it as a **number** where every v2 route reports a string, and `homepage.id` in the same response is a string. Full text goes through `SearchText`/`SearchRawCQL`, which return the cleaned `SearchMatch` the way `FindByTitle` returns `TitleMatch` — and **every field of a match comes from the row's `content` object**, because the row-level `title` is HTML-escaped *and* wrapped in `@@@hl@@@` markers where `content.title` is neither. The `excerpt` exists only at row level, so `cleanExcerpt` strips those markers, unescapes once, and collapses to one line — in the client, so the human and `--json` paths cannot disagree about it. `excerpt=highlight` is passed explicitly and **re-attached when following the cursor** (the `next` link carries `cql` and `limit` but not `excerpt`, and `doJSON` appends params with a bare `?`); an unrecognized value there yields an empty excerpt with a 200, so a rename by Atlassian degrades to no excerpts rather than an error. A row with no `content` object is skipped and **counted** — `type = space` answers with hundreds of them, and a silent skip would report a successful empty result. A bare v1 child row already carries `webui`, `status`, and `extensions.position`, so child listing needs no `expand`. `DownloadAttachment` goes through `send` (inheriting retry/backoff) against `_links.download`; **never** add a `CheckRedirect` that forwards headers — it would leak site credentials to Atlassian's media host, which neither needs nor wants them. `credentials.go` holds the credentials file's own functions — `CredentialsPath`, `ReadCredentials` (the rules `Resolve` uses, minus the permission warning, for a caller about to rewrite the file; one read returning a `CredentialsFile` of values, the lines a rewrite drops, and the mode), `WriteCredentials` (temp-file-and-rename at `0600`, writing through a symbolic link, quoting a value exactly when `readDotenv` would not read it back unchanged, then reading the result back and comparing), `DisplayPath`, and `FetchCloudID`, the unauthenticated `tenant_info` request, which bypasses `send` because `send` always sets basic auth. The setting names (`URLVar`/`UsernameVar`/`TokenVar`/`CloudIDVar`), `CredentialsDoc` and `LooseMode` are exported so `credentials-init` copies none of them. `HTTPError.ScopeMismatch`/`SiteRejectedAuth`, beside `RejectedCredential`, are the shapes `hint` matches, exported for `credentials-init`. `config.go` holds `Resolve` and the env-file reader (`loadDotenv`, which warns, over `readDotenv`, which does not), plus the **permission warning** (#136): a *regular* credentials file or `--env-file` reachable by anyone but its owner (`mode.Perm()&0o077`; a pipe from `--env-file <(pass show …)` reports 0440 and no chmod can fix it) *and* containing `CONFLUENCE_TOKEN` earns a warning naming the file, its mode, and the `chmod`. Both halves matter — a file holding only the URL and username leaks nothing, and a warning that fires on a file with no secret in it is how one becomes something people scroll past. It stats rather than lstats (a link's own `0777` would cry wolf over a `0600` target), lives in `loadDotenv` because that is the one function both the credentials file and `--env-file` pass through, and reaches the reader through `SetSecurityWarner` for the same reason `SetRetryLogger` exists — wired to `cmd/root.go`'s `reportSecurityWarning`, which prints it (human mode) *and* records it via `jsonout.AddWarning`, since stderr under `--json` is a schema-validated document with no room for a stray line. A group/world-*writable* file with no token in it is knowingly **not** covered: the same-source rule means a URL rewritten there can no longer be paired with a token from somewhere else, and the cloud ID follows the URL. Why each of these is shaped this way, with the evidence: [docs/confluence/api.md](docs/confluence/api.md) and [attachments.md](docs/confluence/attachments.md). +- `internal/client` — `ConfluenceClient` over `net/http` with basic auth. Built from a `Config` (site URL, cloud ID, username, token) via `New`; it carries **two bases**: `BaseURL()` is where requests go (the gateway when a cloud ID is set) and `SiteURL()` is always the site. Anything a reader sees uses `SiteURL()` — printed page URLs and, critically, the `baseURL` handed to `convert.MdToConfluence`, since rewritten links are published *into* the page. Pages are Confluence **v2**; attachment writes and the user lookup are **v1** (`/wiki/rest/api/...`). A **folder** — the Cloud content type that can parent a page — has its own v2 route, `GetFolderOrNil` against `/wiki/api/v2/folders/{id}`, because every v2 *page* route answers a folder id with 404; enumerating children, if it is ever added, must be v1, since v2 cannot list inside a folder at all and its page-children route silently omits folders ([docs/confluence/folders.md](docs/confluence/folders.md)). Page **status** — the title lozenge — is v1 only and lives in `state.go` (`PageState`/`AvailableStates`/`SetPageState`, plus `StateVocabulary`, which keeps the space's statuses and the caller's own custom ones in separate fields because only the first are valid for a file); v2 carries no state field on a page in any form and there is no expansion that adds one, so a lozenge is one extra request per page, always. `AvailableStates` must be asked about the page the status is going on — its answer varies by caller *and* page, and it needs edit permission on that page. A **move** is `MovePage` (`move.go`), always the v1 `PUT /content/{id}/move/{position}/{targetId}` with `append` (under a page or folder) or `after` (after the last top-level page, the only way to the top of a space): the v1 route leaves the page version alone where a v2 `parentId` change bumps it, and v2 silently ignores a null `parentId`, so it cannot reach the top at all ([docs/confluence/api.md](docs/confluence/api.md#moving-a-page)). There is deliberately **no `ClearPageState`**: the `DELETE` route exists, but nothing can reach it until a clearing spelling does, and an unused write method is a loaded gun. Typed `HTTPError`, per-attempt context timeouts, centralized retry/backoff in `send`. `HTTPError.Error()` appends a **hint** for the three auth failures whose status misleads, matched on the response *body* rather than deduced from the status and always **appended** to it, never replacing it. The one that matters: **a rejected credential is a 404 on every v2 route**, so a revoked token used to make `read` answer `page ... not found` about a page that exists. `RejectedCredential` tells it apart by the fact that every genuine v2 404 *names* what it could not find and the auth one does not, `notFound` gates the three `…OrNil` helpers on it so they stop reading it as "absent", and `jsonout.CodeFor` checks it before the status switch so `--json` reports `AUTH` rather than `NOT_FOUND`. **Two error types on the request path, and one predicate for them**: an `*HTTPError` once a response has a status, an unexported `requestError` when there is none (a transport failure, a request that would not build, a body that would not decode), and `FromRequest` answers whether an error is either. That is what lets a caller tell a server failure from a local one — `jsonout.CodeOr(err, fallback)` is the whole point of it, since `CodeFor` alone reports every non-`HTTPError` as `NETWORK` and so turns `no title given` into a network problem (#133). The rule is deliberately scoped to the request: `DownloadAttachment` writing to the caller's writer, `uploadAttachment` opening the caller's file, and `Resolve` reading the environment stay untyped, because tagging them would misreport an unreadable file as a network failure. The wrapper carries no message of its own, so `Error()` is the inner text verbatim and nothing a reader sees changed. A 403 that is not one of the two measured credential phrasings gets no hint, because that is what a genuine permission denial looks like ([docs/confluence/api.md](docs/confluence/api.md#scopes)). **Retry rules**: 429 for any method; 502/503/504 for idempotent methods; **any other 5xx only when the response carries `Retry-After`** — that is how a 500 becomes retryable, and it is why `parseRetryAfter` reports the header's *presence* apart from its delay (`Retry-After: 0` means "retry now", not "no header"). The exponential delay is jittered, a server-supplied `Retry-After` never is. Decisions go to a package-level hook (`SetRetryLogger`, set once in `root.go` beside `ui.SetDebug`) and fire whichever way they went, because `internal/client` prints nothing and a silent twelve-minute retry storm is otherwise indistinguishable from a hang. **A versioned PUT is not as idempotent as its method**: `SetContentProperty` retry-once on top (recovers a lost create-POST response) and `UpdatePage`'s `updateLanded` both exist for the same reason — a write whose response was lost gets re-sent, and the re-sent version is refused. `updateLanded` requires version *and* title *and* body to match what was sent, since a concurrent edit could have produced the version alone and claiming success over someone else's content is worse than a false failure ([docs/confluence/api.md](docs/confluence/api.md)). `SyncAttachments` (skip/update by a SHA-256 recorded in the attachment's comment, alongside the source path so `read` recovers image paths exactly; only the current comment form is parsed — an attachment stamped by a markfluence predating a comment-format change reads as unmanaged and is re-uploaded once, the same as any hand-uploaded file — except that a *recorded path disagreeing with the local source* is an update even when the checksum matches, so a mangled path repairs itself instead of surviving every later publish; a comment with no source recorded at all is not a disagreement. Every text part of the upload form must go through `writeTextField`, never `multipart.Writer.WriteField`, which emits no charset and gets decoded as Latin-1), `_links.next` pagination. Every attachment file is read through `LocalAttachment.Open`, twice — once for `planAttachments`' checksum, once by `uploadAttachment`, which reads the file whole and takes the comment's checksum from *those* bytes, since the file may have changed in between and a comment misdescribing its content reads as up to date on the next publish. **Four pagination schemes, and picking the wrong one truncates silently.** v1 *child/attachment* collections page through the generic `listV1` helper by `start`/`limit` offset, never `_links.next` (absent when the results fit one page, so it cannot terminate a loop); `ListAttachments`, `ListChildPages`, and `ListChildFolders` all go through it. v2 collections page through `listV2` by the cursor in `_links.next` (whose loop is `walkV2`, shared rather than copied so a counting caller can stream — a second implementation of v2 paging is how one of them comes to terminate on a short page), which is a `/wiki`-prefixed absolute path `resolveNext` handles unchanged; `ListContentProperties` and `SearchPagesByTitle` share it. **`/wiki/rest/api/search` is neither**: it ignores `start` outright, its `next` is context-relative so it needs the `/wiki` prefix `resolveNext` does not add, a short page does *not* mean the end, and `totalSize` can be nonzero against an empty `results` — so `searchCQL` terminates only on a missing `next` and nothing may branch on `totalSize` ([docs/confluence/search.md](docs/confluence/search.md)). `searchCQLBounded` adds a row bound under it (`SearchCQL` is that call with no bound, which is why `find` is unaffected): it asks for `max+1` and reports the surplus as `more`, since `totalSize` cannot supply a count. **`/wiki/rest/api/space` is a fifth, and the one that punishes the obvious choice**: it pages by `start`/`limit` offset exactly as the child collections do, and **a short page is not the end** — asked for 250 from `start=0` it answered 200, and `start=200` then answered 250 more, against 525 spaces. `listV1` stops on that short page, so `WalkSpaceOperations` has its own loop terminating on an **empty** page, advancing by rows *returned* rather than by the limit asked for, bounded by `maxSpacePages` since an empty page is the only end signal offset paging has here. The first version of the probe that found this trusted the short page and reported 200 spaces with total confidence ([users.md](docs/confluence/users.md)). **`/wiki/rest/api/search/user` is a fourth**, and the one that looks most like an existing scheme while not being it: it pages by `start`/`limit` offset exactly as `listV1` does, so `listV1` is the obvious home for it and is a trap — the route **caps a page at 100 rows while echoing back whatever limit was requested** (101, 250 and 500 all answer 100), and `listV1` asks for `v1PageSize = 250` and reads a short page as the end of the collection, so it would truncate every result set past 100 with no error at all. `SearchUsers` lives in its own `users.go` with `userPageSize = 100` and the measurement beside it for that reason, and `TestPageCapDoesNotTruncate` is the regression. It also carries `maxUserPages`, `searchCQLBounded`'s guard for the same hazard reached a different way: a short page is the *only* end signal offset paging here has, so a server that clamped `start` — or ignored it the way `/wiki/rest/api/search` ignores it outright — would return a full page forever and an unbounded walk would collect rows until it ran out of memory. Its `totalSize` is a *third* kind of wrong: not absent like v1's and not an estimate like `/search`'s, but the row count of the page just fetched, so `limit=3` answers 3 and `limit=500` answers 100 against 304 real matches. `user.go` holds the two identity routes (`CurrentUser`, `UserInfo` — both `read:confluence-user`, both seeing a deactivated account the directory cannot) and `WalkSpaceOperations`; `space.go` holds `GetSpace` (one v1 request answering identity, the caller's own operations, description, labels and the homepage *with its title*), `SpaceStateSettings` (space-admin only, so a 403 that is not a rejected credential is `(nil, nil)` rather than an error) and `WalkSpacePages`. Both space routes decode the space `id` as a `json.Number`: v1 reports it as a **number** where every v2 route reports a string, and `homepage.id` in the same response is a string. Full text goes through `SearchText`/`SearchRawCQL`, which return the cleaned `SearchMatch` the way `FindByTitle` returns `TitleMatch` — and **every field of a match comes from the row's `content` object**, because the row-level `title` is HTML-escaped *and* wrapped in `@@@hl@@@` markers where `content.title` is neither. The `excerpt` exists only at row level, so `cleanExcerpt` strips those markers, unescapes once, and collapses to one line — in the client, so the human and `--json` paths cannot disagree about it. `excerpt=highlight` is passed explicitly and **re-attached when following the cursor** (the `next` link carries `cql` and `limit` but not `excerpt`, and `doJSON` appends params with a bare `?`); an unrecognized value there yields an empty excerpt with a 200, so a rename by Atlassian degrades to no excerpts rather than an error. A row with no `content` object is skipped and **counted** — `type = space` answers with hundreds of them, and a silent skip would report a successful empty result. A bare v1 child row already carries `webui`, `status`, and `extensions.position`, so child listing needs no `expand`. `DownloadAttachment` goes through `send` (inheriting retry/backoff) against `_links.download`; **never** add a `CheckRedirect` that forwards headers — it would leak site credentials to Atlassian's media host, which neither needs nor wants them. `credentials.go` holds the credentials file's own functions — `CredentialsPath`, `ReadCredentials` (the rules `Resolve` uses, minus the permission warning, for a caller about to rewrite the file; one read returning a `CredentialsFile` of values, the lines a rewrite drops, and the mode), `WriteCredentials` (temp-file-and-rename at `0600`, writing through a symbolic link, quoting a value exactly when `readDotenv` would not read it back unchanged, then reading the result back and comparing), `DisplayPath`, and `FetchCloudID`, the unauthenticated `tenant_info` request, which bypasses `send` because `send` always sets basic auth. The setting names (`URLVar`/`UsernameVar`/`TokenVar`/`CloudIDVar`), `CredentialsDoc` and `LooseMode` are exported so `credentials-init` copies none of them. `HTTPError.ScopeMismatch`/`SiteRejectedAuth`, beside `RejectedCredential`, are the shapes `hint` matches, exported for `credentials-init`. `config.go` holds `Resolve` and the env-file reader (`loadDotenv`, which warns, over `readDotenv`, which does not), plus the **permission warning** (#136): a *regular* credentials file or `--env-file` reachable by anyone but its owner (`mode.Perm()&0o077`; a pipe from `--env-file <(pass show …)` reports 0440 and no chmod can fix it) *and* containing `CONFLUENCE_TOKEN` earns a warning naming the file, its mode, and the `chmod`. Both halves matter — a file holding only the URL and username leaks nothing, and a warning that fires on a file with no secret in it is how one becomes something people scroll past. It stats rather than lstats (a link's own `0777` would cry wolf over a `0600` target), lives in `loadDotenv` because that is the one function both the credentials file and `--env-file` pass through, and reaches the reader through `SetSecurityWarner` for the same reason `SetRetryLogger` exists — wired to `cmd/root.go`'s `reportSecurityWarning`, which prints it (human mode) *and* records it via `jsonout.AddWarning`, since stderr under `--json` is a schema-validated document with no room for a stray line. A group/world-*writable* file with no token in it is knowingly **not** covered: the same-source rule means a URL rewritten there can no longer be paired with a token from somewhere else, and the cloud ID follows the URL. Why each of these is shaped this way, with the evidence: [docs/confluence/api.md](docs/confluence/api.md) and [attachments.md](docs/confluence/attachments.md). - `internal/convert` — the converter (the crux). `MdToConfluence(md *frontmatter.MarkdownFile, root *project.Root, index *linkindex.Index, baseURL, spaceKey string) (*ConfluencePage, error)`. `root` bounds which images and parent references may be read (S1/S2) and is what an image's recorded `Source` is relative to; `index` is the tree-wide link/anchor index for `root` (`internal/linkindex.Build`), built once and shared across every file converted under it rather than rebuilt per conversion — both are discovered/built by the caller (`internal/project`/`internal/linkindex`), which is why this package stays client-free. It parses with goldmark (GFM) and renders through a custom `storageRenderer` registered at priority 100 (below the default HTML=1000 and table=500 renderers) that emits Confluence storage format. `shield.go` renames raw `ac:`/`ri:` tags to colon-free sentinels around the goldmark step so pasted storage passes through; `callouts.go` is an AST transformer + blockquote renderer for GitHub alerts; `mention.go` owns the user-mention mapping in both directions (#91): a mention is 80% of all `` usage, and it converts to `[@Display Name](https://home.atlassian.com/people/{accountId})`. Three things decide its shape, each measured rather than reasoned. **The URL is Atlassian Home, not the site** — Confluence's own renderer still emits `{site}/wiki/people/{id}`, which no longer resolves usefully in a browser, so a mention in Markdown names *no site*, needs nothing from configuration, and is therefore recognisable by `check` with no client at all. **Matching is on the path, ignoring host and query**, because several spellings of one target circulate (the Home URL, the modal's `?cloudId=` copy, the `/o/{orgId}` redirect, both Confluence forms, root-relative) and none of `cloudId`/`ref`/the org segment identifies the person — only the id does, and `ri:user` stores nothing else. **The `@` on the link text is the marker**, load-bearing rather than decoration: the URL cannot tell "mention this person" from "link to their profile", so without it anyone writing the second would silently get the first. The account id is **not** pattern-validated (two shapes are live on one instance, so a pattern tight enough for one rejects the other) and `ri:local-id` is never emitted (a mention carrying only the id resolves to the same person, verified via ADF). `MentionMarkdown` is the shared builder for a mention's whole Markdown line, exported because `user-find` (#143) prints exactly it and a second copy there would be a second place to get the `@` marker and name escaping right. `ConfluencePage.Mentions` reports the ids the *forward* direction emitted so the caller can warn about one that names nobody — the `Attachments` arrangement, and necessary because Confluence accepts any id and renders `@Unlicensed user` rather than failing, and the profile URL 200s either way. An unresolvable mention still renders as a link, `[@Unlicensed user](…)` — that wording mirrors Confluence because the only ids reaching it are the ones the page labels that way: a **deactivated account resolves normally** and keeps its name (measured across every mention on a real page — 18 of them, six departed, all 200, returning e.g. `Mark Reid (Deactivated)`), so a departed colleague never takes that branch. Name resolution is `pagedoc.UserCache`, a per-run cross-page cache, and the tri-state is the part to preserve: `client.LookupUser` separates a name from `ErrNoSuchUser` from an unaskable question, `StorageOptions.UserNames` carries that as name / `""` / absent, and only a **confirmed** absence renders the placeholder. Flattening those would write a fabricated name over a real one the moment a VPN dropped mid-export, across a whole tree, into a file that then looks authoritative — which is also why the cache remembers a 404 but not a timeout (one is an answer, the other is not) and why `MentionWarnings` warns only about a confirmed absence; `aclink.go` is the *inverse* direction's one element with enough shape to need its own file — ``, which the editor writes for every internal link and `MdToConfluence` never emits, so nothing in the regression suite covers it. One rule decides its whole mapping: **convert when the Markdown republishes to a link resolving to the same target, pass the storage through when it would not** — so a page link and a space link convert, while a mention and a space link convert, and an attachment link (only images are uploaded, so a relative href would be dead) and an unresolvable target stay raw, which the shield republishes byte-identical. A page target is a **title, never an id**, so `PageLinkTargets` reports what needs resolving and `StorageOptions.PageLinks` carries the answers back. An `ac:anchor` is **percent-encoded** where `confluenceSlug` output is not: decode it before matching a heading, leave it encoded inside a URL. A same-page anchor recovers its heading from the document rather than inverting the slug, which is impossible — `confluenceSlug` turns both a space and a hyphen into `-`. The survey the mapping rests on, and the `xml.HTMLAutoClose` trap that made `` crash the parser outright (#88), are in [docs/confluence/links-and-anchors.md](docs/confluence/links-and-anchors.md); Plain text used as a Markdown link's text goes through `escapeLinkText` (`\`, `[`, `]`), applied to the *raw* sources only — a page title, a space key, an anchor, a display name — and via `inlineTextForLink` to a body whose every descendant is a text node. Never to already-rendered output: an `ac:link-body` holding markup has been converted to Markdown already, and escaping it yields a literal `\*\*bold\*\*`. Both directions are tested, because a fix at either extreme passes one and fails the other. `attachname.go` owns the source-path→attachment-name mapping, which is now the path's **base name** and nothing else (#59/`_plans/029`): the name is the attachment's identity, so an encoded path moved the name every time the file moved and orphaned the old attachment, and the path is recorded in the comment anyway. The mapping is therefore lossy, and what the bijection used to buy is an explicit refusal — two assets in one document whose base names agree return a typed `NameCollisionError` from `MdToConfluence`, which is a *failure* and not a `Broken` entry, since nothing blocks a publish on `Broken`. `check` catches that error and reports it as `Broken` anyway, because there it is a document defect like a dead link rather than a converter failure. A stored name is never interpreted in the other direction either: `sourceFor` reads the recorded path or uses the name verbatim. What names Confluence accepts is in [docs/confluence/attachments.md](docs/confluence/attachments.md); `destination.go` owns the **other** codec, destination↔path (`decodeDestination`/`encodeDestination`), shared by images *and* doc links — a Markdown destination is a URL, so decode inbound (**before** `withinRoot`, or an encoded `..%2F` slips the clamp) and encode outbound in `storage_to_md.go` (or `export` emits Markdown that no longer parses, and `sourceFor`'s absolute-path refusal is undone by the next read); an undecodable destination is a literal `%` in a filename, not an error; the reasoning is in [docs/confluence/links-and-anchors.md](docs/confluence/links-and-anchors.md); `images.go` (resolution stays page-relative like GitHub; the documentation root — cwd — bounds what may be published, and an image above it is `IMAGE BROKEN`), `links.go` (GitHub/Confluence slugs, doc-link + anchor rewriting against `internal/linkindex`'s tree-wide index; `resolveDocKey` resolves a destination to the index's root-relative key and reports `escapes` — a purely lexical check on the *query* side, since the index itself needs no clamp: an escaping key can never be in it, built by walking downward from root). A doc-link target is one of four severities, #42: missing entirely or escaping root is **Broken** (`LINK BROKEN: … (not found|outside the documentation root)`) and replaces the whole `` element — tags and visible text alike — with that literal message, matching `images.go`'s precedent for a missing image (`renderLink` needs a small per-node flag, `linkBrokenText`, since goldmark still invokes a container node's renderer on the matching leaving call regardless of `WalkSkipChildren` on entering, and there is no `` to write in the broken case); existing on disk with no `page_id` yet is unchanged — a **warning**, the normal state of an unpublished tree; a `#fragment` matching no heading on an otherwise-resolving target also **warns**, gated on `linkindex.Index.FileExists` so a missing/escaping target isn't double-reported. `tables.go` (the `` tag, stamped with `data-layout="align-start"` so tables auto-size and left-align — this must stay if column widths are ever emitted, or a `` silently induces a layout; plus cells: an AST transformer consumes a leading `` comment in a cell and `renderTableCell` emits it as `data-highlight-colour`; `storage_to_md.go`'s `cellTexts` reverses this, reading `data-highlight-colour` back into a `bg:` marker (`cellBGNames`, the reverse of `tables.go`'s swatch map — a hex outside the 21 swatches round-trips as the literal hex, and where two names share a hex the British spelling wins, matching Confluence's own `-colour`), and a column's GFM alignment becomes a `

` wrapper **inside** the cell — never the `align` attribute the GFM renderer would emit, which is the one form Confluence discards. Only center and right are emitted: Confluence has no explicit left, so `:---` publishes bare and `read` recovers it as `---`. Since alignment is per-paragraph there and per-column in GFM, `columnSeparators` in `storage_to_md.go` takes each column's most common declared alignment (ties to the first seen) and drops the rest. Rows still fall through to the GFM renderer. A multi-line cell is one `

` per line — Enter in the editor starts a new `

`, it does not insert a `
` — so `renderCellLines` in `storage_to_md.go` joins sibling `

` children with a literal `
` rather than nothing: a GFM table row is exactly one physical line, so a real newline isn't an option, and the same substitution catches a bare mid-line `
` (Shift+Enter) that would otherwise render as the two-space hard break valid in ordinary block content but not inside a table row. A `