think
16px
820px

Offer letter via the .dotx document path

Historical. Written while this was being designed; kept for the reasoning,
not as a description of what shipped. The current state — three templates, the
picker, and the punctuation bug this predates — is in OFFER_LETTERS.md.

Anchored to Obscura 7ca5b4f5 (release v1.7.6). Originally specified against
90da2890 (v1.7.5) and re-verified against source at 7ca5b4f5 on 2026-08-23,
after Valid Deployment pointed out that prod had moved underneath it. Every claim
was produced by a reader agent and then adversarially verified against source by a
second agent; where they disagreed, the verifier won.

What moved between the two. Exactly five files, and only two of them source:

file delta
go/internal/correspondence/app/publish.go +8 / −4
go/internal/correspondence/app/service.go +34 / −6
go/migrations/00218_letters_classification_normalized.sql new
…/app/sifat_test.go, …/adapters/pg_test.go tests

So every citation in this document outside those two files is unmoved and was
re-checked in place. Citations inside them have been re-anchored below, and the
one finding they invalidate (§1, case-and-whitespace) is struck through rather than
deleted, because the reasoning that produced it is what the replacement rests on.


All citations below are relative to /home/efran/remote-development/obscura (verified at 7ca5b4f5); portal paths are relative to /home/efran/remote-development/x056-disc-recruitement.


Review round, 2026-08-23 — what Valid Deployment checked

Three findings from this document were put to Valid Deployment (Obscura) and re-verified against what is actually running. Their answers, and where each landed:

# finding verdict
1 The sifat is stored raw by publish-as-letter (case/whitespace) → unregistered code → leaked in ListLetters Half fixed, half sharper. RAW storage closed by publish.go:95 + migration 00218, verified by Deployment on live prod in a rolled-back transaction. The ListLetters consequence survives and is reachable through the derived path. Routed to the correspondence owner.
2 A derived none sifat files a salary letter as ROUTINE Stands, and is deliberate. The none exemption is intended. What bites is deriving a letter sifat from a document classification by bare string copy. Routed with #1 as one item.
3 POST /letters/{id}/number on a half-published document renders a blank official version Stands. Portal-side fix accepted; no server gate is coming.

The item being routed, in Deployment's words: make ListLetters use the same resolution EffectivePolicy uses, so the register cannot be more generous than the document. Deployment will deploy and verify it against the same checks used for 00218.

Two things this plan depends on that Deployment has now recorded so they are not "fixed" out from under the portal: that MakeOfficialCopy and PUT /documents/{id}/fields do not call refuseLetterBytes (§0), and that field_count is zero everywhere except the list query (§5.5 — unverified when they replied, verified here).

Correction to the record. This document previously asserted the case/whitespace hole was live. It was, at 90da2890. It is not, at 7ca5b4f5. The reading was correct against the code it was made against, and the ground moved after it was read — but a plan that still says so would send someone to fix something already fixed, so §1 now says so plainly.


Offer letter: replacing the HTML-composed letter with the .dotx document path

0. The proposed flow is wrong in two places. Corrected first.

Correction A — step 2 does not merge anything. /use streams the template's blob into AddVersion unchanged; because the blob store is content-addressed the seeded document is the template's bytes. The values become document_field_values rows and substitute at render time only — go/internal/httpapi/handlers_doctemplate.go:406-409, go/migrations/00171_template_fields.sql:5-8, go/internal/httpapi/server.go:1478-1480.

Correction B — step 3 does not merge either, and does not print the number. publish-letter returns {"letter_id","number","register_only":true} and UpdateLetterNumbered is called with an empty content hash on purposego/internal/correspondence/app/publish.go:157-159 (re-anchored; was :136-155 at v1.7.5), go/internal/httpapi/handlers_publish_letter.go:145-149.

Stop after step 3 and you have filed a permanently numbered offer letter whose .docx still reads {{CANDIDATE_NAME}} and {{GAJI_POKOK}}. A fourth call is mandatory: POST /api/v1/documents/{docID}/official-copy, the only document-side path that runs docx.ReplaceFieldsgo/internal/httpapi/handlers_official_copy.go:164-172. It returns 201 {"version": N} (:243-253).

Correction C — {{NOMOR}} can never be filled here. It is a code-registered built-in whose only resolve point is PointLetterNumbering (go/internal/variables/domain/variable.go:126-130); discovery skips built-ins (handlers_doctemplate.go:191-193), PUT /fields refuses them (go/internal/doctemplate/app/service.go:512-518), and a document renders at the document points where NOMOR/TANGGAL/SIFAT/PERIHAL/AGENDA are absent. If Legal's .dotx contains {{NOMOR}} it prints literally, forever. The .dotx must be edited to use {{NOMOR_SURAT}} / {{TANGGAL_SURAT}} — ordinary declarable fields.

That forces the ordering: the number does not exist until publish, so official-copy runs after publish. Verified safe: MakeOfficialCopy never reads doc.EditingLocked (grep: readers are handlers_office.go:498/886, handlers_office_apply.go:199, handlers_make_editable.go:103, handlers_toc_repair.go:41, handlers_mcp_office.go:222 — none in handlers_official_copy.go) and never calls refuseLetterBytes (call sites: handlers_dms.go:174, handlers_dms_nav.go:390, handlers_esign.go:578/1230/1404, handlers_mcp_office.go:228). PUT /documents/{docID}/fields is likewise unguarded against letters — server.go:1482.

None of those files changed between v1.7.5 and v1.7.6, so the citations hold as written. This is a dependence on an absence, though, and Valid Deployment has recorded it as one: the flow is correct only for as long as nobody adds a refuseLetterBytes call to the official-copy or PUT /fields path. If someone does, step 4 and step 5 start returning 409 correspondence.letter.immutable against a document that is already numbered — recoverable, but only by editing the .dotx to stop needing them.


1. THE ANSWER TO THE CENTRAL QUESTION: the sifat

Send "classification": "rahasia" on publish-letter — explicitly, lowercase, trimmed. And send a different, document-registry code on /use. They are two registries with disjoint vocabularies and both must be named.

What happens if the sifat field is omitted

handlers_publish_letter.go:99-110, and at v1.7.6 the handler's own comment now names the split it is making:

//   EXPLICIT — the caller named a sifat, so a code the registry does not know is the same
//     operator mistake CreateLetter now refuses, and it is refused here too.
//   DERIVED  — inherited from the document because the caller named nothing. Refusing
//     would block publishing any document whose classification has no letter twin, which
//     is a legitimate thing to want. EffectivePolicy fails closed on an unresolvable code,
//     so this lands as CONFIDENTIAL: narrower than the document, never wider.
sifat := body.Classification
if sifat == "" {
    sifat = doc.Classification
} else if _, cerr := s.correspondence.GetClassification(r.Context(), strings.TrimSpace(strings.ToLower(sifat))); cerr != nil {
    // → 422 correspondence.classification.unknown
}

That last comment line is the one to read twice. "Narrower than the document, never wider" is true of EffectivePolicy, and EffectivePolicy is not the only reader — see the table's second row. The DERIVED arm is deliberately un-validated, and that is the route by which an unregistered code still reaches the register.

Three outcomes, by what /use put in the document's classification:

document classification resulting letter sifat consequence
"""none" (the default when /use omits it — go/internal/dms/app/service.go:768-777) "none" ROUTINE. EffectivePolicy short-circuits the none sentinel to non-confidential (go/internal/correspondence/app/service.go:615-616, re-anchored from :594-596). DeriveLetterACL returns Inherit:true with no admin grant (go/internal/correspondence/domain/letter_acl.go:133-144), FileLetterAtIssuance files it under Correspondence/Surat Keluar/{yyyy}/{rom} (go/internal/correspondence/domain/filing.go:166-172) whose archive root carries an everyone→AccessRead grant (go/cmd/obscura-server/resolvers.go:92-105), and mayReadLetter returns true on its first arm (go/internal/httpapi/letter_access.go:45-52). A salary letter readable by every holder of correspondence.read, which the shipped member role grants to every position-holder. Neither 90da2890 nor 7ca5b4f5 covers this — the fail-closed arm explicitly exempts "" and "none" (service.go:615-616), and Valid Deployment confirms the exemption is deliberate and staying. The sifat-exemption is doing its job; what bites is publish-as-letter deriving a letter sifat from a document classification through a bare string copy.
secret / confidential "secret" — unregistered in letter_classifications 🔴 THE SURVIVING DEFECT. EffectivePolicy fails closed → Confidential:true (service.go:620-622), so the detail route and download are safe. But ListLetters never calls EffectivePolicy — it builds confidentialCodes from the registry once and does an exact map lookup confidentialCodes[l.Classification] (go/internal/httpapi/handlers_correspondence.go:441-457, the lookup itself at :454). An unregistered code is not in the map, so the branch taken is the one commented "Routine (non-confidential) letter — registry-wide readable" and the register row — subject and number — is listed to every correspondence.read holder. The subject is the document title, i.e. the candidate's name beside a number. Permanently mislabelled and uncorrectable.
literally rahasia correct, by luck but rahasia is not a row in the document classifications registry (seeded none/public/confidential/secret), and the document side fails OPEN on an absent DLP row — download/forward/AI-processing all permitted.

The one-sentence version of the surviving defect

EffectivePolicy fails CLOSED while ListLetters fails OPEN, on the same letter. The detail route hides it; the register lists it. Two readers of one stored string, disagreeing about what an unresolvable code means — and the disagreement is not visible from either side alone.

It is still reachable at v1.7.6, through the DERIVED path, which normalises but deliberately does not validate (handlers_publish_letter.go:99-110, publish.go:88-95). A document classified secret, with no letter twin, still becomes an unregistered letter sifat.

Status: Valid Deployment is routing this to the correspondence owner together with the none row above, as one item — make ListLetters use the same resolution EffectivePolicy uses, so the register cannot be more generous than the document. Deployment will deploy and verify it, running the same checks used for 00218. Nothing in this plan is blocked on it: §1's contract immunises the portal either way, because rahasia is a registered confidential code and therefore is in confidentialCodes. The defect bites the caller who omits the sifat. This plan never omits it.

~~The second, still-live hole: case and whitespace~~ — CLOSED at v1.7.6

Kept, struck through, because the argument is what the surviving finding rests on.

~~publish-as-letter is the one create path that does not go through resolveSifat. The handler validates the lowered copy but passes the raw string (Classification: sifathandlers_publish_letter.go:117, the ORIGINAL) and PublishAsLetter stores it verbatim. So "Rahasia" is accepted and stored unregistered.~~

What actually shipped, verified at 7ca5b4f5. The RAW-storage half is fixed, in two independent layers where the v1.7.5 reading found none:

  1. The service normalises before the insert. publish.go:95classification := normalizeSifatCode(params.Classification)TrimSpace + ToLower, ""none (service.go:559-565). "Rahasia" is stored as rahasia, resolves, and behaves. normalizeSifatCode is split out of resolveSifat precisely so the derived path can normalise without gaining a refusal it must not have.
  2. The database refuses the write. go/migrations/00218_letters_classification_normalized.sql adds CHECK (classification = lower(trim(classification))) on letters, after a UPDATE … SET classification = lower(trim(classification)) repair. Valid Deployment verified on live prod, in a rolled-back transaction, that Postgres itself now rejects a mixed-case write.

handlers_publish_letter.go:117 is unchanged — it still hands the service the raw string — and that is now harmless, because the chokepoint moved below it. resolveSifat also gained a third call site at v1.7.6: service.go:1125 (UpdateLetterMeta), alongside :662 (CreateLetter) and :779 (IntakeLetter). All three re-anchored.

The finding this replaces is not "case" — it is the reader disagreement above. Case was one way to reach an unregistered stored code. The derived path is another, and it is the one still open.

And the method holds. The v1.7.5 reading was right that the commit message over-claimed and the code was the authority. The correction here comes from the same discipline pointed at newer code, not from abandoning it — 47324b6c's own message says as much: Deployment's pre-check found a host holding biasa 15 and Biasa 5, "that is the defect the earlier fix was designed to surface, and it surfaced it."

Therefore, the contract the portal must implement

  • /use"classification": "<DOC_CLASS>" where <DOC_CLASS>{secret, confidential} — a classifications row, so DLP resolves.
  • publish-letter"classification": "rahasia" — a byte-exact lowercase constant, never derived, never user-entered, never uppercased.
  • Two distinct Go constants in two distinct config fields. Do not let one string serve both.
  • Pre-flight (already in the client, keep it): GET /correspondence/classifications/rahasia must return and must be confidential:true. It is the only defence against an admin having edited that row (server.go:1881).
  • Post-publish read-back (new, mandatory): GET /letters/{id} (server.go:1774) → assert classification == "rahasia" byte-exact and status == "numbered"; GET /letters/{id}/effective-policy (server.go:1793) → assert confidential == true. Both are under requireModule("correspondence") (group Use at server.go:1753). A mismatch is not fixable — PATCH refuses any status past draft (service.go:1137, re-anchored from :1103-1110), there is no void route anywhere in server.go:1750-1895, and delete is refused (letter_access.go:139-146) — so this is a loud alarm plus an audit event, not a repair.

Both assertions are implementable exactly as written; the field names are confirmed, not assumed. GetLetter serialises the whole domain.Letter (handlers_correspondence.go:343-358), whose sifat is Classification string \json:"classification"`(go/internal/correspondence/domain/letter.go:140).LetterEffectivePolicyserialisesdomain.Classification(handlers_correspondence_config.go:91-112), whose flag isConfidential bool `json:"confidential"`(go/internal/correspondence/domain/classification.go:19). The portal'sobscura.Lettercurrently decodes onlyid/number/status/subject— it must gainclassification`.

Valid Deployment has endorsed keeping both defences even after the ListLetters fix lands. Their words: "a caller that verifies its own assumption is the one that finds out first when we break it." Which is the point — the read-back is not there to catch today's defect, which the byte-exact constant already avoids. It is there to catch the next change to how a sifat resolves, on the day it ships.


2. THE FLOW

Config: TEMPLATE_ID, SCHEME=OFR, SIFAT=rahasia, DOC_CLASS=secret, FOLDER_ID.

Pre-flight (nothing permanent; all cheap reads)

  1. GET /api/v1/numbering-schemes/OFR/preview (server.go:1764). Keep this. publish-letter inserts the letter facet before reserveNumber (publish.go:101-124 then :147 — re-anchored), so an unknown scheme code leaves a draft letter facet on the document and returns 404.
  2. GET /api/v1/correspondence/classifications/rahasiaconfidential == true, else refuse.
  3. GET /api/v1/document-templates/{TEMPLATE_ID}/fields → the authoritative key set. Compare against the keys stored with the draft; refuse on drift rather than letting Obscura 400 mid-flow.
  4. Portal-side: every required key non-blank; every value ≤ a cap (see Risks).

Step 1 — create the document

POST /api/v1/document-templates/{TEMPLATE_ID}/use
{ "folder_id": "<FOLDER_ID>", "title": "Offer Letter — <name>",
  "classification": "secret", "doc_type": "OFR", "format_id": "",
  "values": { "CANDIDATE_NAME": "...", "POSITION": "...", ... } }
 201 { id, filename, template_id, template_name, values_saved, missing_required? }

server.go:1938template.read and document.create; office module required inside the handler the moment values is non-empty (handlers_doctemplate.go:420-423).

Send "format_id": "" — an omitted format_id mints a document reference from the deployment default and burns a sequence on every retry (go/internal/dms/app/service.go:941-980). The letter number is the number that matters.

Persist document_id immediately. /use has no idempotency key; a lost response is an orphan document you cannot find by id.

Step 2 — prove the values landed (before anything permanent)

GET /api/v1/documents/{docID}/fields   → { applicable, template_id, fields, values }

server.go:1481, AccessRead, not module-gated. Compare values key-by-key against what you sent.

values_saved is not sufficient. The save block is guarded by len(in.Values) > 0 && s.variables != nil, and valuesSaved := true is the initial value — with a nil variables service the block is skipped and the response says true while nothing was stored (handlers_doctemplate.go:509-522). It is a false-negative detector only. If the read-back disagrees, PUT /documents/{docID}/fields to repair (server.go:1482; note it 503s variables.unavailable in the same nil case, handlers_doctemplate_fields.go:278-281), and if that fails, DELETE /documents/{docID} and abort. Everything is still undoable here.

Step 3 — THE POINT OF NO RETURN

POST /api/v1/documents/{docID}/publish-letter
{ "type": "surat", "direction": "outbound", "scheme_code": "OFR",
  "classification": "rahasia", "subject": "Offer Letter — <name>" }
 201 { letter_id (== docID), number, register_only: true }

server.go:1267-1268requireModule("correspondence") + correspondence.write + AccessManage. Note the module is correspondence, not office; they are separate entries in KnownModules (go/internal/platform/config/config.go:747) and the intake only verified office.

Persisted before this call: document_id, the exact values map sent, the sifat string sent, the scheme code, and an offer status of filed. Persisted immediately after: letter_id, number, status issued.

Assert number != "". A missing/empty scheme_code returns the same 201 shape with "number": "" and leaves an unnumbered draft facet — publish.go:133-138 (re-anchored), whose own comment calls it "a legitimate resting state"; and register_only is hard-coded true regardless (handlers_publish_letter.go:148). Note what that resting state is: a draft facet with an empty number, i.e. exactly the state Risk #1 turns into a blank offer letter.

Publishing also moves the document: FileLetterAtIssuance rewrites folder_id, filed_at, status='final' and retention_until in one statement, re-derives the ACL and may mint a reference (go/internal/correspondence/app/letter_acl.go:271-317, go/internal/dms/app/letter_acl_sync.go:190-222). Re-read GET /documents/{docID} after; do not cache the pre-publish header.

Step 4 — the number onto the page (only if the .dotx declares {{NOMOR_SURAT}})

PUT /api/v1/documents/{docID}/fields  { "values": { "NOMOR_SURAT": "<number>", "TANGGAL_SURAT": "<date>" } }  → 204

Step 5 — the merged artifact

POST /api/v1/documents/{docID}/official-copy  {}  → 201 { "version": N }

server.go:1328, AccessReadWrite, needs a configured converter. Retryable — it appends a version. Persist official_copy_version.

Beware handlers_official_copy.go:164-172: the merge is gated on docx.HasFields(raw, fields) and a ReplaceFields error logs a warning and renders the raw source with a 200. A 201 does not prove the merge happened. Step 2's read-back is what makes the merge probable; nothing in the API proves it.

Step 6 — verify

GET /letters/{docID} and GET /letters/{docID}/effective-policy per §1.

Recovery from an ambiguous step 3

Re-POSTing publish-letter is 409 correspondence.publish.already_letter regardless of whether the first call numbered (publish.go:102-105 — re-anchored), and there is no "already published, here is your number" response. GET /documents/{docID} and branch on the letter facet (handlers_dms_nav.go:143-151):

  • no facet → publish never ran. Retry publish.
  • facet, status:"draft", number:"" → facet created, numbering failed. DELETE /api/v1/documents/{docID} (permitted precisely because the letter is still a draft — letter_access.go:139-142), then redo from step 1. Never POST /letters/{id}/number — see Risks #1.
  • facet, status:"numbered", number set → success. Resume at step 4.

Two publish 409s are transient, not terminal: both pre-flight guards fail closed (sealed := true / active := true, cleared only on a nil-error lookup — handlers_publish_letter.go:49-71), so a ListVersions or workflow-service hiccup surfaces as correspondence.publish.sealed / workflow_running on a document that is neither. Do not auto-delete on those.


3. WHAT DIES

internal/letter/ — the whole package. letter.go, defaults.go, letter_test.go. Note this is not just the offer body: letter.Kinds is the only kind list, so the package also backs the console's letter-template editor. All of it goes; the wording lives in the .dotx and is edited in Word.
- Variables/Fill/Required/Missing → replaced by GET /document-templates/{id}/fields, which returns key/label/help/type/options/required/default_value/sort_order (go/internal/doctemplate/domain/field.go:16-27).
- Render/Default/Example/Validate/Describe/Label → replaced by POST /document-templates/{id}/preview (inline PDF).
- LetterDatesurvives, moved into the new package. The server does no date formatting at all: default_value/required/type/options are frontend hints (grep DefaultValue over go/internal yields only domain/field.go:25, adapters/pg.go:225 (a Scan) and :251, handlers_mcp.go:960-961 — nothing in the resolve engine). The portal must send the exact literal string it wants printed.
- RejectMergeTokensdelete. Its stated failure mode does not exist on the docx path: replaceInPart collects every occurrence against the original joined text stream before applying edits right-to-left and never re-scans, and values are XML-escaped (go/internal/platform/docx/replace.go:216, 228-260). Replace it with a length cap (below).

internal/api/settings.go: letterRoutes, listLetters, saveLetter, resetLetter, previewLetter, letterKind, letterTemplateBody. internal/store/templates.go: LetterTemplate, SaveLetterTemplate, ResetLetterTemplate, the LetterTemplate struct. The letter_templates table (dropped in 0019).

internal/auth/token.go: remove the four GET/PUT/DELETE/POST /api/settings/letters… entries at :169-172 and classify any new route. TestEveryRouteIsClassified fails in both directions.

internal/obscura/obscura.go:
| dies | replaced by |
|---|---|
| Create (POST /letters) | UseTemplate (POST /document-templates/{id}/use) |
| AssignNumber, RetryNumber (POST /letters/{id}/number) | PublishLetter (POST /documents/{id}/publish-letter) — and the old route must be deleted outright, see Risks #1 |
| Issue | Issue rewritten as the 5-call ceremony with persistence between |
| Letterhead, Letterhead.Usable, LetterheadID config, OBSCURA_LETTERHEAD_ID | nothing — the kop is in the Word header of the .dotx and for_letters must stay false on this template |
| NewLetter{Type,Classification,Subject,BodyHTML,LetterheadID} | UseRequest{FolderID,Title,Classification,DocType,FormatID,Values} + PublishRequest{Type,Direction,SchemeCode,Classification,Subject} |
| ContentURL | DocumentURL(docID)GET /letters/{id}/content is dead for a published record (409 correspondence.letter.no_content; publish stores an empty content hash and AuthoringHTML with no body — publish.go:111-121, 157-159 (re-anchored); handlers_office_subjects.go:43-56). GET /letters/{id}/preview likewise 404s correspondence.preview.none. And the document byte route is DLP-blocked for a no-download class. Link to Obscura's document page in its own UI. |
| CodeNoRenderer, CodeInProgress handling on the HTML branch | re-derive; the HTML render path is gone |

Gains a field: obscura.Letter currently decodes id/number/status/subject only. It must add Classification string \json:"classification"`— the post-publish read-back in §1 has nothing to assert on otherwise, andGET /letters/{id}` already returns it.

Survives unchanged: SchemeExists, Classification, MarkSent (POST /letters/{letterID}/sent, server.go:1782), Error/problemFrom/do. Note Retryable's "no code means the converter fell over" heuristic no longer applies — and 403s carry human prose in the code field, not a stable code (writeProblemStatus(w, status, detail, code) at go/internal/httpapi/errs.go:92-102, with authz.go:39-40 passing dec.Reason as the code). Branch 403s on status, not code.

cmd/preview/main.go: the fake Obscura (:188-245) is rewritten for /document-templates/{id}/use, /documents/{id}/publish-letter, /documents/{id}/fields, /documents/{id}/official-copy, /letters/{id}, /letters/{id}/effective-policy.

Tests: internal/letter/letter_test.go deleted. internal/api/offer_test.go rewritten — and it must gain the case the whole rewrite exists for: a publish request whose classification is empty or wrongly-cased is refused by the portal before it reaches the wire.


4. WHAT CHANGES

Migration — 0018 is applied; do not touch it. Add 0019_offer_is_a_document.up.sql.

BEGIN;
ALTER TABLE offers DROP CONSTRAINT offers_status_check;           -- name to be read off the DB
ALTER TABLE offers ADD CONSTRAINT offers_status_check
  CHECK (status IN ('draft','filed','issued','sent','accepted','declined'));

ALTER TABLE offers ADD COLUMN document_id  text;
ALTER TABLE offers ADD COLUMN template_id  text;
ALTER TABLE offers ADD COLUMN letter_sifat text;   -- what we SENT, so drift is detectable
ALTER TABLE offers ADD COLUMN official_copy_version int;

-- 'filed' means the document exists and nothing has been numbered: still deletable.
ALTER TABLE offers DROP CONSTRAINT offers_issued_has_letter;
ALTER TABLE offers ADD CONSTRAINT offers_filed_has_document
  CHECK (status = 'draft' OR document_id IS NOT NULL);
ALTER TABLE offers ADD CONSTRAINT offers_issued_has_letter
  CHECK (status IN ('draft','filed') OR letter_id IS NOT NULL);

DROP INDEX offers_one_live_per_candidate;
CREATE UNIQUE INDEX offers_one_live_per_candidate ON offers (candidate_id)
  WHERE status IN ('draft','filed','issued','sent');

CREATE UNIQUE INDEX offers_document_idx ON offers (document_id) WHERE document_id IS NOT NULL;

DROP TABLE letter_templates;
COMMIT;

variables stays jsonb and stays the column name — only its keys change from lower_case to UPPER_SNAKE. 0019 does not translate existing drafts; there is no mapping (the Go body was Indonesian, the .dotx is English). Drafts with a null template_id are stale; the console must say so and offer "start again". Whether to delete them outright is a business call (§6).

internal/obscura/obscura.go

New: UseTemplate, TemplateFields, DocumentFields, SetDocumentFields, PublishLetter, OfficialCopy, Document (for the recovery branch), EffectivePolicy, TemplatePreviewPDF. Issue becomes a sequence with a callback per persisted checkpoint, e.g.

func (c *Client) Issue(ctx, req IssueRequest, save func(Checkpoint) error) (Result, error)

so the store write between /use and publish-letter is structurally impossible to skip — the same reasoning that put RecordLetter between the old two calls, applied to a longer chain.

internal/api/offer.go

  • fieldList() → reads GET /document-templates/{id}/fields and returns {key,label,help,type,options,required,sortOrder}. Labels come from Obscura at runtime, so web/scripts/check-locale.mjs cannot see them — it reads web/src/** and internal|cmd/**.go only. Indonesian labels declared in Obscura would land in the console without failing the build. That is a setup-checklist obligation, not something the build can enforce.
  • derived() → keys become CANDIDATE_NAME, CANDIDATE_EMAIL, COMPANY. Every key must match ^[A-Z][A-Z0-9_]{1,40}$ (go/internal/doctemplate/domain/field.go:45, duplicated at go/internal/variables/domain/variable.go:305) and must not be TITLE, CLASSIFICATION, DOC_TYPE, OWNER, TODAY, DOCUMENT_ID, NOMOR, TANGGAL, SIFAT, PERIHAL or AGENDA.
  • compose() → deleted. department_clause disappears — the .dotx can carry conditional wording or the field can be a whole phrase, but the portal no longer renders a body.
  • previewOffer → proxies POST /document-templates/{id}/preview and returns application/pdf. Warning to encode: preview silently drops undeclared and invalid keys (sanitizedFieldValues, handlers_doctemplate_fields.go:195-207) while /use hard-refuses them, so a green preview does not prove /use will accept the same payload. Validate against GET /fields ourselves first.
  • issueOffer → the five-step ceremony with a store write after every step.
  • retryOfferNumberrepurposed and renamed to resumeOffer: reads GET /documents/{docID}, branches on the letter facet three ways (§2), and never calls a numbering verb. Route pattern POST /api/offers/{offerId}/number should be renamed too so nothing survives by muscle memory; update internal/auth/token.go in the same commit.
  • New: POST /api/offers/{offerId}/copy — re-cut the official copy alone.

internal/store/offer.go

RecordLetter → split into RecordDocument(id, documentID, templateID) (status draftfiled) and RecordLetter(id, letterID, number, sifat) (status filedissued). DiscardOffer gains the filed case — it must DELETE /documents/{docID} in Obscura first and only then drop the row, and it must refuse once letter_id is set. RecordOfficialCopy(id, version).

web/src/routes/console/OfferLetter.tsx

  • Form built from Obscura's declared fields, ordered by sortOrder, with type driving the input (text|textarea|number|date|select|boolean) and help under the label.
  • Preview modal renders a PDF (<object>/blob URL), not HTML. PreviewModal's "Obscura adds the letterhead, the number and the footer" line (:540) is now half wrong: the letterhead is in the file, the number is not added at all unless step 4 runs.
  • Confirmation copy must say what the register actually does: "Obscura records the number against the register. It is printed on the document only after the official copy is cut." (register_only: true.)
  • Status ladder gains Filed between Draft and Issued: "the document exists, nothing is numbered, this can still be withdrawn". That is the last honest cancel point and the console should say so.
  • "Finish the numbering" becomes "Resume", driven by the server's three-way branch, never offering an action that could double-file.
  • Post-issue link → Obscura's document page.

5. THE ONE-OFF SETUP (checklist, in order)

  1. Verify the key. GET /api/v1/auth/mecan_read_templates, can_manage_templates, and enabled_modules containing both office and correspondence (handlers_auth.go:629-644). template.read is granted by no default role (content_access.go:287-288), so an admin role only works if it carries the wildcard admin permission key. Also confirm the key does not carry the mcp scope — that is the one scope the authorizer acts on, and it confines the key to paths ending /mcp (go/internal/auth/app/service.go:1450-1457, go/internal/httpapi/middleware.go:99-102). Mint a separate REST key if the current one was minted for the MCP server.
  2. Audit Valid_Offer_Letter_Template.dotx before uploading. Every token ^[A-Z][A-Z0-9_]{1,40}$; no built-ins (esp. {{TITLE}} if it means the job title — it will print the document title and there is no override); {{NOMOR}}{{NOMOR_SURAT}}, {{TANGGAL}}{{TANGGAL_SURAT}}; kop in the Word header, address in the Word footer (both are scanned and merged — go/internal/platform/docx/replace.go:46-52, go/internal/platform/docx/scan.go:26-56).
  3. Rename the portal's variables to UPPER_SNAKE and make the .dotx and the portal's derived() agree on the exact spelling.
  4. Upload. POST /api/v1/document-templates, multipart, part name file. name must be unique across the whole library including kops (document_templates_name_uniq, go/migrations/00168_document_templates.sql:43) — the staging rows "Valid Standard", "HdrVar Test", "E2E OO Letterhead" share that namespace. Do not set for_letters — only the literal string "true" is truthy (handlers_doctemplate.go:181), and setting it enrols the row in the ungated /letterheads facade and hides the Use button in the console (web/src/api/doctemplates.ts:71, TemplatesPage.tsx:350). Send multipart, not JSON.
  5. Read back the discovered fields. GET /{id}/fields. Anything present in the .dotx but absent from this list is a built-in collision — go back to step 2.

field_count is a trap, and this is now verified rather than asserted (Valid Deployment flagged it as the one claim they had not checked). It is always 0 on the create response and on GET /{id}, and only the LIST query populates it:
- List selects templateCols plus a correlated (SELECT count(*) FROM document_template_fields …) and scans it through scanTemplateWithCountgo/internal/doctemplate/adapters/pg.go:88-98, :49-59.
- GetByID selects templateCols only and scans through scanTemplate, which never touches FieldCountpg.go:114-125, :37-47.
- FieldCount int \json:"field_count"`has **noomitempty** (go/internal/doctemplate/domain/template.go:31-35), so the zero value is serialised as a confident"field_count": 0` rather than being absent.

So a template with twelve declared fields reports field_count: 0 to every reader except the library list. Never gate anything on it; GET /{id}/fields is the only honest source. (The same zero rides into MCP as variable_counthandlers_mcp.go:1083.)
6. Declare labels. GET /fields, add English labels / required / type to the whole set, PUT /{id}/fields with all of it — it is a DELETE-then-INSERT full replace and sort_order comes from the array index, not the payload (adapters/pg.go:236-255). Anything omitted, including auto-discovered keys, is destroyed.
7. Set title_template. PATCH /{id} — and send name, description and category too: they are plain strings while everything else is a pointer, so omitting name is a 400 and omitting description clears it (handlers_doctemplate.go:281-300, app/service.go:397-406).
8. Render a proof. POST /{id}/preview with realistic values → open the PDF. Kop present, footer present, no stray {{. This also proves a PDF converter is wired, which official-copy needs.
9. Confirm the sifat. GET /api/v1/correspondence/classifications/rahasia → exists, confidential: true. (It ships from the boot seed — go/cmd/obscura-server/wire.go:3014-3017 — but an admin can edit the row, and everything downstream keys on the row, not on the word.)
10. Confirm the scheme. GET /api/v1/numbering-schemes/OFR/preview.
11. Choose and verify the document classification. GET /api/v1/classifications; confirm a DLP row exists for the chosen code — an unregistered/unpolicied classification fails open on the document side (go/internal/sharing/adapters/pg.go:348-363).
12. Create the pre-publish folder and give the portal's user Contributor. Publish will move the document out of it, but that folder is where a salary sits between step 1 and step 3.
13. Check the letter-type retention rule. GET /api/v1/correspondence/retention (server.go:1889). No row for suratretention_until stays nil (letter_acl.go:300-310) → the filed record is destroyable via DELETE /documents/{id}/purge, which does not call refuseLetterBytes (handlers_dms_nav.go:441-470).
14. Confirm the portal's key does not hold document.purge or records.admin.
15. Record TEMPLATE_ID, OBSCURA_OFFER_SCHEME=OFR, OBSCURA_OFFER_SIFAT=rahasia, OBSCURA_OFFER_DOC_CLASSIFICATION=secret, OBSCURA_OFFER_FOLDER_ID in the portal config. Retire OBSCURA_LETTERHEAD_ID. Rename OBSCURA_OFFER_CLASSIFICATION (currently defaulted to rahasia at cmd/portal/main.go:208) so it cannot be mistaken for the document one.


6. RISKS

  1. POST /letters/{id}/number on a half-published document destroys the record. Re-verified live at 7ca5b4f5. publish.go:118 sets Authoring: AuthoringHTML with an empty body, so AssignLetterNumber takes the else-branch of if letter.Authoring == corrdomain.AuthoringDocx (handlers_correspondence.go:541-548), renders the empty body to a PDF, stores it as letters.content_hash, and MirrorLetterVersions projects it as a RoleOfficial document version (go/internal/correspondence/app/letter_acl.go:403-414, go/internal/dms/app/letter_acl_sync.go:256) — which ServingVersion then prefers (go/internal/dms/app/service.go:1599-1616). The offer letter becomes a blank page for every reader. Reachable only while the facet is draft with number:"" — exactly the state a failed publish leaves, which is exactly when a developer reaches for it. On a successfully numbered letter it is an inert 409 (service.go:1222, :1235 — re-anchored). Design defence: delete AssignNumber/RetryNumber from the client entirely so the string /number does not exist in the portal.

Accepted by Valid Deployment, explicitly as a portal-side fix rather than a server-side gate: "deleting the string from your client is the right call and cheaper than us gating it." So this is ours to do in the rewrite, and no server change is coming to make it safe. Deleting the method is not enough on its own — the route pattern POST /api/offers/{offerId}/number must be renamed in the same commit (§4) so nothing survives by muscle memory.
2. Filing a letter still full of {{TOKENS}}. Publish never opens the file — the only bytes-adjacent check is the sealed-official guard. Defence: step 2's GET /documents/{docID}/fields read-back gates step 3, and never trust values_saved.
3. The sifat downgrade. §1, and narrowed at v1.7.6: the case/whitespace route is closed by publish.go:95 and migration 00218. What remains is the reader disagreement — EffectivePolicy closed, ListLetters open — reachable only by letting the sifat be derived. Defence is unchanged and endorsed: explicit byte-exact lowercase constant, never derived; pre-flight registry check; post-publish double assertion; and — because nothing can be corrected — an audit event and a red banner rather than a silent retry.
4. Double-filing. /use is not idempotent. Defence: offers_document_idx unique, document_id persisted before publish, and the resume path branching on Obscura's own state instead of on ours.
5. Purge. Defence: no document.purge on the key; a retention rule for surat or a PUT /documents/{docID}/legal-hold after a successful publish.
6. Wrong registry on /use. Sending rahasia there is accepted (classification is stored raw; the validator is wired only into SetDocumentClassification, dms/app/service.go:785 vs :3035) and leaves DLP permissive. Defence: two config keys, two Go constants, a startup assertion that they are different strings.
7. Template drift. POST /{id}/content re-scan adds keys and never removes stale ones; PUT /fields replaces wholesale. Either turns a working integration into 400 doctemplate.field_not_declared. Defence: pin template_id with the draft and re-read GET /fields at issue, refusing on drift with a sentence rather than a 400.
8. Value length and shape. No cap at any layer (value text NOT NULL, no MaxBytesReader on /use), and at render the whole value splices into the first affected <w:t> run. Defence: a per-field cap in the portal.
9. An empty-string value is a DELETE, not a blank (go/internal/variables/adapters/pg.go:220-230) — the {{TOKEN}} round-trips literally. A whitespace-only value is stored and merged. Defence: the portal never sends ""; a genuinely-none field sends the word.
10. Publish moves the document and rewrites its ACL, folder and status. Defence: re-read after publish; never cache the pre-publish header.
11. The console preview after issue. With classification: secret (allow_download=false, go/migrations/00166_dlp_rows_for_every_level.sql:29-37), GET /documents/{id}/versions/{n}/preview is 409 preview.use_secure_or_download whenever a rasterizer is wired, and /content is 403 dlp.download_blocked for a non-owner. Defence: do not build a viewer; link to Obscura.


7. WHAT I SHOULD ASK EFRAN

  1. Must the offer letter show its own number on the page the candidate signs? If yes we declare {{NOMOR_SURAT}} and cut the official copy after publishing (steps 4-5); if no, the number lives in the register and the covering email and the flow stops at step 3. This decides everything after the point of no return.
  2. Which document classification — secret or confidential? The sifat is settled (rahasia); the document side is a separate choice with different DLP and different people able to download.
  3. The new letter is English. Is the date 1 September 2026 or September 1, 2026? And is the subject / perihal in the register English or Indonesian — it is what shows on the register row.
  4. Who owns the wording now? Today a recruiter edits the offer body in the console. After this it is a Word file somebody re-uploads to Obscura. Confirm that is what Legal wants, and name the person who does it — the portal will break if the field set changes without warning.
  5. The existing draft offers written against the Indonesian template have no field mapping to the new one. Delete them, or leave them visible-but-unissuable with a "start again" prompt?
  6. Should the portal put a legal hold or a retention floor on a filed offer letter, and for how long? Without one the record is purgeable by anyone holding Manage + document.purge.
  7. Who besides the portal's own API-key user must be able to read a filed offer letter? A confidential letter's derived ACL is the creator, the correspondence-admin group and explicit ACL grants — and letters.created_by is set from the document's owner, which is the bot. If no correspondence-admin group is configured, HR sees nothing.
  8. Does the portal still "mark sent"? POST /letters/{id}/sent still works, but the candidate now receives an artifact that lives on the document side, not a letter with content. Confirm the operational meaning is unchanged.

Decisions — Efran, 2026-08-23

Answers to §7. These are settled; the plan above is read through them.

# Question Decision What it forces
1 Number on the signed page? Yes. "If they've received the letter, the letter is final with nomor surat." The four-call flow is mandatory. {{NOMOR_SURAT}} is a declared field, and POST /documents/{id}/official-copy runs after publish. Stopping at publish is a bug, not a shortcut.
2 Document classification confidential (not secret) /use sends confidential; publish sends rahasia. Two registries, two constants.
3 Language and date Bilingual letter, Indonesian block on top. Dates 1 September 2026 in BOTH blocks — superseded the split format, see below The .dotx carries both languages. A date token is SHARED by both blocks, so there is one format, not two — 1 September 2026. See docs/OFFER_LETTER_INDONESIAN.md.
4 Who owns the wording Anyone with recruitment-portal admin — "they're trusted enough" Re-uploading the .dotx is a portal-admin act, not a Legal ticket. The portal must therefore survive a field-set change: pin template_id per draft and re-read GET /fields at issue, refusing on drift with a sentence.
5 Existing Indonesian drafts Delete Migration 0019 drops them outright. No mapping is attempted, and none is possible.
6 Retention 3 years A retention rule for surat, or a legal hold set after a successful publish. Closes the purge hole.
7 Who may read a filed offer Group: Directors, Group: Executive After publish, grant both groups read on the letter id via PUT /documents/{letterID}/acl. Without this only the bot and correspondence-admins can read it.
8 Still "mark sent"? Yes, once it is candidate-facing POST /letters/{id}/sent keeps its meaning: the candidate has it.

What decision 1 costs, stated plainly

The number does not exist until publish, and the .docx is not merged until the
official copy. So the artefact the candidate signs is the official copy, not
the document created by /use and not the file as it stood at publish. Three
consequences the console must carry:

  • the point of no return is publish, but the letter is not finished until
    the official copy is cut — a failure between them leaves a numbered letter
    whose file still reads {{CANDIDATE_NAME}}
  • the offers table needs official_copy_version, and a resume path that can cut
    the copy alone
  • "Issued" in the console must not mean "ready to send"

Staging state — 2026-08-23

template (bilingual)  01a02d8d-4fc9-7e7f-9b49-434f73d9610a
template (English)    01a02d60-0e52-7bd0-ae37-ec5f85e867e2   superseded
scheme                OFR          VDI/{code}/{yy}{seq}
letter sifat          rahasia      confidential=true
document class        confidential
proofs                VDI/OFR/260005  placeholder on the page (the bug)
                      VDI/OFR/260006  number on the page (the fix)
                      VDI/OFR/260007  bilingual, both blocks, all tokens filled

Three permanent numbers consumed on staging. That is what proving this costs,
and it is why it was proven there.