IR56M fill → review → confirm → sign (Filings tab, reuse WOPC signing)
2026-06-29 — Accounting IR56M tab: per-recipient signer + Review/Edit → Preview → Request Signature¶
✅ Attestation: read AGENTS.md + checked the board by scope — extends THIS task (IR56M fill→review→sign); the Accounting-tab capture flow is the front half of T-047, so recorded here, not a new task. Source: EOP Local Assistance (fork) · session edb0abc7-6373-449b-8e31-379fd38d391b (local Claude Code). Proposed by: owner. Approved by: owner (decisions quoted below).
Owner request (verbatim, 2026-06-29):
"In the IR56M tab on the Accounting page: 1. The choosing of the Signing Director shouldn't be a universal setting in a dropdown for all IR56M. It should be rebranded as 'Signing Director by Default'. 2. The filing of the IR56M … shouldn't be controlled by a 'File? toggle'. a) Remove the file column and the button b) Make the actions column to have only 2 buttons: 'Review/ Edit' & 'Request Signature' c) Upon clicking Review/ Edit … show the fields as is now, PLUS the choosing of the signing director … d) paginate to a second page that shows the PDF in a previewer … e) at the [bottom] of the preview page, show 3 buttons: Cancel, Back … and Request Signature. … Request Signature would be to show a modal including the email (like the requesting of signature for a Project Invoice …) to … read the email that will be sent, and to confirm the sending out of it."
Decisions (owner, 2026-06-29 — via clarifying questions):
1. Timing gate — "Keep YA-closed gate": Request Signature only enables once the YA fully closes
(figures frozen), matching the Records gate. The signing backend never enforced it (UI-only), so the
gate is the seed API's existing yaClosed read in the Accounting tab.
2. Save model — "Request Signature commits + saves. If user tries to close the IR56M —
What shipped (5 files):
- lib/taxHK/ir56m/filing.ts — signingDirectorId? on IR56MRecipientFiling (per-recipient signer;
overrides the filing default; drives the printed block + the request email). Persisted as-is by
saveIR56MFilingServer (no server change); the seed API already returned yaClosed.
- components/accounting/tax/IR56MFormPdfPreview.tsx (NEW) — client-only react-pdf viewer (dynamic,
ssr:false), mirroring the IR56M sign-flow preview.
- components/accounting/tax/IR56MRecipientModal.tsx — paginated: page 1 (fields + signing-director
picker) → page 2 (embedded PDF preview); footers [Cancel·Preview] / [Cancel·Back·Request Signature];
Cancel/ESC → Save / Discard / Keep-editing prompt; Request Signature disabled until yaClosed.
- components/accounting/tax/IR56MRequestSignatureModal.tsx (NEW) — email-confirm modal mirroring
SendInvoiceModal; on confirm reuses createOrGetIR56MSigningRequest + sendIR56MForSigning (the SAME
backend the Records flow uses).
- components/accounting/tax/IR56MFilingTab.tsx — rebranded dropdown → "Signing Director by Default";
removed File? column + batch Save; Actions = Review/Edit + Request Signature (status-aware, gated);
per-recipient director default + commit-on-save/request persistence.
Verified: tsc clean on all 5 files (--max-old-space-size=8192, exit checked). Runtime UI verification
PENDING — the local dev preview is auth-gated, and the NAS deploy is currently blocked by 2 pre-existing
type errors in lib/wopc.server.ts (from 6dfb0a1d / #806) that fail next build (no ignoreBuildErrors).
Flagged to the owner.
Blast radius: Accounting → Tax → IR56M tab + the IR56M recipient/filing data shape (added optional
signingDirectorId). Reuses the existing signing-request backend (no new endpoints); writes the shared
ir56mFilings store via the existing save path. The Records IR56M tab is untouched (coexists).
2026-06-24 — chop: shuffle-variant button + verified 20 mm diameter (WOPC + IR56M)¶
✅ Attestation: read AGENTS.md + checked the board by scope — refines the shared chop step (WOPC + IR56M signing + the sign-test), not a new/duplicate task. Source: EOP Local Assistance (fork) · session edb0abc7-6373-449b-8e31-379fd38d391b (local Claude Code).
Two owner asks:
(1) Shuffle button — change the chop variant without re-placing.
Owner: "can you create a button for user to switch to another random chop? … a shuffle button … while rendering to the user tapped or clicked position, allow user to shuffle the chop to another variant"
Added a "Shuffle chop" button to the WOPC chop page, the IR56M chop page, and the sign-test. It
re-rolls to a different variant (excludes the current) while KEEPING the placement — both the
cursor-follow preview (desktop, pre-place) and the placed marker (any device, post-place) key off
variant, so the new seal appears immediately at the same spot. Shown when (!isCoarse || placed):
desktop sees it pre-place (preview = what stamps); mobile sees it after the first tap.
(2) Verify the chop renders at 20 mm diameter (owner felt some looked ~22 mm).
Owner: "I feel like I'm seeing some of the variant being rendered at 22 mm … make sure … the rendered chop is at 20 mm of diameter"
Audited ALL 11 ERL chops (downloaded from GCS + measured + viewed). Findings: each is a clean
transparent-background circular seal that fills a square 1024² canvas (the trim() is a no-op because the
ring touches the canvas edges — correctly). They render consistently; there is no 22 mm variant in the
app. The "22mm" is the source-asset FOLDER name (doc/stamps/authorization/erl/22mm/ — the physical
chop family), which the app scales down to CHOP_DIAMETER_PT. Two fixes so the render is provably 20.00 mm:
- CHOP_DIAMETER_PT made exact — (20×72)/25.4 = 56.69 pt. The old Math.round → 57 pt = 20.108 mm
(a hair over). Synced across the composer + all 3 client previews.
- composeChopOntoPdf now fits the seal's longest side to 20 mm (was: width→20 mm, height = width×aspect,
so a non-square trim rendered TALLER than 20 mm — the only path to >20 mm). Aspect preserved (no distortion).
Verified. tsc clean. Replaying the real composition sizing (trim + longest-fit) on all 11 variants → every one renders 20.00 × 20.00 mm. If 22 mm still appears on a printed PDF, that's the viewer/printer scale (not 100%), not the composition.
Blast radius. The shared chop step (lib/wopc/signing/composition.server.ts composeChopOntoPdf +
CHOP_DIAMETER_PT) — affects BOTH WOPC and IR56M signing. Client chop pages + the sign-test get the matching
exact diameter + the shuffle button. No data/wire changes.
2026-06-24 — desktop "sign on the document" editor for IR56M + chop-page load fix¶
✅ Attestation: read AGENTS.md + checked the board by scope — extends this task's Phase-4 signing UX (desktop parity with WOPC + a regression fix), not a new/duplicate task. Source: EOP Local Assistance (fork) · session edb0abc7-6373-449b-8e31-379fd38d391b (local Claude Code).
Two owner reports this session, both fixed:
(1) Bug — IR56M "doesn't load" on the chop page after signing (mobile). Root cause: the portrait
guard added earlier today (RequirePortrait) replaced the page with the rotate prompt in
phone-landscape, which UNMOUNTED the chop/preview PDF container. Those pages size their PDF from a
container measured in a mount-time effect; unmounting ran that effect against a null ref and it never
re-fired on rotation, so the PDF never got a width and hung on a spinner. Fix: the guard now OVERLAYS
the prompt (position:fixed) with children kept mounted + measured behind it — rotating reveals the
already-sized page. (Same "render it, toggle visibility, don't unmount" lesson as the coaching dialog
tabs.) Shipped 4c01cb4e.
Owner: "the IR56M doesn't load at all after I've finished the signing and have arrived the chopping page"
(2) Feature — desktop signs ON the form, like WOPC. IR56M sent every device to the mobile full-screen draw canvas. Now the method page (and the sign-test harness) route by device — phone → draw canvas; tablet/desktop → a new editor that renders the filled IR56M and lets the director draw straight onto it — matching WOPC's "Sign on the document".
Owner: "there's a desktop version of signing where the web app presents the whole form to the user and for the user to sign on it directly. Can we make it so that if the signing process is opened on desktop, then we do that directly instead of providing the dedicated signing page that's supposed to be for mobile device only?"
Proposed by EOP Local Assistance (fork); approved by the owner (the two reports above).
How #2 is built. New reusable components/wopc/Ir56mSignEditor.tsx (renders a PDF + a transparent
draw overlay; mirrors WOPC's editor) drives BOTH the real wizard (app/ir56m/sign/[id]/editor) and the
sign-test harness, so they can't drift. On Done it stores a full-page PNG + placement:{mode:'overlay-page'};
composeSignedIR56MPdf gained an overlay-page branch that draws it 1:1 over the page (the fixed-box +
signing-line path from #2 is the else). placement threads through CapturedSignature → /chop, /preview,
/sign (+ /test) exactly like baselineFraction. Routing keys off useIsCoarsePointer && !useIsTablet.
Verified. tsc clean across all changed files (lone error = the pre-existing croner install gap in an
untouched cron file). Editor + overlay-page composition mirror the proven WOPC editor path.
Blast radius. IR56M signing only, plus two shared components: RequirePortrait (the overlay change
touches ALL its consumers — WOPC + IR56M method/chop/preview — but is strictly SAFER: it stops unmounting,
so nothing can regress) and FreehandSignaturePad (unchanged behaviour). WOPC signing untouched.
2026-06-24 — #2: the on-screen signing line now DRIVES signature placement (IR56M)¶
✅ Attestation: read AGENTS.md + checked the board by scope — a refinement of this task's Phase-4 signing UX, not a new/duplicate task. Source: EOP Local Assistance (fork) · session edb0abc7-6373-449b-8e31-379fd38d391b (local Claude Code).
What & why. The draw screen already had a draggable "signing line", but it was decorative — the cropped signature was stamped at a FIXED offset above the 簽署 rule, ignoring where the director put the line. Now the line is the placement reference: where it falls within the signature maps to the document's rule.
Owner request (verbatim):
"The line shown on the sign screen is still not used as the center point for the signature rendering… use the placeholder line on my signing screen as a center point to determine the rendering height."
and the corrected direction (the decisive constraint):
"if the line on the signing page gets drag down, the signature should appear higher and vice versa"
Build go-ahead: owner, "yes please" (2026-06-24). Proposed by EOP Local Assistance (fork); approved by the owner.
How. On Done the canvas computes f = (lineY − inkCropTop) / inkCropHeight — where the line sits
within the cropped ink (0 = top edge, 1 = bottom edge) — and threads it through
writeStoredSignature → /chop, /preview, /sign (+ /test) → composeSignedIR56MPdf, which places the
signature centre at centreY = RULE_Y + (f − 0.5)·height (clamped on-page). INVERSE by construction:
line dragged DOWN ⇒ more ink above it ⇒ larger f ⇒ signature HIGHER on the page (pdf y-up) — exactly the
owner's constraint. f=0.5 centres on the rule; absent f (saved-upload) keeps the original fixed +5pt
offset (backward-compatible).
Verified. tsc clean across all 12 changed files (lone error = a pre-existing croner install gap in
an untouched cron file). Formula unit-checked: monotonic drag-down→higher PASS, f=0.5→on-rule PASS,
absent→old-offset PASS, extreme f clamps on-page PASS.
Blast radius. IR56M signing compose path + the shared components/wopc/FreehandSignaturePad.tsx
(additive getInkMetrics; toCroppedDataURL refactored to SHARE the crop math, behaviour identical, so
the WOPC draw/editor that also use the pad are unaffected). WOPC signing NOT changed: its mobile draw
places the signature via the HTML template's fixed .pc-signature-space slot (the composeSignedWopcPdf
fixed-box branch is dead legacy), so the WOPC signing line stays decorative until that template is taught
the baseline — a separate, larger change. Flagged to the owner as a follow-up.
STATUS 2026-06-19 — resumed; JC confirmed a reportable sub-contractor (intent ON)¶
Everything built so far is COMMITTED + PROMOTED TO MAIN (no longer "not committed"):
T-045 research, T-045a candidate scan (wired into the WPP), T-047a particulars storage + HKID
input, T-047c PDF fill engine. Owner gave the go-ahead to build the rest in 3 phases:
- Phase 1 — DONE (origin/nightly c802b81f). POST /api/accounting/ir56m/fill (auth-gated;
IR56MFormData → fillIR56MPdf → application/pdf) + next.config outputFileTracingIncludes
(traces mupdf's wasm + the IRD template). mupdf-in-Next DE-RISKED: next build compiled the
route as a serverless function — mupdf's ESM + wasm + top-level-await bundle cleanly. Residual:
one authenticated runtime hit on the nightly deploy to confirm wasm loads + template is traced.
- Phase 2 — DONE (nightly; owner-verify the tab on the deploy).
- 2a DONE (nightly 83d18cd7→2594d460): lib/taxHK/ir56m/filing.ts (pure: IR56MPayerSnapshot /
IR56MRecipientFiling / assembleIR56MFormData / totalRemuneration / validatePeriodWithinYA) +
filings.server.ts (tebs-erl/ir56mFilings/{endingYear} CRUD, mirrors taxFilings, history[] audit) +
9 unit tests.
- 2b DONE (nightly 05f376a0): seed.server.ts buildIR56MFilingSeed (candidate scan + payee
taxProfile + payer → pre-filled recipients; identity + period seeded, capacity/amounts left to owner;
detect-don't-enforce) + pages/api/accounting/ir56m/filing.ts (GET seed+existing, POST save; not period-gated).
- 2c DONE (nightly 243e94d0) — the Filings-tab UI. components/accounting/tax/IR56MFilingTab.tsx
+ IR56MRecipientModal.tsx; registered in AccountingApp (Layered "Filings" group, order 41). Pick YA
(global period selector; All-time prompts to pick) → candidate table (every payee w/ a YA outflow,
threshold-flagged, "File?" toggle) → per-recipient capture modal (capacity / period [YA-constrained
DatePickers] / category split / withheld / remarks + particulars pre-filled, editable) → preview
via /api/accounting/ir56m/fill (opens the filled PDF) → Save via POST /filing (resumes drafts).
tsc 0; suite 340; eslint clean.
- Phase 3. Signing — reuse WOPC request/sign/chop/upload for an IR56M doc-type + the new
"sign from Records" surface; signed PDF → Drive "14d. IR56M".
(The "OPEN RISK" section below = RESOLVED by phase 1.)
DECISION: fill the OFFICIAL IRD PDF via mupdf-wasm (owner, 2026-06-15)¶
The official IR56M PDF is AES-256 (V5/R6) with an EMPTY user password (opens freely;
owner-password restricts editing; form-filling permitted). pdf-lib can't decrypt it;
mupdf (wasm) opens it transparently, the 61 AcroForm field names are fully readable
+ self-documenting, and saveToBuffer('decrypt') after bake() yields a decrypted,
flattened PDF that pdf-lib can then sign. Added dep: mupdf.
T-047c PDF ENGINE — BUILT + VERIFIED + COMMITTED (2d15d4d7, on main) + ROUTE ADDED (phase 1)¶
lib/taxHK/ir56m/ir56m-template.pdf(official IRD form, committed as the fill template).lib/taxHK/ir56m/fields.ts(pure):IR56MFormData+ exact field-name map +buildIR56MFieldValues(text + capacity/title checkboxes; money = whole HK$).lib/taxHK/ir56m/fillIR56MPdf.server.ts: mupdf open → set fields by name → bake → saveToBuffer('decrypt') → Uint8Array.lib/taxHK/ir56m/mupdf.d.ts: ambient shim (project moduleResolution can't see mupdf's own types). Engine has NOimport 'server-only'(would throw in pages/api).- VERIFIED against the owner's real sample (Ngai Wang Chi → Chan Jeffero/陳彥廷, HKID Y360417(7), subcontractor, 03/09/2024–30/03/2025, $134,800): all field VALUES set (pre-bake read-back), decrypted output opens in pdf-lib, page renders correctly. tsc 0 err, lint clean. Sent the owner the filled sample PDF for visual confirmation.
- Field names (mupdf-confirmed): Reporting Year · Sheet Number · Section Number of Employer's File · Employer's File Number · Name/Address of Payer · Name/BRN of Partnership… · Name/BRN of Sole-proprietorship · Title-Mr/Ms/Miss (checkbox) · English/ Chinese Name of Recipient · HKID Number-Prefix/Digits/Check Digit · Indicator-Sex · Indicator-Marital Status · Name of Recipient's Spouse · Spouse's HKID… · Recipient's Postal Address/Telephone Number · Capacity-Subcontractor/Agent/Writer/Consultant/Coach/ Tutor/Others (checkbox) + Capacity Engaged · Start/End Date · Amount-Subcontracting Fees/ Commission/Writer's…/Artiste's…/Copyright…/Consultancy…/Service Fees · Nature/Amount of Other Income · Amount-Total Incomes · Indicator/Amount-Sum Withheld · Remarks · Name of signer · Designation · Date of Signing · Indicator-Form Completed.
~~OPEN RISK~~ RESOLVED (phase 1, c802b81f): Next.js integration of mupdf¶
mupdf is ESM with top-level await + wasm. ~~NOT yet validated in a Next build/route.~~
RESOLVED: the phase-1 route /api/accounting/ir56m/fill builds — next build compiled it as
a serverless function (mupdf ESM + wasm + TLA bundle cleanly). outputFileTracingIncludes traces
the wasm + template into the function. Residual = a runtime hit on the Vercel/nightly deploy.
Goal (owner, 2026-06-15)¶
A Layered-view Filings tab (the group already exists — Profits Tax tax lives
there) to fill an IR56M, review + confirm, then sign it — mirroring WOPC: pick a
"closing director" who signs by (a) logging in → Records → sign in-app, (b) emailed
signature request, (c) company chop/seal, or (d) manual upload of a signed form.
Owner attached blank IR56M.pdf + IR56M_Filled.pdf; IRD form (4/2026):
https://www.ird.gov.hk/chi/pdf/ir56m.pdf
Form fields learned (IR56M 4/2026)¶
Header: 截至 ___ 年 3 月 31 日止的 1 年度內 = year of assessment (year ending 31 Mar). Checkboxes: 附加表格 (additional) / 修訂表格 (revised); sheet X of Y. 1. Payer 付款人: (a) 僱主檔案號碼 employer file no. [e.g. "6B1 77021233"] or BR/HKID; (b) name; (c) address. 2. Recipient if partnership/unincorporated body: name + BR no. 3.(a) if sole proprietorship/uncertain: name + BR no. (b) if INDIVIDUAL: (i) title + English full name (SURNAME, GIVEN) + Chinese name; (ii) HKID (req); (iii) sex M/F (req); (iv) marital status 1/2; (v) spouse name + HKID/passport if married. 4. Recipient address + phone. 5. 服務身分 capacity: 判商(sub-contractor)/代理人/作家/顧問/教練/導師/其他 (delete N/A). 6. 服務期間 period of service: from–to (DD MM YYYY). ← see rule below. 7. Remuneration by category (HK$, no cents): 承判金 sub-contract / 佣金 commission / 作家投稿費 / 演藝費 / 版權專利費 / 顧問費 / 自由職業者服務費 (instructor/coach/photographer) / 其他; + 總額 total. 8. Tax withheld? 0=no / 1=yes (+ amount). (mandatory) 9. Remarks. Signature block: 公司蓋印處 (company chop area) + 簽署 signature + 姓名 name + 職位 title + 日期 date. Footer: provide a completed copy to the recipient.
服務期間 (field 6) rule — answer to owner¶
Must fall WITHIN the year of assessment = 1 April [Y-1] → 31 March [Y] (the form is "the 1-year period ending 31 March [Y]"). Filled example: 03/09/2024 → 30/03/2025, inside YA 2024/25. The "30 March" end is that engagement's actual end date — NOT a rule; there is no "must end 30 Mar" constraint. Build will VALIDATE from/to to [1 Apr, 31 Mar] of the selected YA. (Owner's "Mar 31 to Mar 30" ≈ the YA window; precisely 1 Apr–31 Mar.)
Data mapping (have vs capture)¶
HAVE: payer name/ZH/address/phone + brNumber (fetchSubsidiaryInfoServer('erl')); recipient name/address/phone (payee directory); YA (ir56m.ts yearOfAssessmentForIso); remuneration total (generateIR56MCandidatesServer). CAPTURE (not stored): recipient HKID, sex, marital status (+ spouse), capacity (field 5), per-category split (field 7), period of service (default first/last payment, owner edits), tax-withheld flag.
OWNER CONTEXT (2026-06-15) — STOP dismissing the Jeffero IR56M¶
Jeffero Chan is a director AND a bona-fide sub-contractor to ERL. Payments to him are 5050 Sub-Contractor Fees, backed by WOPCs (the contract: which project/service, paid for what). So IR56M IS legitimately in scope on the merits — do NOT keep warning "he's a director so probably N/A." Threshold depends on the capacity classification the owner picks (判商 sub-contractor $200k vs consultant/other-service $25k).
CORRECTION — in-app signing from Records is NOT supported today¶
Owner clarified: a WOPC closing/assigned director CANNOT log on → Records → WOPC tab → sign there; they only see a Request Signature button. Actual signing happens via the emailed magic-link page (/wopc/sign/[id]). So "director signs from the Records page" is GENUINELY NEW work — build it for IR56M (and it would benefit WOPC too). My earlier "already supported" was wrong.
T-047a DONE (2026-06-15) — Contacts stores IR56M recipient particulars¶
Owner asked to make the web app STORE the lacking fields (input → store → read back). Built on the payee record (Contacts → People → a person → Edit): HKID, Chinese full name, sex, marital status, spouse name, spouse HKID/passport. - lib/payeeDirectory.ts: PayeeTaxProfile + taxProfile on record; read in BOTH parsers; flat + WriteInput fields. - lib/payeeDirectory.server.ts: buildPayeeDocument writes taxProfile; update-merge + change-detect; server parser reads it. - components/people/PeopleApp.tsx: shared Ir56mParticularsFields (add + edit forms), PayeeRow + form-values + row-map + hydrate + both submit bodies + detail read-back. - API route is a passthrough (no per-field allowlist) so fields flow through. - VERIFIED: server write→read round-trip against real Firestore (throwaway payee, deleted after); tsc 0 errors; 0 NEW lint errors (line-intersection check). Sensitive PII (HKID) never logged. COMMITTED to nightly 56c6b257 (NOT main).
Signing — REUSE WOPC mechanics (paths exist; wire an IR56M doc-type)¶
All four WOPC signing paths are reusable (the magic-link sign page, email request, chop,
manual upload); the NEW part is letting the director sign from the Records page:
- Director registry lib/directors/registry.ts (DIRECTORS: jeffero-chan/JC, jake-ngai/JN)
= the "closing director" picker. Cross-issuance rule: if payee is a director, the OTHER
signs (WOPC-specific anti-self-sign on GL5050; for IR56M the signer is just ERL's
authorized director — revisit).
- State machine draft→assigned→sent→signed (lib/wopc/signingRequests/*).
- (a) in-app draw /wopc/sign/[id]/draw; (b) email request via Resend
lib/email/sendWopcSigningRequestEmail.ts + /api/wopc-signing/[id]/send; (c) chop
/wopc/sign/[id]/chop (secured seal assets); (d) manual upload /wopc/sign/[id]/upload.
- Records WOPC tab actions (RecordsApp.tsx ~2263): Request signature / Send / Sign / Withdraw /
Upload signed; director inbox app/wopc/inbox.
- Signed PDF → Drive "14c. WOPC" (lib/wopc/signing/drive.server.ts). IR56M → new "14d. …" folder.
Layered "Filings" group exists: AccountingApp.tsx layerAssignments (tax = order 40);
add ir56m-filing order 41.
Phase 3 — signing build plan (starting 2026-06-19; investigated the WOPC system)¶
Signature-block TEXT (姓名/職位/日期) is DONE (#5, 3bf3d718). Remaining = ink signature + chop on the
filled PDF + the request/sign workflow. REUSE the WOPC signing system, don't duplicate it. Findings:
- composeSignedWopcPdf (lib/wopc/signing/composition.server.ts) embeds the signature + 11 ERL chop
assets via pdf-lib + has the void-stamp logic — but its PLACEMENT COORDINATES are WOPC-A4-specific
(closing-signature line + chop box). IR56M needs its OWN coordinates (its 簽署 line + 公司蓋印處 box).
So reuse the embed technique + chop assets + void logic; supply IR56M placement.
- drive.server.ts files to hardcoded "14c. WOPC" via the accounting Drive SA + lazy-create — reuse
the pattern, new target "14d. IR56M".
- placement.ts (parse signature/chop placement) — generic, reuse as-is.
- signingRequests/ state machine + types are WOPC-COUPLED (wopcRef + the WOPC doc's locks/void).
Sub-phases:
- 3a — DONE (lib/taxHK/ir56m/sign.server.ts) — composeSignedIR56MPdf (signature scaled into the
IR56M signature box above the 簽署 line — render-verified — + optional chop via the reused generic
composeChopOntoPdf + getChopImageBytes) + uploadSignedIR56MToDrive (→ "14d. IR56M", mirrors the
WOPC 14c Drive pattern). tsc 0. Chop placement is director-picked (calibrated when 3c lands).
- 3b — IR56M signing-REQUEST model. DECISION (owner): generalize the WOPC signingRequest to a
doc-type-agnostic model (docType:'wopc'|'ir56m', docRef) — cleaner, but touches the LIVE audit-
critical WOPC system (risk) — vs a PARALLEL ir56mSigningRequest reusing the lower-level utilities —
safer, some duplication. RECOMMEND parallel-but-shared-utilities.
- 3c — the 4 signing paths (in-app draw / emailed magic-link / chop / manual upload) + the NEW
"sign from Records" surface (directors only get Request-Signature today — new even for WOPC).
- Cross-issuance: JC's IR56M signed by the OTHER director (JN). Relates to T-050 (WOPC cross-issuance
bug) — apply the same rule, do NOT duplicate T-050's fix.
NO OVERLAP: signing is wholly within T-047 ("…→ sign"); T-050 is a separate WOPC bug; the WOPC signing
system is REUSED, not duplicated.
Proposed sub-tasks¶
- T-047a — IR56M filing data model (Firestore ir56mFilings/{id}: YA, payer snapshot, per-payee particulars incl. captured HKID/sex/marital, capacity, period, category split, withheld, status) + pure builder/validators (period-within-YA, totals).
- T-047b — Filings tab UI: pick YA → seed payees from candidate scan → fill/capture missing fields → review + confirm.
- T-047c — IR56M PDF render (pdf-lib over the IRD form, or AcroForm fill) + preview.
- T-047d — Signing: reuse WOPC request/sign mechanics for an IR56M doc-type (closing-director pick, in-app sign on Records, email request, chop, manual upload) → file to Drive 14d.
- T-047e — Records "IR56M" surface so the assigned director can find + sign it.
Reality check (carry from T-045)¶
Only >$25k candidate is JC (director) at $85,300 of 5050 subcontractor fees — payments to a director generally are NOT IR56M. ERL likely has NOTHING to file this YA; feature would be ready for when a real unincorporated payee crosses the threshold. Confirm before/while building.
Log¶
- 2026-06-15 created (owner). Studied IR56M.pdf + IR56M_Filled.pdf + IRD form; mapped WOPC signing (reusable) + Layered Filings group (exists). Answered 服務期間 rule. Awaiting go-ahead.
- 2026-06-19 phases 1+2 built (c802b81f / 2594d460 / 05f376a0 / 243e94d0). Owner reviewed the rendered form vs the official specimen (ir56m_completion_e.pdf) + notes (ir6036c_e.pdf).
- 2026-06-19 RENDERING CORRECTIONS (nightly 30fb0cce) — VERIFIED the title/capacity checkboxes STRIKE-when-checked by rendering the template (a "delete the inapplicable" form): 2d title strikes complement (Mr left, Ms/Miss struck); 2f capacity strikes complement (all 6 roles struck → 其他 + "Sub-contractor"); 2e English name surname+SPACE+given, no comma; 2g period DDMMYYYY no separators; 2a Sheet No. alphabetical from 900001 (note 4); 2h whole YA total → Type 1 承判金 (5050 payees); 2i tax-withheld = 0. + 5 engine tests (ir56mFields.test.ts).
- 2026-06-19 amount right-align (nightly 0515eaa4): field-7/8 amount boxes are comb (MaxLen 9); mupdf ignores /Q, so left-pad with spaces to right-align. No "$" rendered (bare digits).
- 2026-06-19 2b/2c via T-070 (nightly e905deeb): Subsidiary tab stores the employer file no. (renders 僱主檔案號碼) + the registered address incl. region "Hong Kong" (so the address ends ", Hong Kong"). Owner to populate ERL's employer file no. + region in Contacts → Subsidiary.
- 2026-06-19 RENDERING CORRECTIONS round 2 (nightly 3bf3d718, owner review #2): #1 Sheet No. — the form PRE-PRINTS the leading "9"; the field holds the 5 digits after it (MaxLen 5) → fill the suffix 900001→"00001" (was sending 6 → truncated). #2 English name — RESTORE the comma "CHAN, Jeffero" (reverses round-1's no-comma 2e; the form's 3(b)(i) text + bottom example both use a comma). #3 Period of service → default to the full YA the period selector resolves to (1 Apr–31 Mar), not first/last payment dates. #4 Payer block (name/address/employer-file-no) now READ-ONLY display from the stored subsidiary info (Contacts → Subsidiary); no longer inline-editable here; the seed reads employerFileSection/Number. #5 Signature block — new "Signing director" selector fills 姓名 + 職位 (Director); 日期 = today.
- REMAINING on T-047: phase 3 = signing (reuse WOPC + "sign from Records" → Drive 14d).
2026-06-22 — phase 3b foundation: parallel ir56mSigningRequest store¶
- ✅ Attestation (EOP Local Assistance): read
AGENTS.md; tracking T-047, board kept current. - Source: EOP Local Assistance · session 63c32698-0f80-464c-a668-f352be2c148e (local Claude Code)
- Built (tsc-clean):
lib/taxHK/ir56m/signingRequests/types.ts+repo.server.ts— the IR56M signing-request store, on a SEPARATEaote-system/ir56mSigningRequestscollection (never touches the livewopcSigningRequests). Keyed on (endingYear, payeeId) = the recipient-filing identity; same draft→assigned→sent→signed/rejected/withdrawn(/voided) state machine; race-safefindOrInsertActiveRequest, transactional guardedapplyTransition,eventsaudit subcollection. The generic signing types (status / source / chop / event) are reused fromlib/wopc/signingRequests/types. - Why parallel, not generalize: generalizing the WOPC signingRequest to a doc-type-agnostic model would touch the live, audit-critical WOPC store (risk); a parallel collection that shares the lower-level utilities is the safer route the task recommended.
- Remaining in phase 3:
- 3b operations (
operations.server.ts): state-machine mutations (createOrAssign / send / record- signature / reject / withdraw / void) wiring the store to the 3a primitives (sign.server.tscomposeSignedIR56MPdf+uploadSignedIR56MToDrive) and flipping the recipientIR56MRecipientStatus(draft→confirmed→signed→filed). Cross-issuance: a director's own IR56M is signed by the OTHER director (mirror WOPC; relates to T-050 — reuse, don't duplicate). - 3c paths + UI: the 4 signing paths (in-app draw / emailed magic-link
/ir56m/sign/[id]/ chop / manual upload) reusing the WOPC sign-page mechanics, + the "sign from Records" surface, + Resend email,- API routes.
- Needs p-eop preview deployment testing before prod — pending the NAS deploy recovery.
Phase 4 — Records-page IR56M tab + close-gated freeze / void (design, owner-agreed 2026-06-22)¶
- ✅ Attestation (EOP Local Assistance): read
AGENTS.md; tracking T-047, board kept current. - Source: EOP Local Assistance · session 63c32698-0f80-464c-a668-f352be2c148e (local Claude Code)
Goal: an IR56M tab on the Records page mirroring the WOPC tab (store + display + sign), reusing the WOPC signing mechanism, with the sign/seal pipeline gated on the YA being fully closed.
Gate (close-aware):
- "YA fully closed" = all months in the YA window (1 Apr–31 Mar) are closed (per-month close,
lib/periodClose). Not fully closed → preview-only: the IR56M tab (Records + Accounting → Filings)
renders the filled PDF, but Sign/Seal is disabled.
- Reopen flips back to preview-only. An in-flight request already sent is paused (Sign disabled)
until re-close — then resumes if the recipient's total is unchanged, or voids + re-issues if it changed.
Freeze-on-close (set in stone): when the YA fully closes, snapshot each reportable recipient's WHOLE
assembled IR56MFormData (declared total → its category + identity particulars + owner-entered capacity/
period/withheld/remarks) to an immutable record so the rendered IR56M can't drift.
- Scope: all reportable recipients over threshold (not just related parties).
- Declared total = GL 5050 sub-contractor fees posted to that recipient within the YA (closed data / the
existing candidate scan).
- Incomplete owner-fields at close → snapshot-as-is + flag incomplete (don't block the freeze).
Re-adjust / VOID (per-recipient): on YA re-close, compare each recipient's new declared total vs its prior snapshot: - Unchanged → no-op (existing IR56M stands). - Changed → void the OLD per-recipient IR56M like a voided WOPC: - unsigned: render the old-snapshot form + VOID chop → auto-download (re-closer's browser) → save to Drive (loc TBD) → show hidden in the Records IR56M tab → re-initiate sign/seal on the new snapshot. - signed: apply the VOID chop to the actual signed PDF → auto-download → save to Drive (TBD) → hidden in Records → re-initiate. - Dropped under threshold → void the old, don't re-issue. Newly over threshold → fresh IR56M. - Re-close must stay a user action (the auto-download needs a live browser).
Signer (reuse WOPC mechanics): cross-issuance — a director recipient's IR56M is signed by the other
director; a non-director recipient → a director the owner picks at send-time. Paths: in-app draw /
emailed magic-link (/ir56m/sign/[id]) / chop / manual upload + the new "sign from Records" surface.
Signed storage + display: signed IR56M → Drive PDF ("14d. IR56M", structure TBD); Accounting → Filings previews it; Records IR56M tab displays it (voided ones shown hidden).
Distribution (Resend): on sign+seal, schedule a send 3 days later (a regret window) to all personnel including the sub-contractor recipient (the IRD-required copy). If the IR56M voids within those 3 days, cancel the pending send. A voided IR56M is emailed to internal personnel only (directors + accountant/bookkeeper), never the recipient (the info is wrong).
Deferred (to discuss): the snapshot Firestore structure + the Drive locations (signed vs hidden voided). Depends on a YA-window-close view; the full FY→YA unification is now its own task T-085. Needs p-eop preview to test before prod.
Evidence — owner, 2026-06-22 (verbatim, key decisions):
"instead of closing a FY, maybe it's better to re-design and unify the web app to close a YA instead"
"if no IR56M has been signed, an IR56M should be rendered with the old information on it with a VOID chop … an auto-download should be triggered … while if an IR56M has been signed … all signed and sealed IR56M with the old information … with a VOID chop"
"we don't send a voided IR56M to the sub-contractor recipient as the information is incorrect … send VOID version to related personnel (not the ultimate recipient), and when a IR56M is signed, send 3 days after it's signed and sealed (so that we could have time to regret if we made any mistakes)"
Phase 4 — decisions LOCKED + build started (2026-06-22)¶
- ✅ Attestation (EOP Local Assistance): read
AGENTS.md; recording the two deferred phase-4 design decisions with the owner's approval before building. - Source: EOP Local Assistance · session 63c32698-0f80-464c-a668-f352be2c148e (local Claude Code)
- Decision 1 — Declared-total FREEZE storage: at YA-close, stamp each recipient on the existing
ir56mFilingsdoc (keyed byendingYear) withdeclaredTotalFrozen+frozenAt+ anincompleteflag — no new collection/structure. (Owner confirmed: "Re: 1 = Freeze".) - Decision 2 — Drive home for signed IR56M: a NEW top-level
70. Tax & Compliance, keyed by Year of Assessment (70. Tax & Compliance/{YA}/IR56M/; voided →…/IR56M/_voided/, internal-only). NOT under14. Expenses Records— IR56M is an employer's statutory return, not an expense (the app already groups it under "Tax Filings" next to Profits Tax). Also the future home for BIR56A / IR56B / profits tax. (Owner asked "is it more about employment?", then chose the70. Tax & Compliancetop-level.) - Bug found + fixed in passing: the planned path
sign.server.ts:83'14d. IR56M'collided with the live'14d. Service Invoices'(T-069). Moving IR56M out of the 14-series removes the collision. - Build order (each increment committed, tsc-clean, lockstep): (1) Drive routing →
70. Tax & Compliance✅ this commit; (2) close-gate + freeze; (3) Records IR56M tab; (4) signing pipeline (request→sign→chop→upload + sign-from-Records); (5) void-on-readjust + 3-day Resend. - Increment 1 (done):
lib/taxHK/ir56m/sign.server.tsnow files to70. Tax & Compliance/{YA}/IR56M[/_voided](lazy-created, app-owned section);uploadSignedIR56MToDrivere-keyedsignYear→yearOfAssessment+ avoided?flag; the14d. Service Invoicescollision is resolved. No external callers (signature change safe). - Increment 2 (done): close-gate + declared-total freeze mechanism — NEW files only, no edits to the
shared
periodClose/ API yet (low blast radius given the heavy parallel cloud work).closeGate.ts(pure YA month math — the salaries-tax YA is the FIXED 1 Apr–31 Mar year, deliberately NOT the configurable profits-tax basis period;requiredCloseMonthsclamps to the incorporation month so pre-incorp months aren't demanded);closeGate.server.tsisYearOfAssessmentClosed;freeze.server.tsfreezeIR56MDeclaredTotalsServer(write-once per recipient, idempotent, sources the total from the candidate scan = the transaction-derived figure that mustn't drift);filing.tsgainsIR56MDeclaredTotalFreeze+frozen?+ pureisRecipientParticularsIncomplete. vitest 10/10, tsc clean for these files (the lone repo error iscronerin the merged T-084 code + a stale local node_modules — not this change). Next (2b) = wiring: hook the freeze into the period-close handler (pages/api/accounting/periods/index.ts, next tomaybeFreezeFiscalYear), exposeyaClosedon the filing API GET, and protect thefrozenblock on the save path so the client can't overwrite it. - Increment 2b (done) — wiring: the freeze now fires automatically from the period-close handler
(
pages/api/accounting/periods/index.ts, besidemaybeFreezeFiscalYear, best-effort — a freeze failure never fails the close), keyed to the just-closed month's YA via the new pureyearOfAssessmentEndingYear. The filing API GET now returnsyaClosed(the tab's sign/seal gate; preview stays open).saveIR56MFilingServernow guards thefrozenblock — set-in-stone: a client save can't alter or drop a frozen total, though a NEW freeze still passes through. vitest 12/12, tsc clean for these files, eslint clean. Blast radius: closing a month now also triggers an idempotent IR56M freeze attempt — additive, but agents onperiodCloseshould know. Phase-4 close-gate + freeze is COMPLETE. Next = increment #3: the Records-page IR56M tab (display filings + frozen totals + preview, WOPC-tab pattern). - Increment 3 (done) — Records IR56M tab: new
components/records/IR56MFilingsTab.tsx(a SEPARATE component registered with a 1-line tab object inRecordsApp.tsx'scanSeeErlarray — kept the footprint in that 3k-line, cloud-edited shared file tiny) + newpages/api/records/ir56m-filings.ts(lists saved filings + a per-filingyaClosed). The tab lists saved filings by YA, expands to recipients showing the declared total (the FROZEN figure once the YA closed, else the live form total) with frozen / incomplete tags + status, a per-recipient PDF preview (reuses/api/accounting/ir56m/fill), and theyaClosedgate as a lock tag. tsc clean for my files; new files eslint-clean (next lintis broken locally — ESLint-version mismatch; RecordsApp's pre-existing lint debt is untouched + unrelated). Browser verification is owner-on-deploy (the Records page is auth-gated). Next = #4: the signing pipeline (request → sign → chop → upload to70. Tax & Compliance, + sign-from-Records), gated onyaClosed. - Increment 4a (done) — signing operations layer: new
lib/taxHK/ir56m/signingRequests/operations.server.ts— the state-machine orchestration mirroring WOPC (createOrGetRequest/assignDirector/sendForSigning/recordSignature/recordRejection/recordWithdrawal/recordVoided), keyed(endingYear, payeeId)on the separateir56mSigningRequestscollection, with no WOPC-style doc-mirror (IR56M signed/voided state lives only on the request). Each transition validates → repo patch (terminal-overwrite-guarded) → audit event. T-089 conflict cleared (an Explore confirmed the reused WOPC primitives —composeChopOntoPdf,getChopImageBytes, the signing types — are stable + untouched by the restructure). Director email deferred (request shows in the inbox oncesent). tsc + eslint clean. Remaining #4: (4b) API endpoints/api/ir56m-signing/*+ the sign action (compose → chop →uploadSignedIR56MToDrive) + client helpers; (4c) magic-link/ir56m/sign/[id] - the Resend email; (4d) the Records-tab signing actions (Request / Withdraw / Upload / Void).
- **Correction (2026-06-22) — Drive location fixed (supersedes the
70. Tax & Compliancepath in Decision 2 - Increment 1 above). The owner flagged that
70. Tax & Compliancedoesn't exist — I'd invented it. Inspected the live ERL drive:70is actually70. Talent & HR Strategy, and the company's employer return (BIR56A/IR56B) + profits tax (BIR51) already live in90. Governmental & Compliance / Inland Revenue Department / Tax Department. So IR56M files there — owner-confirmed structure…/Tax Department/{YA}/IR56M/(signed) +…/{YA}/IR56M/_voided/(voided, internal-only), namedERL_IRD_IR56M_{recipient}_{YYYYMMDD}.pdfto match the existingERL_IRD_<form>_<date>convention.sign.server.tscorrected (requires the 3 human-curated levels, lazy-creates{YA}/IR56M/_voided, + addedbuildIR56MFilename). The old70. Tax & Compliancepath never filed anything** (uploadSignedIR56MToDrivehad no caller yet), so no docs were misfiled. - Source (edit): EOP Local Assistance · session 63c32698-0f80-464c-a668-f352be2c148e
- Evidence — owner, 2026-06-22 (verbatim): > "can you look thru the folder structure of my current Establish Records Limited drive and find the right > place for the uploading of the IR56M? Cos I don't think that there's 70. Tax & Compliance on my Drive?"
- Increment 4b (done) — requester-side signing endpoints + client: new
pages/api/ir56m-signing/endpoints —index(create-or-get, keyedendingYear+payeeId),[requestId]/assign,/send,/withdraw— each thin over the #4a ops layer with auth. NewsigningRequests/apiAuth.tsreuses the GENERICrequireSessionActor/requireMethodfrom the WOPC apiAuth + addsloadAuthorizedIR56MRequest(loads via the IR56M repo) +getRequestIdParam. NewsigningRequests/client.tsfetch wrappers (createOrGetIR56MSigningRequest/assignIR56MDirector/sendIR56MForSigning/withdrawIR56MSigningRequest)./sendtransitions tosent(the director email lands in #4c). tsc + eslint clean. Next (4c) — director-side: inbox + the sign endpoint (compose → chop → upload via the corrected90/IRD/Tax Department/{YA}/IR56Mpath) + the magic-link/ir56m/sign/[id]+ the Resend email; then (4d) the Records-tab signing actions. - Increment 4c (email, done) — IR56M signing email as a SSOT shared template (owner-requested): new
lib/email/templates/IR56MSigningRequestEmail.tsx(React-email component +ir56mSigningRequestSubject) +lib/email/sendIR56MSigningRequestEmail.ts(sends to the assigned signing director'scontactEmail— NOT the payee — with the magic link/ir56m/sign/[requestId]+ recipient/YA/employer in the card; graceful degradation on no-email / no-Resend). Per the owner's ask, the SAME component is the single source of truth for BOTH the sender AND the Tools-page Email Templates preview — registered as one entry (ir56m-signing-request) inpages/api/tools/email-preview.ts, so the live email + the preview can't diverge.sendForSigning(ops layer) now dispatches it (best-effort, lazy-import; never unwinds thesenttransition). tsc + eslint clean; owner can preview it on Tools → Email Templates. Remaining #4c: the sign endpoint (compose → chop → upload) + signature stash + director inbox + the magic-link page; then #4d the Records-tab signing actions. - Increment 4c (signing backend, done) — sign + reject + inbox endpoints:
pages/api/ir56m-signing/[requestId]/sign.ts— the assigned director's sign action: regenerates the FILLED PDF from the SAVED filing (stamping the signer block with the signing director — never client form input), overlays the signature image + company chop viacomposeSignedIR56MPdf, uploads to90/IRD/Tax Department/{YA}/IR56M(named viabuildIR56MFilename), thenrecordSignature. Image sources (drawn / saved-upload) fill+compose; ascanned-uploadPDF (admin upload-on-behalf only) is archived as-is.[requestId]/reject.ts(assigned director declines) +inbox.ts(the director'ssentrequests — queried by director, then filtered/sorted in memory to avoid a new composite Firestore index). tsc + eslint clean. Remaining #4c: the magic-link/ir56m/sign/[id]page (signature canvas + chop picker) + in-app signing; then #4d wires the Records-tab actions (Request / Withdraw / Sign / Void). - Increment 4c (signing UI — route 1/5, done): the magic-link wizard ENTRY (owner chose full WOPC-style
parity).
app/ir56m/sign/[requestId]/—page.tsx(server: NextAuth gate + load request + subsidiary / identity guard → props; best-effort recipient + YA from the saved filing) +client.tsx(step 1: react-pdf preview of the filled IR56M + Reject-confirm modal + Sign →./method). NewGET /api/ir56m-signing/[id]/preview-filled— fills from the saved filing via a sharedlib/taxHK/ir56m/signFill.server.tsresolver, so the preview == exactly what gets signed; +rejectIR56MSigningRequestclient helper. tsc + eslint clean; owner verifies on the deploy (auth-gated; Sign 404s into./methoduntil that route lands — incremental). Remaining wizard routes (mirror WOPC):/method(draw / editor / upload) →/draw+/editor(capture) →/chop(place the seal; + achop-imageendpoint) →/preview(final compose via apreview-signedendpoint + submit). Then #4d the Records-tab actions. (Tiny follow-up: point the sign endpoint atsignFill.server.tsto retire the duplicated assembly.) - Increment 4c (signing UI — route 2/5 + scaffolding, done):
signFlow.client.ts(sessionStorage + pointer/orientation hooks; no placement — IR56M's signature box is fixed), a sharedguard.server.ts(inner-route auth + assigned-director/admin guard, used by all wizard routes except the lenient entry), and/method(page + client)./methodoffers Sign on screen (draw → on every device, since the box is fixed) + Upload a signed copy (scan/photo or own-editor PDF →scanned-upload, straight to preview) + a download-unsigned link. WOPC's "sign on the document" editor is intentionally omitted — IR56M has a fixed signature box, so free-position signing doesn't apply (noted so it's not mistaken for a gap). tsc + eslint clean. Remaining wizard:/draw(canvas) →/chop(place the seal; + achop-imageendpoint) →/preview(final compose; + apreview-signedendpoint) + submit. Then #4d the Records-tab actions. - Increment 4c (signing UI — route 3/5, done):
/draw(page + client) — the full-screen signature canvas, mirroring WOPC: reuses the genericFreehandSignaturePad(perfect-freehand), a draggable baseline guide, a portrait→rotate prompt, undo/clear, and Done → bounding-box-crop → stash (source: 'drawn') →/chop(the cropped ink is later scaled into IR56M's fixed signature box bycomposeSignedIR56MPdf). tsc + eslint clean. Remaining:/chop(place the company seal on the rendered page; + achop-imageendpoint) →/preview(final compose; + apreview-signedendpoint) + submit. Then #4d. - Increment 4c (signing UI — route 4/5, done):
/chop(page + client) — places the company seal, mirroring WOPC's chop step. Two new endpoints back it:GET /api/ir56m-signing/[id]/chop-image?variant=(proxies the securedgetChopImageBytesso the private chop asset never sits in public URL space + reads are auth-gated) andPOST /api/ir56m-signing/[id]/preview-signed(regenerates the FILLED PDF from the saved filing, composes the signature + optional chop viacomposeSignedIR56MPdf, returns the PDF, persists NOTHING; ascanned-uploadPDF is returned as-is). The client renders the signed-no-chop PDF, picks a random ERL chop variant (1–11) + a random ±15–30° tilt to mimic a hand-applied seal, then the chop follows the cursor (fine pointer) / taps to place (coarse), capturing{ xPct, yPct, rotationDeg, variant, pageIndex }in normalised page coords. Chop diameter is pinned to the server's 20 mm/57 pt so the on-screen size == the stamped size. Done →writeStoredChop→/preview. tsc (MY=0; only the known stale-node_modulescroner error remains) + eslint clean. Remaining wizard:/preview(route 5/5 — final compose viapreview-signedwith the stashed signature+chop, then submit → the sign endpoint). Then #4d the Records-tab actions (Request / Withdraw / Sign / Void). - Increment 4c (signing UI — route 5/5, done → magic-link wizard COMPLETE):
/preview(page + client), mirroring WOPC's final preview. Server page reusesrequireIR56MSignAccess+ loads best-effort recipient name + YA from the saved filing (same as the entry page). Client reads the stashed signature (+ chop, if the draw path placed one) and POSTs/api/ir56m-signing/[id]/preview-signedto render the composed PDF via react-pdf, then Submit POSTs the SAME body to/api/ir56m-signing/[id]/sign(re-runs the identical composition → guaranteed to match what's shown), clears the stash, and routes to/records?tab=ir56m. Empty session bounces back to/method. Noplacementin the body — IR56M's signature box is fixed (the one structural divergence from WOPC, by design). tsc (MY=0) + eslint clean;/records?tab=ir56mdeep-link confirmed (RecordsApp honours?tab=on mount). The 5-route magic-link signing wizard (entry → method → draw → chop → preview) is now end-to-end for the two happy paths: (a) draw-on-screen → place chop → submit, and (b) upload a signed PDF → submit (archived as-is). Known gap before #4d: the "upload a scan/photo" path sends ascanned-uploadimage straight to/preview(skipping chop), but the sign endpoint's image branch still requires a chop → it would 400 at submit. A photo of an already-signed page should be archived as a single-page PDF (it's the finished document, not a bare signature to overlay) — fixed next in #4c-5b before #4d. Remaining: #4c-5b (scanned-upload image → archive-as-PDF) then #4d (Records-tab actions: Request / Withdraw / Sign / Void). - Increment 4c-5b (done) — scanned-upload image archives as a PDF (closes the photo-path gap above):
the upload path's two artefacts are now handled symmetrically. New
imageToSinglePagePdfinsign.server.tswraps a PNG/JPEG into a single-page A4 PDF (fit, aspect-preserved, centred on white). Both the/signand/preview-signedendpoints now branch onsource === 'scanned-upload'(the user supplied the FINISHED, hand-signed document) → archive it: a PDF verbatim, a photo/scan wrapped to PDF — no form regeneration, no chop. The overlay branch (adrawnsignature image stamped into the fixed box + chop) is now reached only fordrawn/saved-upload, and rejects a stray PDF there with a clear 400. So "upload a scan / photo of a signed page" now produces a real filed PDF instead of 400-ing for a missing chop. tsc (MY=0) + eslint clean. The full magic-link wizard's three real paths now work end-to-end: draw+chop, upload-PDF, upload-photo. Remaining: #4d the Records-tab signing actions (Request / Withdraw / Sign / Void), + the tiny follow-up to point/signatsignFill.server.ts(retire its duplicated form assembly). - Increment 4d-1 (done) — Records → IR56M tab now shows per-recipient signing status (foundation for the
actions): new
GET /api/ir56m-signing/lookup(keyed byendingYear+payeeId, mirroring the WOPClookup):?payeeId=→{ request },?payeeIds=a,b,c→{ requests: { [payeeId]: wire|null } }, returning the LATEST request (incl. terminal states, so the badge stays accurate after a request closes) + a server-computedcurrentUserCanSign(caller is the assigned director AND the form still awaits a signature). New client helperlookupIR56MSigningRequests(endingYear, payeeIds)+currentUserCanSign?onIR56MSigningRequestWire.IR56MFilingsTabbatch-loads each YA's statuses after the filings load (keyed${endingYear}:${payeeId}, best-effort — the archive never blocks on it) and renders a Signing column per recipient (Draft / Assigned / Sent for signing / Signed / Rejected / Withdrawn / Voided, with the reject/withdraw reason on hover). Read-only for now. tsc (MY=0) + eslint clean. Next #4d-2: the per-recipient action menu — Request (director-picker modal → assign + send + email) / Withdraw / Sign-now deep-link to the wizard; then #4d-3 Void (endpoint + modal). - Increment 4d-2 (done) — per-recipient signing action menu (mirrors the WOPC Records-tab kebab): new
components/records/IR56MSigningRequestModal.tsx(two-mode, mirroringWopcSigningRequestModal): request = a signing-director picker (the two ERL directors from the registry) + a read-only email preview, which on send does create-or-get → assign →sendIR56MForSigning(server dispatches the magic-link email); withdraw = optional reason →withdrawIR56MSigningRequest. The recipient row's Actions cell now carries a kebabDropdown(beside the Preview button) whose items are state-gated, same as WOPC: Request signing (no active/signed request and the YA is closed — frozen; preview-only before that) · Sign now (caller is the assigned director, signature pending → deep-link/ir56m/sign/[id]) · Send for signing (assigned, not yet sent) · Upload a signed copy (sent →/ir56m/sign/[id]/method) · Withdraw request (active). Successful actions patch the badge in place (no reload) viaapplySigningChange. Uses the project'sItemType(antd/es/menu/interface) menu typing, notMenuProps. tsc (MY=0) + eslint clean. Next #4d-3: Void (endpoint + client helper + confirm modal) for a signed IR56M → internal-only_voidedDrive subfolder; then the tiny/sign→signFill.server.tsfollow-up. - Increment 4d-3 (done) — Void a signed IR56M (completes #4d; all four Records-tab actions live): new
POST /api/ir56m-signing/[id]/void, mirroring the WOPC void: downloads the signed PDF, stamps a random VOID impression over it (reusesoverlayStampOntoPdf+getStampVariantImageBytes('void', …)+pickVoidStampPlacement+VOID_STAMP_WIDTH_PT— the shared general stamp assets), files that voided copy into the internal…/Tax Department/{YA}/IR56M/_voidedfolder, trashes the clean signed copy (Drive trash → ~30-day recovery), thenrecordVoided(request →voided). Returns the clean (pre-void) PDF as base64 so the operator gets a local copy. New client helpervoidIR56MSigningRequest+IR56MVoidResult; the tab adds a Void signed IR56M kebab item (statussigned) → a danger confirm modal → on confirm: void, auto-download the clean copy, re-fetch the badge (→ Voided). No email here — the owner-specified internal-only VOID notice + the 3-day-regret send-on-sign are the separate distribution increment (#5); #4d-3 is Drive + status only (no outward send), which is unambiguous and owner-specified, so it was safe to build without a checkpoint. tsc (MY=0) + eslint clean. Phase-4 #4d is COMPLETE (status display + Request / Send / Sign / Upload / Withdraw / Void). Remaining on T-047: #5 the distribution flow (3-day-regret send-to-all on sign + internal-only VOID notice + void-on-YA-readjust) — needs an owner checkpoint on the outward-email behaviour before building; and the tiny/sign→signFill.server.tsdedup follow-up.
Phase 4 #5 — distribution REDESIGNED to interactive per-sign config (owner, 2026-06-23)¶
- ✅ Attestation (EOP Local Assistance): read
AGENTS.md; recording the #5 redesign + the owner's approval/evidence before building, board kept current (tracking T-047). - Source: EOP Local Assistance · local Claude Code session
- What changed: the original #5 ("auto-send to all 3 days after sign; void notice internal-only") is superseded by an interactive, per-sign distribution config the signing director sets at chop/sign time, with the choices stored + editable later. Asked who should receive the completed + void emails; the owner redesigned the whole flow. Decisions LOCKED:
- Grace period is the director's choice, per-sign: at sign time (right after chopping) prompt the signing director to either send immediately or set an X-day grace/regret period before the "completed IR56M" email goes out. Default: enabled with the regret window.
- Recipient picker shows REAL people, not roles: list actual people in the web app with their role — e.g. "Jeffero Chan — Director, Jake Ngai — Director, Alison Tang — Accountant" — multi-select. Default selection (completed email): directors + the recipient + accountant + bookkeeper (degrade gracefully — a defaulted role with no real person yet is skipped, never blocks). This exposed the RBAC roster gap → T-094 (owner-requested; the picker sources from whatever real users exist until T-094 lands).
- Void notice is ALSO director-configured at sign time, default directors-only: when chopping, the director also sets who'd receive the internal VOID notice if it's ever voided. Default: directors only. Never the recipient (the info is wrong).
- Hard rule — never send to no one: the director may hand-pick any people who exist in the web app, but the send list can't be empty — it must include at least the signing director himself.
- Settings are stored + editable on BOTH tabs: once set (incl. the grace period), persist them and allow editing from the IR56M tab on the Accounting page AND the Records page.
- Build plan (#5, each increment tsc-clean + lockstep): (a) distribution data model + the two SSOT
emails (completed / voided) + a people-roster accessor (degrades to directors+recipient today); (b) the
post-chop distribution-config step in the wizard + persist on sign; (c) the queue + cron drain (send when
the grace window elapses) + cancel-on-void; (d) editable settings on the Accounting + Records IR56M
tabs; (e — separate) void-on-YA-readjust. Mechanism: a Firestore
ir56mDistributionsqueue drained by a new/api/cron/ir56m-distributionsjob added to the in-app scheduler (lib/cron/in-app-scheduler.ts), matching the existing cron pattern. - Evidence — owner, 2026-06-23 (verbatim):
"up on signing and sealing the IR56M, prompt the user and let the user decide whether to create a x day grace period at all, or send immediately, while also give the option for user to choose which party for the email to be sent to as well while listing out not just the role name (like, Directors/recipient/super admin/ admin etc...) but the actual information of those people (like, Jeffero Chan - Director, Jake Ngai - Director, Alison Tang - Accountant). But for the time being, let the user decides while set the prompt to default on directors + recipient + accountant + bookkeeper first."
"Let the sign and seal director design upon him finishing the chopping, while default on directors only. While also for both the previous and this question: also allow director to manually pick people exist on the web app to send to, but don't allow the director to send to no one. It has to be sent to at least the closing director himself. If the signing director chose for it to have a grace period, have these settings stored and allow the changing of it on the IR56M tab on the accounting page AND the records page" - Increment 5a-1 (done) — distribution data model + store: new
lib/taxHK/ir56m/distribution/types.ts(IR56MDistribution— one per signed IR56M:graceModeimmediate/grace +graceDays+ immutablesignedAt+ derivedsendAfter;completedRecipients+voidRecipientsas denormalized{kind,refId,name,role,email}snapshots so a stored config never depends on the evolving RBAC roster T-094; completed-send lifecyclepending→sent|cancelled;voidNoticeSentAt; + the never-empty recipients invariantassertNonEmptyRecipients, the owner's "at least the signing director" rule) andstore.server.ts(Firestore CRUD onaote-system/ ir56mDistributions, doc id == signing-request id so create is idempotent + 1:1:createDistribution,getDistribution,listDuePendingDistributions[status==pending thensendAfter<=nowfiltered in memory — no composite index],markDistributionSent,cancelDistribution[no-op unless pending],markVoidNoticeSent,updateDistributionSettings[recomputessendAfterfrom the immutablesignedAt; pending-only]). tsc (MY=0) + eslint clean. Next #5a-2: the people-roster accessor (directors + recipient +aote-system/users, degrading gracefully; defaults = directors+recipient+accountant+bookkeeper / directors-only) + the two SSOT emails (completed / voided). Then #5b config step, #5c cron, #5d editable tabs. - Increment 5a-2 (done) — people-roster accessor + the two SSOT emails:distribution/recipients.server.tsresolveIR56MAddressablePeopleresolves the picker's people from three sources (directors registry → both lists, default-checked, signing directorlocked; the recipient from the payee directory → COMPLETED only, never void, skipped if no email;aote-system/users→ accountant/bookkeeper default-checked in COMPLETED, others addressable-unchecked), de-duped by email, degrading gracefully (a missing source never blocks) — +defaultCompletedRecipients(directors+recipient+accountant+bookkeeper) /defaultVoidRecipients(directors only). The roster is thin until T-094 fills the RBAC users. Two SSOT emails (template + subject imported by BOTH the sender AND the Tools email-preview, registeredir56m-completed+ir56m-voided):IR56MCompletedEmail(signed copy, sent to all configured recipients incl. the sub-contractor — PDF attached since the recipient isn't a web-app user) +IR56MVoidedNoticeEmail(internal-only VOID notice, VOID PDF attached, never to the recipient). SenderssendIR56MCompletedEmail/sendIR56MVoidedNoticeEmailsend individually (no cross-exposure of addresses) + return per-recipient results for the store. Extended the sharedemail/client.tssendEmailwith an optionalattachmentspassthrough (additive). tsc (MY=0) + eslint clean. Next #5b: the post-chop distribution-config step in the wizard (grace toggle + people multi-select w/ defaults + the never-empty/locked-signer rule) → persist on sign. Then #5c cron drain + cancel-on-void, #5d editable on the two tabs. - Increment 5b (done) — sign-time distribution config (folded into/preview, covers ALL sign paths): two endpoints —GET …/distribution-roster(addressable people + the owner's default selections) andPOST …/distribution(creates the record post-sign; enforces server-side that both lists are non-empty and include the signing director). Newdistribution/client.ts(fetch/create helpers +recipientsFromValue+defaultConfigValue). New reusablecomponents/ir56m/IR56MDistributionConfig.tsx— grace toggle (send-now vs hold N days, default 3) + two people checklists (completed / void) with the defaults pre-checked, the signing director locked in both, and the recipient excluded from the void list (internal-only)./previewnow fetches the roster on mount, renders the config below the signed preview, and the submit POSTs/signthen/distribution(best-effort but surfaced — the signed doc is already filed; settings are editable on the tab if the create fails). Folding it into/preview(not a separate/distributeroute) means it uniformly covers draw+chop AND the scanned-upload path, which both land there. The config component is reused for #5d. tsc (MY=0) + eslint clean (typed the antd handler params;RadioChangeEventfromantd/es/radio,CheckboxChangeEventfromantd/es/checkbox). Next #5c: the cron drain (/api/cron/ir56m-distributions+ register in the in-app scheduler) that sends the completed email whensendAfterpasses, + cancel-on-void wiring + the void-notice send in the void endpoint. - Increment 5c (done) — drain + immediate-send + cancel-on-void + void notice:distribution/drain.server.ts(sendOneIR56MDistributionresolves the filing + downloads the signed PDF + sendssendIR56MCompletedEmailtocompletedRecipients, thenmarkDistributionSent; only while the request is stillsigned[voided ones skipped]; leaves the recordpendingfor retry if EVERY recipient fails;drainDueIR56MDistributionsloops the due queue). New cronpages/api/cron/ir56m-distributions.ts(BearerCRON_SECRET) + registered in the in-app scheduler (WORKER_JOBS, daily 04:15 UTC). Immediate sends bypass the poll: the create endpoint drains its own distribution inline whensendAfter <= now(grace 0), so "send now" is now. The void endpoint nowcancelDistributions the pending completed send + emails the internal VOID notice (sendIR56MVoidedNoticeEmail, VOID PDF attached) to the storedvoidRecipients(directors fallback if no distribution) +markVoidNoticeSent— all best-effort, never failing the void. tsc (MY=0; only the pre-existingcronerstale-node_modules error in in-app-scheduler) + eslint clean. Next #5d: edit the stored distribution settings (grace + recipients) from the Accounting + Records IR56M tabs (reusingIR56MDistributionConfig+ a GET/PATCH on the distribution). - Increment 5d (done) — editable distribution settings on BOTH IR56M tabs (completes #5a–5d): the[id]/distributionendpoint now also serves GET (read the stored settings) and PATCH (updateDistributionSettings— pending-only, recomputessendAfter, re-enforces non-empty + signing director) alongside the existing POST. New clientgetIR56MDistribution/updateIR56MDistribution/configValueFromDistribution+ anIR56MDistributionWiretype. New reusablecomponents/ir56m/IR56MDistributionEditModal.tsx— fetches the distribution + roster, seedsIR56MDistributionConfigfrom the stored settings (or roster defaults if none), saves via PATCH (or POST if no record yet); read-only with an explanatory notice once the completed send issent/cancelled. Wired into the Records IR56M tab (a "Distribution settings" kebab item on signed rows) AND the Accounting → Filings IR56M tab (a gear button on signed rows — added a best-effort signing-status lookup there so it knows which recipients are signed). tsc (MY=0) + eslint clean. #5a–5d COMPLETE — the owner's interactive per-sign distribution flow is end-to-end: configure at sign time (grace + recipients, defaults, locked signer) → queue → drain/immediate-send the completed copy → cancel + internal VOID notice on void → edit the stored settings from either tab. Remaining on T-047: #5e void-on-YA-readjust (auto-void + re-issue when a re-closed YA changes a total) + the owner-requested IR56M pipeline test page; and the tiny/sign→signFill.server.tsdedup follow-up. - Sign-and-seal TEST page (done) — owner-requested UX/UI approval harness: the owner asked for a standalone page to walk the sign-and-seal flow and confirm it's smooth + the signature/chop render in the right position. New admin-onlyapp/ir56m/sign-test/(page + client) — a 3-step harness (draw → place chop → preview) against a SAMPLE IR56M (lib/taxHK/ir56m/sampleForm.ts, the owner's verified specimen), reusing the genericFreehandSignaturePad+ the productioncomposeSignedIR56MPdfso placement is identical to a real signing. Three admin-gated test endpoints (/api/ir56m-signing/test/{preview-filled, preview-signed,chop-image}) serve sample data only — persists nothing, files nothing, emails nothing. Linked from the Tools page (a new "IR56M — sign & seal test" panel beside Email Templates). Isolated by design — no test branches threaded through the audit-critical prod sign path. tsc (MY=0) + eslint clean for my files (ToolsApp.tsx's pre-existing lint debt untouched). Owner verifies on the deploy (auth-gated; the harness IS the verification surface). Evidence — owner, 2026-06-23 (verbatim): "a standalone page (or you could integrate the entry point to the Tools page) for me to test the whole sign and seal pipeline for UX/UI approval (to see whether the process is smooth, or the sign and seal are rendered to the right position on the IR56M etc...)". - Follow-up (done) —/signuses the sharedsignFillresolver: the sign endpoint inlined its own filing-load + signer-block stamp +assembleIR56MFormData; it now callsresolveIR56MFormForRequest(the same resolverpreview-filled+preview-signeduse), so what the director previews is byte-for-byte what gets signed. Removed the deadtodayDDMMYYYY+ unused imports. tsc (MY=0) + eslint clean. - Increment 5e (done) — void-on-YA-readjust: when a closed YA is reopened + re-closed with CHANGED transaction data, a frozen declared total (and any IR56M signed against it) is stale. Newlib/taxHK/ir56m/readjust.server.ts—detectIR56MReadjustments(subsidiaryId, endingYear)compares each frozendeclaredTotalagainst a fresh candidate scan (buildIR56MFilingSeed) and returns the diverged recipients;reFreezeIR56MRecipientServerdeliberately re-freezes one recipient to the current figure via the newoverrideRecipientFreezeServerinfilings.server.ts(the ONLY path that bypasses the set-in-stone guard — audited viahistory+ the newreadjustedFromfield on the freeze). NewGET|POST /api/accounting/ir56m/readjust(GET detect = any authed; POST re-freeze = admin-only). The Records IR56M tab now: surfaces a "figure changed → HK$Y" tag (strikes the stale frozen total) on diverged recipients; relabels Void → "Void & re-file (figure changed)" and, after that void, auto-re-freezes to the new total (so re-requesting signing re-files the correct figure); and offers "Update frozen total" for a frozen-but-unsigned diverged recipient (no void needed). The owner's rule is honoured — it stays a user action (the void auto-downloads a copy, which needs a live browser), not a silent cron. tsc (MY=0) + eslint clean. The unsigned-snapshot VOID-render audit nicety in the original design is intentionally omitted — an unsigned recipient has no signed PDF to void; re-freezing to the new figure is sufficient (noted so it's not read as a gap). T-047 phase 4 is now feature-complete (fill → freeze → request → sign → chop → distribute → void → readjust + the test harness); status staysdoingpending the owner's UX/UI approval on the deploy + the T-094-dependent roster filling out the distribution picker. - Post-deploy fixes (2026-06-23, owner tested on the deploy): - Signature placement — owner: the signature rendered too small + too high, and then (after a first pass that bottom-anchored it to the 簽署 line) that it must overlap the 姓名/職位 fields below to look hand-signed ("a tidy on-the-line signature reads as fake"). Render-calibrated against the live form (mupdf):IR56M_SIGNATURE_BOXis now a large, low box{x332,y55,w232,h48}(was{x355,y88,w185,h24}) spanning from above the 簽署 rule down through 姓名 + 職位, signature centred so it sprawls across + overlaps those fields (bottom kept above the 日期 rule). Affects every signed IR56M (real + the harness). - Chop didn't load + preview 500 (WOPC_ASSETS_BUCKET is not set) — NOT a code bug: the chop-image + preview endpoints call the samegetChopImageBytesWOPC uses, which throws if the GCS-bucket env var is unset in the test environment. Fix is config (setWOPC_ASSETS_BUCKETwhere tested —.env.localfor local, already present wherever WOPC's chop renders). - Demo-email send on the Tools page (owner-requested) — the "IR56M — sign & seal test" panel is now a component (components/tools/IR56MSignTestTool.tsx) with an inline email field + Send demo email that POSTspages/api/ir56m-signing/test/send-demo.ts(admin-gated) → sends the real signing-request email template (sample data,[DEMO]subject) with the magic link →{canonical origin}/ir56m/sign-test, so the owner can check the email + signing UX on a phone AND a desktop. tsc (MY=0) + eslint clean.
On-device test round 2 — signature tuning + mobile signing UX + chop-bucket diagnosis (owner, 2026-06-23)¶
- ✅ Attestation (EOP Local Assistance): read
AGENTS.md; checked the board by scope (this is the same T-047 sign-and-seal work, not a new task); recording the refinements + the chop diagnosis. Tracking T-047. - Source: EOP Local Assistance (fork) · session e527df5f-d3ea-4716-8076-b46319d9d830 (local Claude Code)
- Owner asks (verbatim, 2026-06-23): (1) "the center spot of the rendering to be … 5px above the signature placeholder line"; (2) "make it so that the rendering of the signature is ±10px horizontally randomly"; (3) the mobile signing page should "mimic the WOPC signing page that a) makes it available horizontally only, and has an animation icon to remind user to rotate their phone … b) … full screen on a mobile device; c) the chop image is still just a 'frame with an image file icon' … d) upon pressing 'next: preview' … 'HTTP 500 — WOPC_ASSETS_BUCKET is not set'". Plus: "make sure first whether the NAS is hosting a deployment building from the latest commit to main" (it is — verified prod runs the latest image; so c/d are config, not stale-deploy).
- (1)+(2) Signature placement re-tuned in
lib/taxHK/ir56m/sign.server.ts: replaced the fixed box with a centre-anchored placement — the signature is scaled to fit{w232,h48}, then its CENTRE is placedCENTRE_ABOVE_LINE = 5pt above the 簽署 rule (RULE_Y = 86) at horizontal centreCENTRE_X = 448, with a ±JITTER_X = 10pt horizontal jitter derived deterministically from the signature bytes (bytes.reduce(...) % 21 − 10) — so the shift varies per signature but is STABLE within one signing (preview == filed PDF, no horizontal jump between renders). Render-calibrated against the live form (mupdf): centre sits 5 pt above the rule and the ink sprawls down over 姓名/職位. Owner-tunable constants are grouped + commented for the next adjustment. Affects every signed IR56M (real + harness). - (3a)+(3b) Mobile signing UX — harness now a LITERAL mirror of the real wizard /draw. Extracted the
full-screen, landscape-only signature experience into a shared component
components/wopc/Ir56mDrawCanvas.tsx(full-viewport100vh, draggable signing-line, floating Undo/Clear/Done bar, portrait → animated rotate prompt) + the animatedcomponents/wopc/PhoneRotateIcon.tsx. BOTH the production wizard (app/ir56m/sign/[requestId]/draw/client.tsx, now a thin wrapper) AND the test harness (app/ir56m/sign-test/client.tsx, draw step is a full-screen early-return) render the SAME canvas, so the test page can't drift from production. Was: the harness draw was a cramped inline 260 px box with no rotate/full-screen — exactly the gap the owner saw on a phone. tsc (MY=0) + eslint clean. - (3c)+(3d) Chop image + preview-500 — SAME root cause, fully diagnosed; fix is config, awaiting owner OK
for prod. Both the broken chop-image frame and the
HTTP 500 — WOPC_ASSETS_BUCKET is not setcome from one missing env var: the chop-image endpoint and the signed-preview endpoint both callgetChopImageBytes, which throws whenWOPC_ASSETS_BUCKETis unset. Correction to the prior note (~~"already present wherever WOPC's chop renders"~~): I read prod's/volume1/docker/eop/.env.productionvia DSM (key-presence only, 85 keys) andWOPC_ASSETS_BUCKETis ABSENT — so WOPC's own chop would fail in prod too; it's a prod config gap, not a test-only quirk. — corrected after verifying prod env. Edit-out signed: EOP Local Assistance · session e527df5f-d3ea-4716-8076-b46319d9d830. Verified fix value: the bucket isaote-system-assetsand the chop objects exist at the exact path the reader uses —doc/stamps/authorization/erl/22mm/erl-{1..11}.png(all 11 ERL variants listed via the Drive SA, which already has read). Applied locally (.env.local, gitignored) so the harness works on this machine. ~~Prod still needsWOPC_ASSETS_BUCKET=aote-system-assetsadded to/volume1/docker/eop/.env.production… held for owner confirmation (offering to apply it via DSM FileStation …).~~ — superseded: owner approved applying immediately, and the planned.env.productionwrite turned out not to be reachable (below). Edit-out signed: EOP Local Assistance · session e527df5f-d3ea-4716-8076-b46319d9d830.
Prod apply (owner: "apply it to production immediately", 2026-06-24)¶
- ✅ Attestation (EOP Local Assistance): read
AGENTS.md; same T-047 scope; recording the prod-apply path - evidence. Source: EOP Local Assistance (fork) · session e527df5f-d3ea-4716-8076-b46319d9d830 (local Claude Code).
- Owner approval (verbatim, 2026-06-24): "Please apply it to production immediately, while please let me know when it is successfully deployed on the NAS."
- The intended fix — writing
WOPC_ASSETS_BUCKETinto/volume1/docker/eop/.env.production— was NOT reachable from a dev machine. The NAS sits behind a locked-down Cloudflare tunnel; DSMauth.cgi/query.cgianswer (JSON), but FileStationentry.cgifile transfer is edge-blocked —SYNO.FileStation.Download→ HTTP 404 (server: cloudflare),List→ 200 butsuccess:false. So the 140-line prod secrets file can't be safely read-modify-rewritten through this channel (a partial upload could clobber prod config). Local SSH/dockerto the NAS is also unavailable (Tailscale doesn't see the NAS; cloudflared-SSH needs the CF Access service token, which is a GH secret not on this machine). - Applied instead by baking the bucket NAME into the image —
ENV WOPC_ASSETS_BUCKET=aote-system-assetsin theDockerfilerunner stage. Rationale: the bucket name is non-secret (access is gated by the Drive SA creds already in.env.production); changing the Dockerfile changes the image digest, which is what the NAS deploy step keys on to recreate the container (it skips recreate when the digest is unchanged — so a workflow-only or env-file-only change would NOT redeploy).--env-filecan still override it later if infra folds it into.env.production. Pushed tomain→nas-image.ymlbuilds the new image → NAS auto-pulls + recreateseop-appwith the var baked in. Commit + deploy-verification recorded below once green. - One residual to confirm post-deploy: prod's Drive SA must have
storage.objects.getonaote-system-assets. Locally the@aote-pmsSA has it (listed all 11 objects); prod uses the same app Drive creds, so it almost certainly does — if not, the chop would fail with a GCS 403 (not "bucket not set"), which is the signal to grant the role. - ✅ DEPLOYED + VERIFIED on the NAS (2026-06-24). Commit
f749c915(Dockerfile ENV) →nas-image.ymlrun 28040902803 all green, incl. "Deploy to NAS (pre-pull + recreate)". Deploy-step log proves the recreate (not a skip):OLD container image sha256:d517677b…→NEW pulled image sha256:e86898bb…(digest changed) →recreate eop-app (old → eop-app-prev for rollback)→eop-app | Up 6 seconds | ghcr.io/girafeev1/eop-app:main. So the running prod container now carriesWOPC_ASSETS_BUCKET=aote-system-assets; the chop image + signed preview should resolve (functional check = owner opens the IR56M sign-test on a phone/desktop). The earlier signature + mobile-UX push (130cfede, in merge681ce5fe) deployed green too (run prior to this). - ~~the chop image + signed preview should resolve~~ — correction (2026-06-24): the WOPC_ASSETS_BUCKET
fix is correct and live, but the chop still 500s in prod for a separate, deeper reason — the NAS
app can't fetch a Google OAuth token (
"Premature close"ongoogleapis.com/oauth2/v4/token). That's a NAS→Google network/egress issue, NOT the chop code (the identical code reads the chop fine from a dev machine). Tracked as I-009. The chop/preview will stay 500 until I-009's egress problem is resolved. Edit-out signed: EOP Local Assistance (fork) · session e527df5f-d3ea-4716-8076-b46319d9d830 Blast radius for other agents: (a) theDockerfilerunner stage now setsENV WOPC_ASSETS_BUCKET— any WOPC/IR56M chop or stamp read in prod now works without a.env.productionentry; if infra later adds it to.env.production,--env-fileoverrides the image default (same value, no conflict). (b) The signature PLACEMENT changed for every signed IR56M (real + harness). (c) New shared UI:components/wopc/Ir56mDrawCanvas.tsx+PhoneRotateIcon.tsx— the real wizard/drawAND the test harness both render it; a change there touches both. T-047 staysdoing(phase-5 distribution is the remaining scope); this entry closes only the chop/placement/mobile-UX sub-thread.
Mobile signing UX round 2 — portrait guards (#1, shipped) + signature-line placement (#2, direction confirmed) (2026-06-24)¶
- ✅ Attestation: read
AGENTS.md+ checked the board by scope (same T-047 — no dup). Source: EOP Assistance (fork) · session edb0abc7-6373-449b-8e31-379fd38d391b (local Claude Code). - Owner reports (verbatim, 2026-06-24) after testing on a phone — two issues:
- "On my mobile device, after I've signed the signature, before the web app redirects me to the chopping page, it didn't strictly requires me to tilt my phone vertical again for the chopping"
- "The line shown on the sign screen is still not used as the center point for the signature rendering … the web app still renders signature to a fix area, rather to use the placeholder line on my signing screen as a center point to determine the rendering height."
- #2 direction — owner CONFIRMED (verbatim, 2026-06-24): "The line on the signing page should be the reference of where the placeholder line on the document. Meaning if the line on the signing page gets drag down, the signature should appear higher and vice versa?" → the on-screen dotted line is a movable proxy for the document's fixed signature rule; the signature reproduces its relationship to the line against that fixed rule. Drag line down → signature up (NOT a direct position slider — the opposite of my first read-back, corrected by the owner).
- #1 — SHIPPED this commit. New shared phone-only guard
components/wopc/RequirePortrait.tsx: when a PHONE (coarse pointer + min-dimension < 768px) is held in landscape it shows a rotate-to-portrait prompt instead of the page; desktops + tablets pass straight through (not orientation-locked / use the on-document editor). Wrapped every mobile signing step EXCEPT the full-screen signature canvas (which stays landscape): WOPCmethod/chop/preview; IR56Mmethod/chop/preview; the IR56Msign-testharness's chop+preview steps (its draw step early-returns the landscape canvas, left unwrapped). WOPCuploadis desktop/tablet-only (a phone uses the inline picker straight topreview), so the phone-only guard is a no-op there — intentionally not wrapped.PhoneRotateIcongained adirectionprop ('to-landscape'default |'to-portrait') so one icon animates the correct way per prompt. eslint exit 0; tsc clean except the unrelated localcronernode_modules-staleness error (croner is in package.json + lockfile + on main, resolves under Dockernpm ci). - #2 — direction confirmed, building NEXT (not in this commit). Plan: capture where the signing line falls
within the cropped signature (
f = (lineY − cropTop) / cropHeight) at Done, thread it through the stored signature →preview-signed/signroutes → composition, and place so the line lands on the document rule:centreY = RULE_Y + (f − 0.5)·renderedHeight(drag line down → f↑ → signature up). IR56M has an explicit rule (IR56M_SIGNATURE_RULE_Y = 86); WOPC needs a new rule-Y constant near the closing block (best-estimate, owner-tunable after a visual check). Falls back to today's fixed-box placement when no fraction is present (uploads, the editor overlay path). - Blast radius for other agents: new shared component
components/wopc/RequirePortrait.tsx(used by 7 signing pages across WOPC + IR56M);PhoneRotateIcongained adirectionprop (default preserves the old animation, so the existing/drawprompts are unchanged). No server/composition change in THIS commit (that's #2, next). T-047 staysdoing.
2026-06-28 — IR56M signing link crash + director-only auth parity with WOPC¶
- ✅ Attestation (Codex local session): read
AGENTS.md; checked the board by scope, not UID — this is the existing IR56M signing flow scope, not a new task. Tracking T-047. - Source: Codex local session ·
/Users/gutchumi/dev/ArtifactoftheEstablisher-codex-wopc-pdfjs - Owner reports (verbatim, 2026-06-28):
"And upon signing IR56M, the web app is also still showing: layout-c6f98237f9cef9a7.js:1 [firebase] Initializing Firebase app Object 6348-e3eb7a9905920777.js:7 Error: An error occurred in the Server Components render."
"But Jake used a Chrome browser though... If it has to do with him not actually having logged on to the web app, should the web app at any circumstances to redirect the user to a login page instead?" - Root cause found: live-data probing against
aote-systemshowed/api/ir56m-signing/lookupcould 500 becausefindLatestRequestForRecipientqueriedendingYear + payeeIdand ordered bycreatedAt, requiring a composite index that did not exist. The same pattern existed for active-request lookup/create withupdatedAt. Separately, the IR56M signing pages and signer APIs still required broad subsidiary RBAC before checking whether the caller was the assigned director, unlike the WOPC director-only signing fix. - What changed:lib/taxHK/ir56m/signingRequests/repo.server.tsnow queries byendingYear + payeeIdand sorts/filter active/latest requests in memory, removing the composite-index dependency from lookup and create-or-get.app/ir56m/sign/[requestId]/page.tsxandguard.server.tsnow allow the assigned director or admin through even without normal subsidiary access.loadAuthorizedIR56MRequestgainedallowAssignedDirector, enabled only on signer-facing routes: filled preview, signed preview, chop image, reject, sign, distribution roster, and distribution creation. - Verification: local Firestore probes loaded the live requestiOv6bZQSLCtqb6Ox2def(endingYear=2025,payeeId=JC, statussent, assignedDirectorIdE6g30MzNOkhzlCJwKvoe) and resolved the saved filing +resolveIR56MFormForRequestcleanly.git diff --checkclean;NODE_OPTIONS=--max-old-space-size=4096 npx tsc --noEmitclean. - Blast radius: IR56M signing request lookup/create and the director-facing IR56M signing flow. Admin/requester actions (assign,send,withdraw,void) still use the normal subsidiary-access guard.
2026-06-29 — signed-preview button + immediate-send edit regression¶
- ✅ Attestation (Codex local session): read
AGENTS.md; checked the board by scope, not UID — this is the existing IR56M signing/distribution flow scope, not a new task. Tracking T-047. - Source: Codex local session ·
/Users/gutchumi/dev/ArtifactoftheEstablisher-codex-wopc-pdfjs - Owner reports (verbatim, 2026-06-29):
"When an IR56M is signed, the "Preview the filled IR56M PDF" button should show the signed IR56M, and tool tip should say "Preview the signed IR56M PDF""
"When an IR56M that is signed but is set to Hold for a regret window of ? day(s), then send, a) where is this setting saved currently? and b) if anyone (who has the authorization to configure it to be "send immediately", and have the settings saved, then the IR56M should be sent immediately to the selected personnel. As of right now, it doesn't send out anything I change from regret window of ? day(s) to send immediately" - Where the setting is stored: the distribution config lives in the
aote-systemFirestore database atir56mDistributions/{signingRequestId}. The document is keyed 1:1 to the IR56M signing request and storesgraceMode,graceDays, immutablesignedAt, derivedsendAfter,completedRecipients,voidRecipients, andstatus(pending/sent/cancelled). - Root cause #1: Records → IR56M always used/api/accounting/ir56m/fillfor the PDF preview action. That regenerates the filled, unsigned form from filing data, so a signed row still opened an unsigned/filled PDF and the tooltip stayed "Preview the filled IR56M PDF". - Root cause #2: the distribution endpoint drained immediate sends only on initial POST/create. The PATCH/edit path recomputedsendAfterand saved the document, but did not call the drain. So changing a pending distribution fromgracetoimmediatemade it due but waited for the next cron run instead of sending inline. - What changed: addedGET /api/ir56m-signing/[requestId]/signed-pdf, which streams the archived signed Drive PDF through the authenticated app route. Records → IR56M now opens that route for signed rows and changes the tooltip to "Preview the signed IR56M PDF". The distribution endpoint now runs the same immediate-drain helper after both POST and PATCH; if an edit makes a pending distribution due now, the completed IR56M email is sent inline and the response is refreshed from Firestore. The edit modal now reports "saved and sent" when the returned distribution status issent. - Verification:git diff --checkclean;NODE_OPTIONS=--max-old-space-size=4096 npx tsc --noEmitclean. - Blast radius: Records IR56M preview action, one new signed-PDF streaming endpoint, and IR56M distribution POST/PATCH behavior. The distribution Firestore shape is unchanged.
2026-06-29 — signing status now mirrored onto the filing doc (was: signed shows "Draft") I-018¶
- ✅ Attestation: read
AGENTS.md; checked the board by scope, not UID — this is T-047's signing scope (status display), not a new task. Tracking T-047; opened ticket I-018 for the symptom. - Source: Records (Infrastructure) · https://claude.ai/code/session_018RDB37kCqfouHdygVXTAtD
- Owner report (verbatim, 2026-06-29): "the now signed IR56M forms are now signed but still shown as draft on the Records page. … how a IR56M that's marked draft could get marked to the 'complete' sort of stage?" then: "like the WOPCs, an IR56M's status should be stored on the IR56M firestore doc itself, is the IR56MRecipientStatus on the IR56M firestore doc … but not on a separate doc?"
- Finding: the filing doc DOES carry
recipients[].status(IR56MRecipientStatus), but it was seeded'draft'and never advanced — the signing flow wrote only to the separateir56mSigningRequestsdoc (the explicit "no doc-mirror" design inoperations.server.ts). The Records "Status" column reads the filing doc → always "Draft"; the "Signing" column reads the request → correct. So a signed form read "Draft". - Fix (this commit): mirror the lifecycle onto the filing doc itself, like WOPC. New
setRecipientStatusServer(filings.server.ts);recordSignature → 'signed'+recordVoided → 'draft'(signingRequests/operations.server.ts, best-effort + lazy-imported so a mirror failure never unwinds the transition);saveIR56MFilingServerguards a prior'signed'/'filed'against a form-save downgrade (beside the existing frozen-total guard). Mapping =draft ↔ signedonly (lowest-risk, directly fixes the report);'confirmed'(sent) +'filed'(distributed) left available to wire if the owner wants the fuller lifecycle. tsc clean repo-wide; runtime check is owner-on-deploy. - Blast radius:
recordSignature/recordVoided+saveIR56MFilingServernow also touchrecipients[].status; the Records "Status" column now reflects signing. Agents on T-047's signing flow should know the filing doc is now a status mirror (the request doc remains the engine).