Skip to content

T 196


uid: T-196 title: Authorised WOPC signers beyond the two directors (+ reimburse email-preview step) status: done area: accounting created: 2026-07-31 updated: 2026-07-31 owner: girafeev1 assignee: Records (Infrastructure) related: T-160, T-178, T-161, T-048, T-194


βœ… Read AGENTS.md Β· Records (Infrastructure) Β· https://claude.ai/code/session_018RDB37kCqfouHdygVXTAtD Β· checked the board by scope (no dup β€” T-160 is closing-NAME rendering, T-178 is the reimbursement pipeline; neither covers who may sign / expanding the signer pool, so this is genuine new scope).

Renumbered T-195 β†’ T-196 (2026-07-31, on merge). This task was claimed as T-195 on this branch before main merged a different T-195 β€” User Management's "Person-record data migration" (done). Per the board's renumber-on-merge convention (merged-first keeps the UID), this authorised-signers task moved to the next free UID, T-196. Commits/board/code were swept T-195 β†’ T-196; the person-record T-195 refs on main were left untouched.

Why this exists

Every WOPC that a director is paid on must be signed by the other director (a director can't sign off on their own payment). Today that "other director" is hardcoded to a two-person rotation β€” if Jeff (JC) is the payee, Jake (JN) signs, and vice versa (lib/directors/registry.ts is a static two-entry list; getOtherDirector literally "relies on there being exactly two"). The owner wants two things:

  1. Widen the signer pool β€” let an operator choose the closing signer from anyone authorised to sign, not just the two rotating directors, so a third director or a delegated non-director can sign.
  2. A final email-preview step in the Reimburse <employee> modal β€” before issuing a director's reimbursement, preview the "please sign this WOPC" email the signer will receive, aligned with the sub-contractor-fee pipeline (WopcSigningRequestModal already previews + sends).

Owner (2026-07-31, verbatim):

"1. The reimburse modal should guide the user to a final step where the user can preview the email that gets sent to the signing director" "2. … the web app should have feature allowing user to chose from anyone that's more than just the 2 rotating director or personnel (even) to sign it, but currently, it's should be hardcoded to if Jeff's the payee then it's Jake who's gonna sign it and vice versa"

Governance basis (why a non-director may sign)

Signing authority is not limited to the two rotating directors: a director, or any person the directors authorise, may sign. The control worth preserving is independence from the payee (signer β‰  payee) β€” the substance of today's cross-issuance rule β€” not "must be a director". (The specific company-secretarial / Articles citation was dropped from the record at the owner's request, 2026-07-31; the authority itself stands.)

Decisions (owner, 2026-07-31)

  • Who may grant the authority β†’ a DEDICATED permission (not "anyone who can edit a contact"). New RBAC permission wopc_signers:manage. Owner chose "A dedicated permission".
  • Wire the signer picker now (same body of work), not as a later step. Owner chose "Also wire the picker now". β†’ the reimburse signer becomes a choice from {directors + authorised signers} βˆ’ payee.
  • The reimburse email-preview step aligns with the sub-contractor pipeline β†’ preview and send the signing request from the modal (what WopcSigningRequestModal does), instead of punting to Records β†’ WOPCs.
  • Signer field shape β†’ system.authorizedSignature { authorized, grantedBy, grantedAt } β€” a grouped map with a grant trail (renamed from the working wopcSigner; the legal-citation note dropped). The doc's top-level updated only records the last edit of any field, so the grant trail is explicit.
  • Name order is a stored per-person preference β†’ legal.legalNameOrder ('givenFirst' | 'surnameFirst'), flippable from a Contacts drawer toggle; it drives the default document name. The stored director closingName + title are retired β€” the name now derives from legal parts + this order (+ signsAs), and the signature job line derives from isDirector.

βœ… Firestore structure gate β€” APPROVED (owner, 2026-07-31)

The owner approved the consolidated schema after the whole-document before/after was shown in chat (aote-system DB, individuals/{id}). Final shapes now in the code:

  • system.authorizedSignature: { authorized: boolean, grantedBy: string | null, grantedAt: string | null } β€” grouped map + grant trail (renamed from the working wopcSigner; no legal-citation note on the field). Editable only by wopc_signers:manage holders.
  • legal.legalNameOrder: 'givenFirst' | 'surnameFirst' | null β€” flips a person's document name order (Jyut Ping surname-first vs Westernized). Opt-in; drives the default document rendering.
  • Removed system.director.closingName + system.director.title β€” the closing name derives from legal parts + legalNameOrder (+ signsAs), and the job line derives from isDirector.

Staged plan

  • Stage 1 β€” foundation + approved schema (DONE). RBAC permission wopc_signers:manage (super_admin
  • admin, all three Record<Permission,string> maps + admin groups). Plus the gated schema, now landed: legal.legalNameOrder + order-aware document rendering + the Contacts name-order toggle; system.authorizedSignature; and the removal of the director closingName/title facet fields. Rebuilt onto main (past the person-migration T-195); tsc clean, full unit suite green (one pre-existing, unrelated billing-ingest failure aside).
  • Stage 2 β€” the mark (DONE). The Contacts drawer shows an "Authorised WOPC Signer" badge (to everyone, with a granted-by/granted-on tooltip) and, for wopc_signers:manage holders, an edit toggle to grant/revoke it. The write is server-enforced: the individuals PATCH rejects an authorizedSignature change without the permission (403) and stamps the grantedBy/grantedAt trail itself (never trusting the client; re-save keeps the original grant, revoke clears to null).
  • Stage 3 β€” picker + email preview (DONE). An eligible-signers resolver ({directors + authorised signers} βˆ’ payee β€” lib/wopc/eligibleSigners.server.ts + /api/wopc-signing/eligible-signers); the reimburse modal's "β‘’ Signature" step now has a signer picker (default = the other director) + an inline email preview, and on Confirm it issues with the chosen signerId β†’ creates the signing request β†’ sends it, replacing the "request it from Records" punt. NOTES: the preview is inline (not yet factored out of WopcSigningRequestModal β€” deferred; the user-facing preview is delivered). Selecting an authorised non-director resolves + issues, but the send degrades gracefully (caught β†’ "request from Records") until Stage 4 generalises the pipeline.
  • Stage 4 β€” non-director signing pipeline (DONE). A single resolveSigner(id) (lib/wopc/signerResolver.server.ts) resolves a registry director OR an authorised Individual, threaded through request create/assign validation, the email recipient, the closing-name resolution, and the sign-page access guard β€” so an authorised non-director can receive + open + complete the signature. The cross-issuance guard relaxed from "must be the other director" to the surviving substance β€” signer β‰  payee. The director path stays byte-identical. (Not yet exercised end-to-end in the app β€” worth a live non-director run before relying on it.)

Progress / SHAs (append-only, newest last; rebuilt onto main 2026-07-31, renumbered T-195 β†’ T-196)

  • feat(wopc-signers): add wopc_signers:manage permission + authorizedWopcSigner field (stage 1) β€” the RBAC permission (kept). Its interim flat field was reverted below.
  • refactor(T-196): revert the interim flat authorizedWopcSigner field β€” superseded by the approved grouped system.authorizedSignature map.
  • feat(T-196): legalNameOrder β€” flip a person's document name order β€” the approved additive schema (legalNameOrder + authorizedSignature) + order-aware WOPC name resolution + the Contacts name-order toggle. tsc + tests green.
  • refactor(T-196): retire the stored director closingName + title facet fields β€” removes the two facet fields; the name derives from parts + order, the job line from isDirector. The consistency guard drops its now-dead closingName/title drift checks.
  • feat(T-196): stage 2 β€” authorised-signer mark (toggle + badge + server-enforced grant) β€” the Contacts drawer toggle/badge gated on wopc_signers:manage; the individuals PATCH enforces the permission and stamps the grantedBy/grantedAt trail server-side. tsc + tests green.
  • feat(T-196): stage 3 β€” reimburse signer picker + email preview + send β€” eligible-signers resolver
  • endpoint; the reimburse modal picks the closing signer ({directors + authorised} βˆ’ payee), previews the request email, and issues β†’ creates β†’ sends on confirm (was a Records punt). tsc + tests + lint green. Non-director send deferred to Stage 4.
  • feat(T-196): stage 4 β€” non-director signing pipeline (resolveSigner) β€” the signing subsystem accepts an authorised Individual as signer, not just a registry director (request create/assign, email, closing-name, sign-page access); the independence guard becomes signer β‰  payee. tsc + tests + lint green.

Source: Records (Infrastructure) Β· https://claude.ai/code/session_018RDB37kCqfouHdygVXTAtD