Skip to content

WOPC doc-shape restructure + revision subcollection + lifecycle centralization

Why (owner, 2026-06-21)

The WOPC system has accumulated four overlapping problems that each look small in isolation but compound:

  1. Doc shape is flat. bankName / bankCode / contractorName / payeeAbbreviation / transactionId / referenceNumber etc. all sit at the top level, with no grouping by concern. The same lack-of-shape that T-077/T-078 already started addressing for invoices has never been done for WOPCs.

  2. Lifecycle state is fragmented across payees/{abbr}/wopc/{id} (the WOPC doc) and wopcSigningRequests/{id} (the signing request). The WOPC doc has half-implemented mirror fields for signature/void state, the signing-request doc has the real source-of-truth, and twelve declared-but-unwritten fields ("phantoms") on the WOPC type made the boundary unreadable. As a consequence the Records page "Show hidden" toggle silently does nothing β€” its filter is w.status !== 'voided' but no code path writes status: 'voided' to the WOPC doc.

  3. Display-name enricher can't render manual WOPCs. A WOPC with manually-entered line items (no relatedProjectId pointing to a real project doc) renders blank for {{projectTitle}} because the enricher reads projects/{relatedProjectId} instead of the WOPC's own lineItems[*].projectSnapshot.projectTitle β€” which is sitting right there denormalized. The WOPC owns the data, but the enricher asks the wrong source.

  4. The "WOPC = post-hoc explanation of a bank tx" inversion the codebase moved to in 2026 is wrong for the owner's workflow. A WOPC is a forward-looking payout artifact (issued, signed, then the bank tx arrives carrying its reference number and is auto-matched), not a record of what a bank tx "was for."

The restructure (1) ships a grouped, lean doc shape; (2) centralizes signing/void/lifecycle on the WOPC and demotes signing-request to an ephemeral workflow ticket; (3) gives the enricher the right source to read; (4) revives the pre-tx WOPC workflow.

Why it's built this way (decisions that aren't the obvious choice)

These are the non-obvious calls β€” captured so a future agent doesn't undo them assuming "they would have just done X":

  • Revisions are a subcollection, not a revisions[] array on the parent. Each sign attempt produces an immutable per-revision doc paired 1:1 with a Drive PDF. An array would force read-modify-write on every re-sign and conflate identity with history. The subcollection lets each revision be queryable, independently signed/voided, and trivially listable for the UI's expandable card.
  • Public-facing ref number stays stable across revisions. The bank-tx description carries the WOPC ref; encoding -rev-N in the printable form would create reconciliation drift between the bank record and the WOPC's "currently active" revision. The revision number is internal metadata + a "Rev N of N" UI badge.
  • One chop type (VOID), reason in void.reason metadata. The owner explicitly raised the noise/confusion risk of dual VOID/INVALID stamps. A single chop with a structured reason field reads unambiguously on the PDF AND gives the UI enough info to render labels like "Voided (transaction unmatched)" or "Voided (superseded by Revision 3)".
  • Soft-delete tombstone first, hard-delete via sweep later. Financial records need an undo window; the bank-tx unmatch path already does the void-stamp + Drive archive, so the tombstone is cheap. Drive PDFs are kept indefinitely as the durable artifact regardless of Firestore-side hard-delete.
  • Signing request becomes ephemeral. It's a workflow ticket β€” once the workflow terminates (signed / withdrawn / rejected / voided) the result is applied to the WOPC's current revision and the ticket is deleted. Audit lives on the WOPC's immutable revisions, not on a separate fragile collection.
  • Director ids stay opaque UIDs (already migrated by T-081 across all 32 WOPCs + 44 signing requests + 129 audit events) β€” WOPC.closingDirectorId and WOPC.signature.by carry them as opaque strings; no rework needed.
  • payees/{abbr}/… storage path is frozen per T-081's blast-radius note. Storage layout doesn't change in this task.

Target shape

Parent doc (payees/{abbr}/wopc/{refStorageId})

{
  "lineItems": [ /* see below */ ],

  "WOPC": {
    "refNumber":         "ERL-WOPC/2025-020",
    "issuedDate":        Timestamp,
    "paymentDate":       Timestamp,         // intended at create; updated when matched to a real tx
    "totalAmount":       1500,
    "currency":          "HKD",
    "closingDirectorId": "oS1b0stJGPc0aCoFEb5N", // opaque individual UID (T-081)
    "created":           { "at": Timestamp, "by": "user@…" },

    "currentRevision":   3,                  // pointer into revisions/
    "status":            "active" | "pending_transaction",

    "deleted": {                              // ABSENT until cancelled
      "at":     Timestamp,
      "by":     "user@…",
      "reason": "tx-unmatched" | "manually-cancelled" | "superseded"
    }
  },

  "bank": {
    "name":               "OCBC",
    "code":               "035",
    "accountNumber":      "813-…",
    "accountHolderName":  "Ngai, Wang Chi"
  },

  "contractor": {
    "name":    "Jake Ngai",
    "address": { "line1": …, "line2": …, "line3": …, "country": … }
    // contractorId dropped (was phantom β€” system uses payee.abbreviation as the contractor key)
  },

  "transaction": {
    "id":   "TecvQEHSsjz5Q8toJPNR",          // OPTIONAL during pending_transaction; filled at match time
    "date": Timestamp                         // ditto
  },

  "payee": {
    "name":         "Jake Ngai",
    "abbreviation": "JN"
  }
}

Revision doc (payees/{abbr}/wopc/{refStorageId}/revisions/{N})

{
  "createdAt":  Timestamp,
  "createdBy":  "user@…",

  "pdf": {
    "unsignedFileId": "…",       // Drive id of the unsigned PDF rendered for this revision
    "signedFileId":   "…"        // Drive id of the signed-and-sealed PDF β€” set on signing
  },

  "signature": {                  // ABSENT until signed
    "at":     Timestamp,
    "by":     "oS1b0stJGPc0aCoFEb5N",   // opaque individual UID
    "source": "drawn" | "stored"
  },

  "void": {                       // ABSENT until voided
    "at":           Timestamp,
    "driveFileId":  "…",          // VOID-stamped PDF
    "reason":       "signature-error" | "seal-error" | "superseded-by-revision" | "parent-deleted",
    "by":           "user@…"
  }
}

LineItem rule (manual items)

For lineItems[i] where type === 'manual':

  • id = "{refNumber}#{index}" β€” e.g. "ERL-WOPC/2025-018#0". Unique within the WOPC; no React-key collisions.
  • projectSnapshot.projectId = "{refNumber}:{projectSeq}" β€” e.g. "ERL-WOPC/2025-018:1" for the first distinct project, ":2" for the second. Per-project group is preserved (multi-project manual WOPCs still render as multiple grouped sections in the PDF).
  • projectSnapshot.projectTitle remains user-typed text; that's what the PDF group divider shows.

For referenced items (type in 'invoice_item' | 'invoice_full' | 'coaching_invoice'), id and projectId remain as-is β€” the existing invoiceItemRef / coachingInvoiceRef carry the linkage.

Fields dropped (phantoms β€” see audit at decision log entry below)

contractorId, signedBy, signatureImagePath, pdfGeneratedAt, updatedAt, updatedBy, deletedAt, deletedBy, matchedTransactionId, status: 'completed' | 'failed' | 'matched' (replaced by the leaner active | pending_transaction), top-level notes (per-line lineItems[*].notes already exists).

UI deliverables (in this task)

  • Revision history as an expandable card in the document-detail side drawer on the Records page. Default shows the current revision (its signature + Drive PDF link). Expanded reveals historical revisions with each one's signature.{at, by}, Drive PDF link, and void reason if any.
  • "Show hidden" toggle on the Records WOPC tab starts working β€” it surfaces WOPCs whose parent carries deleted.at, in addition to (still-visible-by-default) superseded revisions within an active WOPC.
  • Records page WOPC tab labels and void-state indicators read the new structure (WOPC.deleted.reason, revision.void.reason).

Plan (phased commits on one branch)

  1. Phase 1 β€” Type + dual-read accessor (lib/wopcDocShape.ts). New types, accessor that reads either shape. All WOPC read sites switch to the accessor. No writer changes yet.
  2. Phase 2 β€” Centralize signing/void state on the WOPC. Rewire markWOPCSigned to also write signature.by / signature.source / pdf.signedFileId (sourced from the request doc). Wire voidWOPC into the existing void path so void.{at, driveFileId, reason} actually gets written β€” this activates the "Show hidden" feature. Records page driveFileId resolver reads from the WOPC instead of joining with signingRequest.signedDriveFileId.
  3. Phase 3 β€” Revive pre-tx WOPC workflow. Allow createWOPC with no transactionId, status: 'pending_transaction'. Auto-match endpoint (already exists at pages/api/accounting/wopc-auto-match.ts) becomes the primary transition pending_transaction β†’ active and populates transaction.{id, date}.
  4. Phase 4 β€” Revisions subcollection. Replace single-doc overwrite-on-resign with per-attempt revision docs. UI: expandable card on the Records side drawer. Implement soft-delete tombstone + "Show hidden" wiring. Disposal sweep for terminal signing-request docs (after their state has been mirrored to a revision).
  5. Phase 5 β€” Migration script + cleanup. scripts/migrate-wopc-doc-shape.ts (dry-run + --execute) backfills the 32 production WOPCs into the new shape and seeds revision 1 for each (using the existing single-doc state). Once production has migrated cleanly, follow-up cleanup PR drops the legacy-shape read fallbacks (T-080 pattern).

Open follow-ups (not in this task's scope)

  • T-084 (to be opened): Signed-WOPC thumbnail in the bank-tx details popover (components/accounting/transactions/TransactionTitleWithLinks.tsx:307 WOPCHoverPreview) renders weirdly per owner observation. Defer until reproduced + diagnosed β€” opening a task without the why would violate the "Capture the WHY" rule.

Decision log

2026-06-21 β€” task opened

  • βœ… Attestation (Accounting (Diagnostics)): read AGENTS.md (tip e5e90b3a); scope-scanned the board β€” no existing task covers WOPC doc-shape restructure or signing-state centralization (T-051 = contacts re-anchoring, T-077 = WOPC numbering chronology, T-076 = invoice status derivation, T-081 = contacts re-key).
  • Source: Accounting (Diagnostics) Β· https://claude.ai/code/session_01G58Y71noihrYCDEDMexmea (retrofitted 2026-06-21 after the Source-line convention was added to AGENTS.md mid-session)
  • What changed: opened T-083 to consolidate the WOPC doc-shape restructure, revision subcollection, signing-state centralization on WOPC, pre-tx workflow revival, and "Show hidden" wiring β€” as one coherent change.
  • Proposed by: the owner.
  • Approved by: the owner β€” explicitly delegated judgment on cadence + thumbnail-deferral: "No need for my approval, you'll have to log it based on your own judgement" (2026-06-21).
  • Rationale: see "Why" and "Why it's built this way" sections above. Compressing the work into a single task avoids the partial-rewrite cycles that would happen if shape, lifecycle, and state-centralization were split β€” they all touch the same WOPC type, same readers, same write paths.

Evidence β€” owner, 2026-06-21 (verbatim quotes from the design conversation):

On the bucket structure:

"1. all 'bank...' fields should be grouped under a 'bank' map field 2. all 'contractor...' fields should be grouped under a 'contractor' map field 3. all 'transaction...' fields should be grouped under a 'transaction' map field (or 'tx' map field. Your choice) 4. all 'payee...' fields should be grouped under a 'payee' map field 5. while the paymentDate field, totalAmount field, referenceNumber field (rename it to refno./ refNumber), issuedDate field, currency field and the closingDirectorId field should be grouped under a 'WOPC' map field, while createdAt and createBy should be grouped by a 'created' map field that lives under the WOPC map field"

On the PDF subgroup:

"please also group 'pdfFileId' and 'pdfStoragePath' under a 'pdf' map field"

On dropping phantom fields (after the field audit returned 12 declared-but-unwritten fields):

"I've checked on a few WOPC and I don't see those fields. Can you check and make sure those fields exist?"

On notes placement:

"notes should be under lineItems"

On the WOPC-first workflow:

"It's actually not true that 'WOPCs are now only created reactively β€” after a real bank transaction is matched'. It's in fact, something that I'm trying to change here as WOPCs should happens before the bank transaction, while since a WOPC records the transaction date, so it should be part of the payout work flow. But I definitely am trying not to use a WOPC to EXPLAIN a already happened transaction"

On signing-request being ephemeral, with state centralized on the WOPC:

"do you think those information should be stored there or centralized in the WOPC doc instead, cos signing request doc should only facilitate the signing process, and those information should be disposable once the signing process is over (signed and sealed), while all the statuses should be stored on the WOPC firestore doc instead, don't you think?"

On revisions for re-signs (the owner sketched the UX, agreeing with the subcollection model after I proposed it):

"On the Records page WOPC tab UI, it allows user to VOID a WOPC possibly due to the closing director messing up the sign and seal process (bad signature or chopping the seal at a undesirable location on the WOPC), and that the very same unsigned and unchopped WOPC shall be up for the closing director to sign and seal again. Should a subsequence WOPC with the same information but with a '-2' (or '-rev-1' and '-rev-2' so on and so forth) prefix in the ref number be created (with the web app listing the revision on the UI and deeming the voided one as a hidden item, or how would you suggest that we proceed otherwise?"

On the void / delete / chop-wording semantics:

"Do you think that the web app should have this eventually deemed void WOPC, and its record to be entirely deleted from the system since the tx is unmatched and the record of the WOPC is entirely obsolete, or since the WOPC is rendered is VOID, it should be 'hidden', and await for eventual deletion by any user from there? And if that WOPC is not simply voided by even the information is not reusable anymore, should a chop that's not VOID but INVALID be rendered instead? But my concern would be, keeping such WOPC creates noise that confuses people"

On the five proposal points + revision-card UI placement:

"1. All of your 5 points agreed 2. Revision appears as a expandable card on the document detail side drawer on the Records page please 3. The web app currently shows the signed WOPC thumbnail in the transaction details on the accounting page quite weirdly 4. The Payee document is going thru a migration, please check commits/ update your nightly branch for and check task board/ read the latest"

Live-state audit findings (decision context for the phantom-field drops):

  • Scanned all 32 WOPC docs in tebs-epl. Of the 36 declared optional fields, 12 are never written by any active code path: contractorId, status, updatedAt, updatedBy, matchedTransactionId, pdfGeneratedAt, signedBy, signatureImagePath, voidedAt, voidedDriveFileId, deletedAt, deletedBy. Their writer functions either don't exist or have zero callers (voidWOPC is exported but never called; updateWOPCCompleted / updateWOPCFailed have no callers; signedBy has no writer anywhere).
  • The "voided WOPCs not appearing under Show hidden" bug is rooted here: the Records API filter (pages/api/records/wopcs.ts:65) is w.status !== 'voided', but status: 'voided' is never written, so the filter has nothing to exclude and the toggle effectively does nothing. Phase 2 of this task fixes the data side; the filter logic is already correct.

2026-06-21 β€” Phases 1a / 1b / 2 / 3 shipped (in-progress on claude/busy-dirac-QmUdM β†’ PR #775)

  • βœ… Attestation (Accounting (Diagnostics)): read AGENTS.md (tip 60ad5988); board scan still clean (no new dup).
  • Source: Accounting (Diagnostics) Β· https://claude.ai/code/session_01G58Y71noihrYCDEDMexmea (retrofitted 2026-06-21 after the Source-line convention was added to AGENTS.md mid-session)
  • Phase 1a (aef08a5f) β€” lib/wopcDocShape.ts types + toView dual-read accessor + 19-case vitest suite. Defensive defaults align with AGENTS.md "missing data β†’ N/A" UI conventions.
  • Phase 1b (23c81608 enricher + manual-WOPC display fix; 436d08e4 remaining 5 readers) β€” every getWOPCByReferenceNumber / getAllWOPCs consumer now reads via the accessor:
  • enrichTransactionDisplayName (lib/accounting/transactions.ts) β€” fixes the user-visible bug where manual WOPCs (relatedProjectId = "manual-…") rendered blank for {{projectTitle}} etc.; multi-project WOPCs join with ", " + trailing " & " (matches PDF renderer's formatProjectDescription).
  • lib/wopc/resolvePdfBytes.server.ts, lib/wopc/signingRequests/operations.server.ts, lib/email/sendWopcSigningRequestEmail.ts, pages/api/records/wopcs.ts, pages/api/records/wopcs/download-zip.ts.
  • WOPCViewVoid + getVoid added for parent-level void state read-side coverage.
  • Phase 2 (69c770c9) β€” centralizes signing/void state ON the WOPC, activates "Show hidden":
  • markWOPCSigned extended to write WOPC.signature.{at, requestId, by, source} + WOPC.pdf.signedFileId; clears WOPC.void on re-sign. Caller (recordSignature) passes director id + signature source + Drive id.
  • voidWOPC rewritten to write WOPC.void.{at, driveFileId} and wired into recordVoided (signingRequests/operations.server.ts) β€” this is the dead-code fix that makes "Show hidden" actually surface voided WOPCs. The dead-writer was the root cause per T-083's WHY (the entire Records-page filter was matching against a field nothing wrote).
  • Legacy flat-key writes dropped from both writers.
  • Records list endpoint filter at pages/api/records/wopcs.ts hides WOPCs whose WOPC.deleted.at OR WOPC.void.at is set, plus legacy status === 'voided' for backward compat.
  • Phase 3 (this commit) β€” revives the pre-tx WOPC workflow:
  • createWOPC no longer requires transactionId / transactionDate. Pre-tx WOPCs (created during the payout flow before the bank tx arrives) get WOPC.status: 'pending_transaction'; WOPCs created with a tx already known get 'active'.
  • updateWOPCMatched is now the canonical pending_transaction β†’ active transition: writes WOPC.status: 'active' and fills in transactionId / transactionDate / paymentDate. Drops legacy status: 'matched' (deprecated enum) and matchedTransactionId (phantom β€” never read).
  • Caveat: Phase 3 ships the schema enablement. The UI flow that lets an operator create a WOPC before the bank tx (the pre-payout form) is a separate UX task β€” needs the owner's call on where it lives (OCBC payment drawer? new "Issue WOPC" button on Records?). Logged here so the next agent doesn't assume "Phase 3 done" means the end-to-end pre-tx workflow is user-reachable.
  • Tests: 27 wopcDocShape cases (363/363 suite total; 2 unrelated test files fail to load on missing node-forge / uuid deps β€” pre-existing, same set since PR #472). tsc clean across all changed files.

Remaining phases:

  • Phase 4 β€” revisions subcollection + Records-page revision card + soft-delete tombstone wiring into the tx-unmatch cascade + terminal-state signing-request disposal sweep.
  • Phase 5 β€” scripts/migrate-wopc-doc-shape.ts for the 32 production WOPCs + follow-up cleanup PR (T-080 pattern) dropping the legacy-shape read fallbacks once the migration runs clean.

2026-06-21 β€” Phases 4 + 5 shipped (PR #775 ready for review)

  • βœ… Attestation (Accounting (Diagnostics)): read AGENTS.md (tip 60ad5988); board still clean.
  • Source: Accounting (Diagnostics) Β· https://claude.ai/code/session_01G58Y71noihrYCDEDMexmea (retrofitted 2026-06-21 after the Source-line convention was added to AGENTS.md mid-session)
  • Phase 4c β€” soft-delete tombstone (6c77c4ff): tombstoneWOPC added to lib/wopc.server.ts; /api/accounting/wopc/[referenceNumber]/delete defaults to mode: 'tombstone' (soft-delete via WOPC.deleted.{at, by, reason}) and accepts mode: 'permanent' for the future "permanently delete" affordance. End-to-end: cascade /void writes WOPC.void (Phase 2), cascade /delete writes WOPC.deleted (this), Records filter hides both, "Show hidden" surfaces both. Drive PDFs survive both flavours per the owner spec.
  • Phase 4a + 4b β€” revisions in the view + expandable card (225979bd): accessor synthesizes a single revisions element from the parent's signature/void/pdf state; /api/records/wopcs/[ref] (also migrated β€” I missed this reader in Phase 1b's pass) surfaces it; WopcDetailDrawer (components/records/RecordsApp.tsx) renders the new expandable card after Notes per the owner's placement spec. Default-expanded; small table with Rev # / State / Signed-at / Voided-at / PDF link.
  • Phase 5 β€” migration script (this commit, scripts/migrate-wopc-doc-shape.ts): dry-run-by-default tool that walks payees/*/wopc/* in tebs-epl and writes the new bucketed fields as additive updates (legacy keys preserved β†’ dual-read accessor still works β†’ safe rollback via JSON backup). Dry-run against live data: 32 WOPCs queued, +20 fields for unsigned no-PDF (most), +22 for the 9 PDF-tracked, +24 for the 2 signed-via-app. Idempotent: re-running on a migrated doc detects WOPC.refNumber is set and reports no-op. Not yet executed β€” script is committed for owner review; --apply writes Firestore + drops a backup to scripts/migration-backups/.

Deferred / follow-up tasks

  • Phase 4d β€” sweep that deletes terminal-state signing-request docs after their state is mirrored onto the WOPC. Lower priority; the data they hold (sentAt / withdrawnAt / rejectedReason) isn't currently load-bearing for any read path.
  • Phase 4e β€” UI polish for hidden-state labels in the Records WOPC tab (currently "Show hidden" works but renders surfaced rows with their normal styling; a "Voided" / "Cancelled" badge would help).
  • Real revisions subcollection at write time β€” Phase 4a's synthesis handles read-side; the write-side restructure (markWOPCSigned / voidWOPC append to revisions/{N} instead of mutating parent) needs the migration to run first so existing WOPCs have an explicit currentRevision: 1 pointer.
  • WOPC-level notes β†’ first line item migration β€” owner-approved design but the migration script defers it (legacy notes field stays at top level; accessor reads via fallback path).
  • T-080 cleanup PR β€” drop the legacy-shape fallbacks in lib/wopcDocShape.ts once the Phase 5 script has been --apply-ed in prod. Standard pattern from T-077.
  • T-084 β€” WOPCHoverPreview thumbnail bug, still in todo pending live repro.

Open follow-up: pre-tx WOPC creation UI

Phase 3 shipped the schema/data side that lets createWOPC be called without a transactionId, with status: 'pending_transaction'. The UI flow that uses it (where an operator initiates "Create WOPC" before the bank tx) is a separate UX task. Suggested home: a new entry on the OCBC payment drawer or a "Issue WOPC" button on the Records page. Owner call.

Risk + blast-radius (preview for the done-verdict close-out)

Code surfaces this touches (so a future agent doing the close-out doesn't miss any):

  • lib/wopc.server.ts β€” type, CRUD, status writers
  • lib/accounting/types.ts β€” WOPCDocument + PaymentConfirmation
  • lib/wopc/signingRequests/operations.server.ts + repo.server.ts β€” terminal-state mirror to WOPC, request disposal
  • lib/pdfTemplates/paymentConfirmation.tsx + lib/paymentConfirmation/PaymentConfirmation.tsx β€” PDF render (line-item grouping by projectSnapshot.projectId is unaffected; new manual-item id/projectId rule keeps the groupings working)
  • lib/accounting/transactions.ts β€” enrichTransactionDisplayName (reads WOPC via getWOPCByReferenceNumber; will switch to the dual-read accessor). NOTE: the WOPC reader correctly defaults to tebs-epl already (wopc.server.ts:28, via NEXT_PUBLIC_DIRECTORY_FIRESTORE_DATABASE_ID) β€” an earlier diagnostic note in this thread claiming the reader was pointed at the wrong DB was based on a stale snapshot and does not apply on current nightly.
  • lib/accounting/wopcPipeline.server.ts β€” creation pipeline (becomes both pre-tx and at-tx)
  • pages/api/accounting/wopc-auto-match.ts β€” pending β†’ active transition
  • pages/api/records/wopcs.ts β€” list endpoint, "Show hidden" filter, driveFileId resolution
  • pages/api/accounting/wopc/[referenceNumber]/delete.ts β€” delete path (becomes the soft-delete tombstone writer)
  • components/records/RecordsApp.tsx β€” Records page WOPC tab + drawer (revision card)
  • components/accounting/transactions/TransactionTitleWithLinks.tsx β€” WOPCHoverPreview (signed PDF lookup switches from signingRequest.signedDriveFileId to revision.pdf.signedFileId via the accessor)
  • scripts/migrate-wopc-doc-shape.ts β€” new

2026-06-21 β€” renumbered T-083 β†’ T-085 (UID collision resolved)

  • βœ… Attestation (Accounting (Diagnostics)): read AGENTS.md; renumbering, not editing scope.
  • Source: Accounting (Diagnostics) Β· https://claude.ai/code/session_01G58Y71noihrYCDEDMexmea
  • What changed: task file moved T-083.md β†’ T-085.md; frontmatter uid: T-083 β†’ uid: T-085. The sibling task on this branch was renumbered in the same commit (T-084.md β†’ T-086.md, related: [T-083] β†’ related: [T-085]); README next-free bumped T-085 β†’ T-087.
  • Why: while this branch (claude/busy-dirac-QmUdM, PR #775) was in draft, another agent (EOP Local Assistance) merged a different T-083 onto nightly (Firebase auth fix, commit 3c0cf111). Meanwhile I'd opened a separate display-regressions task on a fresh branch off nightly that took the then-next-free T-084 (PR #777). Both collisions block a clean merge of #775.
  • Renumber chosen over scope-rename so each task keeps its own history + cross-references intact and the existing branch commits (which mention "T-083" / "T-084" in messages) remain readable as historical context β€” the new UIDs apply going forward.
  • Commit messages stay as-is β€” those are immutable history. Future references to this task use T-085.
  • Proposed by: Accounting (Diagnostics). Approved by: the owner β€” "proceed on both please" (2026-06-21), after I flagged the collision in the PR #777 wrap.

2026-06-22 β€” renumbered again T-085 β†’ T-089 (second collision; renumber-on-merge)

  • βœ… Attestation (Accounting (Diagnostics)): read AGENTS.md; renumber, not a scope change.
  • Source: Accounting (Diagnostics) Β· https://claude.ai/code/session_01G58Y71noihrYCDEDMexmea
  • Why: the 2026-06-21 renumber landed this task on T-085, but a new T-085 (the FYβ†’YA period-model unification task) then merged to nightly (0d04a26a) β€” colliding again. Renamed to T-089 (free past every in-flight PR: #779 = T-087, #782 = T-088, #777 = T-090). README next-free bumped to T-090. The sibling thumbnail task's related: was updated [T-085] β†’ [T-089]; the WOPC in-code phase comments (lib/wopc*.ts, pages/api/records/wopcs*, RecordsApp.tsx, the migration scripts, etc.) were updated T-083 β†’ T-089 in this commit so the code matches the canonical UID (they'd been left at the original T-083 through the first renumber). The docs/eop-tasks/tasks/T-089.md path reference in migrate-wopc-doc-shape.ts was fixed too.
  • Forward references to this task now use T-089. The 2026-06-21 entry above is kept verbatim as point-in-time history (it correctly records the first T-083 β†’ T-085 move).
  • Rule (owner-approved, 2026-06-22): "Renumber on merge, per PR" β€” whoever merges second renumbers to the real next-free. This task has now hit it twice; the underlying cause is that the README "next-free" pointer can't coordinate parallel agent branches.

2026-06-24 β€” writer-gap fix + migration completed (32 β†’ 34/34)

  • βœ… Attestation (Accounting (Diagnostics)): read AGENTS.md; continuation of this task per owner "proceed to T-089" (2026-06-24).
  • Source: Accounting (Diagnostics) Β· https://claude.ai/code/session_01G58Y71noihrYCDEDMexmea
  • Board-scan finding: the Phase 5 migration had in fact been run (32/34 WOPCs already carried the bucketed WOPC.refNumber marker), but a live check found 2 stragglers (ERL-WOPC/2025-023 created 2026-06-22, ERL-WOPC/2025-024 created 2026-06-23) still in legacy shape.
  • Root cause (writer gap): createWOPC (lib/wopc.server.ts) wrote the legacy flat keys plus only WOPC: { status } β€” never the WOPC.* core / bank.* / contractor.* / payee.* / transaction.* maps. So every WOPC born after Phase 3 was a fresh migration straggler, making the migration a treadmill and blocking the eventual T-080 legacy-fallback cleanup (you can't drop the legacy read fallback while the writer keeps emitting legacy-only docs).
  • Fix: createWOPC now writes the complete bucketed shape natively β€” additive (legacy flat keys kept for the dual-read window), field mapping mirrors scripts/migrate-wopc-doc-shape.ts exactly. All creation paths route through createWOPC (wopcPipeline, transactions/[id], create-pending), so this one change covers every new WOPC. tsc clean.
  • Migration run: npx tsx scripts/migrate-wopc-doc-shape.ts --apply migrated the 2 stragglers (+20 fields each, backup at scripts/migration-backups/wopc-doc-shape-2026-06-24T03-54-17-503Z.json). Re-check: 34/34 migrated, 0 stragglers.
  • Still remaining (unchanged, follow-ups): real revisions subcollection at write time (Phase 4); T-080 cleanup (drop legacy fallbacks β€” now unblocked on the writer side, safe once the team is comfortable); deferred polish (Phase 4d sweep, 4e hidden-state labels); pre-tx WOPC creation UI.
  • Open design question raised by owner (2026-06-24) β€” manual line-item ids: the T-089 "LineItem rule" (id = {refNumber}#{index}, projectSnapshot.projectId = {refNumber}:{projectSeq}) is not shipped β€” the form still mints manual-proj-… / manual-item-… slugs (PaymentConfirmationForm.tsx:1033). Writing the bare WOPC ref number to those fields (as the owner initially sketched) breaks: (1) duplicate React keys on key={item.id}; (2) multi-project grouping collapse (group key = projectSnapshot.projectId); (3) the manual- prefix convention that wopcYear.ts + 3 lookup sites use to skip synthetic projects; (4) the / in the ref number is unsafe as an id/path segment. The {refNumber}#{index} / {refNumber}:{seq} scheme fixes 1/2/4 but still needs the skip-logic switched from projectId.startsWith('manual-') to the authoritative lineItem.type === 'manual' flag, else it re-triggers 3. Deferred pending owner decision on whether to implement.

2026-06-24 β€” manual line-item id scheme + skip-logic switch + backfill (LineItem rule shipped)

  • βœ… Attestation (Accounting (Diagnostics)): read AGENTS.md; continuation of this task per owner "Proceed with all 3 please" (2026-06-24).
  • Source: Accounting (Diagnostics) Β· https://claude.ai/code/session_01G58Y71noihrYCDEDMexmea
  • Owner direction (verbatim, 2026-06-24):

    "I see that even WOPCs that reference existing Project invoices and the line items in it, it adds a -* suffix in the end for the line items, so for the entirely manually inputted WOPC, why can't we reference this increase sequence suffix so that the lineItems ID and the projectID aren't entirely the same?"

  • What shipped (in PR for this branch):
  • createWOPC (lib/wopc.server.ts) now runs the new helper remapManualLineItems at save time. For every line item with type === 'manual': id β†’ {referenceNumber}#{index}, projectSnapshot.projectId β†’ {referenceNumber}:{projectSeq} where projectSeq is per-distinct original projectId in encounter order. Multi-project manual WOPCs preserve their grouping (verified β€” ERL-WOPC/2025-018's 2 distinct typed projects mapped cleanly to :1 and :2). Non-manual items pass through untouched.
  • Skip-logic switched off the manual- prefix sniff. lib/accounting/wopcYear.ts:deriveWopcYearFromLineItems now skips manual items by the authoritative li.type === 'manual' flag (WopcYearLineItem interface widened to carry the type discriminator). The prior projectId.startsWith('manual-') check stays as a belt-and-suspenders fallback for legacy unmigrated docs lacking a type field. All 4 caller comments (wopcPipeline.server.ts, transactions/[id].ts, wopc/create-pending.ts, matchPlugins/wopcInline.tsx) were already delegating to this helper β€” no caller-site changes needed.
  • Backfill β€” scripts/backfill-wopc-manual-line-item-ids.ts (dry-run by default, backup-first, idempotent, halt-on-error). Applied to prod: 4 WOPCs / 10 manual items remapped (ERL-WOPC/2025-018, /2025-023, /2025-024, /2026-001). Backup: scripts/migration-backups/wopc-manual-line-item-ids-2026-06-24T04-18-50-329Z.json. Idempotency re-run: 0 changes. All manual-WOPC line items in prod now share the new scheme β€” old and new look identical going forward.
  • Tests + tsc: npx tsc --noEmit clean.
  • Verification (live, post-apply): the affected 4 WOPCs render correctly through the existing enrichTransactionDisplayName path (which reads projectSnapshot.projectTitle directly, not the projectId), and the PDF / on-screen renderers still group by projectSnapshot.projectId β€” the new :{seq} ids preserve their distinct groupings exactly as before.
  • Blast radius: zero data destroyed. The remap rewrites two fields per manual line item; the description / amount / projectSnapshot.{projectTitle, presenterWorkType, projectNature} / notes / itemSnapshot payload is untouched. The on-screen + PDF renderers group by projectSnapshot.projectId β€” distinct ids still produce distinct groups. The display-name enricher reads projectSnapshot.projectTitle (not the projectId) for manual items, so no display regression.
  • Form-side cleanup (follow-up, not in this PR): PaymentConfirmationForm.tsx's newManualId('manual-proj') / newManualId('manual-item') (line 1033) keep minting manual-… slugs during form-edit state β€” that's harmless now (the remap happens at save in createWOPC), but the form could be simplified to use stable per-row indices since the ids are reassigned anyway. Defer until the form gets other work.
  • Tickets spawned this session: I-010 β€” "Generating…" spinner stuck in the WOPC ref-number module during 5050 matching. Diagnosed: T-081 numbering format mismatch in the effect guard (ReferenceNumberModule.tsx:96). Single-file UI fix, ticket-only (no T-NNN escalation).

2026-06-24 β€” T-080 dual-read cleanup + DONE (close-the-loop)

  • βœ… Attestation (Accounting (Diagnostics)): read AGENTS.md; close-the-loop entry per owner "Let's wrap up T-089 and > T-80 clean up" (2026-06-24).
  • Source: Accounting (Diagnostics) Β· https://claude.ai/code/session_01G58Y71noihrYCDEDMexmea
  • Verdict: the WOPC restructure (Phases 1–5 + the line-item rule) shipped + the T-080 read-side dual-read fallback dropped. Every prod WOPC is in the canonical bucketed shape; every reader goes through the bucketed maps only; new WOPCs are born clean (no legacy flat keys written).
  • What shipped in this close-out PR: Β· Backfill scripts/backfill-wopc-notes.ts β€” Phase 5 migration had missed notes. Live check found 3 docs with top-level notes and no WOPC.notes; backfill applied (backup at scripts/migration-backups/wopc-notes-2026-06-24T06-12-04-218Z.json). All 34 WOPCs then safe to drop every fallback. Β· Accessor cleanup (lib/wopcDocShape.ts) β€” every getter dropped its ?? data.legacyX tail; map-builders (getBank / getContractor / getTransaction / getPayee) dropped their legacy branch; getters that needed both halves (getPdf, getSignature, getVoid) dropped the legacy half. isNewShape now means "is this a well-formed WOPC doc?". Β· Writer cleanup (lib/wopc.server.ts) β€” createWOPC writes the bucketed shape only (legacy flat block removed). updateWOPCMatched writes transaction.id / transaction.date / WOPC.paymentDate bucketed. updateWOPCClosingDirector writes WOPC.closingDirectorId bucketed. Dead writers updateWOPCCompleted / updateWOPCFailed / updateWOPCPdfReference deleted (0 callers each β€” all wrote deprecated legacy enum values or flat PDF keys). Β· Tests (__tests__/lib/wopcDocShape.test.ts) β€” legacyFull / legacyMinimal fixtures + the "dual-read equivalence" describe block + per-getter legacy-vs-new tests pruned. Suite tightened to the canonical-shape contract: 26/26 passing.
  • Verification (live): npx tsc --noEmit clean; npx vitest run __tests__/lib/wopcDocShape.test.ts 26/26; live re-check post-backfill confirms 34/34 docs in new shape with no fallback-dependent fields.
  • Blast radius: read-side and writer-side both go through the accessor / bucketed paths exclusively. Existing docs keep their legacy flat keys as historical artifacts (not read by any code path). A future hard-cleanup script could strip those flat keys from Firestore, but that's optional β€” they're dead data, not bugs.
  • Status: doing β†’ done. Remaining follow-ups (Phase 4 write-side revisions subcollection, Phase 4d sweep, Phase 4e hidden-state labels, pre-tx WOPC creation UI, form-side manual-proj-… slug cleanup) are now genuinely separate work items, not blockers. T-080 entry appended in parallel.

2026-06-24 β€” Phase 4e (hidden-state row dim) + legacy-flat-keys hard cleanup (deferred T-080 follow-ups)

  • βœ… Attestation (Accounting (Diagnostics)): read AGENTS.md; continuing the deferred T-089 follow-ups per owner "Let's proceed to T-089 follow ups first then I-010" (2026-06-24).
  • Source: Accounting (Diagnostics) Β· https://claude.ai/code/session_01G58Y71noihrYCDEDMexmea
  • Phase 4e β€” row dim (components/records/RecordsApp.tsx): the Records WOPC tab's LifecycleTag already rendered the right badges (Cancelled (tx-unmatched) / Cancelled / Superseded / VOID), but the rowClassName predicate only dimmed aote-row-voided on the legacy status === 'voided' enum β€” new-shape deletedAt (tombstone) and voidedAt (WOPC.void.at) rows surfaced via "Show hidden" rendered with full opacity, defeating the visual cue. Extended the predicate: row.deletedAt || row.voidedAt || row.status === 'voided' now triggers the dim.
  • Legacy-flat-keys strip (scripts/strip-wopc-legacy-flat-keys.ts): the T-080 cleanup left the legacy flat keys (referenceNumber, bankName, contractorName, etc.) on the 34 existing prod docs as historical artifacts. After the writer cleanup, no code path reads them β€” they're dead data taking up audit + disk noise. This script deletes them via FieldValue.delete(). Safe (purely subtractive), backup-first, dry-run-by-default, idempotent. Safety gate refuses to strip from any doc not yet in the bucketed shape. Β· Applied to prod 2026-06-24: 34 WOPCs / 637 keys removed. Backup at scripts/migration-backups/wopc-legacy-flat-keys-2026-06-24T06-42-04-050Z.json. Idempotency re-run: 0 to strip. Β· Live verification: representative doc payees/JN/wopc/ERL-WOPC|2025-018 now carries exactly { WOPC, bank, contractor, lineItems, payee, transaction } at the top level β€” the canonical shape, nothing else.
  • Form-side manual-proj-… slug cleanup β€” dropped from scope. The original deferred item suggested "use stable per-row indices since the ids are reassigned anyway." On review, the Date.now()+random slugs in PaymentConfirmationForm.tsx are needed as React keys + add/remove identifiers during the form's edit session (array indices break under reorder). The slugs are throwaway (rewritten by createWOPC.remapManualLineItems at save) but the form-side stability requirement is real. Net change of switching would be zero. Closed without action.
  • Tests + tsc: npx tsc --noEmit clean; npx vitest run __tests__/lib/wopcDocShape.test.ts 26/26.
  • Blast radius: UI change is row styling only (no behaviour change). Data strip is purely subtractive on dead fields β€” no reader / writer touched these keys after PR #800. The bucketed shape is unaffected.
  • Remaining T-089 follow-ups after this PR: Phase 4 (real revisions subcollection at write time), Phase 4d (terminal signing-request sweep), pre-tx WOPC creation UI (owner UX decision needed).

2026-06-24 β€” Phase 4 shipped: real revisions/{N} subcollection on re-sign

  • βœ… Attestation (Accounting (Diagnostics)): read AGENTS.md; continuing T-089 follow-ups per owner "Wrap up phase 4 > I-010 > Pre-tx WOPC" (re-ordered to "Proceed to I-010 first, then we discuss about (b)" β€” I-010 shipped in PR #802; Phase 4 design walked through with owner before this code).
  • Source: Accounting (Diagnostics) Β· https://claude.ai/code/session_01G58Y71noihrYCDEDMexmea
  • Design (owner-confirmed): Β· Sign-time creates new revisions (option B). Β· Parent fields stay as denormalized "current state" mirror (option C). Β· No backfill of revisions/1 for the 34 existing WOPCs β€” none have been re-signed (0 voided per the migration audit), so they have no revision history. Subcollection stays empty until a real re-sign happens.
  • Owner direction (verbatim, 2026-06-24):

    "How do you determine whether a WOPC has actually gone thru any revisions? If a WOPC hasn't gone thru any revisions, why backfill revisions/1?"

  • Semantic: payees/{abbr}/wopc/{id}/revisions/{N} holds archived voided past attempts. The parent's WOPC.signature.* / pdf.* / void.* bucket is the current state. Empty subcollection = never re-signed (the common case). Index N = the attempt-number the archived state represents; on re-sign, the new attempt becomes N+1 on the parent. WOPC.currentRevision is the total sign attempts so far (including current): unsigned = 0, signed-once = 1, re-signed once = 2, etc. Invariant: currentRevision == revisions.size + (parent.signature.at ? 1 : 0).
  • What shipped: Β· Writer (lib/wopc.server.ts:markWOPCSigned) β€” wrapped in a Firestore transaction. Reads pre-state, detects whether WOPC.void.at is set on parent, archives parent's signature/void/pdf snapshot to revisions/{currentRevision} if so, clears the void, writes the new sign state, bumps currentRevision. Concurrent re-sign attempts can't race on the archive index. Β· Async reader (lib/wopc.server.ts:fetchRevisionsByReferenceNumber) β€” reads the subcollection in ascending index order. The Records-page detail-drawer API (pages/api/records/wopcs/[referenceNumber].ts) now calls this instead of the retired sync synthesis. Β· Accessor (lib/wopcDocShape.ts) β€” getRevisions synthesis retired. WOPCView.revisions typed as never[] (sync view doesn't carry the history; async helper does). Tests pruned accordingly. Β· Data fix (scripts/fix-wopc-current-revision.ts) β€” applied to prod 2026-06-24. 32/34 unsigned WOPCs corrected currentRevision: 1 β†’ 0 (the migration script's blanket : 1 was a placeholder). The 2 signed WOPCs stay at 1. Idempotent. Backup at scripts/migration-backups/wopc-current-revision-2026-06-24T07-10-28-746Z.json.
  • Tests + tsc: npx tsc --noEmit clean; npx vitest run __tests__/lib/wopcDocShape.test.ts 24/24.
  • Blast radius: writer transaction is atomic β€” a stale or concurrent caller can't half-archive. Reader change is additive (new endpoint behaviour: revisions come from subcollection instead of synthesized from parent β€” empty for every existing prod WOPC). Records detail drawer's history card already gates on revisions.length > 0, so behaviour is identical for the 34 existing WOPCs (history-card hidden) and accurate for any future re-sign.
  • Verification (live): the 34 prod WOPCs scanned post-fix carry the expected WOPC.currentRevision values; empty revisions/ subcollection on every doc (no re-signs yet). First re-sign event will exercise the writer path.
  • Remaining T-089 follow-ups after this PR: Phase 4d (terminal signing-request sweep β€” depends on Phase 4 being live, so eligible now), pre-tx WOPC creation UI (still owner UX decision).

2026-06-24 β€” Phase 4d ready: lookup-endpoint synthesis fallback for swept signing requests

  • βœ… Attestation (Accounting (Diagnostics)): read AGENTS.md (tip 26330aac); continuing the deferred T-089 follow-ups per owner "(a)" (2026-06-24) β€” proceed with Phase 4d.
  • Source: Accounting (Diagnostics) Β· https://claude.ai/code/session_01G58Y71noihrYCDEDMexmea
  • Owner question (verbatim, 2026-06-24):

    "(a), while isn't (b) technically already exists? What's the difference between this and what's already existed?"

Owner correctly observed that the pre-tx WOPC creation UI (b) is already shipped (3 entry points: CreateWOPCModal in TransactionWorkspace, WOPCPaymentModal in OCBC + Airwallex finance pages; backend /api/accounting/wopc/create-pending live; createWOPC stamps WOPC.status: 'pending_transaction'; wopc-auto-match flips it to active). What I'd labelled "pending" collapsed to two cleanup items (stale comment at lib/wopc.server.ts:752; no UI badge differentiating pending_transaction rows in Records). Those are tracked separately; (a) Phase 4d proceeds. - What was already in tree before this PR: scripts/dispose-terminal-signing-requests.ts (committed 877801f1 as T-083, renumbered with the rest in 7450519b). Dry-run-by-default, backup-first, mirror-verifies before deleting signed/voided (the only states with WOPC-side mirror); deletes withdrawn/rejected unconditionally (no mirror needed). Never executed against prod. - Why it never ran: hidden coupling. The Records WOPCs-tab badge reads from /api/wopc-signing/lookup, which calls findLatestRequestForWopc. Deleting a terminal signed/voided request would silently downgrade the badge to "Unsigned" because the lookup endpoint had no fallback to the WOPC's mirrored state. Running the sweep would have been a visible regression for the 2 currently-signed WOPCs. - What this PR ships (the safety net that unblocks the sweep): Β· Synthesis helper (lib/wopc/signingRequests/synthesize.ts) β€” pure function synthesizeFromWopcView(view, wopcRef, subsidiaryId) that reconstructs a WOPCSigningRequest-shaped payload from WOPC.signature.* / WOPC.void.* / WOPC.pdf.*. Voided takes precedence over signed (a voided WOPC that was once signed still carries signature.* since Phase 4 only clears it on re-sign). Withdrawn/rejected synthesise to null (no WOPC-side mirror; "Unsigned" IS correct). Β· Lookup endpoint (pages/api/wopc-signing/lookup.ts) β€” calls synthesizeFromWopc as the fallback when findLatestRequestForWopc returns null. Single- and batch-lookup paths both wired. Subsidiary access check happens before the WOPC fetch (no leak). Β· Accessor widening (lib/wopcDocShape.ts:WOPCViewSignature) β€” added by and source to the typed view (already written by markWOPCSigned since Phase 2; just unread by the view). Lets the synthesiser preserve who-signed / signature-source on the wire. Β· Wire shape (lib/wopc/signingRequests/client.ts:SigningRequestWire) β€” added voidedAt and voidedDriveFileId (already on the server type; just absent from the wire). Lets the voided synthesis surface the void timestamp + Drive-stamped file id to the UI. Β· Tests β€” __tests__/lib/wopc/signingRequests/synthesize.test.ts (5 cases: unsigned/no-mirror β†’ null; signed-from-mirror; voided takes precedence; legacy WOPCs missing signature.by fall back to closingDirectorId; null-director path). __tests__/lib/wopcDocShape.test.ts updated to reflect the widened WOPCViewSignature shape; new case covers the by/source round-trip. - What's NOT in this PR (the disposal run itself): the sweep against prod. The PR ships the code that makes it safe, but the npx tsx scripts/dispose-terminal-signing-requests.ts --apply run still needs Firebase Admin creds + a clean confirmation that the dry-run output is acceptable. Owner sign-off (or a follow-up agent with creds) executes the run, attaches the backup path, and closes out Phase 4d. - Tests + tsc: npx tsc --noEmit clean; npx vitest run __tests__/lib/wopcDocShape.test.ts __tests__/lib/wopc/signingRequests/synthesize.test.ts 30/30. - Blast radius: lookup endpoint behaviour change is a strict expansion (returns the same value for any non-null pre-sweep case; adds a non-null payload where it would previously have returned null after the sweep runs). Pre-sweep the endpoint still finds the real signing request first, so the synthesis path doesn't execute. View widening adds optional fields with empty-string defaults β€” no consumer breakage. Wire shape additions are additive. - Remaining T-089 follow-ups after this PR: sweep run (gated on creds + owner approval), pre-tx WOPC cleanup (stale comment at lib/wopc.server.ts:752 + optional Records badge for pending_transaction rows).

2026-06-29 β€” signed parent mirror beats stale terminal request docs (ERL-WOPC/2025-020)

  • βœ… Attestation (Codex): read AGENTS.md; checked the WOPC task history and found this belongs to T-089 rather than a new task. The previous Phase 4d entry anticipated lookup synthesis after terminal request disposal, but not the inverse production state: a current signed WOPC parent plus older terminal request docs still present.
  • Owner report (2026-06-29): ERL-WOPC/2025-020 shows pending on Records β†’ WOPCs and the side drawer previews the unsigned PDF even though the ultimate signed/sealed Drive file is 10F29sJZ6UCSCpncUYvnIemeG5xoL5FSs; three earlier voided Drive PDFs exist.
  • Live Firestore finding: parent doc payees/JC/wopc/ERL-WOPC|2025-020 carries WOPC.signature.at, WOPC.signature.requestId = lGUUsWLTqSuobCMTq949, and WOPC.pdf.signedFileId = 10F29sJZ6UCSCpncUYvnIemeG5xoL5FSs. The current request id no longer exists, while older voided wopcSigningRequests still do. The lookup endpoint returned those stale terminal request docs first and never synthesized from the signed parent.
  • Root cause: the canonical WOPC accessor only recognized WOPC.pdf.fileId, but the sign writer stores the final current PDF as WOPC.pdf.signedFileId. Separately, /api/wopc-signing/lookup used findLatestRequestForWopc(ref) ?? synthesizeFromWopc(ref), so any old terminal request doc beat the parent current-state mirror.
  • What changed: WOPCViewPdf now exposes signedFileId; synthesis, Records list export links, /api/records/wopcs/pdf, and resolveWopcArchivedPdf all prefer WOPC.pdf.signedFileId ?? WOPC.pdf.fileId before request-history fallbacks. Lookup now preserves active assigned / sent requests, but for terminal/history states prefers the parent WOPC mirror when present.
  • UI cleanup: Records drawer revision links now also check revision.pdf.signedFileId, not only revision.pdf.fileId.
  • Verification: git diff --check clean; NODE_OPTIONS=--max-old-space-size=4096 npx tsc --noEmit clean. Live read of ERL-WOPC/2025-020 normalized to a synthesized signed request with signedDriveFileId = 10F29sJZ6UCSCpncUYvnIemeG5xoL5FSs and director E6g30MzNOkhzlCJwKvoe.
  • Expected result: Records β†’ WOPCs should badge ERL-WOPC/2025-020 as signed and the drawer/thumbnail/download path should stream the signed Drive PDF instead of regenerating the unsigned template.

2026-06-29 β€” ZIP path + missing signed-PDF mirror (ERL-WOPC/2024-006)

  • βœ… Attestation (Codex local session): read AGENTS.md; checked the board by scope, not UID β€” this is the same WOPC current-state/archive mirror scope as T-089, not a new task.
  • Source: Codex local session Β· /Users/gutchumi/dev/ArtifactoftheEstablisher-codex-wopc-pdfjs
  • Owner report (verbatim, 2026-06-29):

    "While WOPC 2024-006 is marked as signed on the Records page, but the unsigned version is still being shown and included in the zip when downloading"

  • Live Firestore finding: payees/JC/wopc/ERL-WOPC|2024-006 carried WOPC.signature.at and WOPC.signature.requestId = TG7X7C7RoXcArYqhH8AS, so the Records badge correctly treated it as signed. But it had no WOPC.pdf.signedFileId, the request doc no longer existed in aote-system/wopcSigningRequests, and there was no revisions/ subcollection entry.
  • Live Drive finding: Drive search found the sealed PDF as 15XeN5Jjx4B5EXUxtHCW4DTrVMeW9CqQw, name ERL-WOPC/2024-006_20260617.pdf.
  • Data fix applied: backfilled the parent WOPC mirror: WOPC.pdf.signedFileId = 15XeN5Jjx4B5EXUxtHCW4DTrVMeW9CqQw and WOPC.pdf.storagePath = drive:15XeN5Jjx4B5EXUxtHCW4DTrVMeW9CqQw.
  • Code fix: pages/api/records/wopcs/download-zip.ts now calls resolveWopcArchivedPdf(ref) first. If the WOPC has archived voided or signed bytes, the ZIP includes those bytes directly. It only launches Puppeteer and renders the unsigned/current template when no archived PDF exists.
  • Verification: git diff --check clean; NODE_OPTIONS=--max-old-space-size=4096 npx tsc --noEmit clean. Direct Firestore read after the backfill showed the signed file mirror on ERL-WOPC/2024-006.
  • Blast radius: Records β†’ WOPCs batch ZIP route plus one Firestore parent-doc mirror repair. Single-PDF preview/download already used the shared resolver path from the earlier T-089 follow-up.