External office editing — identified, OTP-verified, classification-capped
Status: design, awaiting review · Date: 2026-08-13
Decision taken: the external party edits a detached copy; what comes back is a
proposed version an internal owner accepts or rejects.
The doctrine
The archive never receives a stranger's bytes.
An outside party never writes to a document. They are handed a detached copy, they edit that,
and what returns is a proposal. Acceptance is an internal act performed by someone who
already holds Editor on the document: it is that person's version, with the external party
recorded as its source. Every existing guarantee about who wrote version N survives unchanged.
This is the difference between "we let outsiders edit our documents" (a sentence no records
officer will sign off) and "an outsider proposed a revision and our staff accepted it" (a
sentence that describes how organisations already work).
Why this is small
Three of the four mechanisms already exist and are in production:
| Need | What already does it |
|---|---|
| A session-less party drives the office editor | /office/content/{token} + /office/callback/{token} — the doc server fetches and saves, the signed token is the authorization |
| An identified outside party (invite + OTP) | /public/sign/{token} + otp/send / otp — per-row token stored as sha256, raw only in the invite link, rate-limited per token+IP |
| Telling internal staff a thing needs a decision | workflow.task_assigned → transactional outbox → in-app inbox + email |
The genuinely new parts are the detached draft (a blob plus a row) and the accept/reject
surface. Everything else is composition.
Non-goals
- In-place editing. Explicitly rejected. If it is ever wanted it is a different spec.
- Anonymous links. Every external editor is a named person with a verified email.
- Letters. Letters have their own lifecycle (numbering, sent-lock, seal chain). v2 at the earliest.
- PDF-only documents. There are no office bytes to edit; the invite is refused with a reason,
not silently degraded. - Marking the outbound copy. See "The hole we are opening" — we are not pretending otherwise.
The flow
- Invite. An internal user holding
documents.invite_externaland Editor on the document
supplies a name, an email, an optional message and an expiry (default 7 days). Server checks
every guard below, snapshots the current version's office bytes into a detached draft blob,
mints an invite token (raw only in the link), and sendsext_edit_invite. - Identify. The external party opens the link and sees the document title, who invited them,
and a "send me a code" button. The code is emailed (ext_edit_code, modelled onsign_code)
and verified against a hash with an attempt cap — the sign ceremony's shape exactly. - Edit. On verification they get a short-lived editor session over OnlyOffice, pointed at the
draft blob, never at the document. Saves land on the draft. - Submit. They press Submit for review, which is terminal for them: the draft freezes, the
office token is revoked, and the inviter (plus the document's owner) get an inbox item. - Decide. An internal user with Editor opens a side-by-side view and either
accepts —dms.Service.AddVersionon the real document with the draft bytes, attributed to
the accepting user, with the external source in the audit line — or rejects with a note.
The external party is told either way.
Staleness
If the document gains a version while a draft is open, the proposal is stale: it was based on
v4 and the document is now v5. Accept then requires an explicit "accept over v5" confirmation.
This mirrors covers_current on the signing side, and for the same reason — the alternative is
silently discarding somebody's work.
Guards (all fail CLOSED)
Classification cap. Configurable, default Internal and below.
🔴
classificationRankreturns0on a registry read error and on an unknown code. A cap
written asrank <= captherefore admits everything exactly when the classification
registry is unavailable. The check must resolve the level by code and refuse if the lookup
fails or the code is absent from the registry — never fall back to a rank.
Refused outright, regardless of classification:
- a document on the encrypted chain (
DocumentOnEncryptedChain) — Private Vault and at-rest vaults; - a
members_onlyfolder; - a document under legal hold;
- a sealed or signed document (
alreadySigned) — an edit would break every signature's/ByteRange; - a document whose current version has no office bytes.
Tenant binding. 🔴 The office token must carry Tenant, like every other office token.
Without it, tokens are interchangeable across tenants and UUID unguessability becomes the only
boundary — which is not a boundary. See the note on officeClaims.Tenant.
Step-up. If the folder requires step-up, the inviter must have satisfied it. Sending a
document outside is a disclosure, not a read.
Rate limits. Invite-open, code-send and code-submit are limited per token+IP, reusing the
pubSignLimiter shape and its metric.
The hole we are opening (say it plainly)
egressProtectPDF is PDF-only. The bytes handed to an external editor are .docx, so they
carry no watermark, no stego mark and no protection floor. There is no office-format carrier
today and this spec does not invent one.
This is the first sanctioned egress of unmarked original bytes to an outside party. What stands
in for marking:
- the classification cap, which keeps the highest-value material out of the flow entirely;
- an identified, email-verified recipient rather than a link anyone can forward and use;
- an audit row naming who invited whom, when, and which version they were given;
- a hard expiry and a one-shot terminal submit.
If that trade is not acceptable for a given deployment, the feature is off — it is module-gated
and permission-gated, so "off" is the default and requires no configuration.
Data model
One table (next free number at time of writing is 00186 — re-check ls go/migrations | tail
before creating it; co-agents race for numbers).
CREATE TABLE external_edit_drafts (
id uuid PRIMARY KEY,
document_id uuid NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
base_version int NOT NULL, -- what they were handed; staleness compares to current
tenant_id text NOT NULL DEFAULT '',
invitee_name text NOT NULL,
invitee_email text NOT NULL,
access_token_hash text NOT NULL, -- sha256(token); the raw token lives only in the link
otp_hash text NOT NULL DEFAULT '',
otp_expires_at timestamptz,
otp_attempts int NOT NULL DEFAULT 0,
status text NOT NULL, -- invited|verified|editing|submitted|accepted|rejected|expired|revoked
draft_blob_hash text NOT NULL DEFAULT '',
draft_mime text NOT NULL DEFAULT '',
invited_by text NOT NULL,
invited_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
submitted_at timestamptz,
decided_by text,
decided_at timestamptz,
decision_note text NOT NULL DEFAULT '',
external_ip text NOT NULL DEFAULT '',
external_ua text NOT NULL DEFAULT ''
);
CREATE UNIQUE INDEX external_edit_drafts_token ON external_edit_drafts (access_token_hash);
CREATE INDEX external_edit_drafts_doc ON external_edit_drafts (document_id, status);
external_ip/external_ua mirror esign_envelope_signers.signed_ip/ua — the same evidence, for
the same reason.
Blob lifecycle. A draft blob is deleted by a sweep 30 days after a decision. Before the
decision it is evidence and stays. A rejected draft keeps its blob for the same 30 days so a
dispute has something to point at.
Surfaces
New public routes (alongside /public/sign/*):
GET /public/edit/{token} → title, inviter, status, expiry (no content)
POST /public/edit/{token}/otp/send
POST /public/edit/{token}/otp → verify; returns the editor config
POST /public/edit/{token}/submit → terminal for the external party
New internal routes:
POST /documents/{docID}/external-edits → invite
GET /documents/{docID}/external-edits → list (open + historical)
DELETE /documents/{docID}/external-edits/{id} → revoke before submission
POST /documents/{docID}/external-edits/{id}/accept
POST /documents/{docID}/external-edits/{id}/reject
Office token. A new subject officeSubExtDraft in officeClaims, carrying the draft id, the
tenant and the external party's row id as UID, so OfficeCallback writes the draft blob and
never the document. The existing content/callback routes are otherwise untouched.
Email templates. ext_edit_invite and ext_edit_code, registered like sign_invite /
sign_code, both editable in Admin → Email templates. 🔴 Links must be absolute
(APP_BASE_URL) — safeURL drops relative URLs, which is how notification links silently
stopped working before.
FE. An external page modelled on the public sign page (unauthenticated shell, OTP panel, then
the OnlyOffice frame), and on the document detail a Proposals section listing open drafts with
accept/reject and the side-by-side compare.
Testing
Unit-shaped checks driven from a scratch cmd/_x main (no go test — the test DSN is the live
demo database):
- the classification cap refuses when the registry lookup fails, and when the code is unknown;
- each of the five outright refusals;
- staleness detection when the document gains a version mid-draft;
- an office token minted for a draft cannot fetch or write the document, and vice versa;
- a token minted for tenant A is refused for tenant B.
End-to-end on the demo: invite → email → OTP → edit → submit → accept → the version lands
attributed to the accepting user with the external source in the audit chain; then the same run
ending in reject; then an expired token; then a revoked one. Cleaned up after.
Rough shape of the work
| Slice | What |
|---|---|
| 1 | Migration, store, domain (draft lifecycle + guards) |
| 2 | Invite/revoke/list endpoints + the classification cap, fail-closed |
| 3 | Public routes + OTP + the officeSubExtDraft token and callback branch |
| 4 | Accept/reject + AddVersion attribution + audit + staleness |
| 5 | Email templates + notifications |
| 6 | FE: external page + Proposals section + compare |
| 7 | e2e, deploy, docs |
Two to three days of focused work, most of it in slices 3 and 6.
Open questions for review
- Who may accept — anyone with Editor, or only the inviter and the document owner?
- Does a rejection tell the external party why, or only that it was declined?
- Cloud — Enterprise/on-prem first is assumed. Cloud needs the doc server reachable per
tenant, which is a separate deployment question. - Does an accepted proposal set the document's status, or leave the lifecycle alone (the
positionmark-senttakes: never a side effect)?