promote(dev): fast-forward integration trunk from main - #860
Merged
Conversation
…ed rows (#834) `scripts/stage_coss_group_payroll_readiness.sql` is the **only** writer of `payroll_draft_lines` — there is no production Rust INSERT — so it is the sole path by which a payroll roster exists. Two holes, both measured against a live PostgreSQL with the real migrations applied. ## 1. A re-stage rewound a PAID run The run upsert ended with an unconditional `DO UPDATE SET status = 'BLOCKED_LEGAL_GATE'`, and the lifecycle drives runs through `ATTENDANCE_CLOSED → CALCULATED → APPROVED → DISBURSEMENT_SCHEDULED → ISSUED → PAID`. Re-running the script over any of those reset the status, rewrote `source_summary`, and — because the line upsert joins the returned run — rewrote every line's `attendance_source_row_count` and `source_data_import_row_ids` **underneath the `close_receipt` that attested them**, desynchronising the append-only `payroll_line_calculations` from the lines they were computed from. Measured, seeded run at `PAID`: | | | |---|---| | guard present | PAID → **PAID** | | guard removed | PAID → **BLOCKED_LEGAL_GATE** | A run past the pre-close states is now skipped entirely — it returns no row, so the line upsert joins nothing and its evidence is untouched. Doing nothing is correct; rewinding a paid run is not. The three admissible states are the ones `CLOSEABLE` already names in `lifecycle.rs`. ## 2. Unapplied and errored import rows counted as payroll source material `data_import_runs.status` admits `PREVIEWED/DRY_RUN/APPLIED/FAILED`; `data_import_rows.row_status` admits `CANDIDATE/PRESERVED/ERROR` (migration 0070). The source CTE filtered on **neither** — the only thing keeping unapplied rows out of a payroll roster was a human hand-typing one vetted `source_filename LIKE '2026/5월/%'`. Measured matrix, one org per case, **two positive controls** so the negatives cannot be vacuous: ``` APPLIED + CANDIDATE -> 1 line (positive control) APPLIED + PRESERVED -> 1 line (positive control) DRY_RUN + CANDIDATE -> 0 lines PREVIEWED + CANDIDATE -> 0 lines FAILED + CANDIDATE -> 0 lines APPLIED + ERROR -> 0 lines filter removed -> DRY_RUN org and ERROR-row org each materialise a line again ``` ## Pinned so it cannot regress Both fixes are pinned in the **G008 gate**, which already reads this file and runs at `ci.yml:1646`. Mutation-proven — removing any one of the three pinned clauses turns G008 red; restored, it passes 21 checks. ## Explicitly not fixed here `is_attendance_source` is JSONB **key presence** (`raw_row ?| array['출근', ...]`), so a row whose 출근 column is blank still counts as attendance material. Provenance now guarantees the rows were *applied*; it does not yet guarantee they *say anything*. That is the next piece, tracked in `console-4eo`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…olumn (#836) The five source-material flags were `raw_row ?| array['출근', ...]` — true when the workbook merely **has** the column, whatever the cell contains. `is_attendance_source` feeds `attendance_source_row_count`, which is exactly what the close preflight's **근태 원천 확보** check reads. So a blank attendance column satisfied the gate. ## Measured, two orgs identical but for one cell | 출근 | before | after | |---|---|---| | `'09:00'` | count = 1 | **1 line, count = 1** | | `''` (blank) | **count = 1** ← the hole | **0 lines** | After the fix a blank row produces *no roster line at all*: every source flag is false and there's no leave balance, so it fails the admission predicate. Combined with #833's `roster_total > 0`, a run whose rows are all blank now **cannot close at all** — rather than closing on evidence that says nothing. Mutation-proven: reverting `is_attendance_source` to the `?|` form brings the blank row straight back at count 1. ## This completes a chain | PR | stopped | |---|---| | #833 | an **empty** roster satisfying the gate | | #834 | **unapplied / errored** import rows becoming roster material | | this | **applied** rows that carry nothing | Provenance said the rows were real; substance says they contain something. The allowlist is unchanged — same Korean headers, same application. Only the test changed, from key presence to a non-blank value, with a `jsonb_typeof(...) = 'object'` guard so a non-object `raw_row` cannot make `jsonb_each_text` raise. ## A fence I had to look at first G008 already pinned this line as *"stage SQL classifies payroll/attendance source rows by allowlisted headers"* — but it pinned the `raw_row?|array[` **idiom**, so it also, accidentally, pinned key-presence as the test. The property it names is the **allowlist**, which this change preserves exactly. The assertion now matches the allowlist itself rather than the operator that consumed it. ## New pins, both mutation-proven - **`?|array` must not reappear** — reverting *any one* of the five flags to key presence turns G008 red. - **All five flags must carry the non-blank test, counted.** A plain `includes` check passed while four of five were weakened — the same shape of hole as the bug itself, so it's pinned by count. G008 passes 23 checks. Exit codes captured directly, not through a pipe. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
… not (#838) `load_payslip_issuance_in_tx` guards `run.status == "PAID"` (`lifecycle.rs:1351`) before selecting money for payslips. **Nothing pinned that guard** — deleting it left the whole suite green. ## A status assertion would not have caught it either `record_payslip_deliveries_in_tx:1406` raises the identical `invalid_state` error, so the caller still gets a clean **409** with the guard gone. What doesn't survive is the vault: `issue_payslips` emits inbox documents **outside the refusing transaction**, so a payslip for an *unpaid* run reaches the employee's inbox while the API reports refusal. So the new assertion counts **documents**, not status codes. ## Measured against real PostgreSQL | | | |---|---| | baseline | 4 passed / 0 failed | | delete `lifecycle.rs:1351-1353` | **FAILED** — *"a run at APPROVED must issue no payslip; the caller's 409 does not undo a document already emitted outside the refusing transaction"* (the 409 assertion stayed **green**) | | restore | 4 passed / 0 failed | | delete `validate_run_release_gate` | the pre-existing `legal_gate` assertion at `:416` reddens — that path is still independently pinned | | restore | 4 passed / 0 failed | ## Order is load-bearing, and the comment says so `emit_inbox_doc` dedups on `payroll-run:{run}:line:{line}`, so a leaked document created *after* the legitimate issuance collapses into it and `leaked == 0` goes vacuously true. The block sits **above** the PAID issuance for that reason. The release gate is registered before the probe so the PAID check is the guard actually under test — otherwise the request is refused by the legal gate and the test proves nothing about status — then removed again so the legal-gate refusal below still exercises its own path. ## What this deliberately does not do `payable` is still not consulted on issuance. Two designs that would have changed that were **refuted**: - **Gating on `payable`** blocks 100% of issuance *permanently* — `0222` re-grants INSERT on every column **except** `payable`, and no role in the tree may set it, so no row is ever payable. - **Giving it a writer** installs a second unwritable attestation that cannot ship a mutation proof until a release-gate writer exists — a false green. Blocking statutory pay is a worse failure than the gap. The gap stays open and tracked in `console-afw`. ## Correcting myself I earlier recorded that `payable` is read by nothing. **That was wrong** — my grep was truncated by `head`. It is read at `lifecycle.rs:744` (`BOOL_AND(payable)`), surfaced as `RunCalcSummary.payable`, and asserted at `run_lifecycle_api.rs:140`. It is *reported but not enforced at issuance* — a narrower defect than "read by nothing". 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
) Audited **all twelve** gate binaries by *running* each from an empty directory. Eleven resolve their root from `current_dir()`, so that is a real test of what they do with nothing to examine. Two passed: ``` console-gate-migration-safety: checking workspace at /tmp/empty console-gate-migration-safety: PASSED console-gate-pii-no-logs: checking workspace at /tmp/empty console-gate-pii-no-logs: PASSED ``` Both report *violations*, so zero files yield zero violations and the gate prints PASSED. The workspace holds hundreds of migrations and thousands of Rust files — zero means the scan didn't find them (moved directory, wrong cwd), never that the migrations are all safe or that nothing logs personal data. For a PII gate that is **absence of evidence reported as evidence of absence**, which is the entire thing the gate exists to assert. ## The rule already exists here `topology.canonical_enforcement` refuses to claim enforcement over zero tables. `tools/ci/gate-sweep.mjs` refuses a manifest declaring zero gates. `fanout.py`'s `_in_slice` denies an empty allow list. Payroll's close preflight did **not**, and an empty roster silently satisfied it (#833). This is that same defect — found by looking for it, rather than by being bitten again. ## Measured | | | |---|---| | empty tree, before | exit 0, `PASSED` | | empty tree, after | exit 1, *"examined no migration files … a gate that examines nothing cannot report safety"* / *"examined no Rust source files … cannot report absence"* | | real tree, after | exit 0 for both — **no legitimate run blocked** | Both `main.rs` already exit 1 on `Err`, so no entry-point change was needed. Each floor ships a regression test **and a positive control**, because a floor can be satisfied by a gate that refuses everything — which would block every migration change and every backend change, worse than the hole. Mutation-proven: deleting either `files.is_empty()` block turns that gate's suite red; restored, **17** and **7** pass. ## Not fixed here, and worth naming `console-gate-writer-ownership` resolves its root from `env!("CARGO_MANIFEST_DIR")` — a compile-time constant — so it cannot be pointed at a tree and this audit could not test it the same way. **A gate that cannot be aimed cannot be tested over an empty tree.** Its shared-authority ratchet (#829) does make it fail when nothing writes the receipt store, so the empty case is covered incidentally rather than by design. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…840) `.config/nextest.toml` has pinned cargo-nextest **0.9.138** since it landed, and `cargo_needs_postgres.sh` has accepted `--runner nextest` since #814. Nothing installed it, so every shard still ran under cargo. Measured on the heaviest multi-test target (`identity-rest-org-setup-pg`): **43.472s cargo → 18.525s nextest, 39/39 tests identical — 2.35×.** ## The recorded blocker was wrong The bead said the prebuilt tarball *"adds a network download the hardening model has no precedent for."* It has **two**: `security.yml` installs Trivy by fetching one exact URL and checking one exact sha256 before extracting — pinned byte-for-byte as `TRIVY_INSTALL` in `check-workflow-hardening.mjs` — and kubectl uses the same `curl` + `sha256sum --check` shape. This installer is modelled on Trivy's, so it's the *existing* posture, not a new one. `.config/nextest.toml`'s comment pointed at `taiki-e/install-action`, which this repo has never used. This script is what actually implements the pin it names. Rolled out to **domain-b only**, as the bead asks — concurrency is proven for one target, not all 209. ## What is pinned, and where The ci.yml step digest proves only that the job **calls** the installer; it says nothing about what the installer does. So the artifact is pinned separately *against the script itself*: exact URL, exact sha256, verification via `sha256sum --check`, and that it happens **before** `tar -xzf`. Without that second pin the digest would look like supply-chain protection while protecting nothing. Comment lines are excluded from those checks deliberately — a plain `includes("sha256sum --check")` was satisfied by the script's own prose, so deleting the real verification left the pin green. Caught by mutation; same shape of hole this gate exists to refuse. ## Mutation-proven (exit codes captured directly, not through a pipe) | mutation | result | |---|---| | domain-b silently reverted to cargo | preflight exit 1 | | installer sha256 swapped | exit 1 | | pinned URL swapped for a moving "latest" | exit 1 | | real verification line deleted | exit 1 | | verification moved *after* extraction | exit 1 — "must verify the sha256 BEFORE extracting" | | restored | exit 0 | Behaviour proven in a **linux/amd64 container**, not inferred: fetches, verifies, extracts, reports `cargo-nextest 0.9.138`; with a wrong hash it exits 1 and installs **no binary at all**. ## It cannot be a silent no-op The installer asserts the installed binary reports the pinned version. An installer that exits 0 having done nothing would drop the shard back to cargo and report a green run that measured nothing — and an untracked 45-byte stub whose entire body was `exit 0` has been sitting in a working tree since 2026-08-18. This replaces it with one that cannot lie. Ratchets moved **up**: run-step coverage 125 → 126, bypass matrix 375 → 378, because the new step goes through the same three mutations as every other. 61 preflight contract tests pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…#841) `ont_action_command_receipts` (0177) is the authority record for **all thirteen dispatch targets**, written by three production crates, and carried **no attribution at all**. #829 bounded *which crates* may write it; it could not express per-row attribution because there was no column to attribute to. `ReceiptOwner::owner_check_constraint_sql()` and `target_check_constraint_sql()` have generated the exact constraint bodies for this widening since the roster landed — but **nothing executed them**. They were referenced only by unit tests asserting the generated *string*, so they described columns PostgreSQL had never seen: a control that looks present and is not. This migration is the one specified in `canonical-domain/src/lib.rs`, and its two CHECK bodies are **copied from those generators**, not retyped. ## Why this shape A plain `ADD COLUMN owner TEXT NOT NULL` fails on any database already holding a receipt, and would break the live REST writer whose INSERT names its columns explicitly. It couldn't be repaired by a backfill `UPDATE` either — 0177's `BEFORE UPDATE OR DELETE` trigger RAISEs on every row. `ADD COLUMN … DEFAULT` is DDL (no row trigger fires), and `ADD CONSTRAINT` then validates pre-existing rows as they stand. ## Proven against real PostgreSQL, on a table holding a pre-existing receipt That's the case the naive form breaks — and the case an empty-table test would miss. **My first attempt did miss it**: an aborted seed left the table empty and the migration "passed" over nothing. ``` apply over 1 existing receipt -> exit 0; row becomes owner='ontology.action', target NULL canonical owner + valid target -> accepted canonical owner, NO target -> rejected ontology.action WITH a target -> rejected owner outside the roster -> rejected canonical owner + unknown target -> rejected ``` Those five are now a **test**, not a transcript. It drives them over `ReceiptOwner::ALL` and `DispatchTarget::ALL` rather than a hand-listed set, so a seventh object key can't be added without either passing here or failing loudly. Mutation-proven: dropping the `(owner = 'ontology.action') = (target IS NULL)` clause turns the test red. ## Wired, not merely written `check-executed-tests.mjs` caught the new file as executing nowhere — *"the repository has 1 more test that cannot fail"* — so it's declared in `TEST_RESOURCE_REQUIREMENTS`, its BUCK target regenerated, and its postgres-cargo-map entry added with a **measured** 3.3s rather than left to imputation. ## Deliberately not here Dropping the `DEFAULT`. That belongs with the change making the REST writer pass `owner` explicitly; dropping it now breaks that writer on the next deploy. Per-row attribution is now **expressible and database-enforced**, but no writer sets a canonical owner yet, so `console-yw0` stays open until they do. Authored as a PR for review per your decision on migration ownership — nothing here is applied to any live database. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
#844) `stage_coss_group_payroll_readiness.sql` scopes a payroll roster with `source_filename LIKE '2026/5월/%'`. `source_filename` is whatever the operator named the upload — `hr.rs` sets it from `upload.filename`, with no convention documented or enforced. **That literal is the only thing standing between a payroll roster and material from the wrong month.** Migration 0224 adds `pay_period_start`/`pay_period_end` to `data_import_runs`: NOT NULL, no DEFAULT, no sentinel, ordering CHECKed, immutable after insert. ## Why the run and not the row Three facts, each sufficient, all re-derived: - `data_import_rows` is **append-only** (`trg_data_import_rows_no_update`) — a mis-parsed per-row period could never be corrected - the only repair is re-import, and the staging script **SUMs across runs**, so re-importing *doubles* every hour and day figure - there's nothing reliable to parse: `지급일` is a **restricted** header, masked in the very preview meant to review it ## Why NOT NULL with no default, and why immutable No live data, nothing deployed — so no row to accommodate and no backfill to invent. A DEFAULT would let an upload acquire a period **nobody chose**, which is the fabricated provenance this column removes. `console_rt` holds table-wide UPDATE (0070:83, never revoked) and 0166's guard only inspects `entity_type` and INSERT-with-APPLIED, so a period-only UPDATE is permitted today. Without the trigger, the roster's scope would be a mutable, unaudited pointer on top of append-only material: the rows couldn't change, but *which rows a payroll run sees* could — silently. ## The upload readers had to be restructured first Both readers `return Ok(...)` from **inside** the `next_field()` loop the moment they had the file. Any part sent *after* `file` was silently discarded — a period field would have vanished with no error, and the upload would have failed later citing the wrong thing. Both now drain every part through one shared reader, then require the period. Missing or unparseable → 422, never a default. ## Blast radius, and why `cargo check` couldn't find it All thirteen `INSERT INTO data_import_runs` sites are plain `sqlx::query`, so a missing NOT NULL column **compiles cleanly and fails at runtime with 23502**. All thirteen updated — six in `hr.rs`, six in tests, and `import_coss_group_workbooks.py`, which now takes an explicit `--pay-period-start/--pay-period-end` rather than deriving them from the folder path (deriving would rebuild the exact coupling this removes). ## Proven against real PostgreSQL | case | result | |---|---| | declared period | accepted *(positive control)* | | no period at all | refused, 23502 | | end before start | refused by CHECK | | period changed after insert | refused by trigger | | status changed, period untouched | still allowed | **Mutation-proven with forced recompilation** — `#[sqlx::test(migrations=…)]` embeds the SQL at *compile* time, so editing a `.sql` alone doesn't rebuild, and my first matrix reported two false passes: ``` drop NOT NULL from both columns -> "no period" test FAILS neuter the ordering CHECK -> ordering test FAILS detach the immutability trigger -> immutability test FAILS restored -> 4 passed ``` Five existing PostgreSQL suites that seed import runs still pass. ## Scope This is the **ledger contract only**. The roster writer that consumes the period is deliberately not here: adversarial review found that shipping both retires the G008 text pins that are currently the *only* mechanical proof of the APPLIED / non-ERROR provenance filters — leaving that gate green over a file nothing runs. The writer needs its own pins and its own PR. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
`payroll_draft_lines` had **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 required `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 is how the two drift — the failure a previous bead was killed for. ## Four deliberate differences 1. **Scope is the declared pay period, by equality.** The script used `source_filename LIKE '2026/5월/%'` — one operator's folder layout. Migration 0224's `pay_period_*` replaces it. Equality, not overlap: an import declared for May is material for the May run, not for one that straddles May. 2. **No `leave_remaining` admission 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. 3. **No reconciliation DELETE.** 0222 revoked DELETE from `console_rt` and asserts it, so a delete raises 42501 at *plan* time and would kill every `payroll.create_run`. 4. **The employee-driven grouping is kept.** Review advised deleting it; that's 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**. 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 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`, so `Err` would be an unbounded hot retry. `close_preflight` already refuses legibly with `명세 대상 없음(로스터 0명)`. ## Mutation-proven | mutation | result | |---|---| | equality → overlap | different-period test **FAILS** | | drop `run.status = 'APPLIED'` | unapplied test **FAILS** | | drop `row_status <> 'ERROR'` | same test **FAILS** | | non-blank → key presence (all four flags) | blank-cells test **FAILS** | | admit everyone | **4 of 7 FAIL** | | restored | 7 passed | 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**. ## Unproven after this lands `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` can't be set from a test (42501 `leave_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](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
G008 carried **twelve pins on the staging script and none on `roster.rs`**. That was right while the script was the only writer of `payroll_draft_lines`. It stopped being right at #846 — `roster::materialise_roster_in_tx` now runs on every `payroll.create_run`, and the pinned file is no longer on the production path. So the mechanical proof of *"what may become a payroll roster"* sat entirely on one encoding while a second encoding did the actual work. Nothing stopped the two drifting, and G008 would have stayed green while the executed one weakened. The tests from #846 do bind these predicates — but from the outside. A text pin is what makes a **reviewer** see the divergence in the diff. ## Six pins, each mutation-proven against the writer | mutation | result | |---|---| | drop `run.status = 'APPLIED'` | exit 1 | | drop `r.row_status <> 'ERROR'` | exit 1 | | equality → overlap on the period | exit 1 | | weaken **one of four** non-blank flags | exit 1 | | reintroduce `?|array` | exit 1 | | add a reconciliation DELETE | exit 1 | | restored | exit 0, **29 checks** | The non-blank pin is **counted**, not `includes` — a plain `includes` passes while three of the four flags are weakened. That's the same hole I shipped once already in #836 and had to fix. ## Deliberately not re-pointed from the script Two of the script's pins would be actively harmful here. `requireMatches(/raw_row\?\|array\[/)` asserts the **key-presence idiom** — the exact fabrication vector where a blank `출근` cell counts as attendance material. Re-pointing it at the writer would make CI *require the bug*. The writer is pinned on the non-blank form instead, plus the **absence** of `?|array`. The DELETE pin isn't stylistic: 0222 revoked DELETE on this table from `console_rt` and asserts the revocation, so a reconciliation delete raises **42501 at plan time** — killing every `payroll.create_run`, not just the re-stage that introduced it. ## The script keeps its twelve pins It's still the operational hand-run path. Retiring it is a separate change, and not a trivial one: reducing it to a read-only query would leave all twelve pins passing over a file that writes nothing — a false green rather than a removal. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Migration 0223 gave `ont_action_command_receipts` an `owner` and a `target`, but **no writer set them**. `owner` DEFAULTs to `'ontology.action'`, so every canonical receipt — Company revise, HR appoint, PayRun decide — claimed to belong to the pre-existing instance-action path. That's worse than the column not existing. An absent column is a known gap; a populated one **reads as an answer**, and this one answered wrongly for six of the seven writers. ## Derived from the command's query, not `action_key` Every canonical query already implements `dispatch_target()` — the same value the projected-dispatch path uses — so attribution is a lookup, not a second mapping that can drift: ```rust let receipt_target = command.query.dispatch_target(); let receipt_owner = ReceiptOwner::Canonical(receipt_target.object()); ``` **`action_key` cannot do this job, and I tried it first.** Its own doc says it is *"unique only per object type"*: these commands carry a bare `"revise"`, which names no target at all, and `employment.rs` reassigns org units under `"internal.reassign_org_unit"`, which is not a dispatch target by construction. Parsing it would have failed closed on a real production path. `ontology/rest` is deliberately **unchanged** — it *is* the instance-action path, the rows the DEFAULT was written for, so `'ontology.action'` with a NULL target is the truth there, and 0223's CHECK requires exactly that pairing. ## Proven against real PostgreSQL | | | |---|---| | a real Company port write | `owner='company'`, `target='company.revise'` | | every `DispatchTarget` | storable under its owning object — looped over `DispatchTarget::ALL`, so a fourteenth target can't arrive unattributed | | canonical receipts | none fall back to the instance-action default | **Mutation-proven:** removing the two binds from `company.rs` turns the port test red with `left: "ontology.action"` — the exact wrong attribution this fixes. All five canonical port suites, the PayRun port suite and the widening suite pass. ## What this does not do The DEFAULT stays — dropping it belongs with the change that makes `ontology/rest` pass `owner` explicitly, and dropping it now breaks that writer on the next deploy. Per-row attribution is now **true where it is written**, but nothing yet stops a crate writing a row it doesn't own. That's the receipt store's writer boundary — bounded to three crates by #829, still without per-row enforcement (`console-yw0`). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…853) #840 put **one** shard on nextest to measure it. Measured on merge_group run 32402124838, the pilot against its nearest neighbour: | shard | targets | runner | wall | |---|---|---|---| | domain adapters **B** | 45 | **nextest** | **6m** | | domain adapters A | 40 | cargo | 15m | More tests, less than half the wall clock — the 2.35× benchmark reproduced on real CI against a bigger shard. The pilot has done its job, so the remaining four shards move to the same hash-pinned installer and runner. ## What this does not do — and the measurement matters more than the change The critical path of a merge_group run is **not** the shards: ``` Backend — fmt / clippy / test / gates 17m <- critical path Test PostgreSQL — platform 15m Test PostgreSQL — domain adapters A 15m Test PostgreSQL — app 15m ``` Taking the shards to ~6–7m leaves wall clock pinned near **17m by Backend**, whose own 17m is an accumulation rather than a hot spot: 5m dev-auth PG suites, 3m clippy, 3m console-app unit, ~4m setup, and 24 further steps at or under a minute. Splitting it is the next lever and a much larger ci.yml change. A second measurement, taken locally while checking isolation: the **platform shard runs 154 tests in 263s, and 263s of that is one test** — `console-gate-writer-ownership::census_executes_against_postgres`. No runner change takes that shard below ~4.5m. Shards are not uniformly runner-bound, and the remaining gains are not evenly distributed. ## Isolation — the actual risk, and why it was piloted nextest runs test binaries in **parallel** where cargo ran them serially, so a suite sharing state would newly fail. Verified locally before pushing: the platform shard passes **154/154** under nextest, with the cluster-global serial group in `.config/nextest.toml` doing its job. Ratchets moved **up**: run-step coverage 126 → 130, bypass matrix 378 → 390, because each new install step goes through the same three mutations as every other. 61 preflight contract tests pass. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Measured on merge_group run 32402124838: CI wall clock ~19m, and this one job was the **17m critical path**. After #853 moved every PostgreSQL shard to nextest (now 4–8m each), Backend is the only thing holding the number up. Its 17m isn't a hot spot — it's an accumulation. Three steps are **757s of ~1,070s** (dev-auth PG suites 355s, clippy 217s, console-app unit 185s); the other 25 steps are all under 82s. So the lever is parallelism, not speed. ## Three legs | leg | steps | |---|---| | `cargo` | rustfmt, clippy, the 11 cargo gates, mutation suites, PR 473 tests, boot smoke (16) | | `buck-app` | platform-authz unit, console-app unit, OpenAPI drift, console-app inline PG (4) | | `buck-dev-auth` | the dev-auth feature PostgreSQL suites (1) | Setup, topology reconcile and collect-failures run on every leg (7). **Every one of the 28 steps lands on exactly one leg or on all three** — verified by parsing the workflow, not by reading it. `fail-fast: false`, so one red leg never hides the others. ## Why a matrix and not separate jobs Separate jobs were designed and **refuted**: they touch every pinned contract structure and create two false-green surfaces — a new job not wired into `required-ci`'s `needs` runs and gates nothing; a `steps.topology.outcome` condition carried into a job with no topology step skips forever while the job stays green. A matrix keeps the job id, its `required-ci` membership, and every step's place in the bypass-mutation matrix exactly where they were. **Ratchets do not move** (130 / 390 after #853): no step was added, removed, or renamed. ## The cache — and a pre-existing hole this closes rust-cache has *one writer* by contract. **It had two.** `migration-expand-contract` carried `save-if: main` under a comment reading *"The ONLY writer"* — copied from `backend` when that job was split out in #817, and false in place ever since — and was absent from `cargoRustCacheJobs`, so the one-writer check never saw it. Now restore-only and listed. With a matrix, "one writer" also stops being enough: three legs saving the same key race on every main push, and the textual `save-if: false` test can't tell one leg from three. The writer is now exactly the `cargo` leg, enforced by a new assertion. ## Two new assertions, both mutation-proven, both from the refutation | mutation | result | |---|---| | rename a leg in the matrix, leave a step's `if:` stale | *"names matrix leg 'buck-app', which is not in strategy.matrix.leg — that step would run on ZERO legs"* | | writer's `save-if` no longer leg-scoped | *"save-if must name exactly ONE matrix leg"* | | restored | preflight exit 0 | Without the first, a coordinated matrix rename plus hash recompute could leave a proof step running on **no leg at all** while every leg, `required-ci`, and `check-executed-tests` stayed green. Preflight exit 0 · contract suite **61/61** · gate sweep 13/13 · workflow-hardening and executed-tests exit 0 · clippy→first-gate adjacency holds on the cargo leg. ## Predicted, not measured Per-leg setup ~55s. Design estimate: cargo ~6.7m, buck-dev-auth ~7.0m, buck-app ~8–10m — the last because app-unit's 185s was measured **warm** after dev-auth had built the platform crates in the same daemon, and buck-out doesn't cross runners. If that holds, Backend drops 17m → ~8–10m and wall clock lands near **10m**. That is a prediction until this PR's own CI run measures it. **5m is not reachable by this change alone**: the platform shard carries one 263s test, and the buck-app leg pays a cold platform build. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…hing (#857) "Domain crates — unit tests" is CI's critical path at **12m** — one step worth 714s. **It was never a slow test suite.** On merge_group run 32434156418 that step is **684.8s of rustc** (1434 `Compiling`, 0 `Fresh`) and ~29s of actual test execution: the 1156-test lib sweep in 1.1s, 18 doctests in 14.3s, everything else sub-second. It was a cold build on every run, masquerading as a slow job. ## The cause rust-cache logged `No cache found.` on **all 14 recent main runs**. The key it asked for was `…-b587c171-`; the writer and all five PostgreSQL shards restore `…-f3304cdf-` with an identical lockfile half. The env half differed by exactly two variables — `CARGO_PROFILE_DEV_DEBUG=0` and `CARGO_PROFILE_TEST_DEBUG=0` — which rust-cache folds into its hash, which 03e7292 added to the shards on 2026-08-18 to fix **this same miss there**, and which `check-ci-preflight.mjs` *forbade* on this job: ```js if (/^ (?:env|defaults):/m.test(domainUnit) ...) failures.push("domain-unit must use the default shell with no job or step env/defaults overrides"); ``` A blanket "no env" ban is what kept the miss in place. The contract was protecting the shell; the side effect was a 685s build per run. ## The fix Two lines of YAML and a narrower ban. The job gains the same env block the shards carry, with the same rationale. The contract no longer forbids job env — it **requires** it to be exactly those two variables, and still forbids step-level env and any `defaults:`. Failure text unchanged, so existing tests keep matching. ## I went in believing the lever was nextest Measured locally on a warm build, the lib sweep is 57.4s under cargo vs 2.08s under nextest — a 27× ratio that turned out to be a **macOS process-spawn artifact** applied to what is, on the runner, about one second of execution. Honest nextest gain here: 10–20s. It would also have opened `.config/nextest.toml` as a second, *unpinned* selection surface for 141 binaries — a profile `default-filter` narrows what runs while the verbatim-pinned ci.yml text is unchanged and `check-executed-tests` keeps reporting everything executed. An adversarial review found both. **No nextest, no new run step, no change to the pinned cargo invocations, ratchets unchanged at 130/390.** ## Proven Preflight exit 0 · contract suite **61/61** · gate sweep 13/13 · executed-tests 0. The env requirement can **fail**: dropping `CARGO_PROFILE_DEV_DEBUG`, appending `RUSTC_WRAPPER`, or re-adding `defaults:` each turns preflight red with the pinned text; restored, exit 0. The envelope digest was recomputed with the preflight's own serializer, not copied. ## Predicted, not measured A warm restore should take this step from ~714s to near the ~30s of real execution plus restore time. **That is the first run's job to confirm.** If it holds, the 12m critical path drops out entirely and wall clock is set by buck-app (11m) and the platform shard (9m). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Agent working directories were tracked and pushed, which the
no-dot-agent-directories rule forbids: `.grok` (41 files), `.claude`
(9), `.cursor` (53), `.codex` (2), `.beads` (10), `.omx` (4).
## Load-bearing content
Unlike oyatie's, console's `.grok` held no source that other code reads.
Its one code reference — `tools/ci/ingest-soft-reds.mjs` — **wrote** to
`.grok/harness/lane-board.live.json`, a generated artifact that was
already gitignored. That output is redirected to `ci/harness/` so no
tool points into an agent directory.
## Untracked, not deleted
`.claude`, `.cursor`, `.codex`, `.beads` and `.omx` remain **on disk** —
agent tools read those exact paths. The requirement is that they not
reach GitHub. `.grok` is gone; nothing consumed it.
Everything is preserved at
`refs/preserved/{grok,claude,codex,cursor,beads,omx}` on this remote.
Recover with `git checkout refs/preserved/<name> -- .`
`.omx` needed a second pass: the first sweep enumerated only the six
dot-directories then known, which is exactly how a stale allowlist lets
something through.
## Tracked hooks
`.githooks/` is tracked and reviewable, and delegates to local untracked
hooks (`.beads/hooks/<name>`, `.git/hooks/<name>.local`), propagating
their exit codes — so per-developer tooling keeps working while stopping
being the only thing between a mistake and the remote.
- `pre-commit` refuses staged agent-directory files and staged
gitignored files
- `pre-push` refuses a workspace that does not compile
Enable with `git config core.hooksPath .githooks`.
Verified by firing them: a staged `.claude` file was refused, a local
delegate ran, and a delegate exiting 3 propagated 3 rather than 0.
## Note
`.beads/` carried executable hooks (`post-checkout`, `pre-commit`). They
remain on disk and work, but wiring them from a tracked location is
follow-up work — a hook that exists only on the machine that wrote it is
a local habit, not enforcement.
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
## Summary - `generated-face-authority` moves off `macos-latest` (10×) onto Linux. ADR-0039 already names deleting that macOS job; this is the cheap cut. - Backend cargo leg also runs on `ubuntu-24.04-arm`. Buck legs stay amd64 (graph honesty, not rustc of the product). - Preflight envelope hashes updated; `node scripts/check-ci-preflight.mjs` and its unit tests pass. Cheap local vs this PR: rustfmt stays a local/backend step; image builds stay `image-release.yml` (CD).
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Integration trunk is
dev.mainis 15 commits ahead and is where merges have been landing. This PR is a no-diff promotion sodevbecomes the tip. Next: default branch =dev;staging>canary>productiononly from the predecessor rung.