Development handoff · Publish workflow · v2 (ten review passes) · 18 Oct 2026

Publish: build specification

Everything the development team needs to build the polished Publish workflow as prototyped: scope, the data model, the state machine, each screen control by control, the AI calls, credits, audit, accessibility, acceptance criteria and the open questions. Nothing here adds a goal the prototype does not show; it makes the prototype precise.

In short

Glossary (one meaning each)

TermMeaning here
source / accreditorThe accreditor instance a cycle belongs to (id "SACSCOC-2018", prints "SACSCOC 2018"). One word: source in the UI, accreditor in prompts.
template section · document sectionTemplate section: a numbered group of standards in Step 1 ("6 Resources"). Document section: a unit of the rendered report in Step 4 (cover | toc | item:{id} | documents | links), the thing "viewed" counts. "Checking {n} of {m} sections" in Step 2 counts document sections.
standard · part · itemA standard is a numbered requirement (4.2); a part is a sub-requirement (4.2.a). Item is the model's word for either. Charging, AI calls and every "{n} standards" count standards only; parts never count as AI units.
findingAny check result, rules or AI. Never "issue" or "item".
holderThe person the approval chain says currently has a standard: its Writer when Not started or in writing, its Reader when In review, its Editor when with the editor. "Approved · written by {name}" names the writer, who is also the holder for a send back. Not the same as SPOL's app roles (reader, writer, editor, coordinator), which this page always writes in lower case.
label · draft_number · final_numberA version carries a draft_number from creation ("Draft 5" while editing, even when it will be published as a final) and gets a final_number only when a final publish succeeds; the label is "Draft n" until then and "Final n" after. Numbers are never reused.
revInteger version of the PublicationVersion row, sent as the ETag; bumped by the row's own writes only (selection, settings, cover, output, confirmations, edits, publish), never by views, runs or finding decisions.
snapshotAn immutable picture of what the version contains (references plus derived passage text and hashes). Its content_hash is what "stale" compares.
staleNo completed rules run exists for this version, or the latest run's snapshot content_hash ≠ the current snapshot's. One definition; the model row points here.
in_flightAn AI readiness run with status running, an Auto-format run, or a publish job on this version. Rules runs, awaiting_approval and preview builds are not in flight.
available · balancebalance = Σ(settle, refund, purchase, allowance). available = balance − open holds. Every "{n} left this year" shows available.
charge · hold · budgetcharge: the credits the institution pays (shown in the quote). hold: a reservation equal to the charge. budget: SPOL's internal USD limit for the run (1.5× the charge); never shown.
result_key · finding_keyresult_key = a1_key (or a2_key for the consistency result): hash of inputs and every pipeline version; what AiResult is stored under. finding_key = hash(item.input_hash, kind, anchor): stable across pipeline version bumps; what decisions attach to.
excludedA selected item with an open SendBack whose exclude flag is on; left out of the document, the score and the AI run until it returns.
coordinator · editorCoordinator is the superset: everything an editor may do plus withdraw, regenerate and clear. "Coordinator or editor" is written out wherever both may act.

Scope and reading paths

Backend: Glossary → Data model → States, gates, events → API → Credits & ledger → Audit → Scenarios → Fixture; AI spec §Pipeline, §Cost, §Audit. Frontend: Glossary → States (gates, feed) → each screen's control table with its rows in the per-control accessibility table → Copy rules → the UI strings quoted in those tables. QA: Fixture → per-screen Acceptance → Scenarios → Cross-cutting → Ledger walk → Audit names. Design: Scope → screens → Copy rules → Accessibility → Open questions (three dialogs are specified in text only: Rename, "Which standard should go back?", Delete with a run in flight).

In

  • Publish landing (with empty state) and the four steps with every state shown in the prototype: send back (dialog, after), AI readiness (locked, unlocked, finding detail, decisions), Auto-format (ask, after), reading mode, published.
  • Phone and tablet layouts for every screen.
  • The AI calls as specified in the AI call spec (A1, A2, B).
  • Credits charging, reserve and ledger entries for the two AI actions.
  • Audit entries for every action listed in §Audit.

Out (unchanged from today)

  • All Accreditation, the approval chain itself, comments on standards, the Document Repository: Publish reads from them and writes send-back comments and status; it does not change their UI.
  • Signature capture: the cover carries typed names and optional dates, as in V10.
  • Versions overview, signer workflow, AI credits administration beyond what PRD R26 already defines. (Copy link and Share with reviewers on the Published screen use the existing share dialog; nothing new is built.)

Reference set (read in this order)

  1. This page: the build specification. handoff.html
  2. Prototype canvas (interactive, 20 screens + sizes): ../index.html
  3. Publish flows (every path, screen by screen): flows.html
  4. AI call spec v5 (prompts, schemas, validators, charging, safety, gates; clean specification, no history): ai-spec.html
  5. How AI readiness works (user-facing dossier; the in-product help panel is board 2g and the text ships as the help article): dossier.html
  6. Polish plan (V10 originals beside the proposal, decisions and why): https://claude.ai/artifact/AyWWZFU7S5QCz3ADYdeM6W
  7. PRD R10, R14, R15, R24–R30: https://claude.ai/artifact/TZEpQk1SHaK6j5TkxqZZj5 and recovery/ux-review/PRD-accreditation-spec.md
  8. Sources: recovery/artifacts/publish-workflow-canvas/ (screens, build scripts, renders).

Data model (new or changed)

Five decisions the model rests on: (1) a Snapshot stores references to immutable repository versions plus derived passage text and hashes, never full copies; only publishing stores a rendered asset. (2) draft_number is assigned when a version is created, final_number when a final is published; numbers are never reused, a withdrawn final keeps its number. (3) Step 4 edits are version-local overrides in the snapshot; the repository is written only when "Also update the response in All Accreditation" is ticked, through the standards service as a new narrative version. (4) Exclusions are derived from open SendBacks per publication, never a flag on the version. (5) One rev per version (the ETag) covers the row, selection, cover and confirmations; section views, runs and finding decisions do not bump it; every write of the version row requires If-Match.

EntityFieldsNotes
Publicationid, institution_id, cycle_id, template_id, title, owner_user_id, next_draft_number, next_final_number, created_at. source_id is a read-only denormalisation of the cycle.UNIQUE(cycle_id, template_id). Title defaults to the template's report name; editable through PATCH. Numbers come from the counters, so a deleted Draft 2 leaves the next draft as Draft 3 and "Draft 2 deleted" stays unambiguous in the audit.
PublicationVersionid, publication_id, draft_number, final_number?, kind (null until published; draft|final), label (derived: "Draft 4" / "Final 1"), status (in_progress|published|withdrawn|deleted), rev (int, the ETag), template_revision_id, step_reached (1–4), selected_item_ids[], content_settings{documents,links,objectives,outcomes: cited|all|none; list_at_end}, response_type (narrative|ie|institutional), cover{preparer_user_id, ceo_user_id, liaison_user_id, ceo_signed_on?, liaison_signed_on?, submit_by, logo_asset_id?, cover_letter_asset_id?}, output{format: pdf|docx|html|website, file_name}, confirmations{sections, formatting, findings: {by, at} | null}, current_snapshot_id, published_snapshot_id?, source_dirty_at?, created_from_version_id?, readiness_run_id?, ai_run_id?, autoformat_run_id?, published_at?, published_by?, publish_idempotency_key?, published_output?{asset_or_bundle_id, format, pages, bytes, watermark}, deleted_at/by?, withdrawn_at/by/reason?, saved_at, saved_byItems are standards or parts (PRD §6: a part can be unselected alone). Computed on GET: gates, blockers[] in the stated order, readiness_stale, excluded[]{item_id, send_back_id}, sections[], run summaries. cover.submit_by "after today" is a gate (cover_valid), not a PATCH validation, so autosave never rejects a date that has since passed. A published version is immutable.
VersionSectionViewversion_id, user_id, section_id, viewed_atSection ids: cover | toc | item:{id} | documents | links; the set is derived from the snapshot. Views never bump rev, so one reader never 412s another.
Snapshotid, version_id, seq, cause, created_at, content_hash, items[]{item_id, included, excluded_by?, narrative_version_id, passages[]{id, hash, text}, judgement, chain_state, holder_user_id, attachments[]{file_id, file_version_id, missing}, link/objective/outcome ids, open_comment_ids[], input_hash}, selection, content_settings, response_type, cover, overrides[]{passage_id, text}, format_ops[], template_revision_idImmutable. Taken on first entry to Step 2, any Step 1 change, an edit save, Auto-format apply or undo, a send-back return, an item removed from the cycle, and lazily on load when source_dirty_at > the current snapshot (the chain's existing feed sets source_dirty_at on every open version that selects the item). Passage ids are stored in the snapshot; only new paragraphs get new ids, so anchors do not shift on insert.
PassageOverridesnapshot.overrides[]: passage_id, text, by, atStep 4 Edit mode. Also written to the repository only when the checkbox is ticked (409 "held in review" when the item is in review).
ReadinessRun (rules)id, version_id, snapshot_id, render_id, status (running|done|cancelled|failed|timed_out), progress{done,total}, effective_item_ids[] (= selected − excluded), started_at, finished_at, checks[]{key, status: pass|findings, counts}, findings[] (RuleFinding), score, verdict_draft, verdict_finalchecks[].key enum (the 8 checks): spell | image_size | table_format | links | attachments | narrative_comments | suggestions | headings. hidden_text is not a check; it is a finding kind raised by the pre-clean and listed report-wide only when found. Runs on entering Step 2 and on "Run checks again". Staleness is computed: run.snapshot.content_hash ≠ current snapshot content_hash.
RuleFindingid, run_id, item_id?, kind (spelling|grammar|punctuation|style|image_size|alt_text_missing|table_width|missing_attachment|placeholder|cover_field_missing|open_comment|suggestion_pending|heading_level|link_changed|hidden_text|not_approved), severity (high|medium|low), count, location{page?, paragraph?, element_id?}, description, excluded (bool)Report-wide findings have no item_id and never count per standard. Findings on excluded items are kept with excluded=true and omitted from the score.
AiResultresult_key (PK), item_id, input_hash, pipeline_versions{}, model, status (done|not_checked), not_checked_reason? (too_long|personal_data|ungrounded|retry_budget|provider_unavailable|refusal|cancelled|timed_out), findings[] (AiFinding), tokens{in,out,thinking}, cost_usd, created_atImmutable and shared: a stored result is reused by any run whose key matches (AI spec §Result keys). This is what makes "Run again" cost 0 for unchanged standards.
AiRunid, version_id, snapshot_id, result_keys[], models{a1,a2}, rubric_version, pipeline_versions{}, quote{charge, cached[], uncached[], needs_second_approval}, reservation_id, idempotency_key, charge_credits, refunded_credits, model_cost_usd, tokens, items_checked[], items_not_checked[]{id, reason}, group_count, score, status (quoted|awaiting_approval|approval_declined|approval_expired|running|done|cancelled|timed_out|failed), planned_finish_at, approval{requested_from, decided_by?, at?, expires_at}, started_at, finished_at, started_byAn injection is an AiFinding of kind injection whose decision must be clear; there is no separate flag list. See the AI call spec, §Pipeline and §Audit.
AiFindingfinding_key = hash(item.input_hash, kind, anchor) (not result_key, so a decision survives template and model version bumps); item_id (or item_ids[2] for contradiction), kind (unsupported_claim|weak_argument|missing_element|clarity|evidence_mismatch|injection|contradiction), severity, confidence, anchor{passage_id?, passage_hash, quote?, rubric_question_id?, comment_id?} or anchor{fact_ids[2], measure} for contradiction, evidence_ref?, why, fix, status (derived: open|sent_back|closed|superseded), outdated (derived: anchor passage_hash ≠ that passage's hash in the current snapshot)"6.1 vs 5.1" is a contradiction with two item_ids. Status is derived from the latest AiFindingDecision, which is what carries decisions to Draft n+1.
AiFindingDecisionpublication_id, finding_key, kind (accept|ignore|reopen|clear), reason? (not_issue|covered|later|accepted_risk; ignore only), note? (free text; clear only), send_back_id? (accept only), by, atScoped to the publication, not the run. clear is coordinator only. "later" reopens on the next draft.
ReviewCommentid, publication_id, item_id, author_user_id, anchor{passage_id, start, end, quote} | null (overall), text, status (draft|posted|sent), posted_comment_id?, send_back_id?, created_at, updated_atThe publisher's review of a standard inside Publish. Drafts are private to the author and survive across drafts of the publication until posted or sent. Posting creates one comment per ReviewComment on the standard's thread (anchored where the comments service supports anchors; otherwise the quote is prefixed to the text). Sending compiles them into the SendBack comment and marks them sent.
SendBackid, publication_id, origin_version_id, item_id, to_role (writer|reader|editor), to_user_id, comment_id, exclude (bool), rule_finding_ids[], ai_finding_keys[], review_comment_ids[], by, at, returned_at?, returned_cause? (resubmitted|approved|removed_from_cycle)Partial UNIQUE(publication_id, item_id) WHERE returned_at IS NULL gives the 409. The comment and chain transition are made in the chain service first; the row is written only on success. Exclusions carry across drafts until returned_at is set.
AutoformatRunid, version_id, snapshot_before_id, snapshot_after_id?, status (running|done|failed|nothing_to_format), idempotency_key, reservation_id?, charge_credits, refunded_credits, operations[]{op, target{element_id, page}, params, applied, undone_at?}, pages_changed[], undo_locked_cause? (edited|published), started_at, finished_at, started_byApply and undo each produce a snapshot; ops persist as Snapshot.format_ops. Page targets are informational; element_id is the anchor. See the AI spec §Call B.
PublishJobid, version_id, idempotency_key, status (queued|rendering|done|failed), progress, error?, output?, started_at, finished_atPublishing is asynchronous (184 pages render in seconds to minutes); the same Idempotency-Key returns the same job.
CreditLedgerEntryid, institution_id, kind (reserve|settle|refund|purchase|allowance), amount (signed), reservation_id?, run_kind?, run_id?, by, at, balance_afterPRD R26. available = balance − open reserves; entries are serialised per institution so balance_after is exact.

Hashes

States, gates and events

Version lifecycle

in_progress ──(Publish draft)──▶ published(draft)       immutable: every write returns 409 "published"
in_progress ──(Publish final)──▶ published(final)       only when final_ready = true
published(final) ──(Withdraw, coordinator)──▶ withdrawn  file stays downloadable, marked Withdrawn; Final numbering continues (Final 2)
in_progress ──(Delete, confirm)──▶ deleted               published versions never delete
any published ──(Start Draft n+1 / Duplicate)──▶ new in_progress version, created on demand only (never automatically)
published(draft) ──▶ cannot be withdrawn or deleted (it is an internal record; Start Draft n+1 instead)
in_progress with a run in flight ──(Delete)──▶ the run is cancelled first (AI: in-flight standards settled, the rest refunded; Auto-format: refunded), then deleted

Duplicate takes the current template revision and copies the source version's selection, settings, cover and output; it is offered on any published or withdrawn version, never on a live draft (that would fork it). Exclusions are carried by reference: the open SendBack rows belong to the publication, so Draft n+1 reads the same rows and sees them lift when the item returns.

Orthogonal state each version carries: stale, the three confirmations, and in_flight (none | ai | autoformat | publish; a preview build is not in flight, it only locks confirmations through confirmations_unlocked). "Clear confirmations" always means null all three and write version.unconfirmed{cause}.

What Draft n+1 inherits: selection, content settings, response type, cover and output settings. Not inherited: confirmations, viewed sections, readiness and Auto-format runs. AI finding decisions carry over when the finding's (input hash, kind, anchor) still matches; "Will fix after this draft" reopens. Send-back exclusions carry until the standard returns.

AiRun status adds approval_expired (7 days without a decision; reservation released; card returns to the quote with "Approval expired · Ask again"). awaiting_approval is not in flight: it blocks nothing and the draft can be published meanwhile; the run, when approved, uses the snapshot it was quoted on, and if that snapshot is no longer current the quote is void (409 "The draft changed since the quote · Quote again").

AiFinding status is derived: open (no decision, or reopened) · sent_back (accept decision with an open SendBack) · closed (ignore, or clear for injection) · superseded (the result key that produced it is no longer in the latest run, because the input changed and the finding did not recur; shown greyed under "Resolved by the latest run", no buttons). When a new run reproduces the same finding_key, the previous decision carries, whatever draft it was made in. Accept and Ignore are refused on an injection finding (only Mark cleared applies), and an injection finding keeps blocking a final even when outdated until it is cleared or superseded. A send-back prefill lists the standard's open findings with each one ticked; unticking one leaves it open.

A standard returning from send back: when the chain reports it resubmitted or approved, SPOL sets SendBack.returned_at, lifts the exclusion, marks readiness stale, clears the confirmations (cause recorded) and shows "{code} came back from {name} · included again · run checks again". Its AI findings stay sent_back until the next AI run. The banner reads "included again" only when the send back had excluded it; otherwise "{code} came back from {name} · run checks again". A chain approval that arrives without a send back only recomputes final_ready (no banner, no clearing). An exclusion is lifted only by the item returning; there is no manual lift, and a standard that is sent back outside Publish cannot be excluded from this version (the coordinator unselects it in Step 1 instead).

Gates (computed, shown as text wherever they block)

Definitions. excluded = selected items with an open SendBack where exclude is on. in_flight = an Auto-format, AI readiness (status running only; awaiting_approval does not block) or publish job is running for this version; a rules run never blocks. stale = no completed rules run, or the run's snapshot content_hash ≠ the current snapshot's. Every gate below is evaluated server-side on every POST as well as in the client; a POST that fails a gate returns 409 {code: "gate", blockers[]} and records nothing.

GateTrue whenBlocker order and message
can_startrole ∈ {coordinator, editor} ∧ ¬impersonating ∧ source, cycle and template chosen"Only coordinators and editors can do this" → "Not available while impersonating" → "Choose a report template to start"
cover_validsubmit_by after today (drafts and finals)Step 3 inline error; Step 4 blocker "Submit date must be after today"
signers_completepreparer, CEO and liaison each have a current holder (finals only)Step 3 "No one holds this role · Assign in Admin" (not an error); Step 4 Final blocker "Add the three signers in Cover"
confirmations_unlocked(user)status = in_progress ∧ role ok ∧ ¬impersonating ∧ preview complete ∧ the user has viewed every section of the current snapshot"Published versions can't be changed" → "Only coordinators and editors can do this" → "Not available while impersonating" → "Preparing the preview · {n} of {N} pages" → "View all {N} sections to confirm ({k} viewed)"
draft_readyall three confirmations present (any confirmer; each entry keeps its own by/at)"Confirm {3−c} more to publish · {v} of {S} sections viewed"
draft_publishablestatus = in_progress ∧ role ok ∧ ¬impersonating ∧ |selected ∖ excluded| ≥ 1 ∧ ¬in_flight ∧ file_name ≠ "" ∧ cover_valid ∧ draft_ready409 published → role → "Not available while impersonating" → "Nothing to publish" → "Wait for {Auto-format | AI readiness | publishing} to finish" → "File name can't be empty" → "Submit date must be after today" → draft_ready message
final_readydraft_publishable ∧ signers_complete ∧ excluded = ∅ ∧ every selected item approved ∧ no injection finding without a clear decision ∧ ¬staledraft_publishable reasons → "Add the three signers in Cover" → "Not yet: {codes} not approved or excluded by send back" → "Text that tries to steer the review was found in {code} · clear it in Readiness" → "Readiness check out of date · Run again" → draft_ready message. On the Published screen the "Final · Blocked · reason" line is evaluated for a hypothetical Draft n+1 and its action is "Start Draft n+1".
readiness_stalesee stale above; a new snapshot is taken on: selection, settings or response-type change; an item removed from the cycle; narrative, evidence or comment change on a selected item (feed); Auto-format apply or undo; an edit save; a send back that excludes; a return from send backStep 2 "Out of date · Run again" (or "Not checked yet · Run checks" when no run exists); Step 4 banner after Edit, Auto-format or Undo

What clears the confirmations

Roles and impersonation

Defaults the screens assume

Navigation and saving

Landing (Publish tab)

Landing screen

Controls

ControlBehaviourCopy
Source selectOptions: the institution's sources. Default: the source of the cycle in the header. Changing it resets Cycle to that source's current cycle and clears Template.Label "Source"
Cycle selectOptions: cycles of the chosen source, current first. Default: the header cycle. Disabled until a source is chosen (aria-disabled, hint "Choose a source first")."Cycle" · "2024–2025 (Current)"
Report template selectOptions: the source's templates. No default. Placeholder "Select a template…". Disabled until a cycle is chosen."Report template"
Start publicationEnabled only when can_start. Creates Publication (if none for template × cycle) and Draft 1, then opens Step 1. If an in-progress draft exists, the server answers 409 and the UI shows "Draft 4 is in progress · Open Draft 4 · Start Draft 5 anyway" (the second sends force=true).Disabled caption "Choose a report template to start" (role=status)
Page copySubtitle "Start a publication, or pick up a draft where you left off."; heading count "{n} in this cycle"; footnote "Published versions can't be deleted. A draft can be deleted from its ⋯ menu after confirming; the deletion is kept in the audit trail."; empty state "No publications for this cycle yet" + "Choose a source, a cycle and a report template above, then start. Drafts you save appear here."
TabsTwo tabs above the table: In progress ({n}) · Published ({n}) (role=tablist; the count badges are part of the tab name). In progress columns: Report · Version · Where it is · Owner · Last edited. Published columns: Report · Version · Format · Pages · Published by · Published on. Search applies to the active tab. Empty states per tab: "No drafts in progress" / "Nothing published yet". The tab chosen is remembered per user.
⋯ row menu (In progress)role=menu, opened by the row's ⋯ (aria-haspopup=menu, aria-expanded): Open (resumes at the step shown) · Rename publication · Duplicate as new draft · Delete draft (red; opens the confirm dialog "Delete Draft 2 of Compliance Certification? This can't be undone. The deletion is recorded in the audit trail." with Cancel / Delete draft). Esc or an outside click closes; focus returns to ⋯.
⋯ row menu (Published)Open · Download {format} · Copy link · Withdraw final (finals only, coordinator; red) · for a published draft the last line reads "Published versions can't be deleted".
Publications tableRows: every version of the cycle's publications, split across the two tabs, newest edit first (prototype seed: Compliance Certification Draft 4 at Step 4, Online MBA Draft 2, Compliance Certification Draft 3 published 10 Oct, Fifth-Year Final 1 published 2 Oct, Compliance Certification Draft 2 at Step 1 and Draft 1 published 17 Sep). Columns: Report (title link + template · cycle), Version, Status (dot + word), Where it is (Step n · name, or for published: format · pages), Owner, Last edited. Row click = title link.Status words: In progress · Published · Withdrawn
⋯ row menu (original note)Open · Rename publication · Duplicate as new draft · Delete draft (drafts only; confirm dialog: "Delete Draft 2? This can't be undone. The deletion is recorded.") · Download (published only) · Withdraw final (coordinator, published finals only; confirm: "Withdraw Final 1? The file stays downloadable and is marked Withdrawn. This is recorded." with a required reason). The Delete dialog adds "An AI readiness run is in progress and will be cancelled" when a run is in flight or awaiting approval.
Loading and errorsSkeleton rows while loading; on failure role=alert "Couldn't load publications · Retry".
SearchFilters rows by title, template, owner (client-side).Placeholder "Search"
Empty stateWhen the cycle has no versions: illustration, "No publications for this cycle yet", one line, no table.

Acceptance

Step 1 · Standards & content

Step 1 screen

Controls

ControlBehaviourCopy
Context rowBreadcrumb Publish › publication title; chip "Draft 4"; template · cycle; stepper (completed steps are links; future steps not links); "Saved" text (saves on every change, debounced 1 s; "Saving…" while pending; "Couldn't save · Retry" on failure).
Heading line"Standards · {selected} of {total} selected · {not_approved} not approved" (the count is a link that applies the Not approved filter).
SearchFilters standards by code or title; sections with no match hide."Search standards"
Filter segmentedAll {N} · Not approved {n} · Unselected {n}. Counts live.
⋯ menuSelect all · Clear all · Expand all · Collapse all.
Section rowChevron (collapse, remembered per user per version), tri-state checkbox (all/none/mixed; click selects or clears all standards and parts in the section), "{n} Name · {k} of {m} selected".
Standard rowCheckbox; code; title; status dot + word (Approved · In review · Not started · Sent back). Selecting a standard selects its parts; a part can be unselected alone.
Part row (4.2.a)Indented 48 px; same controls.
Note under list"{n} selected standards are not approved yet ({codes}). You can still publish an internal draft; a final needs every selected standard approved." or "Every selected standard is approved."
Content settingsMatrix radios per row (Documents, Links, Objectives, Outcomes) × (Linked in narrative, All linked, Don't publish). Defaults from the template. Switch "List documents and links at the end".Helper "What prints with each standard."
Response typeRadios Narrative · Institutional Effectiveness · Institutional, one-line descriptions (to confirm with the team). Default from the template.
FooterSave and exit (to landing) · Next: aria-disabled with "Select at least one standard" when nothing is selected, otherwise enabled.
Standard removed from the cycleOn load the selection is pruned; banner "{code} was removed from this cycle and is no longer selected"; audit selection_changed{cause: standard_removed}; readiness stale; confirmations cleared.
Template revised after Draft 1A version stores template_revision_id. When a newer revision exists: "This template changed on {date}. Draft {n} keeps the version it started with; Draft {n+1} will use the new one."
PartsSelecting a part selects its standard; a part can be unselected alone.

Acceptance

Step 2 · Readiness check

Step 2 screen

Controls

ControlBehaviourCopy
Top line"Last run {date, time} · {k} of 8 checks completed" · Run checks again (re-runs the rules on the current snapshot; progress "Checking {n} of {m} sections" with Cancel) · Download report (Excel) (all rule findings). Before any completed run: ring "—", verdicts "Not checked yet", tables hidden, Next enabled. Cancelled: "Checks cancelled · Run checks". No finish in 10 minutes: "Checks couldn't finish · Run again"; the previous run stays displayed; audit readiness.run{status}.
Page copyChips "Rules · free" and "AI · locked / unlocked"; Standards intro "Content problems go back to the person who holds the standard, with the findings as a comment. Formatting is fixed in Preview with Auto-format, not here."; success banner after a send back "{code} sent back to {name} with your comment · left out of this draft until it returns · recorded in the audit trail."
Readiness (rules)Score ring with text equivalent; "Internal draft: ready/not ready" (ready unless 0 standards selected); "Final: ready" or "Final: not yet · {codes} not approved"; formula line with the computed form ("100 − 18 − 10 − 10 = 62"); five bars with text values: Approvals ("{a} of {n} approved"), Evidence ("{e} of {n} complete" = standards with every expected attachment present), Comments ("{c} open"), Formatting ("{f} findings · Auto-format"), Accessibility ("{h} heading level(s)"); recommendation paragraph generated from the findings (templated sentences, no AI). The twelve checks: Spelling, grammar and punctuation (WProofreader server API over every narrative, with SPOL's accreditation dictionary and the institution's dictionary; Medium for spelling, Low for grammar and punctuation), Style (WProofreader style-guide rules: SPOL's accreditation style guide plus the institution's rules; Low), Headings, Table format, Image sizes, Alt text, Link validation, Missing attachments, Placeholder text (bracketed or templated placeholders, TBD, lorem; High), Required cover fields, Open comments and unresolved edits. Hidden text is raised by the cleaner, which runs as part of every rules run (not one of the 12), and is listed report-wide only when found. WProofreader also runs as you type in the All Accreditation editor with the same dictionaries, so the two never disagree; its checks are licensed by SPOL, cost no credits and are never labelled "AI". Its AI writing assistant stays disabled in the SDK. Only findings tied to a standard count in the score; report-wide formatting is counted once in the Formatting bar and never per standard.Formula: 100 − 6 per unapproved selected standard − 2 per High − 1 per Medium finding on a standard. Prototype seed: 3 unapproved, 5 High, 10 Medium → 62.
AI readiness panel · hide / showBoth states of the AI panel carry a × ("Hide the AI readiness panel"). Hidden, the panel collapses to a one-line strip under the dashboard: locked "AI readiness · Locked · hidden from view · unlock any time for 29 credits · Show"; unlocked "AI readiness 58 · {n} open findings · 1 contradiction · hidden from view, nothing lost · Show". The rules dashboard then takes the full row (max 760 px). The choice persists per user and draft; nothing is deleted or stopped (a running AI run keeps running).Strip role=status; Show returns focus to the panel heading
AI readiness, lockedBlurred preview (aria-hidden) behind the unlock card: title, four bullets, note, cost "{charge} credits for {n} standards · {available} left this year", Unlock AI readiness (primary, AI accent), What it checks (link to help). For writers the cost line is hidden and Unlock is aria-disabled: "Ask a coordinator or editor to unlock AI readiness". Unlock first re-runs the rules if they are older than the snapshot ("Running checks first…"), then starts an AiRun. Above 50 credits a dialog asks for a second coordinator ("This run needs {n} credits. A second coordinator must approve.", coordinator select); the card shows "Waiting for approval from {name}" with Cancel; the request expires after 7 days; audit ai_readiness.approval. Progress "Reading {n} standards… started by {name}" (a second user who unlocks the same key joins the run). Cancel: in-flight standards finish and are charged, A2 is skipped and its 5 credits refunded, "Cancelled · {n} of {N} checked". Reload resumes from the run status. Past the planned finish + 15 minutes: "AI readiness didn't finish · credits for unchecked standards refunded · Try again"; audit status=timed_out. If every standard failed: "AI readiness couldn't run · {n} credits refunded" and the card returns to locked.
AI readiness, unlockedScore ring; "Reviewer view: {n} likely follow-ups"; "Contradictions: {n} to resolve"; five bars; findings list; "Run again" (opens the quote); tally "{open} open · {sent} sent back · {closed} closed · {credits} credits used · {available} left this year · recorded in the audit trail · How AI readiness works". After an excluding send back the top line reads "Out of date · {code} was sent back and left out · last run {date} shown · Run checks again" and the previous run stays displayed.
AI finding rowTitle (standard code + name, or "6.1 vs 5.1"), one-line description, View details / Hide details (aria-expanded). Detail: Kind · Where · Reviewer question (the related rubric question, "—" for a contradiction) · What it saw (quote) · Why it matters · Suggested fix · Confidence (dot + word). Buttons: Accept and send back (opens the Send back dialog for the finding's standard, prefilled with this finding; for a cross-standard finding such as "6.1 vs 5.1" the dialog first asks which of the two standards to send back, both listed with their values) · Ignore (reveals reason select + Mark closed + Cancel). Row shows the severity word before the description. "How AI readiness works" opens the in-product help panel (board 2g): six promises, what it reads and never sees, the five checks, why a finding can be trusted, how the score is built, privacy and data, manipulation, cost, limits, the record, and the rubric, model and pipeline versions in use for this draft; footer links "Open the full dossier" and "Specification for engineers". Content is the dossier, maintained by SPOL, not editable by institutions. Closed rows: chip only, no strikethrough, chip "Closed · ignored", Reopen. Sent rows: chip "Sent back". Outdated rows: chip "Outdated". Standards not checked appear as rows "{code} not checked · {reason}" (too long · personal data found · couldn't be grounded · retry budget · provider unavailable · provider refused · cancelled · timed out) with "{n} credits refunded" (not shown for the two quote-time exclusions, which were never charged). An injection row has "Mark cleared" (coordinator; a free-text note is required and stored as the decision's note; decision kind clear, reason null; audited).Reasons (values): Not an issue: the AI misread it (not_issue) · Already covered elsewhere in the report (covered) · Will fix after this draft (later) · Accepted risk: leave as written (accepted_risk)
Standards in this draftOne expandable row per selected standard, approved or not (12 in the fixture; clean rows read "No findings · All 8 checks passed · approved {date}" with a green "Passed" in the severity column): code + name · "{n} findings" · description · severity counts · "Status · held by" (an approved standard reads "Approved · written by {name}") · Send back (or Remind for Not started: sends the existing reminder to the writer and shows "Reminded"). Sorted by code. Each row has Review (opens the review panel) and Send back (or Remind). Intro: "Open any standard to read it, comment on it, or send it back, approved ones too." Expanded (native disclosure, chevron rotates): one line per finding "{Severity} · {Kind} · {where} — {detail}" (comments carry the quoted comment, author and date; formatting findings say what Auto-format can do; evidence findings give expected vs attached), then "Send back puts these findings in a comment for the person who holds {code}; the text itself is fixed in All Accreditation · Open {code} in All Accreditation". A clean standard expands to "All 8 checks passed. You can still read the response, comment on it, or send it back · Review {code} · Open {code} in All Accreditation". A Not started standard expands to "No response has been written yet, so there is nothing to check. Due {date} · Writer {name}." The first row with findings is open by default on first visit; open state is per user and session. A standard sent back from an AI finding updates this row the same way.
Report-wide items (removed from Step 2)There is no Report-wide block on Readiness. Each item lives where it is acted on: Formatting is the Formatting bar in the rules dashboard ("7 findings · Auto-format", linking to Step 4, where Auto-format runs and shows before and after); the cover letter is Step 3 (Cover & signers) and the "Before you publish" line on Step 4; links are the Links section of the document (Step 4) and count in the Evidence bar; hidden text removed, when it happens, is shown as a banner on Step 2 ("Hidden text removed from 2.1 · see the standard") and in the Download report. Report-wide rule findings never count per standard.
Review panel (S2f)Right-side panel (role=dialog, 720 px, background inert) opened from Review or the standard's name, for any selected standard including approved ones. Header: "{code} {name}", status chip, "{judgment} · written by {writer} · approved {date} by {editor} after review by {reader} · {n} findings · {m} open comments", Close. Body: Response as it will print (read-only; the passages that carry a finding or an open comment are marked ①② in amber, matching the Findings list; "Open {code} in All Accreditation to edit the text"). Inline review comments: selecting any text (or pressing C with the caret in a paragraph) shows a popover (the prototype approximates text selection by clicking a sentence, and a second click in the same paragraph extends the range; the build uses real text selection at character level) "Comment on the selected text" with the quoted selection, a textarea, "Add as comment {letter}" and Cancel; each added comment highlights its passage in blue with a letter mark A, B, C… and is pinned to {passage_id, start, end, quote}. Your review of {code} box: the list of inline comments (letter · text · quote · paragraph · Edit · Remove), an Overall comment textarea for the whole standard, and the line "Your comments stay together, linked to {code}, and go in one of two ways…". Drafts autosave per user and publication until posted or sent · Findings (same lines as the row) · Evidence (each document: name, type, pages; missing ones flagged) · Comments (the standard's thread from R17: open first, resolved greyed with "Resolved by {name}, {date}") · Add a comment textarea + Post comment: writes to the standard's thread with origin: publish, notifies the holder, leaves the chain state unchanged ("the standard stays Approved"), audit comment.created. Footer: Send back with {n} comments (primary; opens the Send back dialog for this standard with one compiled comment: overall, then each lettered comment with its quote and paragraph, then the readiness findings; editable before sending; "Leave out of this draft" is off by default for an approved standard) · Post {n} comments · stays Approved (each inline comment becomes an anchored comment on the standard's thread, the overall one a plain comment, all tagged from Publish; the holder is notified; chain state unchanged) · "A send back reopens the chain; posting alone does not." · Close. Both actions are aria-disabled with "Write a comment first" when there are no drafts. Not started: the Response section reads "No response yet" and Post comment still works.Empty comment → Post comment aria-disabled "Write a comment first". Posting while impersonating → 403 "Not available while impersonating". The panel URL is /publish/versions/{id}/step/2?review={item_id} so Back closes it.
Send back dialogTitle "Send {code} {name} back"; radios Writer · Reader (who holds it) · Editor with one-line effects; Comment textarea prefilled from every open rule and AI finding on that standard except injection findings (which are never sent back); each prefilled finding is ticked and can be unticked; the ticked AI findings become sent_back; checkbox "Leave {code} out of this draft until it comes back" (default on); Cancel · Send back. On send: SendBack created, comment written to the standard (origin: publish; origin: ai when it carries AI findings, so the AI pipeline excludes it from future inputs; with the finding ids), chain state moved per the approval chain's rule, row shows "Sent back to {name}", success banner, audit entry, confirmations cleared if excluded. On failure the dialog stays open with role=alert "Couldn't send {code} back · {reason}"; 409: "Already sent back by {name}"; nothing is recorded on failure. If every selected standard is excluded: verdict "Internal draft: not ready · no standards left in this draft" and Publish is disabled with "Nothing to publish".
FooterBack · Save and exit · Next (always enabled).

Acceptance

Step 3 · Cover & signers

Step 3 screen

Controls

ControlBehaviourCopy
InstitutionName and address read-only from the institution profile; "Edit in Admin › Institution" link.
Report preparerSelect of users with a role in the cycle; default the current user.
Page copyInstitution block locked "From the institution profile" with "Edit in Admin › Institution"; "Branding · Your institution's assets, not SPOL's."; logo row "{file} · School logo · uploaded {d Mon yyyy} · {size}" with Replace and Remove; cover-letter drop zone with "Upload cover letter"; preview caption "Updates as you type."
CEO · Accreditation LiaisonSelects bound to the institution's role holders; default the current holders. A vacant role shows "No one holds this role · Assign in Admin"; a person who left the cycle shows "{name} · no longer in this cycle"; either prints a blank line and is listed in "Before you publish"; a vacant role blocks a final (signers_complete), never a draft.
Institution address empty"Before you publish" lists "Institution address missing · Edit in Admin".
Dates signed (2)Optional date inputs; any date allowed (a signed date is in the past by nature)."Leave the dates blank until the cover has been signed. The names print on the signature lines either way."
Submit dateRequired; must be after today when entered; inline error with icon; prints as "Submit by {d Mon yyyy}". If a saved date has since passed: warning "Submit date has passed", Next stays enabled and Step 4 blocks with "Submit date must be after today".Inline error: "Submit date must be after {today}." · Next reason: "Fix the submit date to continue"
LogoExisting asset row with Replace · Remove; upload accepts SVG/PNG/JPG ≤ 2 MB.
Cover letterOptional upload, DOCX/PDF ≤ 10 MB; shown as a file row once added.
Upload errors"File is larger than {limit}" · "Use SVG, PNG or JPG" / "Use DOCX or PDF" · "Upload failed · Try again"; the previous asset is kept.
Cover previewLive render of the cover from the fields; aria-hidden.
FooterBack · Save and exit · Next (disabled with reason when the submit date is invalid).

Acceptance

Step 4 · Preview & publish

Step 4 screen

Controls

ControlBehaviourCopy
Thumbnail railOne thumbnail per section (cover, contents, each standard, documents, links); viewed tick; comment marker; current outline. Click scrolls the viewer to the section's first page and marks it viewed.
Section navigatorFirst in the toolbar (Preview and reading mode): ‹ Previous · a select listing every document section ("3.1 Governing Board · viewed · comments"), showing the current one · Next ›. Moving marks the target section viewed. Keyboard: ← / → (or J / K) move sections when focus is in the document.Buttons aria-labelled "Previous section" / "Next section", aria-disabled at the ends; select aria-label "Go to section"
Document toolbar"Page {a}–{b} of {N}" · zoom − / 100% / + (75–200%) · Find in document (highlights, Enter cycles) · "{v} of {S} sections viewed" (role=status) · Next unviewed · Preview | Edit · Auto-format with AI (AI accent; accessible name adds "· 4 credits") · Reading mode (maximize icon). Print forms: the accreditor prints as "SACSCOC 2018" on the cover and running heads, "SACSCOC-2018" is the source id in the UI; the cover title is the publication title as entered.
ViewerPaginated render of the generated document (same renderer as the output); pages on a grey canvas; pages load lazily; Find is server-side above 200 pages. While the preview builds: "Preparing the preview · {n} of {N} pages", the rail fills as pages arrive, confirmations stay locked until complete; after 2 minutes "Couldn't build the preview · Retry" (role=alert). A section counts as viewed, per user, when 60% of its first page has been in view for 2 s or its thumbnail is clicked; the person confirming must have viewed every section themselves. A cited evidence file that was deleted prints "(file missing)", raises a High missing_attachment rule finding, marks readiness stale and is listed in "Before you publish".
Edit modeBanner with "Also update the response in All Accreditation" checkbox, Save, Discard; inline text editing of narrative passages only; a save writes a new snapshot, clears confirmations, marks readiness stale, hides Auto-format Undo ("Can't undo after editing"), and (if the box is ticked) updates the standard's response and leaves a comment. The box is aria-disabled while the standard is held in review: "Not available while {code} is in review". Unsaved edits: beforeunload prompt; Back, Save and exit and thumbnail clicks ask "Save your edits? Save · Discard · Cancel"."Editing clears the confirmations and marks the readiness check out of date."
Reading modeHides rail, header, thumbnails and side column; slim bar: Exit (Esc also) · title · section navigator (‹ select ›) · page · zoom (shared with the viewer) · Find · viewed · Next unviewed · Comments (3) toggle (aria-pressed). Comments panel (340 px, right, open by default when the current section has comments): heading "{n} comments on {section} · 3 open in the report"; each comment: author, date, a button with the section name that jumps there, text, Resolve / Reopen with an Open / Resolved chip; a reply box "Reply or add a comment on {section}" with Post comment ("Does not reopen approval."). The page column stays at the same width; the panel scrolls on its own. Viewed sections carry back. Review in reading mode: the same inline review as the Readiness review panel: select (prototype: click) a sentence on the page → comment box under the paragraph → "Add as comment {letter}" pins it with a blue letter mark; amber marks show readiness findings and existing comments; the comments panel opens with Your review of {code} (inline comments with quote and paragraph, Remove, an Overall comment, "Send back {code} with {n} comments" → the bundled Send back dialog, "Post {n} comments"), then the Thread. Drafts are the same ReviewComment records as on Readiness, so a comment started in one place continues in the other.
Confirm & attest"{c} of 3 confirmed · signers attest on the final version"; lock notice while not confirmations_unlocked; three checkboxes with summaries; "Confirming as {name} · not available while impersonating. Signers attest on the cover."
Comments (3)Swaps the side column for the open comments list; Resolve writes to the standard's comment thread; Back to Confirm.
Auto-format with AIDialog (what it fixes, never touches, afterwards, cost); "Auto-format document" (primary; initial focus on Cancel) starts an AutoformatRun. Progress modal (board 4b, aria-busy): "Formatting the document · Nothing changes until it finishes. You can keep reading; the result opens with every change listed, and each one can be undone." · progress bar "{n} of {m} pages laid out · about {s} seconds left" · per kind: Tables {d} of {t} decided (scaled / landscape) · Images {d} of {t} scaled · Headings {d} of {t} candidates checked · Citations {d} of {t} in one style, {k} sent to the model · Paragraph spacing {d} of {t} · Contents rebuilt at the end · "4 credits are charged only when the run completes" · Cancel ("nothing applied, nothing charged"). The modal can be dismissed to keep reading; the footer then shows "Formatting · {n} of {m} pages". Before-and-after view (board 4d, opened from "See before and after" or any row of the change list): summary tiles (tables fitted · images scaled · heading level and spacing fixes · citations), a list of changes (kind · what changed · page; one representative per kind in the prototype, every change in the build, grouped by kind), and for the selected change a side-by-side Before / After of the affected fragment with the page edge marked in red where content was cut off, a one-line "what changed" and "who decided" (AI decision with the alternative, or rules), an Applied / Undone chip, Undo this change / Apply again, "Open this page in reading mode", and the running count "{k} of 23 changes kept · {u} undone · Undo keeps the 4 credits"; footer "Undo all 23" · Done. after: banner, change list in the rail with Undo all (per-change Undo sits in the page margin), margin marks, confirmations cleared, readiness stale. Whole-run failure: "Auto-format couldn't finish · no changes applied · nothing charged · Try again" (settle 0). No candidates: no charge, "Nothing to format · no credits used". Undo is unavailable after an Edit and after publishing.
PublishFormat segmented (PDF · Word · HTML package · Website) with one-line helper; Version radios (Internal draft · Final for submission with the first blocker as the reason when not final_ready); File name input prefilled "{Institution}_{Accreditor}_{Report}_{Version}_{yyyy-mm-dd}{.ext}", characters \ / : * ? " < > | stripped, max 120 characters, extension fixed, empty → "File name can't be empty"; "Before you publish" notice listing open items (open findings, comments, excluded standards, vacant roles, missing address, missing files).
Before you publishWarning line "Before you publish: {n} open findings · {m} open comments · formatting not yet run. None of it blocks a draft." ("formatting not yet run" disappears after Auto-format).
FooterBack · Save and exit · reason text · Publish draft / Publish final (aria-disabled with reason until draft_publishable / final_ready; also "Wait for {Auto-format | AI readiness | publishing} to finish" while a run is in flight). Publishing opens a confirm: "Publish Draft 4 as PDF? Still open: {n} findings, {m} comments. This is recorded." Then a modal "Publishing · {n} of {N} pages" (no cancel, 5-minute limit); the request carries the etag and an idempotency key; failure: "Couldn't publish · nothing was recorded · Try again" (no version number consumed); 412: "This draft changed since you confirmed it. Reload." The prototype's "(prototype only: skip ahead)" link is not built.

Acceptance

Published

Published screen

Acceptance

API (service contracts)

Every write to the version row carries If-Match: "{rev}" (428 when absent, 412 when stale). Every request that creates or charges something carries an Idempotency-Key. Status codes: 403 is always role or impersonation; 409 is state (published, in flight, open send back); 410 deleted; 412 stale rev; 422 invalid input. Error bodies are {code, message, …detail} with the message strings from the screen tables.

EndpointPurposeContract
GET /publish/cycles/{cycle}/publications?cursor&sort=last_edited:descLanding tableFlat rows: version_id, label, kind, status, step_reached, where (step name or Published), owner, last_edited_at/by, allowed_actions[]. Cursor pagination.
POST /publish/publicationsStart publication{source, cycle, template, force?} → 201 {publication, version}; 409 {existing_version_id, label} when a draft is in progress and force is not set; 422 template not in source; 403 writer. Idempotency-Key required (a retry must not create Draft 5 twice).
PATCH /publish/publications/{id}Rename{title}; audit publication.renamed.
GET /publish/versions/{id}Load a versionVersion + computed {gates, blockers[], readiness_stale, excluded[], sections[], run summaries}; ETag header = rev.
PATCH /publish/versions/{id}AutosavePartial body; If-Match required; 412 stale → "Someone else saved this draft. Reload."; 409 published; 410 deleted; 422 item not in cycle scope. A selection, settings or response-type change takes a snapshot; the response carries saved_at and the new ETag.
DELETE /publish/versions/{id}Delete a draft204; 409 published; 403 writer. Audit version.deleted.
POST /publish/versions/{id}/readiness · POST …/readiness/{run}/cancel · GET …/readiness/{run} · GET …/readiness/{run}/report.xlsxRun the rules, cancel, poll, download202 {run_id}; 409 returns the in-flight run; download writes version.downloaded.
GET /publish/versions/{id}/ai-readiness/quoteQuote before unlock or run again{charge, cached[], uncached[], balance, needs_second_approval, rules_rerun_needed, quote_token (hash of snapshot + key set), expires_at}.
POST /publish/versions/{id}/ai-readinessUnlock / run again{quote_token} + Idempotency-Key → 202 {run_id, status: running | awaiting_approval}; 409 quote stale or run in flight (returns the run to join); 402 insufficient credits; 403 writer or impersonating.
POST /publish/ai-readiness/{run}/approve · /decline · /cancelSecond-coordinator approval; cancelapprove/decline 403 when approver = requester; approval expires after 7 days → status approval_expired; cancel settles in-flight standards and refunds the rest.
POST /publish/publications/{id}/ai-findings/{finding_key}/decisionaccept | ignore | reopen | clear{kind, reason?, send_back?{to_role, to_user_id, comment, exclude, item_id for contradictions}}; 422 ignore without reason; 409 already sent_back; 403 writer; clear is coordinator only.
GET /publish/versions/{id}/items/{item_id}/reviewReview panel dataRendered passages with finding and comment anchors, judgment, chain history, evidence with presence, open and resolved comments (from the comments service). Any selected item, whatever its state.
GET/PUT/DELETE /publish/publications/{id}/items/{item_id}/review-comments[/{rc}]Draft review comments (inline and overall)PUT {anchor?, text}; autosaved; 422 when an anchor's quote no longer matches the current snapshot (the client shows "The text changed · re-pin or remove"); 403 writer or impersonating.
POST /publish/publications/{id}/items/{item_id}/review-comments/postPost all drafts to the standard's threadDelegates each to the comments service (R17) with origin: publish and the anchor; 201 {comments[]}; drafts → posted; chain state unchanged; 422 when there are no drafts.
POST /publish/versions/{id}/send-backSend a standard back{item_id, to_role, to_user_id, comment, exclude, rule_finding_ids[], ai_finding_keys[], review_comment_ids[]} → 201 {send_back, item_status}; review drafts → sent; 409 open send back exists; 422 recipient is not the holder; chain service first, row on success.
POST /publish/versions/{id}/remindRemind a Not started writer{item_id}; sends the existing reminder (PRD R5); 429 if reminded within 24 h: "Reminded today".
POST /publish/versions/{id}/viewsRecord viewed sections{section_ids[]} batched; never bumps rev; 403 impersonating.
GET /publish/versions/{id}/comments · POST /comments/{id}/resolveOpen comments in Step 4Delegates to the existing comments service (PRD R17); resolve 403 unless own comment or editor.
POST /publish/versions/{id}/preview · GET …/preview/{render}Build and read the document renderReturns progress, page URLs, section → page map, find index.
POST /publish/versions/{id}/editsStep 4 Edit save{passages[]{passage_id, text}, also_update_all_accreditation} + If-Match; 409 "held in review" when ticked and the item is in review; clears confirmations; audit version.edited.
POST /publish/versions/{id}/autoformat · GET …/autoformat/{run} · POST …/autoformat/{run}/undoAuto-format, poll, undo202; Idempotency-Key; 402; undo {operation_id | "all"}; 409 undo locked {cause: edited | published}.
POST /publish/versions/{id}/confirmationsToggle a confirmation{key, value} + If-Match; 403 impersonating; 409 not unlocked; returns confirmations + gates.
POST /publish/versions/{id}/publish · GET /publish/jobs/{job}Publish draft or finalAsync: 202 {job_id}; same Idempotency-Key → same job; 412 stale rev; 409 published or {blockers[]}; 422 file name. On completion the version is published, pinned file versions recorded (PRD R25), published_snapshot_id set; every later write returns 409 "published".
GET /publish/versions/{id}/download · POST …/link · POST …/regeneratePublished actionsdownload: signed URL, audit version.downloaded; link: audit version.link_copied; regenerate: coordinator, from published_snapshot_id, 404 when a pinned file version is gone.
POST /publish/versions/{id}/withdraw · POST …/duplicateWithdraw a final · start Draft n+1withdraw {reason}, coordinator; duplicate 201 (409 without force when a draft is open).

Permissions

Server feed per open version (events the client reacts to)

EventPayloadWhere it landsWhat the user sees
version.savedrev, saved_byAny stepIf this client's rev is older: "Someone else saved this draft. Reload." (controls revert)
version.published_elsewhereby, at, labelSteps 1–4"Draft 4 was published by {name} at {time} and can't be edited. Start Draft 5 · View published"; controls aria-disabled
version.deletedbyAny step"This draft was deleted by {name}. Back to Publish."
readiness.stalecauseStep 2, Step 4"Out of date · Run checks again" / Step 4 banner
send_back.returneditem_id, name, was_excludedSteps 1, 2, 4"{code} came back from {name} · included again · run checks again" (or without "included again")
run.progress / run.finishedkind, run_id, done, total, statusStep 2 (AI, rules), Step 4 (Auto-format, publish)Progress strings per screen; footer "Wait for {kind} to finish" while in flight
approval.decidedrun_id, decision, byStep 2"Approved by {name} · Reading…" / "Approval declined by {name} · Ask again" / "Approval expired · Ask again"
preview.progressrender_id, done, totalStep 4"Preparing the preview · {n} of {N} pages"

Credits, ledger and what the coordinator sees

Ledger rules (invariants the tests assert)

Ledger walk (12 standards, charge 29, balance B)

PathEntries (kind amount → balance_after)Coordinator sees
12 uncached, donereserve −29 → B; settle −29 → B−29"29 credits for 12 standards" → "Reading 12 standards… started by {name}" → "29 credits used · {B−29} left this year · recorded in the audit trail"
4 stored, donereserve −21; settle −21 → B−21"21 credits for 8 standards · 4 already checked"
Cancel after 4 of 12 (k in flight)reserve −29; settle −(8+2k); refund +(21−2k) → B−8−2k"Cancelled · {4+k} of 12 checked"; next quote 2 × (8−k) + 5
Timeout (d stored)reserve −29; settle −2d; refund +(29−2d) → B−2d (by system)"AI readiness didn't finish · credits for unchecked standards refunded · Try again"
All failedreserve −29; settle −29; refund +29 → B"AI readiness couldn't run · 29 credits refunded"; card locked again
Approval granted / declined / expiredreserve −charge at request; on approval the run proceeds as above; on decline or expiry settle 0 → Bsee Approval above
Auto-format done / failed / nothingreserve −4; settle −4 → B−4 / settle 0 → B / no entriesbanner / "nothing charged" / "Nothing to format · no credits used"
Model cost above quoteidentical to done; SPOL absorbs USD up to 1.5 × the charge, then "not checked: retry budget" and refund 2"{code} not checked · retry budget · 2 credits refunded"

Audit entries

ActionPayload (besides who, when, version)
publication.created · publication.renamed · version.created · version.deleted · version.withdrawnids, titles, numbers
version.selection_changed · version.settings_changed · version.cover_changeddiff
readiness.run · readiness.stalerun id, score, counts, cause of staleness
ai_readiness.quoted · ai_readiness.run{status: done|cancelled|timed_out|failed|approval_declined|approval_expired, cause?} · ai_readiness.approval{decision: requested|approved|declined|expired, by, requested_from} · ai_finding.decision{kind, reason?, send_back_id?}per the AI spec §Audit; one entry per terminal state, reservation_id and ledger entry ids included
send_back.createdstandard, to, comment id, excluded, finding ids
review_comment.posted · review_comment.sentitem, review comment ids, anchors, comment ids or send_back_id
comment.created{origin: publish}item, comment id (the comments service's own entry; Publish adds the origin)
autoformat.run · autoformat.undooperations, pages, credits
version.section_viewed · version.confirmed · version.unconfirmedsection ids; which confirmation; cause when cleared
version.editedpassages changed, also_updated_all_accreditation
version.publishedkind, format, asset id, pages, open findings and comments at publish
send_back.reminded · version.regenerated · version.edit_rejected{held_in_review}item, who; snapshot id; item
version.duplicated · send_back.returned · readiness.run{status: failed|cancelled} · autoformat.run{status: failed|nothing_to_format} · version.downloaded · version.link_copiedids, reasons, who

Accessibility requirements (WCAG 2.2 AA)

Per-control names, roles and announcements

Screen · controlName / roleKeyboard, focus, announcement
Landing · Source, Cycle, TemplateLabelled selects, each marked required (red asterisk in the label, aria-required, one "* Required" legend under the row); Template placeholder is not a valueChanging Source announces "Cycle set to {cycle}; template cleared" (role=status)
Landing · Start publicationbutton, 32 px, bottom-aligned with the three selects in the same row; aria-disabled + aria-describedby="start-why" while gatedReason text sits on the legend line under the row, right-aligned (id=start-why role=status "Choose a report template to start" / "Opens step 1 with your choices filled in")
Landing · tabs and ⋯ row menuTabs role=tablist / tab with aria-selected and aria-controls; ⋯ aria-label "More actions for {title}, {label}", aria-haspopup=menu, aria-expanded; menu role=menu with menuitemsLeft/Right move between tabs; in the menu Arrow keys move, Esc closes and returns focus; after Delete focus moves to the next row's title link or the tab
Landing · Searcharia-label "Search publications""{n} of {N} publications shown" role=status, debounced
Landing · load errorrole=alert with a Retry buttonFocus moves to the alert
Step 1 · treerole=tree; sections role=treeitem aria-expanded; checkbox names "{code} {title}, {status}" and "{n} {name}, {k} of {m} selected" (aria-checked=mixed when partial); chevron "Expand {name}" / "Collapse {name}"Up/Down move rows, Right/Left expand and collapse, Space toggles, Home/End
Step 1 · filterrole=radiogroup "Filter standards"; names "All, 12", "Not approved, 3", "Unselected, 0""{n} standards shown" role=status; the "3 not approved" link applies the filter and announces it
Step 1 · content settingsEach row a radiogroup named by its row header; each radio named by its column header (table with th scope)Switch role=switch aria-checked with its helper via aria-describedby
Step 1 · Next (gated)aria-disabled + reason idReason role=status when it changes; removed-standard banner role=status (not alert)
Step 2 · Run checks againbutton; while running aria-disabled "Checking {n} of {m} sections"Progress role=status; Cancel returns focus to the button; completion announces "Checks completed · {k} of 8 · score {s}"
Step 2 · score ringrole=img "Readiness score 62 out of 100, internal draft ready, final not yet"Bars aria-hidden; text values are the accessible version
Step 2 · Unlock AI readinessbutton "Unlock AI readiness · 29 credits"Blurred panel aria-hidden and inert; "Reading {n} standards…" role=status
Step 2 · Run again (AI)button "Run again" (opens the quote)Quote dialog initial focus on Cancel
Step 2 · View details / Hide detailsaria-expanded, aria-controls, aria-label "View details · {code} {title}"Focus stays on the toggle
Step 2 · Accept and send backbutton aria-haspopup=dialogAfter sending, focus goes to the row's "Sent back" chip
Step 2 · Ignoreselect "Reason for ignoring" aria-required; Mark closed aria-disabled "Choose a reason first"Cancel returns focus to Ignore; on close "Finding closed · ignored" role=status
Step 2 · Reopenbutton "Reopen · {code} {title}""Finding reopened" role=status
Step 2 · chipsText in sentence case ("Sent back", "Closed · ignored", "Outdated"); uppercase by CSS only; not controls
Step 2 · Send back (rows)button aria-label "Send {code} back with comments", aria-haspopup=dialogAfter sending, focus to the row's chip; success banner role=status tabindex=-1 receives focus
Step 2 · Remindbutton; after sending aria-disabled "Reminded {date}""Reminder sent to {name}" role=status
Step 2 · Review panelrole=dialog aria-modal labelled "{code} {name}"; Response region labelled "Response · as it will print"; amber marks ①② aria-labelled "Open comment 1" / "Finding: {kind}"; blue marks aria-labelled "Your comment A"; the selection popover is role=dialog labelled "Comment on the selected text" with the quote as description; the review list is a list named "Your review comments"; Overall comment textarea labelledInitial focus on Close; Esc closes (or closes the popover first); selecting text moves focus nowhere, the popover opens below the paragraph and its textarea takes focus only when the keyboard shortcut C is used; adding announces "Comment A added to paragraph 1" role=status; posting announces "{n} comments posted to {code}"; Tab order: Close → response links → review list actions → Overall → footer
Step 2 · standards in this draftEach row is a <details> with a <summary> named "{code} {name} · {n} findings · show details" (aria-expanded comes from the element); severity cell text "1 High, 1 Medium"; finding lines are a list named "Findings for {code}" with the severity as a text badgeEnter/Space on the summary toggles; the Send back button inside the summary does not toggle (stopPropagation); the expanded list is in the tab order only through its links
Send back dialogrole=dialog aria-modal labelled "Send {code} {title} back"; radiogroup "Send to" with each option's person and effect via aria-describedby; textarea "Comment" (optional, prefilled text editable); native checkbox; Cancel and Send back buttonsInitial focus on the first radio; Arrow keys move and check; Esc = Cancel; on failure the alert receives focus
Step 3 · Submit datelabel "Submit date"; aria-describedby="dhint-hint dhint-err"; hint "Must be after {today}. Prints as "Submit by {d Mon yyyy}"."Error id=dhint-err role=alert "Submit date must be after {today}" with aria-invalid; Next aria-disabled "Fix the submit date to continue"
Step 3 · signer selectsVacant: aria-describedby "No one holds this role · Assign in Admin" (not an alert)
Step 3 · uploads"Replace logo", "Remove logo", "Upload cover letter"Errors role=alert with focus; "{file} uploaded" role=status
Step 3 · profile fieldsreadonly inputs, aria-describedby "From the institution profile"
Step 4 · thumbnailsbutton "{section} · viewed · {n} comments"; aria-current on the current sectionArrow Up/Down move
Step 4 · zoom"Zoom out" / "Zoom in"; value "Zoom 100%" role=statusAt the limits "Minimum zoom" / "Maximum zoom"
Step 4 · Preview | Editradiogroup "Mode" (aria-checked only)
Step 4 · Auto-format with AIbutton "Auto-format with AI · 4 credits", aria-haspopup=dialog; dialog initial focus on Cancel; primary "Auto-format document"Focus returns to the button; after: banner role=status with focus; each Undo "Undo · {change} · p. {n}"; "Undo all" confirms and announces "All 23 changes undone"
Step 4 · Reading modebutton aria-label "Reading mode"; toolbar role=toolbar "Reading mode"; initial focus on "Exit reading mode"; background inertEsc returns focus to the Reading mode button; viewed count changes announced
Step 4 · confirmationsrole=checkbox named title + summary; aria-describedby the lock notice while lockedOn unlock the notice is removed and "You can now confirm" announced
Step 4 · Formatradiogroup "Output format"; role=radio aria-checked (no aria-pressed)
Step 4 · File namearia-describedby "Extension is fixed; \ / : * ? " < > | are removed"Empty → role=alert "File name can't be empty", aria-invalid
Step 4 · Publish draftaria-disabled + the footer status as reason; confirm dialog initial focus on Cancel"Publishing · {n} of {N} pages" role=status aria-busy
Step 4 · Edit modeSave, Discard buttons; checkbox aria-disabled with its reasonSave announces "Saved · confirmations cleared"
Step 4 · Comments (3)button; list replaces the side column; Resolve "Resolve comment by {name}"Focus moves to the list heading; Back returns focus to Comments
Landing · In progress / Published tabsrole=tablist "Publications"; each tab role=tab with aria-selected and its count in the name ("In progress, 2"); the table is the tabpanelLeft/Right move between tabs and activate; the empty state is a paragraph inside the panel, not a dialog
Landing · row ⋯ menubutton "Actions for {title} {version}" aria-haspopup=menu aria-expanded; items role=menuitem (Open, Rename, Duplicate, Download, Copy link, Withdraw, Delete)Enter/Space/Down open on the first item; Up/Down wrap; Esc closes and returns focus to ⋯; Delete opens the confirm dialog with focus on Cancel
Step 1 · Select all / Clear allTwo text buttons beside the count (also in the ⋯ menu)"{n} of 12 selected" role=status updates; focus stays on the button
Step 1 · Content settingsFour native radio groups (one per row: Documents, Links, Objectives, Outcomes) named "{row}: {column}"; column heads are visible text, repeated in each radio's nameArrow keys move within the row; Tab moves to the next row
Step 2 · standard row<details> with a <summary> "{code} {title}, {n} findings, {status}"; Review and Send back are buttons outside the summary so they are not toggled by itEnter/Space on the summary toggles the findings list; the first row is open on arrival; Send back opens the dialog in place with focus on the Send-to group
Step 2 · Send back dialog · commentEditable region "Comment, prefilled, editable" (role=textbox, aria-multiline) under a visible label; findings list uses native checkboxesSend back closes the dialog, marks the row "Sent back" (chip tabindex=-1 receives focus) and shows the "Out of date" banner role=status
Step 2 / Reading mode · sentenceinline span role=button tabindex=0 aria-pressed (not a native button, which would break the paragraph flow); the comment letter is role=img "Comment {letter}", the finding mark role=img "Finding {n}"Space/Enter toggles selection; the comment box appears after the first selection and receives focus; Esc clears the selection
Step 4 · Publish draft (gated)Link when the gate passes; otherwise a span role=button aria-disabled with aria-describedby to the reason ("View all 16 sections to confirm.")Stays in the tab order; Enter does nothing while gated; the reason is read after the name
Step 4 · VersionNative radios "Internal draft" and "Final for submission"; Final is disabled with aria-describedby to the "Not yet:" lineArrow keys skip the disabled option; the reason is read with the group
Reading mode · Comments (phone)aside "Comments" rendered as a bottom sheet at ≤ 900 px; Close button first in itFocus moves into the sheet when opened; Close returns focus to Comments (n)
Step 2 · Send all back…button beside the "Standards in this draft" heading, aria-haspopup=dialog, with the line "{n} of 12 standards carry findings or AI recommendations"; hidden when nothing is left to sendOpens the dialog with focus on Cancel; rows are native checkboxes "Send {code} back"; the primary reads "Send back {n} standards" and is aria-disabled with reason when none is ticked
Step 2 · Send all back dialogrole=dialog "Send all standards with findings back"; one row per standard not already sent and not unstarted: code and title, what the comment includes ("{n} findings · {m} AI"), who holds it, whether it keeps printing or is left out; optional one-line note added to every commentSend marks every ticked row "Sent back" (chips tabindex=-1, first receives focus), sets the AI findings on those standards to Sent back, raises the "Out of date" banner and the status line "{n} standards sent back … · {codes} left out"; Esc cancels
Step 2 · Send back dialog · AI recommendationsWhen AI readiness is unlocked, a second checklist "AI recommendations to include · suggestions only" lists the open AI findings on that standard; the prefilled comment carries them under "AI recommendations (suggestions only, from the AI readiness run of {date})" with the suggested fixSend sets those AI findings to Sent back as well; the writer sees them as suggestions, never as decisions
Step 2 · AI panel · What it checks and howLink in the unlocked panel header, on both hidden strips and on the unlock card, to the "How AI readiness works" panel (board 2g)Opens the dossier panel; Back returns to Readiness
Step 4 · Auto-format with AI dialogrole=dialog "Auto-format with AI": checklist of the change kinds found by the readiness check with counts, pages and how AI decides each ("Fit 2 tables to the page width · p. 15, 118 · scaled, or landscape below 85 %"), the always-on rebuild of contents, "What never changes", "What you get afterwards", the cost line with model and data terms; primary "Auto-format · {n} changes · 4 credits"Untick a kind to leave it; Cancel autofocus; Esc returns focus to the toolbar button
Step 4 · Order sectionsbutton in the Preview toolbar and the reading-mode bar, aria-haspopup=dialog; dialog "Order sections": ordered list of the 16 sections in print order, Cover and Contents fixed first, Documents and Links fixed last, Move up / Move down buttons named "Move {section} up/down" and aria-disabled at the ends, "moved" chip on rows out of template order, status line "{n} sections out of template order…", Reset to template order, Apply order (aria-disabled when unchanged)Apply reorders thumbnails, the section select and ‹ › navigation, rebuilds contents and page numbers, clears the three confirmations, shows "Section order changed …" role=status and writes an audit entry; standard numbers never change
Step 4 · OptionsNative checkboxes under Publish: Table of contents, Page numbers, Page break before each standard, Running headers, Documents and links listed at the end (from Step 1), Cover letter (disabled with reason "None uploaded · add it in Step 3"), "Draft" watermark (disabled, follows the version)Each carries its hint as visible text; disabled ones are aria-describedby their reason; the Published summary lists the options used
Published · headingh2 tabindex=-1 receives focus on arrivalCopy link: "Link copied" role=status; failure role=alert

UI copy rules (one form everywhere)

Cross-cutting acceptance and seed fixture

Cross-cutting

Seed fixture (prototype state, as of 17 Oct 2026)

Worked scenarios (state checks the tests must cover)

  1. Edit after confirming. Given 3 of 3 confirmed. When the publisher edits a passage and saves: confirmations null, audit unconfirmed{edited}, stale, Undo hidden, footer "Confirm 3 more to publish". Discard changes nothing.
  2. Undo after Auto-format. Given Auto-format applied. When Undo all: audit autoformat.undo, no refund, confirmations cleared, stale. When the publisher then edits and saves: Undo reads "Can't undo after editing".
  3. Send back and return. Given 4.2 sent back with exclude on (confirmations null, its AI findings sent_back). When the feed reports it resubmitted: exclusion lifted, stale, confirmations null, findings still sent_back, banner. After Run again, view all, confirm 3, Publish draft → published(draft); Final still says "Not yet: 3.3, 4.2, 6.2 not approved or excluded by send back" until the chain approves them.
  4. Cancel an AI run. Given 12 standards, 4 done. When Cancel with none in flight: settle 8, refund 21 (including the 5 for consistency), "Cancelled · 4 of 12 checked". When Unlock again: "21 credits for 8 standards · 4 already checked".
  5. Impersonation. Given an admin impersonating X, who has viewed all and confirmed 2 of 3. When the admin opens Step 4: checkboxes aria-disabled, POST → 403, no views recorded for X, X's confirmations untouched.
  6. Two confirmers. Given A viewed all and confirmed Sections. When B (9 of 16 viewed) opens Step 4: "1 of 3 confirmed", lock "(9 viewed)". When B views all and confirms the other two: draft_ready, each entry keeps its own by/at.
  7. Auto-format by someone else. Given 3 of 3 confirmed, another user starts Auto-format: footer "Wait for Auto-format to finish" via the feed. When it finishes first: confirmations cleared, rev bumped, this user's Publish → 412 "This draft changed since you confirmed it. Reload."
  8. Final blocked by one standard. Given the variant where 3.3 and 6.2 are approved and 4.2 is excluded: Final radio reason "Not yet: 4.2 not approved or excluded by send back"; a forced POST returns 409 {code: gate, blockers}. When 4.2 returns approved, Run again, reconfirm → final_ready.
  9. Template revised. Given Draft 4 on revision r1; r2 drops 6.3: notice only, Draft 4 unchanged. When Start Draft 5: r2, selection minus 6.3 with the banner, confirmations null, exclusions carried.
  10. Writer fixes a finding. Given 3 of 3 confirmed. When a writer fixes a spelling finding: stale, confirmations kept. When Run checks again: finding set differs → only "Findings and comments" cleared, "2 of 3"; identical set → nothing cleared.
  11. Approval expires. Given a 61-credit run (28 standards) awaiting approval for 7 days: status approval_expired, reservation released, card "Approval expired · Ask again"; nothing was blocked meanwhile.
  12. Delete with a run in flight. Given an AI run with 6 of 12 done. When the owner deletes the draft (confirm dialog adds "An AI readiness run is in progress and will be cancelled"): run cancelled and settled, version deleted, both audited.

Open questions for the SPOL team

  1. What do the three response types pull from? The descriptions in Step 1 are placeholders.
  2. When a standard is sent back from Publish, does review restart at the Reader when resubmitted, or resume at the level that sent it back?
  3. Is "Confirm & Attest" the right term when the CEO and liaison sign the cover separately?
  4. Does a Step 4 edit that ticks "Also update All Accreditation" need the standard's approval again?
  5. Is 4 credits for Auto-format and 2 per standard for AI readiness the pricing the team wants to launch with?
  6. Deferred from the audit. Row menus need roving focus (Up/Down, Home/End) in the build; the prototype only opens and closes them. Content settings should be a real table (th per row and column) if the build keeps the grid. Loading, empty-after-error and offline states are specified in §States but have no boards. The AI score is seeded, not computed from the five bars; the build computes it. After a send back from reading mode, Exit returns to Preview; confirm whether it should land on Step 2 instead.
  7. WProofreader contract. Cloud or self-hosted? Cloud sends narrative text to the vendor's AWS US servers with no published retention period; self-hosted keeps it inside SPOL. The dossier and the order form name WProofreader as a sub-processor if the contract is cloud. Also: does the team want the AI writing assistant at all? If yes it sits behind the institution's AI switches, inside the Assist menu, on SPOL's own Bedrock account, never as the vendor's badge.
  8. Decision needed (specified in text, beyond the 14 screens): the second-coordinator approval surface ("{name} asks you to approve…"); template-revision banners; Withdraw with reason, Duplicate, Rename and Regenerate (they edge toward a versions overview, which the scope lists as out); large-report grouping above 24 standards; the cross-standard "Which standard should go back?" picker; Remind with its 24-hour limit. Each is kept here so the behaviour is defined if built; none is required for the first release.
  9. Still to align after the screen walk (iteration 9, not yet applied): landing seed rows (Draft 1/2/3/4 of Compliance Certification and Fifth-Year Final 1, "6 in this cycle", empty state under 2025–2026); S2c "11 of 12 checked" needs a not-checked row and a 27-credit tally or 12 of 12; "2 contradictions" vs one listed; S2e should read "Out of date · Run again" after the excluding send back; S2d detail panel lacks kind and reviewer question; S3 "Upload file" → "Upload cover letter"; S2 verdict form "Final: not yet · 3.3, 4.2, 6.2 not approved" and the computed formula line; S4b summary-rail copy and L0/S3 subtitles to be quoted in the control tables.
  10. Dr. James Okonkwo is the CEO; the approval-chain editor and the substantive-change owner are now Priya Natarajan in the prototype. Confirm the real role holders for the demo data.

Iteration log

Ten review-and-fix passes over the whole set (screens, prompts, spec, this page). Each pass had a different lens.

  1. Baseline written.
  2. Cross-document consistency. 37 mismatches resolved: credits (29 for 12 standards, 4 for Auto-format) and balances aligned across prototype, plan, flows and AI spec; the rules score reseeded to the formula (62) and what counts stated; "acknowledged items" and "15 items" replaced by 16 open findings; Final for submission made aria-disabled with its reason; the Step 4 edit checkbox and Comments list restored; dates unified to 17 Oct 2026; audit names in one dotted form; decision kinds and ignore-reason values unified; injection message unified; severity added to AI rows; AI bars defined as shares of standards; PRD unlock bullets set to four; superseded round-1 lines marked in the AI spec; formatting counted once report-wide; "23 changes" reseeded; the Auto-format checkbox dropped; share scope corrected; 14 screens; flat landing list; one person per role (Priya Natarajan as editor); cover prints the title as entered; Find and shared zoom in reading mode; Auto-format banner mentions readiness; copy fixes (section labels, confidence colour, "written by", prototype-only links).
  3. States, edge cases and error paths. Added: loading and error states per screen; 409 "Start Draft 5 anyway"; 410 deleted-elsewhere; roles and impersonation rules; published-is-immutable (409 on every write); URL per step and reload rules; autosave and 412 behaviour; zero-standard gate; standard removed from the cycle; template revisions; first-run, cancelled and timed-out readiness; writer view of the AI card; second-coordinator approval above 50 credits; AI cancel, timeout, resume and "not checked" rows; injection "Mark cleared"; send-back failures and the all-excluded case; vacant roles and missing address; upload errors; preview build progress and failure; missing evidence files; per-user viewed sections; unsaved-edit prompts; Auto-format failure and undo limits; file-name rules; blocker order for Final; publish in flight, failure, idempotency and 412; published-elsewhere notice; Withdraw and Duplicate; what Draft n+1 inherits; the return from send back; eight new audit entries; four new endpoints.
  4. Data model and API. Split draft/final numbering with never-reused counters; rev as the ETag with If-Match on every version write; Snapshot entity defined (when taken, what it holds, content_hash); exclusions derived from open SendBacks per publication; AiResult shared by key with AiFindingDecision carrying across drafts; contradiction findings with two item ids; VersionSectionView so views never 412 other users; PublishJob (async publish); missing endpoints added: rename, cancel readiness, report download, quote token, decline, remind, views, comments, preview, edits, download, link, regenerate; permission matrix and server feed written; cover_valid gate; audit entries for remind, regenerate, quote and rejected edits.
  5. State machine. Every gate given a formal definition and a fixed blocker order; final_ready now builds on draft_publishable (closing the 0-standards, in-flight and empty-file-name holes); in_flight defined (awaiting_approval and rules runs do not block); Undo and exclusion added to the stale causes and Undo to what clears confirmations; feed content changes explicitly do not clear confirmations (with the reason); impersonation blocks Start and section views; approval_expired and superseded states added; injection findings refuse Accept/Ignore; published drafts can be neither withdrawn nor deleted; delete cancels runs first; Duplicate semantics fixed; twelve worked scenarios added for the test matrix.
  6. Accessibility and copy per control. Every control on the 14 screens given its name, role, keyboard behaviour, focus return and announcement; ten copy rules fixed (finding, Run checks again / Run again, Submit date, dates with year, holder form, credit lines that carry through a flow, Auto-format verbs, footer, chips, "Not yet:"); prototype fixed: Auto-format dialog overlay (was rendering unstyled at the page top), input border and AI button contrast, no strikethrough on closed findings, duplicate hint id on the submit date, aria-pressed removed from radios, Reading mode label, holder capitalisation, "due 20 Oct 2026", balances 1,837 → 1,833.
  7. Credits, ledger and audit. The hold is the charge (the 1.5× is SPOL's USD budget, not a credit hold); "left this year" is available, not balance; refunds enumerated incl. the 5 consistency credits whenever A2 does not run; approval request/decline/expire with the approver's screen text; Auto-format scans before charging and fails without charging; delete releases holds; five ledger invariants and a ledger walk; one audit form ai_readiness.run{status} + approval + quoted; not_checked reasons gain cancelled and timed_out; 402 and provider-down texts written.
  8. Screen walk against the renders (partial; see Open questions for the rest). Fixture date, balance chain (1,866 → 1,837 → 1,833), viewed count, strikethrough, Formatting severities, Auto-format change counts, Published card and Blocked row aligned to the prototype; prototype: "due 20 Oct 2026", "spacing findings", Final radio reason in the gate-table form, "formatting not yet run" dropped after Auto-format.
  9. Report-wide block removed from Step 2 (user review). Formatting, cover letter and links are handled in Preview, Cover and the document; Step 2 keeps the Formatting bar as the link to Auto-format. Handoff row and fixture updated.
  10. Auto-format shown end to end (user review). New boards 4b (progress modal with per-kind counts, cancel without charge) and 4d (before-and-after view: representative change per kind, side-by-side fragments with the page edge marked, who decided and the alternative, undo / apply again per change, running count, Undo all). Change-list rows and a "See before and after" button open it. 20 screens.
  11. Section content in Preview and reading mode (user review). The document is now rendered per section from a 16-section content set (cover, contents, 12 standards, documents, links) so the navigator, ← →, thumbnails and the comments jump buttons change the page: heading, judgment chip, paragraphs, the 3.1 table, evidence list and page numbers. Inline review comments are stored per section; "Your review" shows the current section's and counts the rest.
  12. Review in reading mode (user review). Reading mode now carries the full inline review: click a sentence to comment, letter marks, Your review list with overall comment, Send back / Post, sharing drafts with the Readiness review panel.
  13. Demo feedback, six items. Landing: working ⋯ menus per row (drafts and published), required markers and legend on Source / Cycle / Template, Start bottom-aligned with the selects and its reason moved to the legend line, In progress / Published tabs with their own columns and empty states (new board 0b). Help text consolidated to one lead per page and one hint per control. Step 2: the AI panel can be hidden to a one-line strip and shown again. Step 4: a section navigator (‹ select ›, ← →) in the Preview toolbar and in reading mode; reading mode gains a comments side panel with jump-to-section, Resolve / Reopen and a reply box. 18 screens.
  14. AI readiness dossier. A user-facing dossier "How AI readiness works" written for coordinators (promises, inputs and exclusions, checks, grounding, score, decisions, privacy, manipulation, cost, quality and fairness, limits, record, FAQ) and added as the in-product help panel (board 2g, linked from both "How AI readiness works" links on Step 2); the AI call spec rewritten as a clean v5 with no review history. 17 screens.
  15. Interactive review panel (user review). Board 2e is now live, not a static picture: the comment box appears only when a passage is selected (prototype: click a sentence, click another to extend), the typed comment is added with a letter mark and listed under "Your review", Remove works, the overall comment counts, and the footer labels update ("Send back with n comments" / "Post n comments"). Static copies of the earlier state removed.
  16. Inline review comments (user review). Selecting text in the review panel pins a comment to that passage (blue letter marks, distinct from the amber finding marks); an Overall comment covers the standard; drafts are listed in "Your review of {code}" and travel together, either posted to the thread (stays Approved) or compiled into one send-back comment with the findings (new board 2f); ReviewComment entity, draft/post endpoints, send-back payload, audit, a11y and acceptance updated; the readiness findings are unchanged. 16 screens.
  17. Review any standard (user review). All 12 selected standards are listed, approved ones too, each with Review and Send back; a Review panel (new board S2f) shows the response with finding and comment marks, the findings, the evidence, the comment thread and an Add-a-comment box that posts to the standard's thread without touching the chain; control table, API (review data, post comment), audit, a11y and an acceptance item added. 15 screens.
  18. Per-standard finding details (user review). Readiness rows now expand to list each rule finding with severity, kind, location and detail (comments quoted with author and date), plus the All Accreditation link; first row open by default; control table, a11y row and an acceptance item added.
  19. Deliverable read (four roles). 23 contradictions resolved (Auto-format failure copy, finding_key base, kind and status enums, impossible AR1 fixture, standard 7.1, 6.2's state, signers split into cover_valid and signers_complete, in_flight, force-start duplicating the last published version, dialog primary, blocker prefix, "items", Reading mode, one submit-date error, Withdraw and Delete dialog text, hidden text in every rules run, injection excluded from prefill, comment origin, clear note, quote field name, fixture balance); glossary added; reading paths per role; feed events as a table; defaults written out; scenarios moved beside the tests; six text-only behaviours marked "decision needed".
  20. Acceptance tests. Every acceptance item rewritten as Given/When/Then with fixture values; Step 3, Step 4 and Published given acceptance lists (they had none); 25 untested behaviours given tests; the summary matrix replaced by cross-cutting tests and a named seed fixture aligned to the prototype (12 of 12 standards, 3.3/4.2/6.2 unapproved, 16 findings → 62); contradictions fixed: score 64→62, Start creates Draft 1, approval_expired as a status, 8 checks vs hidden_text, 60→61 credits, not_approved rows carry no scored severity.
  21. Accessibility and enterprise-UX audit (user review). Three passes over the 21 screens: WCAG 2.2 AA (56 items), enterprise SaaS conventions (59) and a bug sweep (50). Fixed in the prototype: native radios everywhere (custom dots decorative), tabs and menus with full roles, <details> rows on Step 2, sentence buttons with aria-pressed, dialogs placed before main with inert background and focus on Cancel, placeholder and border contrast, Select all / Clear all, required markers, one hint per control, lock reasons as text, the Publish-draft gate as aria-disabled with its reason, zoom and status live regions, chips in sentence case, a single Undo all, page numbers and balances reseeded (1,866 → 1,862), reflow at 1100 / 900 / 390 px (reading-mode bottom sheet, single-column grids), the "Published" landing state after a publish (board 0c). Deferred and listed under Open questions: roving focus inside menus, true table semantics for Content settings, loading and error boards, the AI score arithmetic, the Reading-mode exit target after a send back.
  22. Demo feedback, round two. Send all back: one dialog sends every standard with findings to the person who holds it, each with its own compiled comment (rule findings and, when AI readiness is unlocked, the open AI recommendations with their suggested fixes), an optional line added to every comment, unapproved ones left out of the draft; the single Send back dialog carries the same AI recommendations checklist; both set the AI findings to Sent back. "What it checks and how" links to the AI readiness dossier from the unlocked panel and both strips. Auto-format dialog relaid out: per-kind checklist with counts, pages and how AI decides, what never changes, what you get afterwards, cost with model and data terms. Order sections dialog in Preview and reading mode (fixed first and last sections, move up/down, moved chips, reset, apply rebuilds contents and clears confirmations). Output options under Publish (contents, page numbers, page breaks, running headers, documents list, cover letter, watermark). New boards 2h, 2i, 4a2; 24 screens. Bug fixed from the demo: since the audit pass the dossier panel (2g), the send-back-with-comments dialog (2f) and the Auto-format progress modal (4b) were missing from their boards because their injection point no longer matched, so "How AI readiness works" appeared to do nothing; all three render again and the dossier panel's "Open the full dossier" opens the published dossier.
  23. Free checks aligned with WProofreader (user decision). Spelling, grammar, punctuation and style now run on WProofreader's server API under SPOL's licence, as you type in the editor and in batch at readiness, with SPOL's accreditation dictionary and the institution's dictionary; four rule checks added (Alt text, Placeholder text, Required cover fields, Style) for twelve in all; RuleFinding kinds extended; copy rule that language checks are never labelled AI; the AI writing assistant left disabled; the contract question (cloud vs self-hosted) added to Open questions. Prototype: 2.1 gains a Low style finding (17 open), "12 of 12 checks", "language checks by WProofreader" in the last-run line.

Sources and build scripts: recovery/artifacts/publish-workflow-canvas/. Renders at 1390 px.