think
16px
820px

Deploy note: single-document representation model (version roles)

Audience: whoever (human or agent) deploys this change to another environment
(e.g. PROD dms.val.id). It changes document/version semantics, not just UI, and it
ships without backwards-compatibility shims — backend and web MUST deploy together
(the standard deploy/update.sh does exactly that).

What changed

A document is now ONE logical record whose version chain carries representation
roles instead of spawning sibling documents:

role meaning written by
source uploaded/native file (docx/xlsx/pptx/pdf/…) uploads, office-editor saves, finalize-as-new
working in-app editable HTML (TipTap) editor saves, DOCX → editable conversion
official PDF rendered from an editable representation for distribution Make official copy / editor finalize (replace mode) / set-official

⚠️ The role is official, not published (renamed — see mig 00141)

This role shipped as published, and that word turned out to mean three
different things at once: the act of rendering the PDF, the version that gets
served, and the document's lifecycle state. (A fourth word, "final", was floating
around for the same version.) Each concept now has exactly one name:

concept old word now
the act of rendering the editable source to the distribution PDF publish / finalize make official copy (POST /documents/{id}/official-copy)
the version viewers, share links and signing serve role published / "final PDF" role official
the document went out to the client status published status sent (POST /documents/{id}/mark-sent)
the record has been replaced by a newer one status obsolete status superseded

The substantive fix is the third row. In this business "published" never meant
"a PDF exists" — it meant the document went to the client. Producing a file and
dispatching a record are different events, so they no longer share a word, and no
longer share a trigger: see Dispatch is now an explicit act below.

go/migrations/00141_document_sent_official.sql rewrites the stored values
(role='published''official', status='published''sent',
status='obsolete''superseded'), re-states the role CHECK over the new value
set, and adds a documents_status_chk CHECK pinning the lifecycle to
draft|sent|superseded. There are no back-compat aliases — the old strings are
refused by the constraint after this migration, so any hand-written fixture or SQL
that sets them must be updated.

Serving rule: read-oriented surfaces — detail hero preview + Download default,
share links (unless pinned to a version), signing (single-doc and envelope), folder
zip export — serve the latest official version, falling back to the current
version
when none exists. Uploads-only documents (no official copy ever) behave
exactly as before; that is what makes this deploy safe on existing data.

Signed / e-Meterai-sealed outputs inherit the role of the version they transform
(sealing the official copy keeps it the official artifact). Version rollback inherits
the role of the version rolled back to.

Dispatch is now an explicit act

The lifecycle status is a separate axis from the version roles, and only a human
moves it:

  • Making the official copy does not change the status. A rendered PDF sitting on
    disk is not a document that went to the client.
  • Completing an approval workflow does not change the status either (it used to
    force-publish).
  • POST /documents/{id}/mark-sent (Editor access) is the only forward verb:
    draftsent. It stamps the retention floor from the records type's schedule,
    which thereafter blocks deletion.
  • 409 dms.sent.no_official_copy — the document carries no official version yet.
  • 409 dms.sent.not_draft — a superseded document is past sent, not before it.
  • Repeating it is idempotent: 200 {"status":"sent","already_sent":true}, no write.
  • There is no unmark-sent verb. A status set in error is corrected with
    PUT /documents/{id}/status (also Editor access — both commit the organisation to
    a retention obligation).

Migration (auto-runs at boot)

go/migrations/00113_version_roles.sql — adds document_versions.role
(source|working|published, default source) with backfill:

  • text/html versions → working
  • a PDF that follows an HTML/DOCX version in the same document → published
    (that is exactly the old finalize-to-PDF output)
  • everything else → source

go/migrations/00141_document_sent_official.sql then renames the value in place
(publishedofficial) and the two lifecycle statuses (publishedsent,
obsoletesuperseded). On a fresh environment both run back-to-back and you land
on the final vocabulary; on an environment already carrying 00113, 00141 is the only
one that does anything.

Forward-only, no downtime, idempotent by goose bookkeeping. update.sh's pre-update
DB snapshot is the rollback net.

⚠️ Migration numbering: 00113 is now taken on main. The unmerged
worktree-peruri-esign branch carries its own 00113/00114 — renumber those to the
next free numbers before merging that branch (goose refuses out-of-order versions).

Behavioral / API changes (no back-compat)

  1. POST /documents/{id}/make-editable no longer creates a "(title) (editable)"
    sibling document. It appends a working HTML version to the SAME document and
    returns {"id": "<same doc id>"} (response shape unchanged — callers navigate to
    the same document's editor). Route access tightened Read → ReadWrite.
  2. New POST /documents/{id}/official-copy (ReadWrite; shipped as /publish):
    renders the newest editable representation to PDF → official version. Office
    files (docx/xlsx/pptx) render via the OnlyOffice conversion service when the module
    is active, falling back to gotenberg's LibreOffice route; working HTML goes through
    the Chromium route (optional letterhead_id). It leaves the document's lifecycle
    status alone — it used to reset it to draft.
  3. New POST /documents/{id}/versions/{version}/set-official (ReadWrite; shipped
    as /set-final): retags an existing PDF version as the official copy. The escape
    hatch for a directly-uploaded PDF, which the render endpoint cannot reach. Only a
    PDF qualifies; idempotent. Also leaves the status alone.
  4. New POST /documents/{id}/mark-sent (Editor) — see Dispatch is now an
    explicit act
    above.
  5. POST /documents/{id}/finalize (the editor's Save): replace-mode output is now
    tagged official (new-mode stays source on the new standalone doc).
  6. Office editor (/office-config) now targets the newest office version in the
    chain (not the current version) — so editing keeps working after an official-copy
    render puts a PDF on top. Its cache key + content/callback tokens follow that version.
  7. Share links without a pinned version serve the official copy.
  8. Folder zip export serves each doc's official copy (still egress-watermarked
    like any download).
  9. GET /documents/{id}/versions rows now include Role.

Existing "(editable)" sibling documents

Documents already created by the OLD convert flow are left untouched — they keep
working as independent documents (their own html working + pdf official after
backfill). There is no auto-merge. If an environment wants them folded back into
their originals, that is a manual/agent cleanup: upload the sibling's HTML as a new
version of the original (it will land as working by MIME inference), then delete
the sibling. Only worth it for actively-used documents.

UI changes (same deploy)

  • ONE Edit content action (newest editable representation wins; the other editor
    moves to the kebab). "Edit details" (metadata) also lives in the kebab now.
  • New Make official copy action with an edits-since-the-official-copy dot + stale
    hint, and a separate Mark as sent action (confirm modal; it starts the records
    clock). Two buttons, because they are two claims.
  • Hero preview captions which representation is shown; Versions tab shows role badges
    (Source / Working copy / Official copy).
  • Kebab: "Download source file" when the served version isn't the source.
  • Mobile carries the same pair on the document screen's Overview callout
    (official-copy-button, mark-sent-button).

Deploy + verify

Standard: ./deploy/update.sh --yes (snapshot → build obscura+web → migrate on boot
→ health-gate → auto-rollback). No new services, env vars, or images.

Post-deploy smoke:
1. /me still lists the expected modules for the environment.
2. Upload a .docx → detail shows ONE document, "Edit content" opens an editor.
3. Make official copy → hero preview + Download now serve the PDF; Versions tab
shows source (docx) + official (pdf) rows; only ONE document in the folder. The
document is still Draft — producing the copy does not dispatch it.
4. Mark as sent → status flips to Sent and retention_until fills in from the
records type. Press it again: 200 already_sent, nothing changes. On a document
with no official copy the action is not offered (and the API 409s
dms.sent.no_official_copy).
5. Edit again (office editor or working copy) → the action shows the
edits-since-the-official-copy dot; making a new official copy refreshes the PDF.
6. A pre-existing plain-PDF document still previews/downloads identically (no official
copy → current-version fallback).
7. Spot-check the rename landed on data: SELECT DISTINCT status FROM documents;
returns only draft|sent|superseded, and SELECT DISTINCT role FROM document_versions; only source|working|official.