For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Let a requester invite an external party (no account) to sign a document on the Internal tier — verified by an Obscura-emailed OTP and sealed with an in-house PAdES “self-hosted attestation” signature — with no Mekari dependency and no per-signature cost.
Architecture: An internal-tier request that contains ≥1 external signer creates a provider="local" envelope reusing the Phase-1 roster + per-signer token + public-page rails, but with no Mekari call. Three steps branch on the envelope’s provider: OTP-send (local = email a code), OTP-submit (local = validate the local code), and apply-signature (local = in-house PAdES per submit). The local envelope holds only external signers; internal signers of the same request keep their existing workflow-task path unchanged (the mixed case inherits Phase 1’s exact completion behavior — no new edge).
Tech Stack: Go modular monolith (chi, pgx v5, goose migrations) under go/internal/; React + Carbon SPA under web/src/; digitorus/pdfsign in-house PAdES; SMTP (mailpit in dev) for email.
Spec: docs/superpowers/specs/2026-06-29-external-signing-phase2-internal-tier-design.md
go test — the test DSN points at the LIVE demo Postgres (:55432 == deploy-postgres-1). Verify with cd go && go build ./... && go vet ./..., cd web && npx tsc --noEmit && npx vite build, curl, and the mailpit-driven e2e.docker compose -f deploy/docker-compose.yml --env-file deploy/mekari.env up -d --build obscura web. After every deploy assert /me enabled_modules == [correspondence,watermarking,ai,esign] (dev-login may race startup — retry).cd web && npm run gen:api after any api/openapi.yaml edit. en/id i18n parity is enforced by the Translations type (tsc catches mismatches).main; do NOT push unless asked. Clean up test artifacts. The demo (mock provider, all 4 modules, the shipped Global flow) must stay intact.go/migrations/00068_esign_local_signer_otp.sql — local-OTP columns.go/internal/esign/adapters/envelope_pg.go — local-OTP repo methods.go/internal/esign/app/service.go — Repository iface; CreateSignEnvelope local branch; SendSignerOTPByToken/SubmitSignerOTPByToken provider branch; attestation-cert + version-reader helpers.go/internal/esign/adapters/inviter.go + inviter_email.go — SendOTP on the Inviter port + EmailInviter.go/cmd/obscura-server/resolvers.go + wire.go — wire a VersionReader port to dms.go/internal/httpapi/handlers_esign.go — relax external⇒global; buildEnvelope runs for internal+external.web/src/features/documents/RequestSignatureModal.tsx — tier selector usable with externals; internal-external requires email.web/src/features/documents/DocumentDetailView.tsx + web/src/i18n/locales/{en,id}.ts — attestation tooltip + copy.Files:
- Create: go/migrations/00068_esign_local_signer_otp.sql
- Modify: go/internal/esign/adapters/envelope_pg.go
- Modify: go/internal/esign/app/service.go (Repository interface)
go/migrations/00067_esign_signer_submit_claim.sql. Content:-- +goose Up
-- Local (Obscura-issued) email OTP state for internal-tier external signers. NULL for Global rows
-- (their OTP lives at Mekari). Internal lock/credential columns — NOT part of the domain read model.
ALTER TABLE esign_envelope_signers
ADD COLUMN otp_code_hash text,
ADD COLUMN otp_expires_at timestamptz,
ADD COLUMN otp_attempts int NOT NULL DEFAULT 0;
-- +goose Down
ALTER TABLE esign_envelope_signers
DROP COLUMN otp_code_hash, DROP COLUMN otp_expires_at, DROP COLUMN otp_attempts;
go/internal/esign/adapters/envelope_pg.go (append near RotateSignerToken). LocalOTP is a tiny value type returned for validation:// LocalOTP is the stored email-OTP state for an internal-tier external signer.
type LocalOTP struct {
CodeHash string
ExpiresAt *time.Time
Attempts int
}
// SetSignerLocalOTP stores a freshly issued email OTP (hash + expiry) and resets the attempt counter.
func (s *Store) SetSignerLocalOTP(ctx context.Context, signerID, codeHash string, expiresAt time.Time) error {
_, err := s.db.Exec(ctx).Exec(ctx,
`UPDATE esign_envelope_signers SET otp_code_hash=$2, otp_expires_at=$3, otp_attempts=0 WHERE id=$1`,
signerID, codeHash, expiresAt)
if err != nil {
return fmt.Errorf("esign set signer local otp: %w", err)
}
return nil
}
// GetSignerLocalOTP reads the stored OTP state for validation.
func (s *Store) GetSignerLocalOTP(ctx context.Context, signerID string) (LocalOTP, error) {
var o LocalOTP
var hash *string
err := s.db.Exec(ctx).QueryRow(ctx,
`SELECT otp_code_hash, otp_expires_at, otp_attempts FROM esign_envelope_signers WHERE id=$1`,
signerID).Scan(&hash, &o.ExpiresAt, &o.Attempts)
if err != nil {
return LocalOTP{}, fmt.Errorf("esign get signer local otp: %w", err)
}
if hash != nil {
o.CodeHash = *hash
}
return o, nil
}
// IncrementSignerOTPAttempt bumps the failed-attempt counter (wrong-code throttle).
func (s *Store) IncrementSignerOTPAttempt(ctx context.Context, signerID string) error {
_, err := s.db.Exec(ctx).Exec(ctx,
`UPDATE esign_envelope_signers SET otp_attempts = otp_attempts + 1 WHERE id=$1`, signerID)
if err != nil {
return fmt.Errorf("esign increment signer otp attempt: %w", err)
}
return nil
}
Repository interface in go/internal/esign/app/service.go (next to RotateSignerToken): SetSignerLocalOTP(ctx context.Context, signerID, codeHash string, expiresAt time.Time) error
GetSignerLocalOTP(ctx context.Context, signerID string) (LocalOTP, error)
IncrementSignerOTPAttempt(ctx context.Context, signerID string) error
Add LocalOTP as an exported type alias in the app package so the interface compiles. Simplest: define the same struct in service.go and have the adapter return adapters-package… NO — to avoid an import cycle, define LocalOTP in the app package (service.go) and have the adapter import/return app.LocalOTP. Update the adapter methods’ return type to app.LocalOTP accordingly (the adapter already imports the app package).
[ ] Step 4: Verify — cd /home/efran/remote-development/obscura/go && go build ./... && go vet ./internal/esign/.... Expect clean.
[ ] Step 5: Commit
git add go/migrations/00068_esign_local_signer_otp.sql go/internal/esign/adapters/envelope_pg.go go/internal/esign/app/service.go
git commit -m "feat(esign): local email-OTP storage for internal-tier external signers (Phase 2)"
VersionReader port + attestation-cert helperFiles:
- Modify: go/internal/esign/app/service.go (add VersionReader port + SetVersionReader; add signAttestation helper)
- Modify: go/cmd/obscura-server/resolvers.go (implement the reader over dms)
- Modify: go/cmd/obscura-server/wire.go (wire it)
service.go (near VersionWriter):// VersionReader reads a document version's raw PDF bytes (the in-house local signer signs the CURRENT
// version so multi-signer attestation signatures stack). Returns the bytes + the version it read.
type VersionReader interface {
CurrentVersionPDF(ctx context.Context, docID string) (pdf []byte, version int, err error)
}
func (s *Service) SetVersionReader(r VersionReader) { s.versionReader = r }
Add versionReader VersionReader to the Service struct.
service.go:// attestationPrincipalID is the reserved system identity whose in-house cert seals internal-tier
// external (self-hosted attestation) signatures. EnsureSigningCert is idempotent, so the cert is
// issued once from the in-house CA on first use.
const attestationPrincipalID = "system:obscura-attestation"
// signAttestation applies an in-house PAdES "self-hosted attestation" signature to pdf for an external
// signer who has no account: the Obscura Attestation service cert is the cryptographic identity; the
// external's name/email + the email-verification note live in the visible appearance + Reason.
func (s *Service) signAttestation(ctx context.Context, pdf []byte, name, email string, image []byte, placement *Placement) ([]byte, error) {
cert, err := s.EnsureSigningCert(ctx, attestationPrincipalID, "Obscura Attestation", "Obscura")
if err != nil {
return nil, err
}
ca, err := s.repo.GetCA(ctx) // same accessor SignPDF uses to fetch the CA cert PEM
if err != nil {
return nil, err
}
reason := fmt.Sprintf("Self-hosted attestation — %s <%s>, email-verified, %s", name, email, s.clock.Now().UTC().Format(time.RFC3339))
return s.signer.Sign(cert.CertPEM, cert.KeyPEM, ca.CertPEM, pdf, SignInfo{
Name: name, Reason: reason, Image: image, Placement: placement,
})
}
NOTE to implementer: confirm the CA accessor name by reading SignPDF (grep GetCA/ca, err := in service.go around the SignPDF body, lines ~558–579) and match it exactly; fmt is already imported.
go/cmd/obscura-server/resolvers.go, add a reader implementation:// dmsVersionReader implements the esign VersionReader port over the DMS service.
type dmsVersionReader struct {
dms *dmsapp.Service
}
func (r dmsVersionReader) CurrentVersionPDF(ctx context.Context, docID string) ([]byte, int, error) {
doc, err := r.dms.GetDocument(ctx, docID)
if err != nil {
return nil, 0, err
}
rc, err := r.dms.OpenVersionContent(ctx, docID, doc.CurrentVersion)
if err != nil {
return nil, 0, err
}
defer rc.Close()
b, err := io.ReadAll(rc)
if err != nil {
return nil, 0, err
}
return b, doc.CurrentVersion, nil
}
Add "io" to the resolvers.go import block if missing.
go/cmd/obscura-server/wire.go, next to esignSvc.SetVersionWriter(...): esignSvc.SetVersionReader(dmsVersionReader{dms: dmsSvc})
[ ] Step 5: Verify — cd go && go build ./... && go vet ./internal/esign/... ./cmd/.... Clean.
[ ] Step 6: Commit
git add go/internal/esign/app/service.go go/cmd/obscura-server/resolvers.go go/cmd/obscura-server/wire.go
git commit -m "feat(esign): VersionReader port + self-hosted attestation signer (Phase 2)"
SendOTP on the Inviter port (email the code)Files:
- Modify: go/internal/esign/app/service.go (Inviter interface)
- Modify: go/internal/esign/adapters/inviter.go (multiInviter route)
- Modify: go/internal/esign/adapters/inviter_email.go (EmailInviter.SendOTP)
Inviter interface in service.go: // SendOTP emails a one-time signing code to an external signer (internal-tier email OTP).
SendOTP(ctx context.Context, name, email, code, docTitle string) error
inviter_email.go (mirror SendInvite’s SMTP build, different body):// SendOTP emails a 6-digit signing code. No-op when no email is present.
func (e *EmailInviter) SendOTP(_ context.Context, name, email, code, docTitle string) error {
if email == "" {
return nil
}
subject := "Your Obscura signing code"
body := fmt.Sprintf("Hi %s,\r\n\r\nYour one-time code to sign \"%s\" is:\r\n\r\n %s\r\n\r\nIt expires in 10 minutes. If you didn't request this, ignore this email.\r\n\r\n— Obscura",
name, docTitle, code)
return e.send(email, subject, body)
}
Refactor the existing SendInvite to share an unexported send(to, subject, body string) error helper that does the Dial/TLS/auth/Mail/Rcpt/Data sequence (DRY — the OTP and invite paths reuse it). Keep SendInvite’s existing behaviour identical.
inviter.go add the route:func (m *multiInviter) SendOTP(ctx context.Context, name, email, code, docTitle string) error {
if email == "" {
return nil
}
return m.email.SendOTP(ctx, name, email, code, docTitle)
}
[ ] Step 4: Verify — cd go && go build ./... && go vet ./internal/esign/.... Clean.
[ ] Step 5: Commit
git add go/internal/esign/app/service.go go/internal/esign/adapters/inviter.go go/internal/esign/adapters/inviter_email.go
git commit -m "feat(esign): SendOTP email path on the Inviter port (Phase 2)"
provider="local" envelope creation (no Mekari)Files:
- Modify: go/internal/esign/app/service.go (CreateSignEnvelope branch)
Read the current CreateSignEnvelope body first (grep func (s *Service) CreateSignEnvelope). It currently: maps signers []EnvelopeSignerInput → []MultiSigner, calls ms.RequestMultiSign(...) to get res.ProviderEnvelopeID + an email→signer_id map, then inserts the envelope (Provider: ms.Name() / “mekari”) + signer rows (each with SignerID from Mekari + minting tokens for externals + sending invites).
CreateSignEnvelope on signKind. When signKind != "global" (internal/local), take a local path that skips Mekari entirely: // LOCAL (internal-tier) path: no external provider. Build the envelope ourselves — provider="local",
// no Mekari RequestMultiSign, signer rows carry no provider signer_id; external rows get a minted
// access token + an email invite. The OTP + signing are Obscura-driven (see Send/Submit + attestation).
if signKind != "global" {
envID := kernel.NewID()
now := s.clock.Now()
if err := s.uow.Do(ctx, func(ctx context.Context) error {
if err := s.repo.InsertSignEnvelope(ctx, domain.SignEnvelope{
ID: envID, Provider: "local", ProviderEnvelopeID: "", SubjectType: subjectType, SubjectID: subjectID,
DocTitle: docTitle, SourceVersion: sourceVersion, SignKind: signKind, SigningOrder: signingOrder,
DeadlineAt: deadlineAt, InstanceID: instanceID, Status: domain.EnvelopeStatusPending, CreatedAt: now,
}); err != nil {
return err
}
for _, in := range signers {
raw, hash := "", ""
if in.IsExternal {
raw, hash = newAccessToken()
}
sg := domain.EnvelopeSigner{
ID: kernel.NewID(), EnvelopeID: envID, UserID: in.UserID, SignerID: "", OrderIndex: in.OrderIndex,
OTPChannel: "email", Status: domain.SignerStatusPending,
IsExternal: in.IsExternal, Name: in.Name, Email: in.Email, Phone: in.Phone,
}
if in.IsExternal {
sg.AccessTokenHash = hash
sg.InviteChannel = "email"
sg.InvitedAt = &now
}
if err := s.repo.InsertEnvelopeSigner(ctx, sg); err != nil {
return err
}
// stash raw token for the post-commit invite send (don't email inside the tx)
if in.IsExternal {
pendingInvites = append(pendingInvites, localInvite{name: in.Name, email: in.Email, raw: raw})
}
}
return nil
}); err != nil {
return "", err
}
// Email the invite links (best-effort, outside the tx) — mirrors the Global invite loop.
if s.inviter != nil {
deadlineStr := ""
if deadlineAt != nil {
deadlineStr = deadlineAt.Format("2 Jan 2006")
}
title := docTitle
if title == "" {
title = "a document"
}
for _, pi := range pendingInvites {
_ = s.inviter.SendInvite(ctx, InviteRequest{
Name: pi.name, Email: pi.email, Channel: "email", DocTitle: title,
SignURL: strings.TrimRight(s.appBaseURL, "/") + "/sign/" + pi.raw, DeadlineAt: deadlineStr,
})
}
}
return envID, nil
}
Declare the helpers just above the branch:
type localInvite struct{ name, email, raw string }
var pendingInvites []localInvite
(Match domain.SignEnvelope field names exactly by reading go/internal/esign/domain/envelope.go — e.g. confirm CreatedAt, DocTitle, SourceVersion, InstanceID, SigningOrder are present; drop any the struct doesn’t have.)
[ ] Step 2: Verify — cd go && go build ./... && go vet ./internal/esign/.... Clean.
[ ] Step 3: Commit
git add go/internal/esign/app/service.go
git commit -m "feat(esign): provider=local envelope creation (no Mekari) for internal-tier (Phase 2)"
Files:
- Modify: go/internal/httpapi/handlers_esign.go (RequestSignature validation + buildEnvelope)
Read RequestSignature (the per-external phone/email validation loop + if hasExternal && signKind != "global") and buildEnvelope first.
RequestSignature, REMOVE the if hasExternal && signKind != "global" { ...external_requires_global... } block. Replace the per-external validation loop so the required contact depends on the tier: hasExternal := false
for _, sp := range specs {
if sp.external {
hasExternal = true
who := sp.name
if who == "" {
who = sp.email
}
if signKind == "global" {
if sp.phone == "" {
writeProblem(w, &kernel.Error{Kind: kernel.ErrValidation, Code: "esign.request_sign.external_phone_required", Message: "external signer " + who + " needs a phone number (the Global one-time code is sent over WhatsApp)"})
return
}
}
if sp.email == "" {
writeProblem(w, &kernel.Error{Kind: kernel.ErrValidation, Code: "esign.request_sign.external_email_required", Message: "external signer " + who + " needs an email address"})
return
}
}
}
(Email stays required for all externals — Global uses it for the link, Internal uses it for the link + OTP. Phone is required only for Global.)
if signKind == "global" to: var envelopeID string
if signKind == "global" || (hasExternal && signKind == "internal") {
envelopeID, err = s.buildEnvelope(r.Context(), p, docID, signKind, signingOrder, &deadlineAt, instanceID, specs)
if err != nil {
_ = s.workflow.CancelInstance(r.Context(), p, instanceID)
writeProblem(w, err)
return
}
}
buildEnvelope — pass the tier + only externals for local. Add a signKind string param to buildEnvelope. For the global case keep the current behaviour (all signers; internal users resolved via profile + saved signature). For the internal/local case, build inputs from the external specs only (internal signers in an internal-tier request keep their workflow-task path; they are NOT roster rows), and require nothing from the in-house signer/profile. Concretely, inside buildEnvelope: inputs := make([]esignapp.EnvelopeSignerInput, 0, len(specs))
for i, sp := range specs {
if signKind != "global" && !sp.external {
continue // internal-tier internal signers sign via workflow tasks, not the local roster
}
if sp.external {
inputs = append(inputs, esignapp.EnvelopeSignerInput{
Name: sp.name, Email: sp.email, Phone: sp.phone, OrderIndex: i, IsExternal: true, InviteChannel: "email",
})
continue
}
// ... existing internal-user resolution (GetUser + phone + saved signature + SignatureB64) — global only ...
}
if len(inputs) == 0 {
return "", nil // nothing to put in an envelope (shouldn't happen given the caller gate)
}
return s.esign.CreateSignEnvelope(ctx, p, "document", docID, doc.Title, version, pdf, signKind, inputs, signingOrder == "sequential", deadlineAt, instanceID)
Update the one buildEnvelope(...) call site (Step 2) to pass signKind.
[ ] Step 4: Verify — cd go && go build ./... && go vet ./internal/httpapi/.... Clean.
[ ] Step 5: Commit
git add go/internal/httpapi/handlers_esign.go
git commit -m "feat(esign): allow internal-tier external signers; build the local envelope (Phase 2)"
Files:
- Modify: go/internal/esign/app/service.go (SendSignerOTPByToken, SubmitSignerOTPByToken)
Read both functions first (heads at SendSignerOTPByToken ~L1108, SubmitSignerOTPByToken ~L1141). Both currently load (env, signer) via GetSignerByTokenHash, run guards (status, envelopeExpired, ensureSignerTurn), then call the Mekari ChallengeSealer. Branch on env.Provider == "local".
SendSignerOTPByToken, after the existing guards (expired, env.Status==pending, signer.Status==pending, ensureSignerTurn) and BEFORE the ch, ok := s.sealer.(ChallengeSealer) block, insert: if env.Provider == "local" {
code, err := newOTPCode() // 6-digit numeric
if err != nil {
return "", err
}
if err := s.repo.SetSignerLocalOTP(ctx, signer.ID, sha256Hex([]byte(code)), s.clock.Now().Add(localOTPTTL)); err != nil {
return "", err
}
if s.inviter != nil {
if err := s.inviter.SendOTP(ctx, signer.Name, signer.Email, code, env.DocTitle); err != nil {
return "", err
}
}
return "email", nil
}
Add near the other consts: const localOTPTTL = 10 * time.Minute and const localOTPMaxAttempts = 5. Add the code generator (uses crypto/rand, already imported):
// newOTPCode returns a uniformly-random 6-digit numeric one-time code as a string.
func newOTPCode() (string, error) {
n, err := rand.Int(rand.Reader, big.NewInt(1_000_000))
if err != nil {
return "", err
}
return fmt.Sprintf("%06d", n.Int64()), nil
}
Add "math/big" to the service.go imports.
SubmitSignerOTPByToken, after the already-completed short-circuit, envelopeExpired, the signer.Status==signed → finalize and pending guards, and ensureSignerTurn, but BEFORE the ch, ok := s.sealer.(ChallengeSealer) + provider claim/submit, insert a local branch that validates the code and applies the in-house attestation signature itself: if env.Provider == "local" {
ot, oerr := s.repo.GetSignerLocalOTP(ctx, signer.ID)
if oerr != nil {
return false, 0, oerr
}
if ot.CodeHash == "" || ot.ExpiresAt == nil || s.clock.Now().After(*ot.ExpiresAt) {
return false, 0, &kernel.Error{Kind: kernel.ErrValidation, Code: "esign.otp.expired", Message: "this one-time code has expired — request a new one"}
}
if ot.Attempts >= localOTPMaxAttempts {
return false, 0, &kernel.Error{Kind: kernel.ErrRateLimited, Code: "esign.otp.too_many", Message: "too many incorrect codes — request a new one"}
}
if sha256Hex([]byte(code)) != ot.CodeHash {
_ = s.repo.IncrementSignerOTPAttempt(ctx, signer.ID)
return false, 0, &kernel.Error{Kind: kernel.ErrValidation, Code: "esign.otp.invalid", Message: "that code is incorrect — please check and try again"}
}
// Apply the in-house self-hosted attestation signature to the CURRENT version, then land it.
if s.versionReader == nil || s.versionWriter == nil {
return false, 0, &kernel.Error{Kind: kernel.ErrConflict, Code: "esign.local.unwired", Message: "local signing is not fully configured"}
}
curPDF, _, rerr := s.versionReader.CurrentVersionPDF(ctx, env.SubjectID)
if rerr != nil {
return false, 0, rerr
}
signedPDF, serr := s.signAttestation(ctx, curPDF, signer.Name, signer.Email, raw, signerPlacement(signer))
if serr != nil {
return false, 0, serr
}
author := kernel.Principal{} // version writer falls back to the doc owner (external has no account)
newVersion, aerr := s.versionWriter.AddSignedVersion(ctx, author, env.SubjectID, signedPDF, "sign")
if aerr != nil {
return false, 0, aerr
}
now := s.clock.Now()
if err := s.uow.Do(ctx, func(ctx context.Context) error {
if ierr := s.repo.InsertSignature(ctx, domain.SignatureRecord{
ID: kernel.NewID(), SubjectType: env.SubjectType, SubjectID: env.SubjectID, SignerUserID: "",
SourceHash: "", SignedHash: sha256Hex(signedPDF), SignedAt: now, Assurance: domain.AssuranceInternal,
SignerName: signer.Name, IsExternal: true,
}); ierr != nil {
return ierr
}
return s.repo.MarkSignerSignedExternal(ctx, signer.ID, ip, ua, now)
}); err != nil {
return false, 0, err
}
// Roster-driven completion: finalize the envelope (mark completed + FinalizeInstance) when all signed.
return s.finalizeLocalEnvelopeIfComplete(ctx, env, newVersion)
}
raw here is the decoded PNG bytes — reuse the existing raw, derr := decodePNG(signatureB64) at the top of the function (confirm the variable name; the Phase-1 code decodes the image up front). signerPlacement(signer) returns *Placement (nil for the default box) — internal-tier external rows have no stored placement, so define a trivial helper func signerPlacement(domain.EnvelopeSigner) *Placement { return nil } (placement support is future).
finalizeLocalEnvelopeIfComplete. Add a small finalizer (the local envelope has no provider seal to fetch — each signer already applied their signature, so completion is purely “are all roster rows signed?”):// finalizeLocalEnvelopeIfComplete marks a local (no-provider) envelope completed once every roster row
// has signed, then advances the workflow instance. Each signer already applied their attestation
// signature in-line, so there is nothing to fetch/seal here. Idempotent.
func (s *Service) finalizeLocalEnvelopeIfComplete(ctx context.Context, env domain.SignEnvelope, lastVersion int) (bool, int, error) {
signers, err := s.repo.ListEnvelopeSigners(ctx, env.ID)
if err != nil {
return false, 0, err
}
for _, sg := range signers {
if sg.Status != domain.SignerStatusSigned {
return false, 0, nil // more signers to go; this signer's signature already landed
}
}
now := s.clock.Now()
if err := s.uow.Do(ctx, func(ctx context.Context) error {
return s.repo.MarkEnvelopeCompleted(ctx, env.ID, lastVersion, now)
}); err != nil {
return false, 0, err
}
if s.workflowAdvancer != nil && env.InstanceID != "" {
_ = s.workflowAdvancer.FinalizeInstance(ctx, env.InstanceID)
}
return true, lastVersion, nil
}
NOTE: a non-last signer returns (false, 0, nil) → the public handler maps that to HTTP 202 “processing”, but the signer’s signature HAS landed — that’s fine (the 202 ‘submitted/finalizing’ UI from the polish work reads correctly; the doc already has their signature). The LAST signer returns (true, version, nil) → 200 completed.
Also: the local envelope path must be excluded from the Mekari poll sweep. In finalizeEnvelopeIfComplete (the Mekari sweep finalizer), add an early guard at the top: if env.Provider == "local" { return s.finalizeLocalEnvelopeIfComplete(ctx, env, versionForLocal) } — but the sweep has no lastVersion. Simpler: have FinalizePendingEnvelopes/finalizeEnvelope skip provider="local" envelopes (they complete in-line on the last submit; the sweep is a Mekari poll fallback only). Add at the top of finalizeEnvelope: if env.Provider == "local" { return false, 0, nil }. (Local envelopes never need the provider poll; if a local row is stuck pending it’s because a signer hasn’t signed, which the sweep can’t fix.)
[ ] Step 4: Verify — cd go && go build ./... && go vet ./internal/esign/.... Clean.
[ ] Step 5: Commit
git add go/internal/esign/app/service.go
git commit -m "feat(esign): local email-OTP + self-hosted attestation in Send/Submit (Phase 2 core)"
Files: none (verification task)
[ ] Step 1: Deploy from repo root: docker compose -f deploy/docker-compose.yml --env-file deploy/mekari.env up -d --build obscura web. Wait for healthz 200. Assert migrations 00068 applied (docker compose -f deploy/docker-compose.yml exec -T postgres psql -U obscura -d obscura -t -A -c "select column_name from information_schema.columns where table_name='esign_envelope_signers' and column_name like 'otp_%';" → 3 rows). Assert /me enabled_modules == [correspondence,watermarking,ai,esign].
[ ] Step 2: Self-driven e2e (no user OTP relay — the code is emailed to mailpit at http://localhost:8025). As admin (dev-login): create a doc + upload a 1-page PDF; POST /api/v1/documents/{doc}/request-signature with {"signers":[{"name":"Vendor X","email":"vendor@example.com"}],"sign_kind":"internal","signing_order":"parallel"}. Expect 201 with an envelope_id. Grab the raw /sign/<token> from the obscura logs (docker compose ... logs obscura --since 30s | grep -o '/sign/[A-Za-z0-9_-]*' | tail -1). Then:
GET /api/v1/public/sign/<token> → signable=true, otp_channel="email", masked email shown, doc title present.GET .../preview → 200 application/pdf.POST .../otp/send → 200 {"otp_channel":"email"}.curl -s http://localhost:8025/api/v1/messages (latest message) → fetch its body → extract the code.POST .../otp with {"otp":"<code>","signature_image":"data:image/png;base64,..."} (generate a small PNG). Expect 200 {"status":"completed","signed_version":2} (single signer → last signer → completed).GET /api/v1/documents/{doc}/signatures shows one row assurance:"internal", is_external:true, signer_name:"Vendor X"; the workflow instance is approved; the public link is now dead (signable=false).esign.otp.invalid and bumps attempts; >5 wrong → esign.otp.too_many; backdating the deadline makes the link dead.[ ] Step 3: Confirm the Global flow still works (regression): the existing Global request path still returns 201 and the demo modules are intact. Clean up the test doc.
[ ] Step 4: Commit — none (verification). If any fix was needed, commit it with fix(esign): <what> (Phase 2 e2e).
Files:
- Modify: web/src/features/documents/RequestSignatureModal.tsx
- Modify: web/src/i18n/locales/en.ts + id.ts
Read the modal first. Today: hasExternal forces signKind to global and disables the tier Dropdown; addExternal requires name + phone + email.
[ ] Step 1: Remove the “external locks Global” behaviour: delete the useEffect that forces setSignKind(globalItem) when hasExternal, and stop disabling the tier Dropdown when hasExternal. The requester now chooses the tier freely.
[ ] Step 2: Make the external sub-form’s required fields depend on the chosen tier. addExternal: require name + email always; require phone only when signKind.id === 'global':
const addExternal = () => {
const name = extName.trim(), phone = extPhone.trim(), email = extEmail.trim()
const needPhone = signKind.id === 'global'
if (!name || !email || (needPhone && !phone)) {
setError(t(needPhone ? 'docview.requestSignature.extValidationGlobal' : 'docview.requestSignature.extValidationInternal'))
return
}
setExternals((prev) => [...prev, { name, email, phone }])
setExtName(''); setExtEmail(''); setExtPhone(''); setError(null)
}
Mark the phone field’s label optional vs required based on the tier (e.g. append (${t('docview.requestSignature.optional')}) to the phone label when signKind.id !== 'global').
[ ] Step 3: Add a tier-aware helper line under the external sub-form: when signKind.id === 'internal' and hasExternal, show t('docview.requestSignature.internalExternalHint') (“External signers are verified by email and signed with this organization’s in-house attestation — no certified provider, no per-signature cost.”).
[ ] Step 4: i18n — add to the docview.requestSignature block in en.ts + id.ts: extValidationGlobal (“A Global external signer needs a name, an email, and a phone number.”), extValidationInternal (“An external signer needs a name and an email address.”), optional (“optional”), internalExternalHint (the sentence above). Translate for id. (Keep the old extValidation key or remove it if now unused — grep usages first; if unused, remove from both locales to keep parity.)
[ ] Step 5: Verify — cd web && npx tsc --noEmit. Clean.
[ ] Step 6: Commit
git add web/src/features/documents/RequestSignatureModal.tsx web/src/i18n/locales/en.ts web/src/i18n/locales/id.ts
git commit -m "feat(esign): request modal — internal-tier external signers (email, no phone) (Phase 2)"
Files:
- Modify: web/src/features/documents/DocumentDetailView.tsx (the SignaturesSection row render)
- Modify: web/src/i18n/locales/en.ts + id.ts
[ ] Step 1: In the signatures list row, when a signature is assurance === 'internal' AND isExternal, add a small “attestation” tooltip/label so the in-house external signature reads honestly. The row already renders the “In-house” assurance tag + “External” badge — give the External badge (or a new inline note) a title of t('docview.attestationDesc') for these rows. Keep certified (Global) rows unchanged.
[ ] Step 2: i18n — add docview.attestationDesc to en.ts + id.ts: “Self-hosted attestation — verified by email and signed under this organization’s own authority (not a government-certified identity).” Translate for id.
[ ] Step 3: Verify — cd web && npx tsc --noEmit && npx vite build. Clean.
[ ] Step 4: Commit
git add web/src/features/documents/DocumentDetailView.tsx web/src/i18n/locales/en.ts web/src/i18n/locales/id.ts
git commit -m "feat(esign): signatures list — self-hosted attestation tooltip (Phase 2)"
Files: none (verification)
signing_order:"sequential"): both get invites; signer 1 signs (OTP from mailpit) → 202/processing + a signed version lands; signer 2 signs → 200 completed + the second signature stacks + instance approved + both links dead./sign/<token> route renders (200 text/html) and the request modal lets you pick Internal tier with an external (manual or curl-level check that an internal+external request returns 201).provider="local" string used consistently; LocalOTP defined in the app package; signAttestation/finalizeLocalEnvelopeIfComplete/newOTPCode/signerPlacement referenced where defined; buildEnvelope gains a signKind param updated at its one call site.