feat(payroll): give payroll_draft_lines a production writer - #846
Conversation
`payroll_draft_lines` had no production writer at all. Its only writer was `scripts/stage_coss_group_payroll_readiness.sql`, a hand-run operational script — so every payroll run production code created had an empty roster, and once the close preflight learned to require `roster_total > 0` (#833), could never close. PayRun, the last step of the product order, was not runnable by the application. `roster::materialise_roster_in_tx` is a PORT of that script, called from `stage_draft_run_inner` so a run and its roster are created in one transaction. Deriving a second mapping alongside the script is how the two drift; that is the failure a previous bead was killed for. Four deliberate differences, each with a reason recorded at the call site: 1. SCOPE IS THE DECLARED PAY PERIOD, BY EQUALITY. The script scoped with `source_filename LIKE '2026/5월/%'` -- one operator's folder layout, and the only thing keeping the wrong month out of a roster. Migration 0224's `pay_period_*` replaces it. Equality, not overlap: an import declared for May is material for the May run, not for a run that straddles May. 2. NO `leave_remaining` ADMISSION DISJUNCT. The script admitted an employee with leave and no imported rows. Such a line carries no evidence, so it can only ever block the close it is counted toward. 3. NO RECONCILIATION DELETE. 0222 revoked DELETE on payroll_draft_lines from `console_rt` and asserts the revocation, so a delete raises 42501 at PLAN time and would kill every `payroll.create_run`, not just the re-stage. Retraction is a separate design. 4. THE EMPLOYEE-DRIVEN GROUPING IS KEPT. Review advised deleting it; that is wrong and the review's own residual-risk note says why. `data_import_rows.source_key` is `filename:…|sheet:…|row:…`, so grouping on it yields one line per SPREADSHEET ROW. The person key is `canonical_row->>'source_key'` joined to `employees.source_key`. A test pins it: two rows for one person make one line. CALLED FROM ALL THREE SUCCESS PATHS, never gated on `created`. A run whose header exists but whose roster was never written -- a previous attempt dying between the two -- would otherwise never acquire one, and is unclosable forever with no repair. A draft with no declared period writes nothing: there is no scope, and guessing one is the fabricated provenance 0224 removes. AN EMPTY ROSTER IS NOT AN ERROR. The drain leaves a failed event PENDING without incrementing `attempt_count`, so returning Err would be an unbounded hot retry. `close_preflight` already refuses an empty roster legibly with `명세 대상 없음(로스터 0명)`, which is where an operator should meet it. Seven tests against real PostgreSQL, each a NEAR-MISS with an exact count. The fixture SATISFIES 0166's writer guard rather than routing around it -- `console_leave_definer`, an armed `app.current_org`, and a same-transaction `data_import.apply` audit row -- and asserts the run actually reached APPLIED, because without the org GUC the transition matched zero rows and succeeded SILENTLY. Mutation-proven, every property: equality -> overlap -> the different-period test FAILS drop `run.status = 'APPLIED'` -> the unapplied test FAILS drop `row_status <> 'ERROR'` -> the same test FAILS non-blank -> key presence (all four flags) -> the blank-cells test FAILS admit everyone -> 4 of 7 FAIL restored -> 7 passed UNPROVEN AFTER THIS LANDS, and worth naming: `attendance_event_count` still has no writer, so 근태 원천 확보 is attested from payroll-workbook columns alone. The pay period is attributed and frozen but never verified against the rows it scopes. And one near-miss is weaker than I wanted: `employees.leave_remaining` cannot be set from a test (42501 `leave_write.command_required`), so the no-material case is proven without the leave balance that the deleted disjunct keyed on. The script is NOT retired here. G008's three text pins are still the only mechanical proof of the APPLIED / non-ERROR filters; retiring the script without moving them would leave that gate green over a file nothing runs. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Executive OverviewPR #846 delivers a critical fix to the payroll subsystem by providing a robust, production-grade writer for The implementation is an exact, disciplined port of 16-Lens Canonical Reasoning Evaluation
Critical Risks & Findings
Findings not addressable in the diff
🤖 [Reviewed] by Oyatie Anvil |
|
❌ Blocked — 6 finding(s) across 68 gates; 5 gate(s) produced no measurement.
🤖 [Blocked] by Oyatie Anvil |
jason931225
left a comment
There was a problem hiding this comment.
FAIL size @ ebe8d77
backend/crates/payroll/adapter-postgres/tests/roster_materialisation.rsis 386. Bar 100–300. Split the fixtures.
Checked: roster.rs 224 (in range), pay_run.rs +33 on inherited 945 (not this PR's split), one owner (payroll adapter), port of the existing script not a third mapping, tests wired into BUCK + postgres-cargo-map so they execute, no leftover oya- names, no new required check.
…est crate Keep the integration crate root and move fixtures into a #[path] module so no file is over 300. Not a second tests/*.rs crate. Writer behavior unchanged.
Executive OverviewThis incremental review evaluates the delta commits from The delta consists of a clean refactoring of integration test fixtures: extracting database seeding logic, test fixture definitions ( 16-Lens Canonical Reasoning Framework Evaluation
Critical Risks & Findings
🤖 [Reviewed] by Oyatie Anvil |
jason931225
left a comment
There was a problem hiding this comment.
PASS @ 70fc494
Size: roster_materialisation.rs 214, seed.rs 188, roster.rs 224 (writer untouched). pay_run.rs +33 on inherited 945 (not this PR's split).
Fit existing crate: writer + tests + BUCK/map/baseline wiring. seed.rs is a #[path] module of the same test crate, not a second binary.
One capability: payroll adapter-postgres. Port of the existing script, not a third mapping.
No leftover oya- names. No new required check. postgres-cargo-map recorded the shard.
Hold APPROVE.
Executive OverviewIncremental Review Delta: Commit This incremental update updates
This change ensures hermetic test sandboxing under Buck2 for all integration test suites verifying the new 16-Lens Adversarial Review Rubric
Critical Risks & Residual Risk Assessment
🤖 [Reviewed] by Oyatie Anvil |
`rustfmt check` failed on this branch after the helpers were split into
`tests/roster_materialisation/seed.rs`: the resulting `use seed::{...}` list
was hand-ordered, and rustfmt sorts uppercase constants ahead of lowercase
items. Formatting only; the seven tests still pass and their mutation proofs
are unchanged.
Executive OverviewIncremental Review Delta: SHA This incremental delta consists solely of a formatting / import-ordering adjustment in 16-Lens Canonical Reasoning Framework Evaluation
VerdictVerdict: 🤖 [Reviewed] by Oyatie Anvil |
|
🛠️ Merge Queue Self-Healing Applied:
Re-enlisting into GitHub Merge Queue... 🤖 [Healed] by Oyatie Anvil |
🟢 Pre-Merge Quality ApprovalAll automated review, documentation parity, clean architecture, and hyperscale safety gates have passed with 100% compliance. Certified for merge queue admission. 🤖 [Reviewed] by Oyatie Anvil |
|
🚀 Enlisted in Merge Queue:
🤖 [Enlisted] by Oyatie Anvil |
payroll_draft_lineshad no production writer at all — its only writer was a hand-run SQL script. So every payroll run production code created had an empty roster, and once the close preflight requiredroster_total > 0(#833), could never close. PayRun, the last step of the product order, was not runnable by the application.roster::materialise_roster_in_txis a port of that script, called fromstage_draft_run_innerso a run and its roster are created in one transaction. Deriving a second mapping is how the two drift — the failure a previous bead was killed for.Four deliberate differences
source_filename LIKE '2026/5월/%'— one operator's folder layout. Migration 0224'spay_period_*replaces it. Equality, not overlap: an import declared for May is material for the May run, not for one that straddles May.leave_remainingadmission disjunct. The script admitted employees with leave and no imported rows — lines that carry no evidence and can only block the close they're counted toward.console_rtand asserts it, so a delete raises 42501 at plan time and would kill everypayroll.create_run.data_import_rows.source_keyisfilename:…|sheet:…|row:…, so grouping on it yields one line per spreadsheet row. A test pins it: two rows for one person make one line.Called from all three success paths, never gated on
createdA run whose header exists but whose roster was never written — a previous attempt dying between the two — would otherwise never acquire one, and be unclosable forever with no repair. A draft with no declared period writes nothing: no scope, and guessing one is the fabricated provenance 0224 removes.
An empty roster is not an error. The drain leaves a failed event PENDING without incrementing
attempt_count, soErrwould be an unbounded hot retry.close_preflightalready refuses legibly with명세 대상 없음(로스터 0명).Mutation-proven
run.status = 'APPLIED'row_status <> 'ERROR'The fixture satisfies 0166's writer guard rather than routing around it —
console_leave_definer, an armedapp.current_org, and a same-transactiondata_import.applyaudit row — and asserts the run actually reached APPLIED, because without the org GUC the transition matched zero rows and succeeded silently.Unproven after this lands
attendance_event_countstill has no writer, so 근태 원천 확보 is attested from payroll-workbook columns alone. The pay period is attributed and frozen but never verified against the rows it scopes. And one near-miss is weaker than I wanted:employees.leave_remainingcan't be set from a test (42501leave_write.command_required), so the no-material case is proven without the leave balance the deleted disjunct keyed on.The script is not retired here. G008's three text pins remain the only mechanical proof of the APPLIED / non-ERROR filters; retiring it without moving them would leave that gate green over a file nothing runs.
🤖 Generated with Claude Code