Skip to content

feat(procore): Track A P0 hardening — fail-closed, durable, async /v1 webhook (T1-T4,T10)#24

Merged
mcxl merged 6 commits into
mainfrom
procore/track-a-p0-hardening
Jun 17, 2026
Merged

feat(procore): Track A P0 hardening — fail-closed, durable, async /v1 webhook (T1-T4,T10)#24
mcxl merged 6 commits into
mainfrom
procore/track-a-p0-hardening

Conversation

@mcxl

@mcxl mcxl commented Jun 17, 2026

Copy link
Copy Markdown
Owner

Summary

Track A — Procore go-live P0 hardening (P0 blockers from the Stage 3 certification review). Bounds the integration to a single, fail-closed, durable, async /v1/procore/webhook. Confirmation-independent work only; Procore-gated final wiring (real auth scheme, write-back resource) is deferred.

Source of truth: docs/procore/STAGE3_CERTIFICATION_PLAN_V2.md (tickets T1–T10).

Tickets in this PR (one commit each)

  • T1 feat(procore): disable legacy /procore route behind a flag — only /v1 is registered by default; /procore → 404 unless PROCORE_LEGACY_ROUTE_ENABLED=true. Removes the pre-auth-ordering attack surface.
  • T10 feat(procore): run baseline review when no project rule pack — empty fallback pack so baseline/structural review always runs; project_review_status=UNAVAILABLE, project_id preserved.
  • T2 feat(procore): scheme-agnostic fail-closed webhook auth — pluggable verify(headers, body) selected by env (PROCORE_REQUIRE_AUTH, PROCORE_AUTH_SCHEME); fail-closed by default; runs before any side effect. No Procore scheme invented (UNVERIFIED pending Procore).
  • T3 feat(procore): durable idempotency reserved before side effects — Supabase reserve RPC (migration 007, private schema + SECURITY DEFINER + service-role EXECUTE) with in-memory fallback; SHA-256(body) key when no delivery_id; reserved before logging/fetch/review/comment.
  • T4 feat(procore): async 202 + dead-letter failure surface — pipeline extracted to a background task; endpoint returns 202 fast; failures recorded + alerted via new core/procore/alerts.py, never raised.

Deployment prerequisites (not done in this PR)

  • Apply migration 007_procore_webhook_deliveries.sql to the Supabase project; set SUPABASE_SERVICE_ROLE_KEY in the app env (else idempotency degrades to in-memory).
  • Set PROCORE_AUTH_SCHEME (+ secret) once Procore confirms the real scheme — until then the endpoint is fail-closed (401).

Out of scope (next batches)

  • T5 (gate local JSONL), T6 (audit tables + retention), T9 (SBOM/CI) — next Track A batch.
  • T7 (OAuth), T8 (write-back resource) — Procore-confirmation-gated.

Tests

pytest tests/test_procore_webhook.py → 67 passed. Full suite green (pre-push gate passed). flake8 clean.

🤖 Generated with Claude Code

mcxl and others added 6 commits June 17, 2026 15:07
The deprecated Stage 2 /procore webhook route is registered by default
alongside the canonical /v1/procore/webhook, exposing a second (pre-auth-
ordering) attack surface. Disable its registration unless
PROCORE_LEGACY_ROUTE_ENABLED=true (rollback only); the lazy import means
api/procore.py is not loaded by default. Module stays on disk for the
direct-import tests in tests/test_procore_stage2.py.

Adds a request-level test asserting POST /procore/webhook -> 404 by default.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A submittal for a project without a rule pack returned early with
status=no_rule_pack and received NO review at all, even though the engine
supports pack-less structural review. Replace the early return with an
empty fallback pack carrying project_id=event.project_id, so the baseline/
structural review always runs; the rule pack only ADDS project-specific
criteria. project_review_status resolves to UNAVAILABLE and the artifact's
project_id stays populated.

Updates the request test: no rule pack -> review runs (200/reviewed),
project_id preserved, project_review_status=UNAVAILABLE.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
/v1/procore/webhook previously verified the signature only when a secret
was configured (fail-open) and hardcoded an unverified header. Replace it
with a pluggable _verify_procore_request selected by env so Procore's
confirmed scheme is a config swap, not a code change:
- PROCORE_REQUIRE_AUTH (default true), PROCORE_AUTH_SCHEME (default
  unverified -> reject), schemes hmac_sha256 / authorization_bearer, and a
  configurable PROCORE_SIGNATURE_HEADER.
- Fail closed: unknown/unset scheme rejects. Auth runs before any side
  effect (logging, idempotency, fetch, review, comment).
- No Procore scheme invented; validate_signature is one selectable scheme.

Tests: TestWebhookAuth covers unverified->401 with no side effects,
no-secret->401, bad bearer->401, valid bearer/HMAC->200. Functional tests
run with auth bypassed via an autouse fixture.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
In-memory idempotency was lost on restart and checked AFTER log_payload;
deliveries without a delivery_id were never de-duplicated. Add a durable
reservation:
- delivery_key() prefers delivery_id, falls back to sha256(raw body).
- reserve_delivery() reserves via a Supabase RPC when configured, else an
  in-memory fallback (logged as non-durable); on RPC error it falls back too.
- The route reserves right after parse_event, BEFORE log_payload/fetch/
  review/comment; duplicate -> already_processed.

Migration 007 adds the deliveries table in a private (non-exposed) schema
with RLS, and a SECURITY DEFINER reserve RPC in public with EXECUTE granted
to service_role only. Not yet applied to the project; until then the route
degrades to the in-memory fallback.

Tests: body-hash fallback dedupes, reservation precedes log_payload, durable
path used when configured, falls back to memory on error.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
/v1/procore/webhook processed fetch/review/store/compare/comment
synchronously and returned the review body, risking Procore's webhook
timeout. Extract the pipeline into _process_procore_v1_webhook and run it
via BackgroundTasks: after auth + durable reservation + log_payload, the
endpoint returns 202 'accepted' immediately.

- Failures in the background pipeline are recorded (record_state) and
  alerted via a new canonical core/procore/alerts.py, never raised to the
  caller (which already got 202). /v1 no longer depends on api/procore.py.
- BackgroundTasks is interim async; certification-grade durability may need
  a worker/queue (noted in code).

Tests: endpoint asserts 202/accepted; TestProcorePipeline covers reviewed,
baseline-UNAVAILABLE, ignored, and failure-alerted-not-raised. record_state
stubbed so background tasks never hit Supabase.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4265868fda

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread api/main.py
Comment on lines +1524 to +1528
if not reserve_delivery(delivery_key(event.delivery_id, body), correlation_id):
return JSONResponse(content={"status": "already_processed", "delivery_id": event.delivery_id})

# Phase 1: only handle submittal events
if "submittal" not in event.event_type:
return JSONResponse(content={"status": "ignored", "reason": "not a submittal event"})

# Extract PDF attachments
attachments = extract_submittal_attachments(event)
if not attachments:
return JSONResponse(content={
"status": "no_pdf",
"reason": "No PDF attachments found in submittal",
})

# Load project rule pack
_PROCORE_RULE_PACKS_DIR.mkdir(parents=True, exist_ok=True)
rule_pack_path = _PROCORE_RULE_PACKS_DIR / f"project_{event.project_id}.json"
if not rule_pack_path.exists():
return JSONResponse(content={
"status": "no_rule_pack",
"reason": f"No project rule pack found for project {event.project_id}",
"project_id": event.project_id,
})

with open(rule_pack_path, encoding="utf-8") as f:
rule_pack = json.load(f)

att = attachments[0]
swms_text = ""
retrieval_mode = "none"

# Phase 1B: try live Procore API retrieval first
try:
from core.procore.api_client import fetch_attachment, is_live_configured
if is_live_configured() and att.url:
pdf_bytes = fetch_attachment(event.project_id, att.url)
from core.intake_extractor import extract_from_pdf
extraction = extract_from_pdf(pdf_bytes, source_label=att.filename)
if extraction.text_extraction_succeeded:
swms_text = extraction.raw_text
retrieval_mode = "live_api"
logger.info(f"Live retrieval: {len(swms_text)} chars from {att.filename}")
except Exception as live_err:
logger.warning(f"Live Procore retrieval failed: {live_err}")

# Phase 1A fallback: simulated/fixture text
if not swms_text:
swms_text = payload.get("_simulated_swms_text", "")
if swms_text:
retrieval_mode = "simulated"

if not swms_text:
fixture_path = payload.get("_fixture_pdf_text_path", "")
if fixture_path and _Path(fixture_path).exists():
swms_text = _Path(fixture_path).read_text(encoding="utf-8")
retrieval_mode = "fixture"

if not swms_text:
return JSONResponse(content={
"status": "no_text",
"reason": f"Could not extract text from {att.filename}. "
"Configure PROCORE_ACCESS_TOKEN for live retrieval, "
"or provide _simulated_swms_text for testing.",
"attachment": att.filename,
})
# Log payload for replay/debugging (after reservation).
log_payload(event)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not poison idempotency on payload log failures

When log_payload raises, for example on a read-only container filesystem or a full disk, this path has already committed the delivery key via reserve_delivery but has not yet scheduled _process_procore_v1_webhook. The request then fails, and any Procore retry is answered already_processed, so the submittal is never reviewed or alerted; make the replay log best-effort or only finalize the reservation once the background work is safely queued.

Useful? React with 👍 / 👎.

@mcxl mcxl merged commit 4382d83 into main Jun 17, 2026
1 check passed
@mcxl mcxl deleted the procore/track-a-p0-hardening branch June 17, 2026 07:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant