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, notpublished(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 officialthe document went out to the client status publishedstatus sent(POST /documents/{id}/mark-sent)the record has been replaced by a newer one status obsoletestatus supersededThe 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.sqlrewrites the stored values
(role='published'→'official',status='published'→'sent',
status='obsolete'→'superseded'), re-states the role CHECK over the new value
set, and adds adocuments_status_chkCHECK 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:
draft→sent. It stamps the retention floor from the records type's schedule,
which thereafter blocks deletion.409 dms.sent.no_official_copy— the document carries noofficialversion yet.409 dms.sent.not_draft— asupersededdocument is pastsent, 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/htmlversions →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
(published → official) and the two lifecycle statuses (published → sent,
obsolete → superseded). 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)
POST /documents/{id}/make-editableno longer creates a "(title) (editable)"
sibling document. It appends aworkingHTML 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.- New
POST /documents/{id}/official-copy(ReadWrite; shipped as/publish):
renders the newest editable representation to PDF →officialversion. 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 (optionalletterhead_id). It leaves the document's lifecycle
status alone — it used to reset it to draft. - 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. - New
POST /documents/{id}/mark-sent(Editor) — see Dispatch is now an
explicit act above. POST /documents/{id}/finalize(the editor's Save): replace-mode output is now
taggedofficial(new-mode stayssourceon the new standalone doc).- 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. - Share links without a pinned version serve the official copy.
- Folder zip export serves each doc's official copy (still egress-watermarked
like any download). GET /documents/{id}/versionsrows now includeRole.
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.