## Summary
New ECS pipeline runner. Daily cron pulls Grainne agent updates into klair_pg.action_hub.pain_points, then idempotently mirrors Status / dates / owner / notes back to Salesforce. Replaces the manual Klair-side script with a scheduled, observable Surtr pipeline.
This branch is the source of truth for pull_from_grainne.py going forward. The klair-side copy is being deleted in [Klair#2722](https://github.com/AI-Builder-Team/Klair/pull/2722), which should land before or with this PR because the UI write path shares the same SF/date contract.
## What's in the runner
pipelines/runners/grainne-pull/├── pipeline.json # ECS, cron(0 1 * * ? *), 256/512/0.25h
├── Dockerfile, pyproject.toml, requirements.txt, uv.lock
├── src/main.py # orchestrator: secrets + script + run summary
├── scripts/pull_from_grainne.py # cron entry — no CLI, single mode
├── scripts/sf_writeback.py # vendored from klair-api @ 7402eeb3a
└── tests/ # 68 pure-helper tests, no DB or network
## Behavior
- Picklist mapping fixed. Resolved → "Solved" (SF rejected "Resolved"); Assigned → "Assigned" (was silently downgrading to "New").
- Idempotent SF push. Pushes current PG state every pull, regardless of what changed. Self-heals prior silent failures.
- Date/transition bugs fixed. Blocked → In Progress no longer rejected as regression; closed_at is only cleared when leaving Resolved; assigned_at preserves first-stamp with CASE WHEN assigned_at IS NULL THEN NOW() ELSE assigned_at END.
- Status audit trail. Every status change writes to pain_point_status_events.
- Cron-safe filters. Skips terminal-on-both-sides rows older than 7 days; short-circuits when Grainne actioned_at has not advanced.
- SF orphan handling. Deleted Salesforce rows are logged as sf_writeback_orphan_skipped and do not fail the cron.
- Canonical Owner_Name__c shape. Single format_sf_owner_name(name, email) helper used by both build_canonical_fields_from_row (cron) and build_status_transition_fields (backend). Emits "Display Name (email)" when both are present, bare display name when email is missing, skips the field when name is blank. Backend (Klair#2722) threads named_owner_email into the helper so cron and UI writes are byte-identical.
- Step Function input normalization. Empty/partial manual execution input is normalized before CreateRunRecord, defaulting trigger_type: ON_DEMAND, triggered_by: UNKNOWN, and params: {} while preserving EventBridge/dashboard/upstream overrides.
## DDL dependency
Lives in [Klair#2722](https://github.com/AI-Builder-Team/Klair/pull/2722) and is already applied to klair_pg:
- pain_points.grainne_last_status
- pain_points.grainne_last_actioned_at
- pain_points.grainne_last_notes
- pain_point_status_events audit table
## Production verification
End-to-end against the deployed dev ECS pipeline, prod klair_pg, prod Grainne, prod Salesforce.
L4 dry preview — all 68 candidates:
candidates=68 fetched=59 grainne_404=9 grainne_5xx=0status_changed=16 assignee_changed=2 notes_posted=3
sf_pushes_attempted=118 sf_pushes_failed=0 duration=93s
L5 single-row live run: a3Efu000000K9LhEAK — PG status flipped Open → Resolved, watermark set, audit row written with source='grainne-pull', SF moved New → Solved, Closed_Date__c stamped.
Historical reconciliation: 68 rows reconciled; 4 USAA SF orphans closed in PG with source='admin-sf-orphan-cleanup' audit rows.
Deployed/verified dev pipeline:
- cdk deploy Pipeline-grainne-pull-dev -c env=dev completed cleanly.
- EventBridge schedule enabled.
- Correct CDK asset-backed ECS image deployed after confirming manual pushes to pipeline-grainne-pull-dev:latest do not affect the task definition.
- Run 5 verified orphan handling live:
candidates=68 fetched=59 sf_pushes_attempted=8 sf_pushes_failed=0 duration=37.83s
Two sf_writeback_orphan_skipped warnings fired for a3Efu000000KHcbEAG (canonical state + notes), as expected.
## Run summary contract
Written to /tmp/grainne_pull_summary.json and merged into pipeline_runs.output_summary:
candidates, fetched, grainne_404, grainne_5xx,status_changed, assignee_changed, notes_posted,
sf_pushes_attempted, sf_pushes_failed, duration_seconds
## Still intentionally out of scope
- grainne_5xx counter name currently includes non-404 4xx responses; cosmetic metric label issue.
- No automated vendored-helper drift check between Surtr and Klair copies.
- No live E2E test harness; live coverage currently depends on manual/prod verification because it needs Grainne creds + klair_pg + Salesforce.
## Local validation on latest branch tip
cd pipelines/cdk && npm test -- --runTestsByPath test/constructs/step-function.test.ts test/constructs/ecs-step-function.test.tscd pipelines/cdk && npm run build
cd pipelines/runners/grainne-pull && uv run --with pytest --with pytest-asyncio --with simple-salesforce pytest tests/test_sf_writeback.py -q
All pass locally. CDK Jest prints the existing MapProps#parameters deprecation warning.
## Status
- [x] DDL applied to prod klair_pg.
- [x] Dev pipeline deployed and verified live.
- [x] SF orphan handler verified live.
- [x] Step Function empty-input foot-gun fixed in code.
- [x] CI checks pending on this branch update.
- [ ] Needs review/approval.