PCRecruiter role sync (PR #3): review and merge plan
Reviewed 2026-10-06 against origin/feat/pcrecruiter-order-sync at 943ec6d
(12 commits on top of 4198538, +6,014 / −12 lines, 59 files), the draft PR
https://github.com/job-com-org/job-com/pull/3 by Max (Molret), the Supabase
preview branch pcr-order-sync (project ref foolqzjouuwgleuvamdw, linked to
the PR) and production (pjupiksxikbttgqnipaa). Max's message of 7:30 PM is
decoded in §3.
1. Verdict
Not mergeable today. Mergeable after the seven blockers in §5.1, which are
small, plus the production adoption steps in §8. The engineering is strong:
the worker is read-only against PCR, every write goes through one
transactional SQL command with per-row quarantine, identity is PCR JobId
(not the reusable portal requisition id), closures need two direct 404s, and
the branch ships a reproducible database chain with CI that replays it. The
preview reconciles all 4,690 current PCR orders with zero diff on replay.
What is missing is the last mile into production and into the Desk UI:
- The CI "Workspace checks" job is red (lockfile formatting), so lint, types, tests and builds never ran in CI. I ran them locally; they pass (§4).
- The worker and the adoption tooling hard-refuse the production project, so as written the feature cannot run in production at all.
- The sync never writes
posted_atorstart_date, so every synced role shows no Posted/Age/Start in Desk, and Today's "uncovered" ordering degrades. The timezone question that justified this is now settled (§5.1, B4). - Production already holds 4,840 open roles. The sync links the ones PCR still lists and silently leaves the rest open forever, because the branch also blocks the order-report import that used to close them (§5.1, B5).
- Desk has no surface for any of it: the Today strip, the Roles "Order report" filter and the role panel still speak only about the spreadsheet import (§6).
2. What the branch does
| Area | What landed |
|---|---|
apps/pcr-sync | A Vercel cron worker (api/tick.ts, every 15 min, CRON_SECRET Bearer check with timingSafeEqual) and a CLI (src/main.ts --dry-run --force). client.ts is a GET-only PCR client (POST allowed only for /access-token; endpoint allowlist positionsV2, companiesV2; 30 s timeout; one retry on 429/5xx honouring Retry-After up to 30 s; two auth attempts 11 s apart). api-source.ts pages positionsV2 by JobId keyset, 500 per page, statuses Available/Hold/Pending. run.ts reconciles; store.ts calls one RPC. |
Migration …_desk_pcr_bridge | Tables pcr_bridge_settings/runs/requests/companies/orders/changes/errors (RLS on, service_role only). public.pcr_bridge_command(p_action, p) does claim/lease, quota reserve, request audit, company mapping, apply/deleted with revision-safe field ownership (P1001–P1007 codes), history and activities. role_rows_for and match_role_source hide end_date for managed roles. apply_role_import refuses to run while the bridge is enabled on the API transport. pcr_bridge_role_history (staff) and pcr_bridge_health (manager). |
Migration 20261001000000_desk_environment_baseline | A new first migration that creates the extensions, private, pcr and bullhorn schemas, five archive tables, and the six private.* helpers Desk functions depend on. Fresh databases (CI, preview) can now replay the whole chain. Production already has all of it, so production must adopt the version by metadata only (§8). |
| CI and lifecycle | .github/workflows/ci.yml (format, lint, types, tests, builds; a second job replays migrations on a disposable local Supabase, runs the ten SQL suites and compares generated types byte-for-byte). scripts/database/* verify/plan/adopt the baseline with a derived contract; supabase/ci/seed.sql synthetic fixtures; [db.seed] enabled = false in config.toml. |
| Desk | src/lib/order-sync/* and src/lib/pcrecruiter/* (a planning model and normalizers that nothing imports, see S9), regenerated database.types.ts, and parse-report.ts now imports ./report-date.ts with an extension so the worker can load it under Node type stripping. |
| Docs | docs/product/live-orders-bridge.md describes the implemented behaviour. |
3. Max's message, decoded
"Everything is working in Preview… reconciles 4690/4690 roles… CI/database lifecycle work… supabase review is passing."
True, with one caveat: the Supabase preview checks pass, but the GitHub "Workspace checks" job fails at the first step (§4). The preview run log confirms the rest (§4, table 2).
"For the Oct 1 verification, do we have a PCR snapshot/history from around Oct 1 with JobId + status?"
No, and none can be produced. PCR's API has no history endpoint; the pcr
archive in Supabase is frozen at 2026-08-21; the only Oct 1 artefact is the
Oct 1 Rollup Report (import #18, applied in production, plus its staged rows in
private.order_report_2026_09_30), which carries the portal "Company Job ID",
not the PCR JobId. What we do know from the Oct 6 check of the report against
the live API: the report is a snapshot of PCR positions with Status =
Available at about 8 AM ET; the 1,649 job ids that left the report between Oct
1 and Oct 6 really are closed in PCR (1,639 Closed, 1,424 of them in one bulk
event on Oct 2 at 19:00). The closest possible "verification" is report job
key → pcr_bridge_orders.applied->>'vms_job_id' after the first production
run; it is a historical curiosity, not a merge blocker. Answer: drop it.
"And for the ~105 missing orders, do you or Jason have the list/report that number came from?"
The number is not in the repository, the memory notes, or the PR. It came
from Jason verbally. Max is right not to guess. Ask Jason for the requisition
list (MSP, requisition id, date). Once it exists, the check is one query per
row: GET positionsV2?Query=EXISTS (CustomFields.FieldName eq 'Company Job ID' AND CustomFields.Value eq '<id>'), or locally select * from public.pcr_bridge_orders where applied->>'vms_job_id' = '<id>'. Not a merge
blocker.
"Production already has the required schema, but we'll need to coordinate a migration-history adoption step before merging/deploying the new baseline."
Correct and important. §8 gives the exact steps. His tooling refuses to do it against production by design, so the adoption is a two-statement SQL run by you through the Supabase MCP.
4. What I verified
Table 1. Branch checks (run locally in a worktree at 943ec6d).
| Check | Result |
|---|---|
pnpm install --frozen-lockfile | ok, lockfile passes supply-chain policies |
pnpm --filter pcr-sync --filter desk lint | ok |
pnpm --filter pcr-sync --filter desk check-types | ok (TypeScript 7.0.2; Desk next typegen ok) |
pnpm --filter pcr-sync --filter desk test | pcr-sync 64/64, desk 1,647/1,647 |
node --test scripts/database/*.test.mjs | 12/12 |
pnpm --filter desk build | ok (Next production build, all routes) |
pnpm format:check (GitHub CI, run 37562095834) | fails: pnpm-lock.yaml is not Prettier-formatted |
Same with pnpm-lock.yaml in .prettierignore | passes (2,017 files) |
| GitHub "Supabase migration and SQL checks" job | pass (3 m 41 s) |
Supabase preview branch deploy (foolqzjouuwgleuvamdw) | pass; migration history = main's 44 + baseline + bridge |
| Vercel checks on the PR | blocked: "Git author Molret must have access to the project" (§5.2 S12) |
Table 2. Preview database (foolqzjouuwgleuvamdw), read 2026-10-07 02:50 UTC.
| Fact | Value |
|---|---|
| Settings | enabled, transport api, interval 1,440 min, monthly budget 40,000, next run 2026-10-08 00:11 UTC |
| Runs | 7: one lease_expired failure (first attempt), one dry run (4,568 created / 125 updated planned), one partial live run (2 quarantined), a 2-row fix-up, then 2,665 created + 2,025 unchanged, then two clean runs with 4,690 unchanged and 0 errors |
| Quarantine | 2 errors, both identity_already_linked (two PCR JobIds reusing a portal requisition), resolved by the next run |
| Orders linked | 4,690 (4,567 Available → open, 121 Hold + 2 Pending → on_hold); every JobId above 2^31, all within roles_id_check |
| Companies mapped | 738 |
| PCR requests | 161 total: 4 auth, 40 positionsV2, 34 companiesV2 pages, 83 single-company reads |
| Roles data quality | description missing or trivial 56; vms_job_id null 63; bill_rate null 85; state null 81; discipline null 251; posted_at null 4,690; start_date null 4,690 |
| MSP normalization | confirmed 4,343, unconfirmed 273 (Wand, ShiftWise, StatStaff, FieldGlass, PeopleFluent, MedicalSolutions, "Adams 12 Five Star Schools - MSP"), empty 74 |
| Security advisors | INFO only for the new tables (RLS with no policy = service_role only, as intended); two new SECURITY DEFINER RPCs callable by authenticated, consistent with the existing 33 |
Table 3. Production (pjupiksxikbttgqnipaa), read the same hour.
| Fact | Value |
|---|---|
| Migration history | 44 versions, names equal to the files; no baseline, no bridge, no temporary desk_branch_api_schemas row |
Baseline contract (scripts/database/lifecycle.mjs sql) | []: production satisfies every table, column, primary key, RLS/grant, helper-function hash and extension the baseline declares |
roles | 92,231: open 4,840 (3,030 source='pcr', 1,810 source='report'), closed 87,391 (86,705 "archive", 686 "Not on the order report") |
| Role ids | source='pcr' roles use the PCR JobId as id (3,030 of 3,030 open ones match pcr.positions.job_id); report roles use the sequence |
| Companies | 17,882, all source='pcr', ids equal to pcr.companies.company_id |
| Current PCR set (4,690 ids from the preview) vs production | By PCR JobId: 1,555 are open in production already; 1,040 are closed (1,032 "archive", 8 "Not on the order report") and will be reopened by the sync; 2,095 have no role with that id (created in PCR after the Aug 21 archive freeze; the 1,810 report-sourced roles are their likely matches by requisition id). The other way round: 3,285 production open roles are not in the current PCR set (1,475 source='pcr', 1,810 source='report'); 962 of the pcr ones are already non-Available in the archive; 30 of the 3,285 have pipeline rows or assignments. |
| PCR timezone | pcr.positions.date_posted for job 999065291722174 is 2025-11-14T10:03:22.08; the API returned the same wall-clock DatePosted and LastModified for the same job. PCR returns database-local time, and the archive loader already treats that as America/New_York (private.archive_ts). |
| Latest report | import #76 "Rollup Report (10.6.2026).xlsx", 4,556 rows, previewed, not applied |
| Open roles with work on them | 70 have pipeline rows, 8 are assigned, 1,794 non-system activities |
5. Findings
5.1 Blockers (fix on the branch before merge)
B1. CI is red on formatting. scripts/ci/format-check.mjs keeps
pnpm-lock.yaml in its "existing debt" list but checks any debt file the PR
touched, and the PR touched the lockfile. Prettier and pnpm disagree on YAML
style, so formatting the lockfile would be undone by the next install. Fix:
add a root .prettierignore containing pnpm-lock.yaml and remove it from
baselineDebt. Verified: the check then passes. (Prettier reads
.prettierignore from the project root.)
B2. Production refusal is hard-coded in two places.
apps/pcr-sync/src/store.ts assertBranch throws for
pjupiksxikbttgqnipaa; scripts/database/contract.mjs PRODUCTION makes
lifecycle.mjs adopt refuse it. This was a sensible guard while building, but
the deliverable is a production sync. Replace the hard-coded refusal with an
explicit opt-in that keeps the safety: the worker requires
PCR_SYNC_TARGET_REF to equal the ref parsed from SUPABASE_URL and
additionally PCR_SYNC_ALLOW_PRODUCTION=true when that ref is production;
lifecycle.mjs adopt takes --allow-production. Keep the tests, inverted.
The production rollout in §8 still does not use adopt (no DB password on this
machine; the metadata insert runs through MCP), so the worker change is the one
that matters.
B3. First-run duration versus the cron deadline. runTick defaults to a
240 s deadline inside a 300 s function. The preview's first live run (4,690
creates in batches of 25, plus 117 company requests) took 2 m 35 s with an
empty database and 5 min in dry-run mode; production will also link 3,000+
legacy rows and classify discipline/specialty per insert. A deadline failure
marks the run failed and, by design since ac5dd63, pushes next_run_at a
full interval out, so a slow first run costs a day. Fix two things: raise
maxDuration to 600 and the default deadline to 540 s (Pro plan allows 800 s
with Fluid compute), and run the first production sync from a laptop with
pnpm --filter pcr-sync start -- --dry-run --force then --force (the CLI
uses a 1 h deadline). Also make a deadline/failed run schedule a retry at
now() + 15 min rather than a full interval; only succeeded/partial runs
should honour the cadence.
B4. Dates are never written to roles. pcr_bridge_command stores
DatePosted, BeginDate, EndDate, LastModified only in
pcr_bridge_orders.raw_dates "until PCR's timezone is confirmed". It is
confirmed (Table 3): the API returns the same naive wall-clock values as the
SQL Server export that private.archive_ts already converts as
America/New_York. Consequences today: roles.posted_at and start_date are
null for every synced role, so the Roles grid's Posted/Age/Start columns are
empty, today_uncovered_roles (ordered by priority desc, posted_at asc nulls last) sorts synced roles last, and report_role_aging falls back to
created_at, which makes every role look brand new on the day of the first
run. Fix in apply: posted_at = private.archive_ts((p->'raw_dates'->>'DatePosted')::timestamp),
start_date = (p->'raw_dates'->>'BeginDate')::date. Leave end_date as it is
(masked for managed open roles by role_rows_for, never a closure signal, per
Julia), and keep the raw values in raw_dates. Add posted_at and
start_date to the owned-field list, the "unchanged" comparison and the
desk_pcr_bridge.sql test (posted_at must equal
'2026-10-06T10:11:12.227'::timestamp at time zone 'America/New_York').
B5. Cutover: the legacy open set. Once pcr_bridge_settings.enabled is
true on the API transport, apply_role_import raises P0409 ("PCRecruiter
sync is authoritative"). That removes the only mechanism that closed roles the
report no longer listed. The sync closes a linked role only when PCR says so,
and it never looks at open roles it has not linked. So every production open
role that PCR no longer lists stays open forever. Table 3 quantifies the set:
3,285 open roles are not in PCR's current set, and 2,095 current orders have
no role by id, so without the linking fix below the first run creates about
2,000 new roles while the 1,810 report-sourced roles they duplicate stay open.
Two fixes:
- Linking:
applylinks a legacy row byroles.id = JobId and source='pcr', else byvms_job_idand the samecompany_id. Report-sourced roles got theircompany_idby name match against the PCR company list, so a company-id mismatch produces a duplicate (new role created, old one left open). Add a third tier before creating: exactly one unlinked open role with the samevms_job_idand the same normalized company name (private.norm_name), else quarantine asP1001. This mirrorsprivate.role_import_plan's "job+company" tier. - A one-time, reviewed cutover after the first clean production run: close
open roles with
source in ('pcr','report')that are not inpcr_bridge_orders, withclosed_reason = 'Not in PCRecruiter at sync cutover', moving active pipeline rows tojob_closedviaprivate.do_move, exactly asapply_role_importdoes. Roles with a pipeline row or an assignment (Table 3) get a manual look first. Add the new reason to the P1004 allowlist so PCR can reopen them later. This is a data step, not a migration (§8 step 9).
B6. Dead code in Desk. apps/desk/src/lib/order-sync/{model,plan}.ts and
apps/desk/src/lib/pcrecruiter/{normalize,schemas,export}.ts (plus their
tests, 459 test lines) are an earlier design (provider/scope identities,
sourceUpdatedAt, allowSourceReopen) that neither Desk nor the worker
imports; the worker has its own normalize.ts and run.ts. They would be
maintained forever by whoever touches roles. Delete them, or move anything
worth keeping into the worker. The single import the worker really needs from
Desk is parse-report.ts (+ report-date.ts), and it reaches it with
../../desk/src/lib/role-imports/parse-report.ts. Prefer a tiny workspace
package packages/order-report (parser + date helper, no React) that both
apps import; it also removes the .ts-extension import that the branch added
to Desk only for Node's type stripping (TypeScript requires
allowImportingTsExtensions, Node requires the extension).
B7. .gitignore now ignores .env.*, which also ignores any future
.env.example. Add !.env.example and commit apps/pcr-sync/.env.example
listing the variables in environment.ts.
5.2 Should fix before enabling in production
S1. The budget model has the wrong unit. PCRecruiter's plans are
per day: Free up to 5,000 calls/day, Premium ($100/month) up to 50,000
calls/day; logins count. The branch budgets 40,000 per month and defaults
the interval to 24 h, which is why the docs say "40,000-call monthly operating
budget under the 50,000-call plan". Keep the monthly cap as a cost guard but
add a daily cap (daily_budget default 4,000 under Free, or 40,000 under
Premium) in reserve, and raise the cadence: at 60 minutes a run costs about
13 calls (1 auth + 10 position pages + 1–2 company pages) plus two probes per
closure, roughly 500 calls/day. The order report arrives once a day at ~8 AM
ET; an hourly sync is strictly better and still under the Free tier. Also,
PCR rejects more than three /access-token requests in a 10 s window with 400;
the client's 11 s back-off is correct, keep it.
S2. Status map gaps. normalize.ts maps Available, Hold, Pending,
Filled, OfferAccepted, Closed, Expired, NotAvailable. PCR's documented set is
A/F/I/P/H/C (Internal Only exists); extra values are instance-specific. A
linked open role whose PCR status becomes InternalOnly (or anything
unmapped) fails normalization on every direct probe, which makes every run
partial and never closes the role. For a linked role map unknown
non-current statuses to closed; keep refusing to create from them. Confirm
with Julia what Internal Only and Pending mean at Job.com.
S3. "Order report" semantics are now wrong. The Roles filter/view "Not on
the latest report", the grid column, the role panel's Source line and
role_detail.onLatestReport all compare roles.source_verified_at with
private.latest_report_at() (the newest applied report). The bridge never sets
source_verified_at, and no report will be applied again, so every synced
role reads "never on a report" and the view fills with all of them. Fix in SQL
without touching role_row: define private.latest_source_check_at() =
greatest(latest_report_at(), max(pcr_bridge_runs.started_at where status in ('succeeded','partial'))) and, in role_rows_for and role_filter_sql, use
greatest(r.source_verified_at, o.last_seen_at) from a left join on
pcr_bridge_orders. Then "verified" means "confirmed by the latest source
check", which is what recruiters want. Desk copy changes in §6.
S4. Recruiter edits versus source ownership. update_role lets any
assigned recruiter edit title, city, state, zip, bill/pay rate, openings,
description, start/end date; all but the dates are source-owned fields in the
bridge. A later PCR change to the same field raises P1005 and the order is
quarantined on every run until someone edits the field back; there is no UI
for it. Decide one of: (a) Desk locks source-owned facts on managed roles
(§6, recommended; the job-section editors already take a muted/read-only
path), or (b) source wins and the bridge logs the overwrite as an activity.
Keep P1005 as the safety net either way.
S5. pcr_bridge_health(p_scope) needs the scope hash (sha256 of the PCR
DatabaseId), and pcr_bridge_settings is unreadable to authenticated, so
Desk cannot discover it. Make p_scope optional (null → every scope) or add
pcr_bridge_scopes() for managers.
S6. Quarantine growth. record_error is unique per
(run_id, external_id, stage, error_code), so a persistent row error adds one
row per run. Add a weekly pg_cron job that deletes resolved errors older than
90 days and pcr_bridge_requests older than 13 months.
S7. supabase/config.toml [db.seed] enabled = false also changes local
development for apps/app, whose generated supabase/seed.sql was loaded by
supabase db reset. Either keep enabled = true for local and disable it per
remote with [remotes.<branch>.db.seed], or tell Alton that db reset no
longer seeds. Not a correctness issue for Desk.
S8. Branching runs each migration in a single transaction. Nothing in the
two new files needs concurrently, so this is fine, but note it for future
index migrations: they will pass db push and fail in branching.
S9. On-hold roles are invisible by default. 123 Hold/Pending orders map
to on_hold, and the Roles "Open" view defaults to statuses: ["open"] with
the Status column hidden. Add on_hold to the default view or show a status
chip count. Small, but recruiters will ask where those went.
S10. Unconfirmed MSP aliases. 273 orders carry MSP names not in the alias
map (Table 2). The map is code (normalize.ts). Approve the list or move it
to a pcr_bridge_msp_aliases table a manager can edit.
S11. Worker secrets and deployment. No Vercel project exists for
apps/pcr-sync yet. Create job-com-pcr-sync with Root Directory
apps/pcr-sync, Framework "Other", Node 24, Pro cron (*/15 needs Pro; Hobby
allows once a day), environment variables from environment.ts, and
CRON_SECRET (≥32 chars). job-com-matching is the template: it runs the
same api/tick.ts web-handler pattern with framework: null in production
today, so the pattern is proven. Vercel invokes the production deployment
only, never retries a failed invocation, and may deliver duplicates; the lease
in claim handles duplicates. Keep "Include source files outside of the Root
Directory" on (default) until B6 moves the parser into a package.
S12. Vercel blocks Max's deployments. "Git author must have access to the project" is a documented security boundary with no off switch: add Max to the Goldkey Pro team (the PR comment carries the invite link) or accept that PR deploy previews stay blocked and only merges by a team member deploy.
S13. Vendor terms. The PCRecruiter Vendor API Access Terms (v1.0, 2026-07-22) require prior written approval for "database replication or data synchronization functions" and for AI use. The memory note from the planning session records this as blocker B2 with no resolution. The preview has already made 161 live calls. Confirm the approval status before the production cron is enabled; this is a business gate, not a code change.
5.3 Fine as built (noted so nobody re-reviews them)
- Identity on
JobId, with portal-id matching only for unlinked legacy rows andP1001for ambiguity; regression-tested indesk_pcr_bridge_identity.sql. - Closure needs two consecutive direct 404s; file transport can never close.
- Field ownership: the bridge records what it applied and refuses to overwrite
a recruiter's edit (
P1005); staff closures are protected (P1004); filled is protected (P1003). - Dry run returns outcomes without writing roles, and still records the run.
- Diagnostics redact row values and constraint failures; tests prove it.
- The lease (15 min, renewed per command) plus the per-account advisory lock
make overlapping ticks safe;
claimreturns null when not due, so an idle tick makes zero PCR requests. P0411stops the run when the budget is exhausted; retries and auth count.- The baseline/adoption tooling derives its contract from the committed SQL, verifies read-only, and refuses when history or content drifted; CI proves the adoption transition on a disposable database.
6. Desk changes needed (apps/desk)
Nothing in the Desk UI knows about the sync. These are the concrete changes,
all following AGENTS.md conventions (store in lib/<feature>/store.ts,
copy in copy.ts, RPC calls through the typed client, route handlers gated).
- Copy (
src/lib/roles/copy.ts):sources.pcr"PCR archive" → "PCRecruiter"; addpanel.values.syncedAt(when)"Synced from PCRecruiter · {when}";filters.verified"Order report" → "Source check",verifiedOn"Confirmed by the latest check",verifiedOff"Not confirmed by the latest check";views.unverified"Not confirmed";columns.verified"Last confirmed".src/lib/activities/model.ts: labels forpcr_created"Added from PCRecruiter",pcr_updated"Updated from PCRecruiter",pcr_closed"Closed in PCRecruiter".copy.test.tslints them. - Today (
components/today/manager/report-strip.tsx,lib/today/store/manager.ts): when the bridge is enabled, replace the "Today's order report is not imported" banner with a sync line: "PCRecruiter sync · last run 8:14 AM · 4,690 current · 12 new since yesterday" from a newlib/pcr-sync/store.tsreadingpcr_bridge_health. Keep the import banner only when the bridge is disabled or on file transport. - Order Reports pages (
components/role-imports/*): map the newP0409message to copy ("PCRecruiter sync is on; reports are for checking only") instead of the generic "stale"; better, give the SQL guard its own SQLSTATEP0412and addsync_authoritativetoIMPORT_SQLSTATES. Hide the Upload action and the sidebar "Import report" entry while the bridge is on; keep the history pages. - Role panel (
components/roles/panel/source-section.tsx): for managed roles show "Synced from PCRecruiter · {RelativeTime last_seen_at}", the PCRPositionId, and the raw MSP value when it was normalized; load throughGET /api/roles/[id]/pcr→pcr_bridge_role_history.job-section.tsx: render source-owned facts read-only on managed roles (S4a) with a lock hint; discipline, specialty, job type and priority stay editable. - Settings → System → "PCRecruiter sync" (manager): health card (last
run, next run, cadence, dry-run flag, enabled), request count today versus
the daily budget, the last 20 runs with counts, and the quarantine list
(each row linking to the role or showing the PCR id). Pattern:
components/settings/match-runs-section.tsx. Needs S5. "Sync now" is out of scope until an authenticated trigger exists (the cron route is secret-gated; apcr_bridge_command('configure', {next_run_at: now()})wrapper for managers would do). - Grid: no
role_rowchange; with S3 the existing "verified" column and view keep working under new names. Consider showingon_holdin the Open view (S9). - Mock (
mock/server.mjs): stubpcr_bridge_healthandpcr_bridge_role_historysopnpm test:e2e:mockcovers the new surfaces (run from a separate worktree; it refuses a checkout with a running dev server).
Estimated size: 2–3 days, one person, after the branch fixes land.
7. Implementation plan
Ordered. Each step has an owner and a check. Steps 1–8 are branch commits; 9–16 are production work that happens around the merge.
| # | Step | Owner | Done when |
|---|---|---|---|
| 1 | .prettierignore with pnpm-lock.yaml; drop it from baselineDebt (B1) | Max | "Workspace checks" green on the PR |
| 2 | Production opt-in in store.ts and lifecycle.mjs (B2); tests inverted | Max | pnpm --filter pcr-sync test and pnpm test:database-lifecycle green |
| 3 | maxDuration 600, deadline 540 s, failed/deadline runs retry in 15 min (B3) | Max | tick.test.ts covers the retry schedule; desk_pcr_bridge.sql asserts next_run_at on failed |
| 4 | Write posted_at and start_date from raw_dates with private.archive_ts (B4) | Max | Test added; preview re-run shows 0 null posted_at |
| 5 | Third linking tier by vms_job_id + normalized company name; new closed reason in the P1004 allowlist (B5.1) | Max | desk_pcr_bridge_identity.sql case for a report role with a different company id |
| 6 | Delete lib/order-sync and lib/pcrecruiter from Desk; move the parser to packages/order-report and import it from both apps (B6); .gitignore and .env.example (B7) | Max | Desk and worker typecheck; Vercel preview of the worker builds |
| 7 | Daily budget + hourly default cadence + docs correction (S1); unknown statuses close linked roles (S2); pcr_bridge_health without scope (S5); cleanup cron (S6) | Max | Tests; docs updated |
| 8 | latest_source_check_at and the greatest(source_verified_at, last_seen_at) rule in role_rows_for and role_filter_sql (S3) | Max | desk_rls.sql verified-filter assertions still pass; a new assertion for a synced role |
| 9 | Production adoption (§8 steps 1–3): verify contract read-only, insert the baseline version row, apply the bridge migration through MCP with the file's exact name | Stan | supabase_migrations.schema_migrations on production lists 46 versions equal to the files |
| 10 | Merge the PR (squash). The Supabase GitHub integration then finds no new migration for production; the preview branch is deleted automatically | Stan | Integration comment on the merge shows nothing pending; main CI green |
| 11 | Vercel project job-com-pcr-sync (S11), env vars, first production deploy; confirm GET /api/tick without the secret answers 401 | Stan | Deployment Ready; cron listed in the project's Cron Jobs tab |
| 12 | First production dry run from a laptop: pnpm --filter pcr-sync start -- --dry-run --force against production; review counts (created vs linked by id vs linked by requisition) and the quarantine | Stan | Created count is small (new since Oct 6); no systemic failures |
| 13 | First live run (--force), then a second run that must report all unchanged | Stan | Second run: 0 created, 0 errors |
| 14 | Cutover closure (B5.2) as a reviewed SQL run through MCP, after a manual look at the open-not-current roles that have pipeline rows or assignments | Stan | Open roles = linked live orders + Desk-created roles |
| 15 | Desk changes (§6) on a follow-up branch | Stan | e2e mock green; copy lint green |
| 16 | Enable the cron cadence (configure interval 60), watch three days of pcr_bridge_runs, then retire the daily report upload | Stan | Three consecutive clean runs; Julia confirms the Today strip |
Gate before step 11: S13 (vendor approval) confirmed in writing.
8. Production adoption of the baseline, step by step
Why this is needed: the branch adds 20261001000000_desk_environment_baseline
with a timestamp earlier than every applied migration. Production already
has everything it creates (schemas, the five archive tables, the six helpers,
the extensions) from the manual archive load. The file uses plain
create table, so if it ever executes on production it fails on
pcr.candidates already existing. The Supabase GitHub integration, with
"Deploy to production" enabled, applies "new migrations" on merge to the
production branch; Supabase tracks applied versions in
supabase_migrations.schema_migrations. The safe sequence is therefore:
record the baseline as applied before the merge, and apply the bridge
migration ourselves so nothing is left for the integration to run.
-
Verify the contract, read-only (already done once for this review, Table 3). Regenerate with
node scripts/database/lifecycle.mjs sqlfrom the branch and run the SELECT through the Supabase MCPexecute_sqlagainst production. It must return[]. -
Record the baseline (the same statement
metadataSqlwould run; the SHA256 is of the committed file):1begin; 2lock table supabase_migrations.schema_migrations in exclusive mode; 3insert into supabase_migrations.schema_migrations(version, name, statements) 4select '20261001000000', 'desk_environment_baseline', 5 array['-- Baseline adopted after complete read-only contract verification; historical SQL was not replayed. SQL SHA256: 47b40aef14598529e7515d4b114b703e6ac693789d9907b834a6db8c085d00a4'] 6where not exists (select 1 from supabase_migrations.schema_migrations where version = '20261001000000'); 7commit;That hash is the file at
943ec6d; recompute it withshasum -a 256if the file changes before the merge. The CLI equivalent, for a linked checkout, issupabase migration repair 20261001000000 --status applied. -
Apply the bridge migration through MCP
apply_migrationwith namedesk_pcr_bridgeand the file's content at the merge commit, so the recorded version is20261006202911and matches the file. (If step 8 of §7 changes the file, apply the final content.) Confirm:select version, name from supabase_migrations.schema_migrations order by version desc limit 3. -
Then merge. If "Deploy to production" is on, the integration compares history with the files and finds nothing new. If it is off, nothing happens either way. Either is fine; check the integration's comment on the merge commit.
-
Never run
pnpm db:adoptorsupabase db pushagainst production from a developer machine. The tooling refuses by design; the plan above keeps it that way.
Rollback: the bridge migration is additive (new tables, new functions, and
create or replace of role_rows_for, match_role_source,
apply_role_import). To back out, drop the pcr_bridge_* tables and
functions and re-run the three previous definitions from
20261006052713_desk_review_hardening.sql and 20261001232507_…source_guard.sql;
delete the two history rows. Roles created by the sync carry source='pcr'
and an activity row, so they are identifiable.
9. Reply to Max (ready to paste)
Thanks Max, this is solid work and the preview numbers check out on my side. Three answers and a short list before we merge.
Oct 1: there is no PCR snapshot from Oct 1 and none can be made (no history endpoint; the archive froze Aug 21). We did verify on Oct 6 that the report is PCR "Available" at ~8 AM ET and that the 1,649 ids that left the report between Oct 1 and Oct 6 are really closed in PCR. Drop the Oct 1 reconciliation; it is not a merge condition.
~105 orders: the list came from Jason verbally; I will get it with MSP, requisition id and date. Not a merge condition either.
Before merge: (1) CI is red on
pnpm-lock.yamlformatting; add it to a.prettierignore. (2) Make production an explicit opt-in instead of a hard refusal instore.tsandlifecycle.mjs. (3) Writeposted_atandstart_datefrom the raw dates withprivate.archive_ts; the timezone is confirmed (API values equal the SQL Server export). (4) Raise the function limit to 600 s and the deadline to 540 s, and retry a failed run in 15 min instead of a full interval. (5) Add a linking tier by requisition id plus normalized company name, and allow reopening roles closed with the cutover reason. (6) Remove the unusedorder-syncandpcrecruiterlibs from Desk and move the report parser into a small package. (7).gitignoreshould not swallow.env.example. Also note PCR's plans are per day (50k/day on Premium), so we can sync hourly; make the daily budget the real cap.I will handle the production adoption (version row + bridge migration via MCP) right before merging, then run the first sync from my machine, do the cutover of stale open roles, and wire the Desk UI on a follow-up branch.
10. Citations
Official documentation used for the findings above (fetched 2026-10-06).
Supabase
- Branching, GitHub integration, "Deploy to production", what is deployed on merge, failure handling: https://supabase.com/docs/guides/deployment/branching/github-integration
- Branching overview, ephemeral vs persistent, deletion on PR close/merge,
with_data: https://supabase.com/docs/guides/deployment/branching, https://supabase.com/docs/reference/api/v1-create-a-branch - Branching troubleshooting (single transaction per migration, timestamp order, production branch mapping): https://supabase.com/docs/guides/deployment/branching/troubleshooting
- Migration history table and
migration repair --status applied: https://supabase.com/docs/guides/deployment/database-migrations, https://supabase.com/docs/reference/cli/supabase-migration-repair db pushskips applied versions;--include-all: https://supabase.com/docs/reference/cli/supabase-db-push, https://supabase.com/docs/reference/cli/supabase-migration-up[db.seed]config and per-remote overrides: https://supabase.com/docs/guides/local-development/cli/config#db.seed.enabled, https://supabase.com/docs/guides/deployment/branching/configuration- pg_cron + pg_net alternative scheduler (if Vercel cron is ever dropped): https://supabase.com/docs/guides/database/extensions/pg_net, https://supabase.com/docs/guides/cron/quickstart
Vercel
- Cron jobs:
CRON_SECRETBearer header, production-only invocation, no retries, plan limits (Hobby once per day, Pro per minute): https://vercel.com/docs/cron-jobs, https://vercel.com/docs/cron-jobs/manage-cron-jobs, https://vercel.com/docs/cron-jobs/usage-and-pricing - Function duration with Fluid compute (Pro max 800 s) and
vercel.jsonfunctions: https://vercel.com/docs/functions/configuring-functions/duration - Web-standard functions under
/apiwithframework: null; TypeScript support: https://vercel.com/docs/functions/functions-api-reference, https://vercel.com/docs/functions/runtimes/node-js, https://vercel.com/docs/project-configuration/vercel-json#framework - Root Directory and "Include source files outside of the Root Directory": https://vercel.com/docs/monorepos/monorepo-faq, https://vercel.com/docs/builds/configure-a-build#root-directory
- Git author access check: https://vercel.com/kb/guide/commits-not-triggering-deployments
PCRecruiter
- Access token, Bearer sessions, inactivity expiry,
DELETE /access-token, the 10-second token throttle: https://www.pcrecruiter.net/APIDOCS_V2/additional-references/Access-Token.html, https://www.pcrecruiter.net/APIDOCS_V2/api-reference/Quick-Start-Guide.html - Query grammar (
eq,gt,ge, AND/OR,EXISTS (CustomFields…)),ResultsPerPagemax 500,Order,Fields,TotalRecords: https://www.pcrecruiter.net/APIDOCS_V2/additional-references/Search-References.html, https://www.pcrecruiter.net/APIDOCS_V2/additional-references/Search-Parameters.html, https://www.pcrecruiter.net/APIDOCS_V2/additional-references/Positions-V2.html - Plans (Free 5,000 calls/day; Premium $100/month, 50,000 calls/day; logins count) and the Vendor API Access Terms (approval for sync/replication and AI use): https://pcrecruiter.net/solutions/api, https://www.pcrecruiter.net/site/developer-api/
- Position status codes (A, F, I, P, H, C) and database time offset: https://learning.pcrecruiter.net/site/?p=10938, https://learnpcr.pcrecruiter.net/site/?p=5601
TypeScript and Node
allowImportingTsExtensionsrequiresnoEmit: https://www.typescriptlang.org/tsconfig/#allowImportingTsExtensions- Node type stripping requires explicit
.tsextensions in imports: https://nodejs.org/api/typescript.html
Repository and project references
- Original plan:
docs/product/pcr-sync-implementation-plan.md(§0 blockers, §8.6 linking, §9.1 coexistence with the report import, §11 Desk UI). - Branch docs:
docs/product/live-orders-bridge.md. - Desk conventions:
apps/desk/AGENTS.md.