External-party signing Phase 2 — Internal-tier external signers — Implementation Plan

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

HARD discipline (every task)

File map


Phase A — Backend

Task 1: Local-OTP storage (migration + repo)

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)

-- +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;
// 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
}
    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).

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)"

Task 2: VersionReader port + attestation-cert helper

Files:
- 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)

// 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.

// 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.

// 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.

    esignSvc.SetVersionReader(dmsVersionReader{dms: dmsSvc})
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)"

Task 3: 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)

    // 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
// 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.

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)
}
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)"

Task 4: 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).

    // 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.)

git add go/internal/esign/app/service.go
git commit -m "feat(esign): provider=local envelope creation (no Mekari) for internal-tier (Phase 2)"

Task 5: Handler — allow internal-tier externals; build the local envelope

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.

    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.)

    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
        }
    }
    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.

git add go/internal/httpapi/handlers_esign.go
git commit -m "feat(esign): allow internal-tier external signers; build the local envelope (Phase 2)"

Task 6: Provider branch in Send/Submit OTP-by-token (the heart)

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".

    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.

    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 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.)

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)"

Task 7: Deploy + backend e2e gate (mailpit-driven, self-contained)

Files: none (verification task)


Phase B — UI

Task 8: Request modal — tier selector usable with externals

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.

  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').

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)"

Task 9: Signatures list — self-hosted attestation copy

Files:
- Modify: web/src/features/documents/DocumentDetailView.tsx (the SignaturesSection row render)
- Modify: web/src/i18n/locales/en.ts + id.ts

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)"

Task 10: Final gate — deploy web + full UI-aware e2e

Files: none (verification)


Self-review notes (author)