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:
-
Doc shape is flat.
bankName/bankCode/contractorName/payeeAbbreviation/transactionId/referenceNumberetc. 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. -
Lifecycle state is fragmented across
payees/{abbr}/wopc/{id}(the WOPC doc) andwopcSigningRequests/{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 isw.status !== 'voided'but no code path writesstatus: 'voided'to the WOPC doc. -
Display-name enricher can't render manual WOPCs. A WOPC with manually-entered line items (no
relatedProjectIdpointing to a real project doc) renders blank for{{projectTitle}}because the enricher readsprojects/{relatedProjectId}instead of the WOPC's ownlineItems[*].projectSnapshot.projectTitleβ which is sitting right there denormalized. The WOPC owns the data, but the enricher asks the wrong source. -
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-Nin 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 invoid.reasonmetadata. The owner explicitly raised the noise/confusion risk of dualVOID/INVALIDstamps. 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.closingDirectorIdandWOPC.signature.bycarry 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.projectTitleremains 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)¶
- 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. - Phase 2 β Centralize signing/void state on the WOPC. Rewire
markWOPCSignedto also writesignature.by/signature.source/pdf.signedFileId(sourced from the request doc). WirevoidWOPCinto the existing void path sovoid.{at, driveFileId, reason}actually gets written β this activates the "Show hidden" feature. Records pagedriveFileIdresolver reads from the WOPC instead of joining withsigningRequest.signedDriveFileId. - Phase 3 β Revive pre-tx WOPC workflow. Allow
createWOPCwith notransactionId,status: 'pending_transaction'. Auto-match endpoint (already exists atpages/api/accounting/wopc-auto-match.ts) becomes the primary transitionpending_transaction β activeand populatestransaction.{id, date}. - 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).
- 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 revision1for 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:307WOPCHoverPreview) 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(tipe5e90b3a); 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 (voidWOPCis exported but never called;updateWOPCCompleted/updateWOPCFailedhave no callers;signedByhas 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) isw.status !== 'voided', butstatus: '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(tip60ad5988); 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.tstypes +toViewdual-read accessor + 19-case vitest suite. Defensive defaults align with AGENTS.md "missing data β N/A" UI conventions. - Phase 1b (
23c81608enricher + manual-WOPC display fix;436d08e4remaining 5 readers) β everygetWOPCByReferenceNumber/getAllWOPCsconsumer 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'sformatProjectDescription).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+getVoidadded for parent-level void state read-side coverage.- Phase 2 (
69c770c9) β centralizes signing/void state ON the WOPC, activates "Show hidden": markWOPCSignedextended to writeWOPC.signature.{at, requestId, by, source}+WOPC.pdf.signedFileId; clearsWOPC.voidon re-sign. Caller (recordSignature) passes director id + signature source + Drive id.voidWOPCrewritten to writeWOPC.void.{at, driveFileId}and wired intorecordVoided(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.tshides WOPCs whoseWOPC.deleted.atORWOPC.void.atis set, plus legacystatus === 'voided'for backward compat. - Phase 3 (this commit) β revives the pre-tx WOPC workflow:
createWOPCno longer requirestransactionId/transactionDate. Pre-tx WOPCs (created during the payout flow before the bank tx arrives) getWOPC.status: 'pending_transaction'; WOPCs created with a tx already known get'active'.updateWOPCMatchedis now the canonicalpending_transaction β activetransition: writesWOPC.status: 'active'and fills intransactionId/transactionDate/paymentDate. Drops legacystatus: 'matched'(deprecated enum) andmatchedTransactionId(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/uuiddeps β 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.tsfor 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(tip60ad5988); 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):tombstoneWOPCadded tolib/wopc.server.ts;/api/accounting/wopc/[referenceNumber]/deletedefaults tomode: 'tombstone'(soft-delete viaWOPC.deleted.{at, by, reason}) and acceptsmode: 'permanent'for the future "permanently delete" affordance. End-to-end: cascade /void writesWOPC.void(Phase 2), cascade /delete writesWOPC.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 singlerevisionselement 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 walkspayees/*/wopc/*intebs-epland 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 detectsWOPC.refNumberis set and reports no-op. Not yet executed β script is committed for owner review;--applywrites Firestore + drops a backup toscripts/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 explicitcurrentRevision: 1pointer. - WOPC-level notes β first line item migration β owner-approved design but the migration script defers it (legacy
notesfield stays at top level; accessor reads via fallback path). - T-080 cleanup PR β drop the legacy-shape fallbacks in
lib/wopcDocShape.tsonce the Phase 5 script has been--apply-ed in prod. Standard pattern from T-077. - T-084 β WOPCHoverPreview thumbnail bug, still in
todopending 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 writerslib/accounting/types.tsβWOPCDocument+PaymentConfirmationlib/wopc/signingRequests/operations.server.ts+repo.server.tsβ terminal-state mirror to WOPC, request disposallib/pdfTemplates/paymentConfirmation.tsx+lib/paymentConfirmation/PaymentConfirmation.tsxβ PDF render (line-item grouping byprojectSnapshot.projectIdis unaffected; new manual-item id/projectId rule keeps the groupings working)lib/accounting/transactions.tsβenrichTransactionDisplayName(reads WOPC viagetWOPCByReferenceNumber; will switch to the dual-read accessor). NOTE: the WOPC reader correctly defaults totebs-eplalready (wopc.server.ts:28, viaNEXT_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 currentnightly.lib/accounting/wopcPipeline.server.tsβ creation pipeline (becomes both pre-tx and at-tx)pages/api/accounting/wopc-auto-match.tsβ pending β active transitionpages/api/records/wopcs.tsβ list endpoint, "Show hidden" filter,driveFileIdresolutionpages/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 fromsigningRequest.signedDriveFileIdtorevision.pdf.signedFileIdvia 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; frontmatteruid: 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 bumpedT-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, commit3c0cf111). 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'srelated: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 updatedT-083βT-089in this commit so the code matches the canonical UID (they'd been left at the originalT-083through the first renumber). Thedocs/eop-tasks/tasks/T-089.mdpath reference inmigrate-wopc-doc-shape.tswas 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.refNumbermarker), but a live check found 2 stragglers (ERL-WOPC/2025-023created 2026-06-22,ERL-WOPC/2025-024created 2026-06-23) still in legacy shape. - Root cause (writer gap):
createWOPC(lib/wopc.server.ts) wrote the legacy flat keys plus onlyWOPC: { status }β never theWOPC.*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:
createWOPCnow writes the complete bucketed shape natively β additive (legacy flat keys kept for the dual-read window), field mapping mirrorsscripts/migrate-wopc-doc-shape.tsexactly. All creation paths route throughcreateWOPC(wopcPipeline, transactions/[id], create-pending), so this one change covers every new WOPC.tscclean. - Migration run:
npx tsx scripts/migrate-wopc-doc-shape.ts --applymigrated the 2 stragglers (+20 fields each, backup atscripts/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 mintsmanual-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 onkey={item.id}; (2) multi-project grouping collapse (group key =projectSnapshot.projectId); (3) themanual-prefix convention thatwopcYear.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 fromprojectId.startsWith('manual-')to the authoritativelineItem.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 helperremapManualLineItemsat save time. For every line item withtype === 'manual':idβ{referenceNumber}#{index},projectSnapshot.projectIdβ{referenceNumber}:{projectSeq}whereprojectSeqis 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:1and:2). Non-manual items pass through untouched.- Skip-logic switched off the
manual-prefix sniff.lib/accounting/wopcYear.ts:deriveWopcYearFromLineItemsnow skips manual items by the authoritativeli.type === 'manual'flag (WopcYearLineIteminterface widened to carry thetypediscriminator). The priorprojectId.startsWith('manual-')check stays as a belt-and-suspenders fallback for legacy unmigrated docs lacking atypefield. 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 --noEmitclean. - Verification (live, post-apply): the affected 4 WOPCs render correctly through the existing
enrichTransactionDisplayNamepath (which readsprojectSnapshot.projectTitledirectly, not the projectId), and the PDF / on-screen renderers still group byprojectSnapshot.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/itemSnapshotpayload is untouched. The on-screen + PDF renderers group byprojectSnapshot.projectIdβ distinct ids still produce distinct groups. The display-name enricher readsprojectSnapshot.projectTitle(not the projectId) for manual items, so no display regression. - Form-side cleanup (follow-up, not in this PR):
PaymentConfirmationForm.tsx'snewManualId('manual-proj')/newManualId('manual-item')(line 1033) keep mintingmanual-β¦slugs during form-edit state β that's harmless now (the remap happens at save increateWOPC), 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 missednotes. Live check found 3 docs with top-levelnotesand noWOPC.notes; backfill applied (backup atscripts/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.legacyXtail; map-builders (getBank/getContractor/getTransaction/getPayee) dropped their legacy branch; getters that needed both halves (getPdf,getSignature,getVoid) dropped the legacy half.isNewShapenow means "is this a well-formed WOPC doc?". Β· Writer cleanup (lib/wopc.server.ts) βcreateWOPCwrites the bucketed shape only (legacy flat block removed).updateWOPCMatchedwritestransaction.id/transaction.date/WOPC.paymentDatebucketed.updateWOPCClosingDirectorwritesWOPC.closingDirectorIdbucketed. Dead writersupdateWOPCCompleted/updateWOPCFailed/updateWOPCPdfReferencedeleted (0 callers each β all wrote deprecated legacy enum values or flat PDF keys). Β· Tests (__tests__/lib/wopcDocShape.test.ts) βlegacyFull/legacyMinimalfixtures + 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 --noEmitclean;npx vitest run __tests__/lib/wopcDocShape.test.ts26/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'sLifecycleTagalready rendered the right badges (Cancelled (tx-unmatched)/Cancelled/Superseded/VOID), but therowClassNamepredicate only dimmedaote-row-voidedon the legacystatus === 'voided'enum β new-shapedeletedAt(tombstone) andvoidedAt(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 viaFieldValue.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 atscripts/migration-backups/wopc-legacy-flat-keys-2026-06-24T06-42-04-050Z.json. Idempotency re-run: 0 to strip. Β· Live verification: representative docpayees/JN/wopc/ERL-WOPC|2025-018now 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, theDate.now()+randomslugs inPaymentConfirmationForm.tsxare needed as React keys + add/remove identifiers during the form's edit session (array indices break under reorder). The slugs are throwaway (rewritten bycreateWOPC.remapManualLineItemsat save) but the form-side stability requirement is real. Net change of switching would be zero. Closed without action. - Tests + tsc:
npx tsc --noEmitclean;npx vitest run __tests__/lib/wopcDocShape.test.ts26/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/1for 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'sWOPC.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.currentRevisionis 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 whetherWOPC.void.atis set on parent, archives parent's signature/void/pdf snapshot torevisions/{currentRevision}if so, clears the void, writes the new sign state, bumpscurrentRevision. 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) βgetRevisionssynthesis retired.WOPCView.revisionstyped asnever[](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 correctedcurrentRevision: 1 β 0(the migration script's blanket: 1was a placeholder). The 2 signed WOPCs stay at 1. Idempotent. Backup atscripts/migration-backups/wopc-current-revision-2026-06-24T07-10-28-746Z.json. - Tests + tsc:
npx tsc --noEmitclean;npx vitest run __tests__/lib/wopcDocShape.test.ts24/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.currentRevisionvalues; emptyrevisions/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(tip26330aac); 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-020shows pending on Records β WOPCs and the side drawer previews the unsigned PDF even though the ultimate signed/sealed Drive file is10F29sJZ6UCSCpncUYvnIemeG5xoL5FSs; three earlier voided Drive PDFs exist. - Live Firestore finding: parent doc
payees/JC/wopc/ERL-WOPC|2025-020carriesWOPC.signature.at,WOPC.signature.requestId = lGUUsWLTqSuobCMTq949, andWOPC.pdf.signedFileId = 10F29sJZ6UCSCpncUYvnIemeG5xoL5FSs. The current request id no longer exists, while older voidedwopcSigningRequestsstill 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 asWOPC.pdf.signedFileId. Separately,/api/wopc-signing/lookupusedfindLatestRequestForWopc(ref) ?? synthesizeFromWopc(ref), so any old terminal request doc beat the parent current-state mirror. - What changed:
WOPCViewPdfnow exposessignedFileId; synthesis, Records list export links,/api/records/wopcs/pdf, andresolveWopcArchivedPdfall preferWOPC.pdf.signedFileId ?? WOPC.pdf.fileIdbefore request-history fallbacks. Lookup now preserves activeassigned/sentrequests, 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 onlyrevision.pdf.fileId. - Verification:
git diff --checkclean;NODE_OPTIONS=--max-old-space-size=4096 npx tsc --noEmitclean. Live read ofERL-WOPC/2025-020normalized to a synthesizedsignedrequest withsignedDriveFileId = 10F29sJZ6UCSCpncUYvnIemeG5xoL5FSsand directorE6g30MzNOkhzlCJwKvoe. - Expected result: Records β WOPCs should badge
ERL-WOPC/2025-020as 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-006carriedWOPC.signature.atandWOPC.signature.requestId = TG7X7C7RoXcArYqhH8AS, so the Records badge correctly treated it as signed. But it had noWOPC.pdf.signedFileId, the request doc no longer existed inaote-system/wopcSigningRequests, and there was norevisions/subcollection entry. - Live Drive finding: Drive search found the sealed PDF as
15XeN5Jjx4B5EXUxtHCW4DTrVMeW9CqQw, nameERL-WOPC/2024-006_20260617.pdf. - Data fix applied: backfilled the parent WOPC mirror:
WOPC.pdf.signedFileId = 15XeN5Jjx4B5EXUxtHCW4DTrVMeW9CqQwandWOPC.pdf.storagePath = drive:15XeN5Jjx4B5EXUxtHCW4DTrVMeW9CqQw. - Code fix:
pages/api/records/wopcs/download-zip.tsnow callsresolveWopcArchivedPdf(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 --checkclean;NODE_OPTIONS=--max-old-space-size=4096 npx tsc --noEmitclean. Direct Firestore read after the backfill showed the signed file mirror onERL-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.