Add a page_status frontmatter field for the lozenge beside the page title - #169
Merged
Merged
Conversation
The lozenge next to a page title is a v1-only "content state", and three findings from probing it decide the whole design: a PUT naming a state the server does not recognise creates one that no route can delete, the only trustworthy vocabulary route is per-page and behind a write scope, and a state write bumps the page version. For #168.
A page status -- the coloured lozenge beside the title -- is Atlassian's "content state", and v2 exposes nothing about it, so state.go is v1 only. SetPageState sends the id and nothing else, which is a safety property rather than economy: the body's id is optional, and a PUT naming a status the server does not recognise creates one, with a 200, that no API route can delete. With an id present the name and colour are ignored outright. AvailableStates keeps the space's statuses and the caller's own custom ones in separate fields. Custom statuses follow the account, not the space, so only the first are valid for a committed file; the second are carried so a refusal can say why. ResolveSpaceHomepage rides the /spaces request ResolveSpaceID already makes. create needs some page id in the target space to ask what statuses the space offers, at a point where the page it is about to make does not exist. For #168.
Declared is pure and offline, which is what lets check use it. Resolve turns the name a file declares into the status to write, against the space the page is in; Apply reads before writing, because a status write is the one metadata pass that bumps the page version. Three decisions that are not obvious from the code: The match is case-insensitive with nothing rewritten, departing from labels, which repairs case and warns. Labels sends the author's string to the server; this sends an id, so a case variant cannot reach the page. A name matching only one of the caller's custom statuses gets its own message. Those follow the account rather than the space, so the file publishes for its author and fails for everyone else -- and a message listing only the space's statuses, while the author can see theirs in Confluence's own picker, is how that becomes an unresolvable bug report. An empty value is a validation failure rather than an instruction. Every null spelling of a scalar reads as "", indistinguishable from an unfinished edit, so there is no way to clear a status from a file; labels: [] can mean "remove them all" only because a sequence has an empty spelling a scalar lacks. For #168.
The frontmatter reader needs the single-line scalar guarantee for it, like the other five fields whose readers return a plain string with nowhere to put an error; the project file needs it in entryFields, or a field would be expressible in a file and not in a pages: entry. pagemeta needs no change: coordinates is a whitelist, so a new field is graded as visible -- a disagreement between the two locations warns and frontmatter wins -- which is right for a status and is now pinned by a test. For #168.
A page_status: line is asserted; an absent one makes no state request at all, read included, which is the same property labels pins and is what keeps this free for every tree not using the field. update resolves the name once the page is known, before any write, so a name the space does not offer fails the file rather than half-publishing it. create resolves it in preflight, against the space homepage -- the vocabulary route is per-page and the page being created does not exist yet -- so a bad name aborts the batch before anything is reserved, which is #127's reasoning: there is no later run that repairs a created page with no status. The version handling is the part that is not obvious, and it came out of the first live create. A status write is the only metadata pass that bumps the page version -- a content-property write and a label write both leave it alone -- so recording the body PUT's version as the merge base left the page one version ahead of its own base, and the next update refused the file as diverged. pagestatus.VersionAfter re-reads it, and only when a status was actually written. create does not persist page_status, alongside labels and for both of that field's reasons: persist records what create resolved, a status is something the author already wrote down, and writing it back would rewrite their spelling to the space's canonical one. For #168.
info grows two rows, which is the browse path for "what can I write here": the page's own status, and page_status/available, the statuses its space offers. A space's statuses are its own configuration rather than a fixed vocabulary, so nothing in --help can document them and completion cannot offer them -- it may not call Confluence. info's existing status row and --json page_status field are renamed to content_status. That key was already taken by Confluence's content status (current/archived/trashed), and with three things wearing the word, neither of these can just be called "status". read and export emit the key, omitted both for a page with no status and for a failed read. That is labels' rule, and here it needs no argument of its own: nothing clears a status, so an emitted empty value would not parse as an instruction at all. check reports a present-but-empty value and nothing else, which is the present-but-empty title exception's reasoning -- both verbs reject it, so no verb makes it valid. The name it cannot check, and its Long now says so: this is the first frontmatter field whose vocabulary is per-space server state, and a green check would otherwise read as a promise the status will publish. diff compares the resolved id, not the spelling, which is parent's precedent: the match is case-insensitive and only an id travels, so two spellings of one status are not a difference. A failed page-side read is comparable: false rather than a difference. For #168.
…notes markdown_file.md gets the field row and the reason there is no project-wide default: a width is house style, a status is a claim about one page. json-output.md gets the two shapes and the content_status/page_status split. guarantees.md records that page_status holds L9 in both verbs, with the set-but-never-clear asymmetry against labels: [] written down rather than left to be inferred. api.md gets the three routes and their scopes, both of which a working token already carries -- and the note that the vocabulary route is a read behind a write scope, which is Atlassian's oddity. docs/commands/ is regenerated. For #168.
Two versions were being reported for one run: the log recorded where the page ended up, --json and the success line reported where the body PUT left it. A consumer comparing version.new against a later info therefore saw a mismatch for a bump markfluence made itself. versionFinal is now what both the base and --json report, and versionNew stays the body publish own version, which is what the "Updating (vA -> vB)" line is about; a status write shows as its own line and one more version. VersionAfter also returns its error instead of folding it into the fallback. Failing there is not benign: the status write has already landed, so the fallback is a version known to be behind the page, and recording it makes the next update of that file refuse it as diverged -- a conflict markfluence caused itself, with nothing to explain it. Both call sites now warn. And the name resolution moves after the divergence check. It has to stay before any request that could change the page, but a file about to be refused should pay for nothing, which is the rule the merge-base read above it already follows. For #168.
statusDifference read the local value through declared(), which treats a present-but-empty value as silence -- so a file with a bare page_status: produced no row and no warning and diff exited 0 "in sync" about a file neither update nor create will publish and check calls broken. That is the bug the labels comparison was fixed for, in the same function, with the reason written above it. pagestatus.Declared is the predicate that knows an empty value is a failure rather than silence. For #168.
…osts read and export both list the frontmatter fields they emit, and both had gone stale: they write page_status now. --help is this project reference and docs-check cannot catch this, since it regenerates from the same strings. The homepage lookup was described as free in three places. It reads the same /spaces response ResolveSpaceID reads, but it is a second request -- resolveSpace is not memoized -- bounded to once per space per run by create own cache and skipped for a file declaring no status. Also noted: because a create preflight failure aborts the batch, a tree exported from one space and created in another fails on the first file whose status the destination space does not offer. And two help paragraphs are rewrapped, one of which check gained a ragged line. For #168.
Measured with a scoped service-account token, which is what settled the question users.md left open: user-find needs read:content-details:confluence, and nothing implies it -- the token held fifteen other scopes, including read:confluence-content.all, and still got "scope does not match". Every other command worked. The README copy-pasteable list was also missing write:confluence-content, which labels have needed since #138 and page status needs now. The hint on an auth failure points at that list, so it has to be the whole list. Also recorded: a scope and a permission fail differently. A missing scope is a 401 saying so; a missing permission is a 403 naming it, or a 404 on a v2 route. A token with every scope here still writes nothing until its account has Confluence permission on the space.
… space Testing with a second account settled something the first could not. The statuses a page may be given depend on the caller and the page, not on the space: one account was offered four on a page it had created and three on a page in the same space that it had not, and the write enforces it with "User is not permitted to use this ContentState on this content." Two things rested on the wrong premise and are gone. The vocabulary cache was keyed by space id, so a batch could hand one page another page answer -- accepting a status the second page will refuse, or refusing one it would take. There is no cache now; Resolve takes the page it is asking about and holds no state. One request per page that declares the field, which is what the field being declared already gates. And create validated the name against the space homepage, which was wrong twice over: the homepage answers for itself rather than for the page about to be created (measured refusing Verified for a page that then accepted it), and the route needs edit permission on the page it is asked of -- which a collaborator who can create pages often lacks on a homepage, so create with a page_status failed outright for exactly the token this was tested with. The name is now resolved after the page exists, against the only page that can answer. That pays #127 cost for this one field -- a misspelled status is a warning on a created page rather than a refusal -- knowingly, because the alternative refuses files whose status is fine, and because the offline half is still caught in preflight. ResolveSpaceHomepage goes with it; it had no other caller. Messages now say "this page can be given ..." rather than "this space offers ...", which would send an author to space settings for a difference that is not there. For #168.
Moving create resolution out of preflight left its reasoning behind in several places, and a specification that contradicts itself is worse than one that is merely out of date. The record comment still described a resolved-in-preflight status field that no longer exists, immediately above the comment saying the opposite; publishOne opened with "the name was resolved to an id in preflight, so nothing here can fail", three lines above the code handling exactly that failure; and CLAUDE.md described the rejected design as the shipped one while contradicting itself two bullets earlier about a cache that is gone. The same drift reached the published contract. info Long, its --json comment, docs/json-output.md and the schema all called page_status_available what the space offers. It is what THIS page can be given, for THIS account -- the finding the whole reversal was about -- and a consumer who reads the schema and asks a convenient page about another page gets an answer the write then refuses. The schema is the artifact that outlives the help text, so it says so at length. Two real fixes with it: unknownStatus ran its custom-status branch before the empty-space guard, so a page that can be given nothing ended the sentence "This page can be given " and trailed off; and the frontmatter examples in docs and README ordered page_status after page_width, which is not what RenderFrontmatter emits -- it sorts, so read and export produce the opposite. And the coverage gap that matters: create publish phase had no tests at all, though it is where the name is now resolved and where the version-behind bug that motivated the reversal actually lived. Four, including the create half of TestAStatusWriteIsRecordedAtThePagesFinalVersion. For #168.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #168. Plan:
_plans/044_page-status.md. Findings:docs/confluence/page-status.md.Confluence shows a coloured lozenge beside a page title —
Rough draft,In progress,Ready for review,Verified. markfluence could not see it, set it, or report it, so a tree published from markdown lost a signal its readers use and a page adopted from Confluence silently dropped it. This adds one frontmatter field:updateandcreateassert it,readandexportemit it,inforeports it,diffcompares it,checkvalidates what it can offline. Absent means untouched — literally: a file with nopage_statusmakes no state request at all, read included, which is the propertylabelspins and what keeps this free for trees that do not use it.The safety property the whole design rests on
A name is never sent to Confluence; the wire format is an id. The
PUTbody'sidis optional, and a request without one takes a create path indistinguishable in shape and status code from the set path:{"name":"ready for review","color":"#57d9a3"}against a space spelling itReady for reviewanswers 200 and creates a new custom status — which no API route can delete (DELETE /rest/api/content-states/{id}404s, the collection 405s). Two such states are still stuck on my account from probing this.So markfluence resolves the name locally and writes
{"id": N}and nothing else, which makes that outcome unreachable rather than something validation has to remember to prevent.TestSetPageStateSendsOnlyTheIDfails the build if anameorcolorever appears in that body.What a second account changed
Most of this was built against one (unscoped, space-owner) token. Testing with a scoped service-account token given collaborator access falsified two premises the implementation rested on, and the last three commits are the correction.
The statuses a page may be given are per
(caller, page), not per space. Same account, same space: four statuses offered on a page it had created, three on a page it had not — and the write enforces the difference withUser is not permitted to use this ContentState on this content.Two things followed. The vocabulary cache, keyed by space id, could hand one page another page's answer; it is gone, andResolvenow takes the page it is asking about and holds no state. Andcreatecould not validate up front at all, because the only authoritative page is the one it has not made yet — probing the parent or the space homepage was measured refusing a name the new page then accepted.So
createresolves the name after the page exists, knowingly paying #127's cost for this one field: a misspelled status is a warning on a created page rather than a refusal before anything is made. That is the right trade only because the alternative refuses files whose status is fine — and because the offline half, a present-but-empty value, is still caught in preflight.state/availableneeds edit permission on the page it is asked about, consistent with the write scope it sits behind. A collaborator who can create pages in a space often cannot edit its homepage, which is exactly what broke the original probe. Useful side effect: it is a free, write-free "can this account edit this page?" check, which is how the permission testing here was done without creating anything.A status write bumps the page version, and that reaches the action log
Unlike a label write or a content-property write, setting a status produces a full page version —
minorEdit: false, empty message, an ordinary edit in history. Two consequences, both found by running it rather than reading it:Applyreads and compares before writing, so an unchanged status produces noPUT. Without that, every run would add a version to every page carrying the field.And the merge base (#149) has to name where the page ended up. The first live
createrecorded the bodyPUT's version, the status write moved the page past it, and the very nextupdaterefused the file as diverged — a conflict markfluence caused itself.pagestatus.VersionAfterre-reads it (a read, not+1: thePUTresponse carries no version, and inferring one would be wrong exactly when somebody else edited in between, which is the case the base exists to catch). Its failure is a warning rather than a swallowed error, because the fallback is a version known to be behind.Scopes
This also settles the question #167 left open.
user-find's scope isread:content-details:confluence, granular, and implied by nothing — measured with a token holding fifteen other scopes includingread:confluence-content.all, which still got401 scope does not matchwhile every other command worked.The README's copy-pasteable list was separately missing
write:confluence-content, which labels have needed since #138 and page status needs now. Both are added. Also recorded inapi.md: a scope and a permission fail differently — a missing scope is a 401 saying so, a missing permission is a 403 naming it or a 404 on a v2 route — so a token with every scope still writes nothing until its account has Confluence permission on the space.Smaller decisions worth knowing
The match is case-insensitive and nothing is rewritten, departing from
labels' warn-and-lowercase for a precise reason: labels sends the author's string to the server, this sends an id, so a case variant cannot reach the page and is not worth a warning. Two of a page's own statuses differing only in case are refused as ambiguous.There is no way to clear a status from a file.
labels: []can mean "remove them all" because a sequence has an empty spelling distinguishable from a null scalar; a scalar has none —page_status:,page_status: ~andpage_status: nullall read as"", which is what an unfinished edit looks like. So an empty value is a validation failure.guarantees.mdrecords this under L9 as a missing declaration rather than an unasserted one; the law is unweakened and its status unchanged.No project-level default, unlike
page_width: a width is house style, a status is a claim about one page, and a project-wideRough draftwould assert something false about most of a tree on every publish.inforenames itspage_statusfield tocontent_status. That key already meantcurrent/archived, and with three things wearing the word, neither can just be "status".--json'spage_status_availableis documented at length as what this page can be given for this account — not a space property — because the schema is the artifact that outlives the help text, and a consumer who asks a convenient page about another page gets a write refused.checkvalidates the shape and not the name, and itsLongsays so: this is the first frontmatter field whose vocabulary is per-space, per-page server state, so a greencheckwould otherwise read as a promise the status will publish.Testing
make checkclean. Verified end to end against mozilla-hub with both tokens — the space owner's and the scoped service account's:create(including--dry-run, and specifically a status the parent does not offer but the new page does),update(body, status, labels, width, unchanged-skip, refused name),read,export,diff,info,check, and--jsonthroughout.Two rounds of
/code-review. The first found the version-reporting split, a swallowed error that would have produced false conflicts, anddiffgoing silent on an unpublishable file. The second found no control-flow bugs but a cluster of comments and docs — including the published schema — left describing the pre-reversal design, plus acreatepublish-phase coverage gap on the exact path where the live bug had been.Four fixture pages are left in my personal space, two owned by the service account, which are the only evidence of the per-page finding on that instance.