feat(oferta/apply): jurisdiction-prohibited content signal — table + Block G + apply-form warning - #2020
Conversation
📝 WalkthroughWalkthroughAdds jurisdiction-aware compliance checks, hardens batch and lock handling, introduces content-based cadence statistics, adds interview workflow signals, and adds an Interamt scanner with pipeline date parsing. ChangesJurisdiction-aware compliance workflows
Batch, cadence, and lock reliability
Interview workflow enhancements
Interamt scanning and pipeline presentation
Estimated code review effort: 5 (Critical) | ~120 minutes Possibly related issues
Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 3 | ❌ 2❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
Ten days is too long for this to have sat without a direction, @Schlaflied, and I'm sorry. Santiago and I went through the whole set this evening and the line we landed on is written up in #2280. This one is on the right side of it and lands. It describes a pattern rather than asserting a figure that changes on a schedule, so it stays correct without anyone maintaining it. You'll need a rebase where it's drifted, and it still gets a normal review. But the direction question is answered: no more waiting on that. Worth saying, because the reasoning matters more than the verdict here: the line is where it is because of what I can verify, not because of what's valuable. I can check code, tests and behaviour. I cannot check that a regulation says what a row claims. That's my limit, and it's the honest reason a numeric row is treated differently from a descriptive one. |
…Block G + apply-form warning (closes career-ops-hq#2018) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
e00472c to
a0917aa
Compare
There was a problem hiding this comment.
Actionable comments posted: 2
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@templates/jurisdiction-prohibited-content.yml`:
- Around line 54-57: Replace the Ontario and California source labels in
templates/jurisdiction-prohibited-content.yml with independently citable source
titles paired with stable canonical locators. Update the entryOk validation near
test-all.mjs lines 2170-2179 to require the expected citation structure and
validate each locator, rather than accepting merely non-empty arrays; apply the
corresponding changes at all three listed sites.
- Around line 37-52: Update the matching description to trigger only when
Canadian experience is explicitly required, removing “prefers” and preserving
exclusions for legitimate location, authorization, credential, and genuine
market-knowledge content. Revise legal_basis to cite Ontario Employment
Standards Act, 2000, s. 8.3(1), and remove the unsupported Working for Workers
Five Act reference.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: e18bef88-ede1-42fc-ad50-77fcb33e2d0b
📥 Commits
Reviewing files that changed from the base of the PR and between e00472c0e9bf9cee2dcf9cdc26f132350669de79 and a0917aa.
📒 Files selected for processing (5)
modes/apply.mdmodes/oferta.mdtemplates/README.mdtemplates/jurisdiction-prohibited-content.ymltest-all.mjs
| matching: >- | ||
| Fires when the posting/form requires or prefers "Canadian experience" | ||
| or an equivalent formulation (e.g. "Canadian work experience required", | ||
| "must have experience in the Canadian market" used as a proxy for | ||
| work-history location rather than genuine market knowledge). Does NOT | ||
| fire on legitimate location/authorization content: being legally | ||
| entitled to work in Canada, being located in Canada, holding a | ||
| Canada-specific license or credential, or knowledge-of-market | ||
| requirements that are plausibly genuine for the role. Applies to | ||
| publicly advertised postings; scope exemptions exist and are not | ||
| verifiable from the JD — which is one reason the output states facts | ||
| about the posting text only. | ||
| legal_basis: >- | ||
| Ontario Employment Standards Act, 2000 amendments introduced by the | ||
| Working for Workers Four Act, 2024 and Working for Workers Five Act, | ||
| 2024, together with O. Reg. 476/24 |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Use the precise Ontario prohibition and do not match mere preferences.
The matcher fires on “prefers,” but ESA s. 8.3(1) prohibits requirements related to Canadian experience; the supplied basis does not establish that a preference alone is covered. It also omits s. 8.3(1), the controlling provision, and unnecessarily includes Working for Workers Five for this prohibition. This can produce an unsupported legal signal. Restrict matching to actual requirements unless a source supports the broader scope, and cite ESA s. 8.3(1) precisely. (ontario.ca)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@templates/jurisdiction-prohibited-content.yml` around lines 37 - 52, Update
the matching description to trigger only when Canadian experience is explicitly
required, removing “prefers” and preserving exclusions for legitimate location,
authorization, credential, and genuine market-knowledge content. Revise
legal_basis to cite Ontario Employment Standards Act, 2000, s. 8.3(1), and
remove the unsupported Working for Workers Five Act reference.
| sources: | ||
| - "Fasken client bulletin on Ontario's job-posting requirements in force January 1, 2026" | ||
| - "Littler insight on Ontario's Working for Workers Four/Five Acts job-posting rules (2026)" | ||
| - "Hicks Morley FTR Now bulletin on the ESA job-posting requirements effective January 1, 2026" |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Make the required legal sources independently citable and enforce it.
The current source values are topic descriptions, not locatable citations, and entryOk accepts them solely because the arrays are non-empty.
templates/jurisdiction-prohibited-content.yml#L54-L57: replace the Ontario labels with identifiable source titles and stable canonical locators.templates/jurisdiction-prohibited-content.yml#L76-L78: replace the California labels with identifiable source titles and stable canonical locators.test-all.mjs#L2170-L2179: validate the citation structure/locator so future entries cannot satisfy the contribution rule with arbitrary text.
📍 Affects 2 files
templates/jurisdiction-prohibited-content.yml#L54-L57(this comment)templates/jurisdiction-prohibited-content.yml#L76-L78test-all.mjs#L2170-L2179
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@templates/jurisdiction-prohibited-content.yml` around lines 54 - 57, Replace
the Ontario and California source labels in
templates/jurisdiction-prohibited-content.yml with independently citable source
titles paired with stable canonical locators. Update the entryOk validation near
test-all.mjs lines 2170-2179 to require the expected citation structure and
validate each locator, rather than accepting merely non-empty arrays; apply the
corresponding changes at all three listed sites.
…manently disable stale-lock recovery
…ck so a just-created lock can't be stolen
…rch before Block 4
… misread as columns
…pointer for agency-mediated postings (closes career-ops-hq#2037)
…iction table + posting signal + form warning
…ed to 12, test to section 69)
|
And that completes all six descriptive jurisdiction signals from the batch, @Schlaflied: salary-history in forms was the sharpest gap of the set (forms carry it far more than JD text, as you noted). The salary-expectations vs salary-history distinction in the matching guidance is what keeps this from false-firing on lawful screening. Renumbered to signal 12 in the merge. Merged. 🚀 |
There was a problem hiding this comment.
Actionable comments posted: 19
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
batch/batch-runner.sh (1)
449-493: 🩺 Stability & Availability | 🟠 Major | ⚡ Quick winReservation failure isn't checked before proceeding.
reserve_report_num_unlockedreturns an empty string whennode reserve-report-num.mjsfails (throws/crashes; stderr is discarded), butprocess_offernever checks for that before building the prompt and launching a fullclaude -pworker. The offer only gets caught later by the report-file-existence check (line 696), after wasting a worker invocation with a blank{{REPORT_NUM}}and a malformed log filename ($LOGS_DIR/-${id}.log).🔧 Proposed fix: fail fast right after reservation
local report_num report_num=$(reserve_report_num "$id" "$url" "$started_at" "$retries") + if [[ -z "$report_num" ]]; then + local reserve_failed_at + reserve_failed_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) + update_state "$id" "$url" "failed" "$started_at" "$reserve_failed_at" "-" "-" "failed to reserve a report number" "$retries" + echo " ❌ Failed to reserve a report number for #$id ($url)" + return 0 + fi local date date=$(date +%Y-%m-%d)🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@batch/batch-runner.sh` around lines 449 - 493, Make process_offer fail fast when reserve_report_num returns an empty reservation. Immediately after the report_num assignment, validate that it is non-empty; if reservation failed, log or return using the existing failure-handling path and do not build the prompt or launch the claude worker. Keep successful reservations flowing unchanged through the existing processing logic.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@followup-cadence.mjs`:
- Around line 290-297: Update analyzeFromContent to accept an explicit
overdueOnly parameter defaulting to false, and stop reading the shared
process.argv --overdue-only state within this reusable content-based flow. Pass
the parameter through to the result-entry filtering and ensure
computeColdAppNums receives the complete entries by default, while preserving
CLI behavior by explicitly supplying the flag value at the command-line call
site.
In `@invite-match.mjs`:
- Around line 272-276: Update the PLATFORM_URL_PATTERNS port matching so each
platform regex accepts only ports in the valid 0–65535 range, rejecting values
such as :65536 before classification. Add a regression case covering an invalid
:65536 URL and preserve the existing silence behavior for malformed links.
In `@merge-tracker.mjs`:
- Around line 39-69: Update loadFailedReportNumbers to resolve each report
number by its latest batch-state row rather than collecting every failed row;
when a later completed or otherwise non-failed row reuses the number, remove or
exclude it from FAILED_REPORT_NUMBERS. Preserve ignoring malformed, header,
missing, and placeholder report-number rows.
In `@modes/interview-redflag.md`:
- Around line 102-106: Update the “Evidence tiers” rules in the interview
red-flag mode so a single unequivocal protected-ground question has an explicit
deterministic outcome: report it with its grounding and legal basis, but
classify it as a single observation that is not scored. Remove motive, intent,
and Japan-specific rationale, and apply the same rule consistently in the
related Step 2/Step 3 guidance while generating claims only from approved source
files or the current conversation.
In `@modes/interview/debrief.md`:
- Around line 64-77: Update the contradiction-handling guidance in the debrief
workflow to distinguish interviewer-confirmed facts from candidate recall. Only
apply in-place strikethrough corrections and “confirmed” wording when supported
by an explicit interviewer statement or transcript; otherwise retain the
existing cited fact and record the discrepancy as candidate-reported/unverified.
In `@modes/interview/plan.md`:
- Around line 77-80: Update the existing-prep-file decision in the interview
planning instructions so file existence alone is not sufficient: inspect Step
1/Step 3 for sourced questions applicable to the current round’s audience, reuse
them when available, and run the scoped Step 1 WebSearch queries when they are
absent or unusable. Preserve the existing tagging conventions and explicitly
report genuinely empty search results before falling back to JD/profile
inference.
In `@pipeline-lock.mjs`:
- Around line 84-99: Recovery guards must use verifiable ownership rather than
age alone. In pipeline-lock.mjs lines 84-99, write PID/token metadata for
recovery guards and have lockCanRecover use owner liveness; in followup-seed.mjs
lines 338-348, only recover or remove a guard after validating its owner/token;
in tracker-utils.mjs lines 226-247, keep guard ownership validation separate
from metadata-free lock-age fallback. Extend followup-seed-tests.mjs lines
403-429, test/pipeline-lock.test.mjs lines 199-222, and
tracker-writer-lock-tests.mjs lines 537-569 with delayed live-holder coverage
beyond the grace threshold, preserving locking and data-integrity guarantees.
In `@scan-interamt.mjs`:
- Around line 62-76: Validate the shapes of config.interamt_searches,
titleFilter.positive, titleFilter.negative, and the location-filter fields
before calling map or consuming them in scan-interamt.mjs. Reject non-array or
otherwise malformed values with a clear configuration error, and validate that
the resulting keywords list is non-empty before continuing startup; preserve the
existing defaults for omitted fields.
- Line 259: Make deduplication atomic with pipeline insertion by adding a helper
around appendToPipeline() that reloads seen URLs while holding its lock, filters
offers against that locked snapshot, writes only unseen offers, and returns the
accepted offers. Update the scan flow near loadSeenUrls() and the history
persistence sections around the referenced blocks to use this returned set,
preventing duplicate pipeline and history rows across concurrent scans.
- Around line 312-315: Update the error handling in the keyword scan catch
blocks of the main scan flow to ensure the process exits with a nonzero status
when any scan fails. Preserve collecting each failure in errors and printing the
existing error output, then set the final process status based on whether errors
contains any entries, including the corresponding catch block around the
additional scan path.
- Around line 220-236: Update the pagination loop around loadMoreBtn so it stops
immediately when clicking “load more” produces no row growth. Capture whether
the waitForFunction condition succeeds, and break when it times out or the row
count remains at or below beforeCount; preserve the existing visibility check
and successful pagination behavior.
In `@templates/immigration-status-requirements.yml`:
- Around line 104-108: Update the sources entries in the immigration-status
requirements data, including the DOJ/IER and Canada OHRC/law-firm references,
with exact source titles, publication dates, and stable URLs or identifiers.
Replace generic descriptions with individually reproducible citations while
retaining the as_of verification context.
- Around line 32-51: Resolve each rule by applicable role, worksite,
recruitment, or service nexus rather than config/profile.yml residence. Add
applicability metadata to templates/immigration-status-requirements.yml:32-51,
templates/agency-licensing.yml:30-44 and :58-70, and
templates/protected-grounds.yml:36-54, then update the guidance in
modes/apply.md:81-83, modes/oferta.md:334-340, :352-358, :370-376, and
modes/interview-redflag.md:96-100 to gate signals using those scopes; preserve
the Haseeb outcome for work in Sarnia, Ontario even when the applicant or
operator resides elsewhere.
In `@templates/protected-grounds.yml`:
- Around line 74-126: Expand the Ontario ground entries in the grounds
configuration with the missing section 24 legitimate_contexts for race, sex
(including pregnancy), marital status, age, disability, and record of offences,
following the cited OHRC guidance. Update the record-of-offences example to
distinguish unpardoned Criminal Code convictions from pardoned convictions and
provincial offences, while retaining narrow job-related inquiries and existing
contexts.
In `@templates/README.md`:
- Around line 18-19: Keep the jurisdiction-prohibited-content.yml row contiguous
with the surrounding Markdown table by removing the blank line before it, and
update its modes/oferta.md reference from Block G signal 10 to signal 12.
In `@test-all.mjs`:
- Around line 2173-2185: Expand the validation routines covering agency
licensing, protected grounds, and prohibited content to iterate over every
contributed row rather than only the current seed keys. For each row, enforce
the complete expected schema, require strict date formats including agency
`as_of`, and reject duplicate jurisdiction or lookup keys; retain the existing
CA-ON/JP and other seed-specific assertions as additional pins.
- Around line 2207-2209: Update the section extraction checks around
alStart/alEnd and the corresponding immigration and Step 5d slices so each end
boundary is the earliest next section heading, not a broad later marker. Ensure
the agency, immigration, and Step 5d contract checks exclude subsequent Signals
or Step 5c content while preserving the intended section text.
In `@web/src/lib/career-ops.ts`:
- Around line 81-91: Update the postedAt assignment in the jobs parsing flow to
validate posted as a real calendar date, not just a YYYY-MM-DD-shaped string.
Parse the value and round-trip it to the same year, month, and day before
assigning postedAt; otherwise leave it undefined so the scan-history fallback
remains available.
- Around line 50-63: Restrict LABELED_SEGMENT and the positional parsing logic
in the pipeline-row parser to the contract-defined labels posted, trust, and
note instead of treating every word-prefixed segment as metadata. Preserve valid
values such as “Remote: Germany” as positional location data and prevent
labeled-segment filtering from shifting compensation into location; require any
future labels to be added explicitly to the parser contract.
---
Outside diff comments:
In `@batch/batch-runner.sh`:
- Around line 449-493: Make process_offer fail fast when reserve_report_num
returns an empty reservation. Immediately after the report_num assignment,
validate that it is non-empty; if reservation failed, log or return using the
existing failure-handling path and do not build the prompt or launch the claude
worker. Keep successful reservations flowing unchanged through the existing
processing logic.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro Plus
Run ID: 4f76487e-7653-429c-a531-229ac79d0716
📒 Files selected for processing (33)
AGENTS.mdbatch/batch-prompt.mdbatch/batch-runner.shfollowup-cadence.mjsfollowup-cadence.test.mjsfollowup-seed-tests.mjsfollowup-seed.mjsinvite-match.mjsinvite-match.test.mjsmerge-tracker.mjsmodes/apply.mdmodes/interview-prep.mdmodes/interview-redflag.mdmodes/interview/debrief.mdmodes/interview/plan.mdmodes/oferta.mdpackage.jsonpipeline-lock.mjsscan-interamt.mjsset-status-tests.mjsstats.mjstemplates/README.mdtemplates/agency-licensing.ymltemplates/immigration-status-requirements.ymltemplates/portals.example.ymltemplates/protected-grounds.ymltest-all.mjstest/pipeline-lock.test.mjstests/stats.test.mjstracker-utils.mjstracker-writer-lock-tests.mjsupdate-system.mjsweb/src/lib/career-ops.ts
| export function analyzeFromContent(trackerContent, followupsContent = '') { | ||
| const apps = parseTrackerContent(trackerContent); | ||
| if (apps.length === 0) { | ||
| return { error: 'No applications found in tracker.' }; | ||
| } | ||
|
|
||
| const followups = readFollowups(); | ||
| const overrides = parseOverrides(); | ||
| const followups = parseFollowups(followupsContent); | ||
| const overrides = parseNextOverrides(String(followupsContent ?? '')); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
rg -n 'overdueOnly' followup-cadence.mjsRepository: santifer/career-ops
Length of output: 248
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== followup-cadence.mjs outline/contents around relevant sections =="
wc -l followup-cadence.mjs
sed -n '1,50p' followup-cadence.mjs
sed -n '280,390p' followup-cadence.mjs
echo
echo "== stats.mjs references to analyzeFromContent / computeColdAppNums =="
rg -n 'computeColdAppNums|analyzeFromContent|followup-cadence|mjs' stats.mjs
sed -n '1,220p' stats.mjsRepository: santifer/career-ops
Length of output: 18230
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== analyzeFromContent return fields with overdueOnly branch =="
sed -n '370,405p' followup-cadence.mjs
echo
echo "== current followup status in repo files =="
git status --shortRepository: santifer/career-ops
Length of output: 1516
Make analyzeFromContent stop filtering on the shared --overdue-only CLI flag.
overdueOnly is global process.argv state, while analyzeFromContent is documented as the reusable content-based core and stats.mjs calls computeColdAppNums(...) through it. In an in-process invocation path, an unrelated CLI flag from the importing process can drop cold rows from result.entries; computeColdAppNums then filters only that already-filtered result and may miss cold applications. Thread this as an explicit overdueOnly parameter, defaulting to false.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@followup-cadence.mjs` around lines 290 - 297, Update analyzeFromContent to
accept an explicit overdueOnly parameter defaulting to false, and stop reading
the shared process.argv --overdue-only state within this reusable content-based
flow. Pass the parameter through to the result-entry filtering and ensure
computeColdAppNums receives the complete entries by default, while preserving
CLI behavior by explicitly supplying the flag value at the command-line call
site.
| const PLATFORM_URL_PATTERNS = [ | ||
| { name: 'Zoom', pattern: /(?:^|[^\w@./?=&#-])(?:https?:\/\/)?(?:[\w-]+\.)?zoom\.us(?::\d{1,5})?(?:[/?#\s]|$)/i }, | ||
| { name: 'Microsoft Teams', pattern: /(?:^|[^\w@./?=&#-])(?:https?:\/\/)?(?:[\w-]+\.)?teams\.(?:microsoft|live)\.com(?::\d{1,5})?(?:[/?#\s]|$)/i }, | ||
| { name: 'Google Meet', pattern: /(?:^|[^\w@./?=&#-])(?:https?:\/\/)?(?:[\w-]+\.)?meet\.google\.com(?::\d{1,5})?(?:[/?#\s]|$)/i }, | ||
| ]; |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Reject invalid URL ports before classifying a platform.
The pattern accepts https://zoom.us:65536/... through :99999, although those are invalid ports. That turns malformed links into platform signals despite the “silence stays silence” contract. Limit ports to 0–65535 and add a regression case for :65536.
Proposed fix
- (?::\d{1,5})?
+ (?::(?:\d{1,4}|[1-5]\d{4}|6[0-4]\d{3}|65[0-4]\d{2}|655[0-2]\d|6553[0-5]))?📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| const PLATFORM_URL_PATTERNS = [ | |
| { name: 'Zoom', pattern: /(?:^|[^\w@./?=&#-])(?:https?:\/\/)?(?:[\w-]+\.)?zoom\.us(?::\d{1,5})?(?:[/?#\s]|$)/i }, | |
| { name: 'Microsoft Teams', pattern: /(?:^|[^\w@./?=&#-])(?:https?:\/\/)?(?:[\w-]+\.)?teams\.(?:microsoft|live)\.com(?::\d{1,5})?(?:[/?#\s]|$)/i }, | |
| { name: 'Google Meet', pattern: /(?:^|[^\w@./?=&#-])(?:https?:\/\/)?(?:[\w-]+\.)?meet\.google\.com(?::\d{1,5})?(?:[/?#\s]|$)/i }, | |
| ]; | |
| const PLATFORM_URL_PATTERNS = [ | |
| { name: 'Zoom', pattern: /(?:^|[^\w@./?=&`#-`])(?:https?:\/\/)?(?:[\w-]+\.)?zoom\.us(?::(?:\d{1,4}|[1-5]\d{4}|6[0-4]\d{3}|65[0-4]\d{2}|655[0-2]\d|6553[0-5]))?(?:[/?#\s]|$)/i }, | |
| { name: 'Microsoft Teams', pattern: /(?:^|[^\w@./?=&`#-`])(?:https?:\/\/)?(?:[\w-]+\.)?teams\.(?:microsoft|live)\.com(?::(?:\d{1,4}|[1-5]\d{4}|6[0-4]\d{3}|65[0-4]\d{2}|655[0-2]\d|6553[0-5]))?(?:[/?#\s]|$)/i }, | |
| { name: 'Google Meet', pattern: /(?:^|[^\w@./?=&`#-`])(?:https?:\/\/)?(?:[\w-]+\.)?meet\.google\.com(?::(?:\d{1,4}|[1-5]\d{4}|6[0-4]\d{3}|65[0-4]\d{2}|655[0-2]\d|6553[0-5]))?(?:[/?#\s]|$)/i }, | |
| ]; |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@invite-match.mjs` around lines 272 - 276, Update the PLATFORM_URL_PATTERNS
port matching so each platform regex accepts only ports in the valid 0–65535
range, rejecting values such as :65536 before classification. Add a regression
case covering an invalid :65536 URL and preserve the existing silence behavior
for malformed links.
| // CAREER_OPS_BATCH_STATE overrides the batch-state.tsv path (used by tests). | ||
| const BATCH_STATE_FILE = process.env.CAREER_OPS_BATCH_STATE | ||
| ? process.env.CAREER_OPS_BATCH_STATE | ||
| : join(CAREER_OPS, 'batch/batch-state.tsv'); | ||
|
|
||
| // Cross-check against batch-state.tsv (found 2026-07-30): a worker can write | ||
| // a well-formed tracker TSV even when its own JSON result said "failed" -- | ||
| // e.g. two workers that fabricated a placeholder score (0.0/5, "Suspicious") | ||
| // for a posting they never actually read, after being unable to extract the | ||
| // JD. batch-runner.sh's JSON-status detection is the authority on whether an | ||
| // offer really succeeded; a TSV whose report number maps to a "failed" row | ||
| // there is fabricated evidence, not just cosmetically ambiguous like the | ||
| // score/status column-swap check below -- it must never merge, however | ||
| // well-formed the TSV itself looks in isolation. | ||
| function loadFailedReportNumbers(path) { | ||
| const failed = new Set(); | ||
| if (!existsSync(path)) return failed; | ||
| for (const line of readFileSync(path, 'utf-8').split(/\r?\n/)) { | ||
| if (!line.trim() || line.startsWith('id\t')) continue; | ||
| const cols = line.split('\t'); | ||
| if (cols.length < 6) continue; | ||
| const status = cols[2]; | ||
| const reportNum = cols[5]; | ||
| if (status === 'failed' && reportNum && reportNum !== '-') { | ||
| const n = parseInt(reportNum, 10); | ||
| if (!isNaN(n)) failed.add(n); | ||
| } | ||
| } | ||
| return failed; | ||
| } | ||
| const FAILED_REPORT_NUMBERS = loadFailedReportNumbers(BATCH_STATE_FILE); |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Stale failed rows can block a legitimately completed offer that reused the same report number.
batch-runner.sh now releases report-number reservations on failure and can reuse them for a later, unrelated offer (its base = highestNumber(occupied) + 1 picks up a released top number on the next reservation). batch-state.tsv keeps historical rows per offer id indefinitely, so it can end up containing both a stale failed row for offer A and a completed row for a later offer B that reused the same report_num. loadFailedReportNumbers only checks for status === 'failed' anywhere in the file, so it will add that number to FAILED_REPORT_NUMBERS and permanently block merging offer B's genuinely completed tracker line.
🔧 Proposed fix: don't let a stale failed row shadow a later completed one
function loadFailedReportNumbers(path) {
const failed = new Set();
+ const completed = new Set();
if (!existsSync(path)) return failed;
for (const line of readFileSync(path, 'utf-8').split(/\r?\n/)) {
if (!line.trim() || line.startsWith('id\t')) continue;
const cols = line.split('\t');
if (cols.length < 6) continue;
const status = cols[2];
const reportNum = cols[5];
- if (status === 'failed' && reportNum && reportNum !== '-') {
- const n = parseInt(reportNum, 10);
- if (!isNaN(n)) failed.add(n);
- }
+ if (reportNum && reportNum !== '-') {
+ const n = parseInt(reportNum, 10);
+ if (!isNaN(n)) {
+ if (status === 'failed') failed.add(n);
+ if (status === 'completed') completed.add(n);
+ }
+ }
}
+ // A released report number can be re-claimed by a later, unrelated offer.
+ // If that later offer completed, trust it over a stale failed row from a
+ // prior, since-abandoned attempt that happened to share the same number.
+ for (const n of completed) failed.delete(n);
return failed;
}📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // CAREER_OPS_BATCH_STATE overrides the batch-state.tsv path (used by tests). | |
| const BATCH_STATE_FILE = process.env.CAREER_OPS_BATCH_STATE | |
| ? process.env.CAREER_OPS_BATCH_STATE | |
| : join(CAREER_OPS, 'batch/batch-state.tsv'); | |
| // Cross-check against batch-state.tsv (found 2026-07-30): a worker can write | |
| // a well-formed tracker TSV even when its own JSON result said "failed" -- | |
| // e.g. two workers that fabricated a placeholder score (0.0/5, "Suspicious") | |
| // for a posting they never actually read, after being unable to extract the | |
| // JD. batch-runner.sh's JSON-status detection is the authority on whether an | |
| // offer really succeeded; a TSV whose report number maps to a "failed" row | |
| // there is fabricated evidence, not just cosmetically ambiguous like the | |
| // score/status column-swap check below -- it must never merge, however | |
| // well-formed the TSV itself looks in isolation. | |
| function loadFailedReportNumbers(path) { | |
| const failed = new Set(); | |
| if (!existsSync(path)) return failed; | |
| for (const line of readFileSync(path, 'utf-8').split(/\r?\n/)) { | |
| if (!line.trim() || line.startsWith('id\t')) continue; | |
| const cols = line.split('\t'); | |
| if (cols.length < 6) continue; | |
| const status = cols[2]; | |
| const reportNum = cols[5]; | |
| if (status === 'failed' && reportNum && reportNum !== '-') { | |
| const n = parseInt(reportNum, 10); | |
| if (!isNaN(n)) failed.add(n); | |
| } | |
| } | |
| return failed; | |
| } | |
| const FAILED_REPORT_NUMBERS = loadFailedReportNumbers(BATCH_STATE_FILE); | |
| function loadFailedReportNumbers(path) { | |
| const failed = new Set(); | |
| const completed = new Set(); | |
| if (!existsSync(path)) return failed; | |
| for (const line of readFileSync(path, 'utf-8').split(/\r?\n/)) { | |
| if (!line.trim() || line.startsWith('id\t')) continue; | |
| const cols = line.split('\t'); | |
| if (cols.length < 6) continue; | |
| const status = cols[2]; | |
| const reportNum = cols[5]; | |
| if (reportNum && reportNum !== '-') { | |
| const n = parseInt(reportNum, 10); | |
| if (!isNaN(n)) { | |
| if (status === 'failed') failed.add(n); | |
| if (status === 'completed') completed.add(n); | |
| } | |
| } | |
| } | |
| // A released report number can be re-claimed by a later, unrelated offer. | |
| // If that later offer completed, trust it over a stale failed row from a | |
| // prior, since-abandoned attempt that happened to share the same number. | |
| for (const n of completed) failed.delete(n); | |
| return failed; | |
| } |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@merge-tracker.mjs` around lines 39 - 69, Update loadFailedReportNumbers to
resolve each report number by its latest batch-state row rather than collecting
every failed row; when a later completed or otherwise non-failed row reuses the
number, remove or exclude it from FAILED_REPORT_NUMBERS. Preserve ignoring
malformed, header, missing, and placeholder report-number rows.
| **Evidence tiers (the mode's existing single-vs-pattern weighing, applied here):** | ||
|
|
||
| - **Single borderline question in a session** = a low-confidence observation. Report it (ground + quote + legal basis) but do **not** mark the signal present for that session. Regulator enforcement data supports this humility: MHLW's own fair-hiring reports show family questions dominate and are typically asked carelessly to soften the interview atmosphere, not maliciously — and the candidate often doesn't register the question as improper in the moment, which is exactly why this post-interview check exists. | ||
| - **Signal present for a session** = repeated probing of the same ground within the session, or questions touching **2+ distinct grounds** in the session. This clears the corroboration bar the same way Step 2's other signals treat pattern-vs-noise. | ||
| - Once present, the signal aggregates in Step 3 **exactly like the four existing signals** (+1 for one session, +2 for 2+ sessions) and contributes to the existing verdict tiers — including, at `🚩 Reconsider`, the existing blacklist-suggestion sub-block. A pattern of protected-ground probing is precisely the kind of reason line that sub-block was built for. |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Make single-question classification deterministic and motive-neutral.
Line 104 handles only a “single borderline” question, while Line 105 defines a present signal only for repetition or multiple grounds. A single unequivocal question is therefore undefined. The output also labels singles “usually careless … not malice,” contradicting the hard rule against intent inference and applying Japan-specific rationale to every jurisdiction.
Define whether an unequivocal single counts, and replace the motive claim with “single observation — reported, not scored.”
As per coding guidelines, modes must generate user-facing content only from approved source-of-truth files and current-conversation statements, and never fabricate unsupported claims.
Also applies to: 204-221
🧰 Tools
🪛 LanguageTool
[style] ~104-~104: Consider an alternative for the overused word “exactly”.
Context: ...ion as improper in the moment, which is exactly why this post-interview check exists. -...
(EXACTLY_PRECISELY)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@modes/interview-redflag.md` around lines 102 - 106, Update the “Evidence
tiers” rules in the interview red-flag mode so a single unequivocal
protected-ground question has an explicit deterministic outcome: report it with
its grounding and legal basis, but classify it as a single observation that is
not scored. Remove motive, intent, and Japan-specific rationale, and apply the
same rule consistently in the related Step 2/Step 3 guidance while generating
claims only from approved source files or the current conversation.
Source: Coding guidelines
| **The distinction that matters:** most of what an interview surfaces is *new information* — a new gap, a new story, a new detail that wasn't in the prep file before. That's append-only, and Steps 4/5/8 below handle it exactly as they always have. But sometimes what the interview surfaces isn't new — it's a **direct contradiction of a specific fact the prep file already asserts** (location, comp range, team size, reporting structure, tech/system stack, etc.). That's not a gap to close or a story to add; it's an existing claim that is now known to be wrong. | ||
|
|
||
| - **"This is new information" → appends.** Use the existing Step 4 / Step 5 / Step 8 flows unchanged. | ||
| - **"This directly contradicts something the prep file already asserts as fact" → correct in place.** Edit the original line in the role-specific prep file itself, rather than leaving the wrong claim untouched and only noting the discrepancy in a new section below it. | ||
|
|
||
| When correcting in place, use a strikethrough-plus-correction format so the history of what was believed vs. confirmed stays visible in the diff: | ||
|
|
||
| ```markdown | ||
| ~~Metro Hall, on-site~~ **Metro Hall — hybrid** (confirmed on the {date} call) | ||
| ``` | ||
|
|
||
| **Resolve inference tags on contradiction or confirmation.** If the original line carried an inference marker — `[inferred from JD]`, or prose noting the source was an expired/inaccessible posting — and the interview either confirms or corrects it, resolve the tag rather than leaving a now-settled fact permanently marked as uncertain: replace the marker with the confirmed fact and its real source (the interview/call itself), using the same strikethrough-plus-correction shape when the value changed, or a plain edit to drop the marker and cite the new source when the value was merely confirmed as-is. | ||
|
|
||
| This step never touches `interview-prep/retracted-claims.md` or the story bank — those stay reserved for the candidate's own claims, not for facts about the role. It also never rewrites Step 4's "Gaps to Close" additions; a contradicted fact is corrected at its original location, not logged as a gap. |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Do not mark recall-derived corrections as confirmed.
Step 1 explicitly permits candidate recall, but this flow can overwrite an existing fact with a bold “confirmed” replacement based on that recall. Require an explicit interviewer statement or transcript before in-place confirmation; otherwise retain the cited fact and record the contradiction as candidate-reported/unverified.
Also applies to: 241-241
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@modes/interview/debrief.md` around lines 64 - 77, Update the
contradiction-handling guidance in the debrief workflow to distinguish
interviewer-confirmed facts from candidate recall. Only apply in-place
strikethrough corrections and “confirmed” wording when supported by an explicit
interviewer statement or transcript; otherwise retain the existing cited fact
and record the discrepancy as candidate-reported/unverified.
|
|
||
| | `jurisdiction-prohibited-content.yml` | `modes/oferta.md` (Block G signal 10), `modes/apply.md` (Step 5c) | Jurisdiction-keyed table of content employers are legally prohibited from requiring/asking for (e.g. "Canadian experience" in Ontario postings, salary-history questions in California). Prompt-level data reference, agent-judged matching — no script reads it. Contribution rule: no entry without a citable legal source and effective date. | |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Keep this row inside the table and correct its signal number.
The blank line terminates the Markdown table, and modes/oferta.md defines this check as Signal 12, not Signal 10.
Proposed fix
-
-| `jurisdiction-prohibited-content.yml` | `modes/oferta.md` (Block G signal 10), `modes/apply.md` (Step 5c) |
+| `jurisdiction-prohibited-content.yml` | `modes/oferta.md` (Block G signal 12), `modes/apply.md` (Step 5c) |🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@templates/README.md` around lines 18 - 19, Keep the
jurisdiction-prohibited-content.yml row contiguous with the surrounding Markdown
table by removing the blank line before it, and update its modes/oferta.md
reference from Block G signal 10 to signal 12.
| const on = al?.jurisdictions?.['CA-ON']; | ||
| if ( | ||
| on && | ||
| on.licensing_required_for === 'both' && | ||
| String(on.effective) === '2024-07-01' && | ||
| typeof on.registry?.url === 'string' && on.registry.url.includes('ontario.ca') && | ||
| typeof on.registry?.what_it_shows === 'string' && on.registry.what_it_shows.length > 0 && | ||
| typeof on.legal_basis === 'string' && on.legal_basis.includes('O. Reg. 99/23') && | ||
| typeof on.client_side_prohibition === 'string' && on.client_side_prohibition.length > 0 && | ||
| typeof on.penalties === 'string' && on.penalties.length > 0 && | ||
| typeof on.transitional_notes === 'string' && on.transitional_notes.length > 0 && | ||
| Array.isArray(on.sources) && on.sources.length > 0 && | ||
| Boolean(on.as_of) |
There was a problem hiding this comment.
🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win
Validate every contributed row, not only the seed rows.
Agency licensing checks only CA-ON; protected grounds check only CA-ON and JP; prohibited content checks only two lookup keys. A malformed or duplicate future row therefore passes despite the tables being community-extensible. Agency as_of also accepts any truthy value.
Iterate every row, enforce the complete schema and strict date formats, and reject duplicate jurisdiction keys. Keep seed-specific assertions as additional pins.
As per coding guidelines, Node.js .mjs automation must preserve data-integrity guarantees, including validation.
Also applies to: 11229-11254, 11475-11486
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@test-all.mjs` around lines 2173 - 2185, Expand the validation routines
covering agency licensing, protected grounds, and prohibited content to iterate
over every contributed row rather than only the current seed keys. For each row,
enforce the complete expected schema, require strict date formats including
agency `as_of`, and reject duplicate jurisdiction or lookup keys; retain the
existing CA-ON/JP and other seed-specific assertions as additional pins.
Source: Coding guidelines
| const alStart = ofertaMode.indexOf('Agency Licensing Check'); | ||
| const alEnd = ofertaMode.indexOf('### Output format:', Math.max(alStart, 0)); | ||
| const alSection = alStart >= 0 && alEnd > alStart ? ofertaMode.slice(alStart, alEnd) : ''; |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Bound each contract check to the next section.
The agency slice runs through Signals 11–12, the immigration slice includes Signal 12, and Step 5d includes Step 5c. Consequently, later sections can supply tokens such as not legal advice after the intended section loses them.
Proposed fix
-const alEnd = ofertaMode.indexOf('### Output format:', Math.max(alStart, 0));
+const alEnd = ofertaMode.indexOf('**11. Immigration-Status Requirement Overreach**', Math.max(alStart, 0));
-const sigEnd = ofertaNow.indexOf('### Output format:', Math.max(sigStart, 0));
+const sigEnd = ofertaNow.indexOf('**12. Jurisdiction-Prohibited Content**', Math.max(sigStart, 0));
-const stepEnd = applyNow.indexOf('**Applying to several roles', Math.max(stepStart, 0));
+const stepEnd = applyNow.indexOf('## Step 5c — Jurisdiction-prohibited content check', Math.max(stepStart, 0));Also applies to: 11398-11400, 11417-11419
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@test-all.mjs` around lines 2207 - 2209, Update the section extraction checks
around alStart/alEnd and the corresponding immigration and Step 5d slices so
each end boundary is the earliest next section heading, not a broad later
marker. Ensure the agency, immigration, and Step 5d contract checks exclude
subsequent Signals or Step 5c content while preserving the intended section
text.
| /** A pipeline-row segment like `posted: 2026-07-14`, `trust: 62 stale` or | ||
| * `note: …` — the core appends these LABELED segments after whatever | ||
| * positional shape a row has (3/4/5 columns), so a naive positional reader | ||
| * would misread them as location/compensation on short rows. Any | ||
| * `word:`-prefixed segment is treated as labeled (forward-compatible with | ||
| * labels the core hasn't invented yet). */ | ||
| const LABELED_SEGMENT = /^([a-z][a-z_-]*):\s*(.*)$/i; | ||
|
|
||
| /** Parse data/pipeline.md — `- [ ] URL | Company | Role [| Location [| Compensation]] [| label: …]*`. | ||
| * Positional split for the first columns (the optional 4th `location` #1015 | ||
| * and 5th `compensation` #1017 must NOT bleed into `role`); labeled segments | ||
| * (posted:/trust:/note:/…) are filtered out of positional assignment wherever | ||
| * they appear and surfaced when useful (posted: → postedAt). Unknown labels | ||
| * and further trailing columns are ignored gracefully. */ |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Recognize only contract-defined pipeline labels.
Treating every word: segment as metadata misparses valid locations such as Remote: Germany. On a five-column row, removing that segment also shifts compensation into location.
Use an allowlist such as posted, trust, and note; new labels should update the parser contract explicitly.
Proposed fix
const LABELED_SEGMENT = /^([a-z][a-z_-]*):\s*(.*)$/i;
+const PIPELINE_LABELS = new Set(["posted", "trust", "note"]);
...
const lm = i >= 3 ? seg.match(LABELED_SEGMENT) : null;
- if (lm) labels.set(lm[1].toLowerCase(), lm[2].trim());
+ const label = lm?.[1].toLowerCase();
+ if (lm && label && PIPELINE_LABELS.has(label)) {
+ labels.set(label, lm[2].trim());
+ }
else parts.push(seg);Also applies to: 74-88
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@web/src/lib/career-ops.ts` around lines 50 - 63, Restrict LABELED_SEGMENT and
the positional parsing logic in the pipeline-row parser to the contract-defined
labels posted, trust, and note instead of treating every word-prefixed segment
as metadata. Preserve valid values such as “Remote: Germany” as positional
location data and prevent labeled-segment filtering from shifting compensation
into location; require any future labels to be added explicitly to the parser
contract.
| const posted = labels.get("posted"); | ||
| jobs.push({ | ||
| done: m[1].toLowerCase() === "x", | ||
| url: parts[0], | ||
| company: parts[1], | ||
| role: parts[2], | ||
| location: parts[3] || undefined, // optional 4th column (#1015) | ||
| compensation: parts[4] || undefined, // optional 5th column (#1017); 6th+ ignored | ||
| // the row's own posting date (scan.mjs `posted:` label) — a more direct | ||
| // freshness signal than the scan-history join, which stays as fallback | ||
| postedAt: posted && /^\d{4}-\d{2}-\d{2}$/.test(posted) ? posted : undefined, |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Validate that posted: is a real calendar date.
Values such as 2026-99-99 pass the regex and then suppress the valid scan-history fallback. Parse and round-trip the date before assigning postedAt.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@web/src/lib/career-ops.ts` around lines 81 - 91, Update the postedAt
assignment in the jobs parsing flow to validate posted as a real calendar date,
not just a YYYY-MM-DD-shaped string. Parse the value and round-trip it to the
same year, month, and day before assigning postedAt; otherwise leave it
undefined so the scan-history fallback remains available.
Closes #2018
What this adds
A jurisdiction-keyed prohibited-content table plus prompt-level signals in the two modes that read postings and forms. No new executable script — same prompt-level, warn-only shape as the existing Block G signal family (#1631, #1685, #1936).
1.
templates/jurisdiction-prohibited-content.yml(new, system layer)Entries of
{jurisdiction, prohibited, matching, legal_basis, effective, sources}. Seeded with exactly the two verified rows from the issue:Source discipline is documented in the file header: no entry without a citable legal source AND an effective date — "lawyer-bulletin-grade source required", not "heard it somewhere". Known candidate rows (other US salary-history-ban states, EU photo/age/marital restrictions) are listed as comments for contributors, not as entries.
Each entry carries
matchingguidance because detection is agent-judged, never naive regex — "we will never ask for your salary history" in a fraud-warning footer must not fire, and salary expectations ≠ salary history.2.
modes/oferta.md— Block G signal 9When JD text contains content matching a table entry for the candidate's jurisdiction (from
config/profile.yml→location, same region-aware pattern as signal 6), emit a warn-only note. Phrasing discipline pinned per the issue: state the verifiable fact ("this posting contains a 'Canadian experience' requirement, which Ontario's ESA has prohibited in publicly advertised postings since 2026-01-01") — never an employer-lawbreaking assertion, since employer size, posting type, and exemptions aren't verifiable from the JD. Output includes a not-legal-advice note. Does not change the legitimacy tier; never blocks or discourages an application on its own. Following signal 8's precedent, no new Risk Summary row.3.
modes/apply.md— Step 5cWhen an application-form field asks for content matching a table entry, warn the candidate before an answer is drafted, note they are generally not obligated to answer, and hand the decision to them.
Guarantees: never auto-answer, never auto-skip, never block — candidate decides, warn-only. The existing prepare-don't-submit machinery (#1248), the Step 6
needs_candidate_confirmationcontract, and Step 5b knock-out handling are untouched.Conventions
[Render in {language.output}: ...]localization pattern; data tokens (legal_basis,effective) stay fixed.templates/directory entry inupdate-system.mjs(verified withvalidate-system-paths-coverage.mjs— 751 tracked files covered); no per-file template entries exist for any sibling template, so none added.templates/README.md's file table (sibling-template granularity; AGENTS.md doesn't list individual data templates, so no change there).Tests
4 new pins in
test-all.mjs:node test-all.mjs --quick: 1802 passed, 0 failed (1 pre-existing warning:cv-sync-check.mjswithout user data).Coordination note — signal numbering vs #1938
Main currently tops out at Block G signal 8, so this PR takes signal 9. Open PR #1938 (third-party platform location tag mismatch) also adds a Block G signal to
modes/oferta.md; whichever of the two merges second will need a trivial renumber (9 → 10) plus the matching test-pin string update. Happy to rebase this one if #1938 lands first.🤖 Generated with Claude Code
Summary by CodeRabbit