Skip to content

019fb836 DfxApproval-GSheets durch API-Workflow ersetzen - #4553

Open
TaprootFreak wants to merge 15 commits into
developfrom
codex/dfxapproval-api-workflow
Open

019fb836 DfxApproval-GSheets durch API-Workflow ersetzen#4553
TaprootFreak wants to merge 15 commits into
developfrom
codex/dfxapproval-api-workflow

Conversation

@TaprootFreak

@TaprootFreak TaprootFreak commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Purpose

Replaces the eight minute-interval personal DfxApproval GSheets with a fail-closed API workflow.
Context: DFXswiss/gsheet#9.

Sheets replaced

  1. DfxApproval approval
  2. DfxApproval risk flags
  3. GwGFileCover
  4. IdentificationForm
  5. CustomerProfile
  6. RiskProfile
  7. FormA
  8. DfxNameCheck

IdentReport and PersonalNameCheck stay in their existing KYC processes and remain part of the
approval gate. Organizations are not part of this personal migration.

Implementation

  • minute-interval API cron with PostgreSQL advisory locks and an atomic approval
  • separate candidate sets matching the productive Sheet queries (DfxApproval, FinancialData and
    personal accounts on level 30-40)
  • the same country, risk, status, ident and document conditions as the productive Apps Scripts
  • six PDF generators on unchanged exports of the productive Google Sheet templates, rendered with an
    embedded Unicode font
  • exact file names, page counts, dynamic fields and checkbox logic
  • idempotent WORM storage under a unique generationKey
  • every document has exactly one generation path and one lock key: GwGFileCover, IdentificationForm
    and DfxNameCheck under the DfxApproval step lock, CustomerProfile under the FinancialData step
    lock, RiskProfile and FormA under the user-data lock
  • per-document error isolation: one failing PDF does not block the independent documents
  • default-off cutover switch and a separate process kill switch
  • no change and no new dependency in DFXswiss/services

Corrections after review

Two loads could not work against a real database, and the mocked tests hid both. Every correction
below was reproduced and then re-verified against a real PostgreSQL 16 with the driver version this
repository pins.

  • Approval never completed. The completion loaded the step without its user data relation.
    KycStep.userData is a ManyToOne without eager loading, so it stayed undefined and reading its
    ID threw for every case - caught by the cron and only logged. The step is now loaded through a
    query builder that joins the user data and locks the step alone.
  • Risk data was never initialised. Locking user data through a plain find joins the eager,
    nullable relations of UserData, and PostgreSQL rejects FOR UPDATE on the nullable side of an
    outer join. It now locks through a query builder without those joins. Both lock aliases are lower
    case on purpose: the driver emits FOR UPDATE OF <alias> unquoted, so a camelCase alias is not
    found in the FROM clause.
  • Documents failed for non-Latin-1 names. The overlay text used a PDF standard font, which is
    limited to WinAnsi and throws on Polish, Turkish, Baltic, Czech or Cyrillic characters - the case
    then waits forever without its evidence. The text is now set in an embedded Liberation Sans
    (metrically compatible with Arial/Helvetica, about 9 kB embedded per document); characters without
    a glyph are substituted instead of failing the document.
  • RiskProfile and FormA skipped accounts. Both documents follow the account, not a KYC step, but
    the workflow required a DfxApproval or FinancialData step as an anchor. Measured against
    production, 82 of 355 current candidates have neither. They are now generated from user data alone.
  • Missing NameCheck data blocked the whole case. The check ran before the loop, so a case without
    NameCheck evidence also lost its GwGFileCover and IdentificationForm. It now fails only the
    NameCheck document.
  • Risk flags could be written during an open sanctions review. The batch that initialises empty
    risk fields assumed no name check was open. A new sanctioned hit does not refresh
    lastNameCheckDate - that only happens on a clean result - so the date was no proof. The flag is
    now queried per account.
  • The same document could be written from two lock keys. CustomerProfile, RiskProfile and FormA
    were reachable from two entry points with different advisory-lock keys, so two instances could
    render and upload the same document concurrently, each with its own embedded document number. Each
    document now has a single path.
  • The approval audit entry was incomplete. It recorded step status, KYC level and KYC status,
    while the same transaction also overwrites complexOrgStructure, highRisk, depositLimit and
    amlAccountType. A limit set by Compliance was therefore not recoverable from the database. All
    four previous values are now part of the entry, written before the columns change.

Smaller corrections: gate blockers are logged instead of discarded, so a waiting case is
distinguishable from one nobody looked at; the excluded account IDs moved from the source tree into
the dfxApprovalDocumentExclusions setting; the storage reuse path lists the key instead of
downloading the whole document and fails loudly when a document marked valid is gone; a regenerated
document keeps the file name of the first attempt, which is also printed into it as its document
number; values that do not fit their field are logged instead of being cut silently; the level log
keeps the wording of the existing approval path; the new cron job is in docs/cron-jobs.md; the
index name, import order and log helpers follow the repository conventions.

Cutover

The cron stays off after the deployment. Fill dfxApprovalDocumentExclusions with the productive
legacy exceptions, disable and verify all eight Apps Script triggers, then set
KYC_DFX_APPROVAL_WORKFLOW_ENABLED=true. Rollback and operational checks are in
docs/dfx-approval-migration.md.

Verification

  • 147 tests in 10 suites for the KYC subdomain, including regression tests for each correction above
  • SHA-256 golden tests for all six productive PDF templates
  • both corrected loads executed against a real PostgreSQL 16; the previous versions fail there with
    TypeError and FOR UPDATE cannot be applied to the nullable side of an outer join
  • npm run lint, npx nest build, and the built dist verified to contain the templates and fonts

Open questions for the operator

  1. On approval, depositLimit is set to 100000 unconditionally, overwriting a different existing
    value. Is that the intended Sheet behaviour, or should an existing limit be preserved? The
    previous value is now recorded in kyc_log either way.
  2. The gate requires a valid RiskProfile for every case, while the document is only generated for a
    FATF-enabled country of residence - the same combination the Sheet process had. An account outside
    such a country therefore stays with Compliance instead of being approved automatically. No country
    in the seed data has fatfEnable = false; a test pins the coupling so both sides can only be
    changed together. Leave as is, or make the requirement conditional?
  3. The audit package documents the selection rules of the six document sheets from their layout tabs;
    the SQL header holding the where cell is not part of it, and six of the eight scripts could not
    be exported. The candidate sets should be compared against one live sheet before the cutover.
  4. The sample PDFs in the audit package were rendered with the previous standard font and should be
    regenerated after this change.

@TaprootFreak
TaprootFreak force-pushed the codex/dfxapproval-api-workflow branch from 18f69f6 to d05a9d7 Compare August 1, 2026 00:52
@TaprootFreak
TaprootFreak marked this pull request as ready for review August 1, 2026 00:58
The overlay text was drawn with a PDF standard font, which is limited to WinAnsi and throws on
every character outside Latin-1. Names, streets and employers in Polish, Turkish, Baltic, Czech or
Cyrillic spelling therefore failed the whole document, leaving the case without the evidence its
approval depends on - silently, once per minute.

The text is now set in an embedded Liberation Sans, which is metrically compatible with
Arial/Helvetica, so the layout is unchanged. Only the glyphs actually used are embedded (about 9 kB
per document). Characters the font has no glyph for, such as CJK, are substituted instead of
failing the document.
The reuse path fetched the whole blob only to find out whether it exists, and discarded it. It now
lists the key instead and fails loudly when a document that the database marks valid is gone from
storage.

It also exposes the registered file of a generation key, so a retry after a failed upload can keep
the name of the first attempt - that same string is printed into the document as its document
number, so name and content must not drift apart.
Two loads in the workflow could not work against a real database, and the mocked tests hid it:

- The completion loaded the step without its user data relation. `KycStep.userData` is a ManyToOne
  without eager loading, so it stayed undefined and reading its ID threw for every case. The step is
  now loaded through a query builder that joins the user data and locks the step alone.
- The risk-data initialisation locked user data with a plain find. That joins the eager, nullable
  relations of UserData, and Postgres rejects FOR UPDATE on the nullable side of an outer join. It
  now locks through a query builder without those joins.

Both lock aliases are lower case on purpose: the driver emits FOR UPDATE OF <alias> unquoted and
Postgres folds it to lower case, so a camelCase alias is not found in the FROM clause.

Beyond the two loads:

- RiskProfile and FormA follow the account, not a KYC step. Requiring a DfxApproval or FinancialData
  step as an anchor silently skipped every account without one, which the productive Sheet covers.
  Both documents are now generated from user data alone, serialised on a user-data advisory lock.
- Missing NameCheck evidence only fails the NameCheck document instead of the whole case.
- The gate blockers are logged instead of discarded: a case waiting forever was indistinguishable
  from a case nobody looked at.
- The excluded account IDs move from the source tree into the `dfxApprovalDocumentExclusions`
  setting, and the level log keeps the wording of the existing approval path.
Records the embedded Unicode font, the account-bound selection of RiskProfile and FormA, the
exclusion setting that has to be filled before the cutover, and the gate-blocker log line as a
monitoring source.
Every other document under docs/ is in English, and this repository is public. Same content, plus
the operator account is no longer named explicitly.
@TaprootFreak

Copy link
Copy Markdown
Collaborator Author

Verification state after the review corrections:

  • 144 tests in 10 suites for the KYC subdomain locally, all 13 GitHub checks green
  • both corrected loads executed against a real PostgreSQL 16 with the pinned driver version: the
    previous versions fail there with TypeError: Cannot read properties of undefined (reading 'id')
    and FOR UPDATE cannot be applied to the nullable side of an outer join, the new ones return the
    locked rows
  • the lock alias case is covered by a test: the driver emits FOR UPDATE OF <alias> unquoted, and a
    camelCase alias fails with relation "kycstep" in FOR UPDATE clause not found in FROM clause
  • PDF rendering exercised with Polish, Turkish, Cyrillic and Baltic names plus a CJK substitution case
  • npx nest build verified to place both the templates and the fonts into dist
  • candidate counts measured against production: 355 accounts in scope for RiskProfile/FormA, 82 of
    them without any DfxApproval or FinancialData step

Three points in the PR description need an operator decision before the cutover.

Three points from the review round:

- The batch that initialises empty risk fields passed `hasOpenNameChecks = false` instead of asking.
  A new sanctioned hit does not refresh `lastNameCheckDate` - that only happens on a clean result -
  so a still-valid date is no proof that no check is open. Such an account could get
  `highRisk = false` written, and a RiskProfile stating exactly that, while a sanctions match was
  still unresolved. The flag is now queried per account.
- RiskProfile and FormA were reachable from two different advisory-lock namespaces: the step lock in
  the approval loop and the user-data lock in the account-bound batch. Two instances could therefore
  render and upload the same document concurrently under the same generation key, each with its own
  embedded timestamp. Both documents now belong to the account-bound path alone.
- The approval audit entry recorded only step status, KYC level and KYC status, while the same
  transaction also overwrites `complexOrgStructure`, `highRisk`, `depositLimit` and `amlAccountType`.
  A limit set by Compliance was therefore not recoverable from the database. All four previous values
  are now part of the entry, which is written before the columns change.

The log entries and the step completion now go through the existing `KycLogService.createLogInternal`
and `KycStep.complete()` instead of rebuilding both by hand.
…ntory

- The unique index on `kyc_file.generationKey` carried a hand-written name, which the contribution
  guide rules out twice. Entity and migration now use the name TypeORM generates itself, and the
  constraint check in the storage service refers to the same constant.
- The new minute-interval job was missing from the cron inventory the contribution guide requires.
  Added, with the counts recomputed the way that document describes: 132 declarations across 93
  files, 111 of them with a flag, 50 on a minute interval.
CustomerProfile had the same cross-lock gap that RiskProfile and FormA had: the approval loop
requested it under the lock of the DfxApproval step while the batch generated it under the lock of
the FinancialData step. The two candidate sets overlap exactly at `kycLevel = 40`, so two instances
could render and upload the same document concurrently, each with its own embedded document number.

Each document now has a single path and a single lock key: GwGFileCover, IdentificationForm and
DfxNameCheck under the DfxApproval step lock, CustomerProfile under the FinancialData step lock,
RiskProfile and FormA under the user-data lock. That makes the combined selection helper redundant.

Also: the module imports are back in alphabetical order, and the comment on the exclusion setting
now says what the setting actually affects.
The contribution guide asks for alphabetically sorted imports. Moving the status DTO out of the
`output` directory and adding the fontkit import had left four import blocks out of order.
Applies the repository's import order - non-relative paths first, then relative ones, each
alphabetically - to all files this pull request touches, not just the ones spotted so far.
@TaprootFreak

Copy link
Copy Markdown
Collaborator Author

Five review passes were needed before two independent reviews came back with no findings.

What the passes changed, beyond the corrections already listed in the description:

  • Pass 1 — risk flags could be written while a sanctions review was still open; the same document
    could be generated from two different advisory-lock keys; the approval audit entry did not record
    the previous values of the four compliance columns the same transaction overwrites; the new cron
    job was missing from the job inventory; the unique index carried a hand-written name.
  • Pass 2 — CustomerProfile turned out to have the same two-lock-key gap that RiskProfile and
    FormA had. Every generated document now has exactly one generation path and one lock key.
  • Passes 3-4 — import order across the touched files.
  • Pass 5 — no findings from either review.

Two reported points were deliberately not applied, with the reasoning recorded in the pull request:
moving the approval gate onto the entity (it aggregates state from a separate query and the document
matrix of this workflow, which entity getters here deliberately do not carry), and making the
RiskProfile requirement conditional on the country (that would loosen a compliance gate and is an
operator decision, not a review correction - a test now pins the coupling instead).

Three properties of the Sheet process are reproduced as they are - the compliance defaults written
on approval, the RiskProfile requirement outside a FATF-enabled country, and skipping documents that
already exist. Each is a property of the process being replaced, not of this implementation, and
changing any of them would change the outcome rather than the mechanism.

The residual risk is stated as well: the selection rules of the six document sheets come from the
audit package rather than from their source, because the script export of those projects is refused.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant