Skip to content

feat(pebble): L2 swarm Wave 0 — launch-dark gate + cost ledger (JP-only) - #191

Open
jpb33333 wants to merge 35 commits into
devfrom
feat/pebble-l2-swarm
Open

feat(pebble): L2 swarm Wave 0 — launch-dark gate + cost ledger (JP-only)#191
jpb33333 wants to merge 35 commits into
devfrom
feat/pebble-l2-swarm

Conversation

@jpb33333

Copy link
Copy Markdown
Collaborator

Summary

Wave 0 of the L2 Research Swarm plan. Lands on main as launch-dark — only jp@pursuit.org can access Pebble (sidebar entry + routes both gated). Other admins (Jac, etc.) lose Pebble access on main until the gate is widened.

Three commits on top of feat/pebble-phase-0 (PR #184):

  • a497ba8Wave 0 foundation: 3 idempotent migrations for ledger instrumentation (extends pebble_harness_log with cache + run context columns; new pebble_tool_call_log), swarm runtime (pebble_meta_alerts, pebble_research_action_idempotency, pebble_scratchpad.events_jsonl), and HNW domain tables (pebble_network_edges, pebble_giving_history). Cache-aware token capture in model_client.py — captures cache_creation_input_tokens + cache_read_input_tokens from the Anthropic SDK and routes through the canonical pebble.llm.cost.calculate_cost_usd for the 1.25× / 0.10× rates.
  • 08ed7d9JP-only launch-dark gate: new pebble_access permission key, default false in all 4 profiles. ADMIN_AUTOFILL_EXCLUDED carve-out keeps Admin auto-fill from granting it. Per-user permission_overrides JSONB column on user_config; seeded {"pebble_access": true} for jp@pursuit.org. Composite check_pebble_permission(sub) gate + standalone require_pebble_access for cockpit endpoints. Frontend usePebbleAccess() hook (false-while-loading), PebbleAccessGate route component, sidebar nav filter.
  • 04c1c44Token + cost ledger (pebble/orchestrator/ledger.py): Pydantic models for TokenEvent / ToolCallEvent / RunLedger. compute_run_rollup() aggregates pebble_harness_log + pebble_tool_call_log into per-cluster + per-purpose rollups with cache-hit metrics. record_token_event() + record_tool_call() write helpers, both best-effort (swallow DB exceptions to keep the swarm step running). harness_result_to_event_kwargs() adapter from HarnessResult.tokens_used.

Why launch-dark, why JP-only

Per JP's 2026-05-18 directive: ship to main now so we can test the L2 swarm work for real, but keep it inaccessible to everyone except JP. Even other admins must not see Pebble. The pebble_access permission defaults false everywhere; only JP's user_config.permission_overrides grants it. When Pebble is ready to widen, flip the profile bit in a follow-up migration.

Tests

  • Pebble: 702 passing (was 687; +15 ledger tests).
  • Bedrock: 1070 passing + 22 skipped (was 1052; +18 gate tests).
  • Frontend: 6 new tests for PebbleAccessGate (runs on CI).
  • Combined: 1772 backend tests pass, zero regressions.

Migration order (ops)

Apply in this sequence as bedrock owner against staging then prod:

  1. financial_forecasting/db/migrations/2026-05-18-pebble-ledger-instrumentation.sql
  2. financial_forecasting/db/migrations/2026-05-18-pebble-swarm-runtime.sql
  3. financial_forecasting/db/migrations/2026-05-18-pebble-network-and-giving.sql
  4. financial_forecasting/db/migrations/2026-05-18-pebble-access-launch-dark.sql

All four are idempotent (CREATE TABLE IF NOT EXISTS, ADD COLUMN IF NOT EXISTS, ON CONFLICT DO UPDATE). Safe to re-run.

The launch-dark seed (#4) emits a RAISE NOTICE if jp@pursuit.org isn't yet in public.org_users — re-run after JP's first login on the post-deploy environment, or insert the override manually.

What's NOT in this PR (Wave 0 remainder)

  • Prompt-caching wire-up — passing cache_control: {"type": "ephemeral"} on stable guardrail prefix blocks (~$0.10-0.20/T3 savings).
  • Six-section ResearchScratchpad — Pydantic model for the research-side XML scratchpad (separate from the existing chat scratchpad).
  • @with_ledger decorator on pebble/data_sources/*.py HTTP fetches → writes to pebble_tool_call_log.

Follow-up commits to this branch will add these.

Test plan

  • Apply migrations to staging in the order above
  • Verify staging: log in as JP, confirm Pebble sidebar entry visible + /pebble route loads
  • Verify staging: log in as another admin (Jac), confirm Pebble sidebar entry hidden + /pebble URL redirects to /dashboard
  • Verify staging: SELECT permission_overrides FROM bedrock.user_config WHERE org_user_id = (SELECT id FROM public.org_users WHERE email = 'jp@pursuit.org') returns {"pebble_access": true}
  • Verify staging: send a Pebble Ask request, check bedrock.pebble_harness_log for the new cache columns populated
  • After staging signoff, apply to prod

🤖 Generated with Claude Code

JP and others added 30 commits May 6, 2026 19:03
…LED)

First slice of Pebble 1.0 plan §0.5 — runtime flag that halts
Pebble's internal-key write paths during incidents without a redeploy.
Reads via the same internal key and all JWT-authenticated user
requests are unaffected.

What changed:
- auth.py: new `_pebble_writes_disabled()` reads env on every call
  so flipping the flag doesn't require a deploy. Check fires after
  the hmac key validation succeeds and only on POST/PUT/PATCH/DELETE.
  GET/HEAD/OPTIONS via internal key still work, so research/lookup
  paths keep functioning during a write-side incident. Wrong-key
  callers fall through to require_auth as before — the kill switch
  doesn't leak its own state to unauthenticated traffic.
- tests/test_pebble_kill_switch.py: 23 tests covering 5 invariants
  (switch off / writes blocked / reads pass / JWT unaffected /
  wrong-key falls through), parameterized across truthy/falsy env
  values and all four write methods. All passing.

Plan ref: tasks/pebble-overhaul-plan.md §0.5
Adversary ref: tasks/pebble-adversary-security.md S2

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three independently-grounded specs (backend, ux, security) at
tasks/pebble-search-spec-{backend,ux,security}.md each ~3 pages.
This file is the build order — picks the load-bearing decisions,
links to the deep specs for argued tradeoffs.

Locked decisions:
  * Backend = PG FTS + pgvector overlay; one denormalized
    bedrock.search_doc table; queue-drain indexer; pre-filter
    permission via accessible-id subquery.
  * X-Originating-User mandatory on every internal-key write +
    search; Pebble becomes a delegated principal not a god
    principal.
  * Two audit tables: pebble_write_audit (writes) + search_audit
    (queries). 90d hot, 13mo Parquet archive. Admin reveals of
    query_text logged to a separate table that survives RTBF.
  * GlobalSearch.tsx: dual-mode (Find | Ask), permanent footer
    chip, ?/ prefix triggers, /pebble sidebar entry.
  * Multi-tenant org_id from day 1 as outermost predicate.
  * Trace propagation FE → Bedrock → Pebble → DB.
  * Circuit breakers per backend with explicit fallback order.
  * SLO p95 200ms Find / 3s first-token Ask.
  * No SaaS data egress for v1.0 — all in-house.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 0.1 + 0.7 of the Pebble 1.0 plan.

bedrock.pebble_write_audit captures every internal-key write with:
  * request_id (UNIQUE — doubles as 24h replay defense)
  * route + method + http status + payload_hash + payload
  * sf_object_type + sf_object_id (subject)
  * originating_user_email (the human; mandatory upstream via
    auth.py:require_auth_or_internal — next commit)
  * service_user (e.g. "service:pebble")
  * side_effects JSONB (auto-award, activity log, future fanout)
  * latency_ms + org_id (multi-tenant outer guard)

Three independent adversarial reviews flagged the un-attributed
"service:pebble" pattern as a 1.0 blocker; this table closes it.

bedrock.search_audit_access_log captures admins reading other
users' search history with query_text revealed. Append-only;
lives separately from search_audit so RTBF/DSAR purges of
search_audit cannot also erase the accountability trail.

Both grants restricted to bedrock_user; pebble_write_audit has
no UPDATE grant (audit immutability for forensics) and no
DELETE grant (retention job uses a different role, added when
the 90d cleanup script lands).

Migration is idempotent. Apply as bedrock owner.

Plan ref: tasks/pebble-search-spec.md decisions §3, §4
Adversary refs:
  pebble-adversary-security.md H3, M3, S2, S3
  pebble-adversary-architecture.md #6
  pebble-adversary-ux.md #4

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 0.6. Refuses to start when production-shaped Pebble has a
misconfigured service-to-service bridge to Bedrock.

Failure modes this catches:
  * BEDROCK_API_URL unset → defaults to http://localhost:8000 in
    crm_bridge.py:14. Every Pebble write silently routes to
    localhost in prod and bypasses TLS.
  * BEDROCK_INTERNAL_API_KEY unset → crm_bridge.py:27-28 sends
    requests with no internal-key header. Bedrock falls through
    to JWT auth; every Pebble write fails 401.

Production detection mirrors auth.py:25-26 (FRONTEND_URL starts
with https://) so dev/staging/prod classify identically across
the two FastAPI processes. PEBBLE_ENV=production overrides the
FRONTEND_URL check for headless cron deployments.

Whitespace-only API keys count as missing — defensive against
.env files with `BEDROCK_INTERNAL_API_KEY="   "`.

11 tests covering: dev path no-op, prod path raises on each
failure mode, PEBBLE_ENV override, case-insensitive prod detect,
whitespace-only-key rejection.

Plan ref: tasks/pebble-search-spec.md §6, §10
Adversary ref: pebble-adversary-security.md S4

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 0.2 + 0.4 of the Pebble 1.0 plan. Pebble becomes a
delegated principal, not a god principal.

What changed:
  * require_auth_or_internal now requires three headers when the
    internal-key path is taken:
      - X-Internal-Key (existing) — service-to-service auth
      - X-Originating-User (new) — the human whose session
        triggered this call. RFC 5321-shaped email; rejected
        if missing, malformed, or > 254 chars.
      - X-Request-Id (new) — UUID for replay defense via
        UNIQUE(request_id) on bedrock.pebble_write_audit.
  * Synthetic user dict gains originating_user_email, request_id,
    and a scopes tuple. X-Pebble-Scopes header (optional)
    overrides the default ("*",) full grant — future tightening
    will narrow scopes per-call instead of carrying full grant.
  * Wrong-key callers fall through to require_auth WITHOUT
    surfacing the new contract. We don't leak the kill-switch
    state or the originating-user requirement to unauthenticated
    traffic.
  * Validation kept lightweight on the auth path — durable
    membership check is at the audit-row INSERT (FK lookup vs
    org_users) per the security spec.

Why mandatory on reads too: Pebble must always carry its
delegated principal so research/lookup paths get audited the
same way as writes. Silent reads on behalf of a phantom user
are an audit hole.

34 new tests in test_pebble_internal_auth.py covering 8
invariants. Existing kill-switch tests updated to satisfy the
new contract; all 23 still pass.

Plan ref: tasks/pebble-search-spec.md decision §3
Adversary refs:
  pebble-adversary-security.md H1, H3, M3, S1
  pebble-adversary-architecture.md #6
  pebble-adversary-ux.md #4

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

Phase 0.9. Single source of truth for SF Opportunity stage
values, replacing the eight independent locations that today
carry stage strings literally:
  * models.py:OpportunityStage enum
  * frontend-v2/src/lib/{stages,funnelStages}.ts
  * frontend-v2/src/components/StageProgression.tsx
  * frontend-v2/src/pages/Cleanup.tsx
  * services/crm_parser.py
  * routes/opportunities_extra.py:53
  * services/awards_service.ELIGIBLE_STAGES_BY_RECORD_TYPE

Every SF picklist rename (commit 58c360e renamed three at once)
required hand-edits across all eight. This module collapses
them into one fetch.

Architecture:
  * services/sf_stages.py — async read API (`get_stages`,
    `get_entry_stage`) + sync bucket predicates
    (`is_revenue_earning`, `is_open`, `is_closed`, `is_lost`)
  * 24h read-through cache layered over a process-local 5min
    in-memory cache layered over the static fallback table
  * Static fallback mirrors models.OpportunityStage as of
    2026-05-06 — last-line-of-defense for fresh deploys before
    the picklist refresh job has run

Buckets are frozenset[str] preserving the F1 PR #134 contract:
reporting semantics LAYER ON TOP of stages, never replace them.
Code that says `if stage in REVENUE_EARNING_STAGES:` keeps
working when SF adds a new stage that the refresh job
classifies into the bucket — no code change needed.

bedrock.sf_picklist_cache migration:
  * Composite key (org_id, sf_object, field_name, record_type, value)
  * Per-row buckets TEXT[] for reporting classification
  * GIN index on buckets, partial index on stale rows
  * 24h refresh_after column drives the cache TTL
  * Multi-tenant org_id outer guard from day 1

26 tests covering static fallback / cache hit / cache clear /
DB preferred / DB error / entry stage / bucket predicates /
bucket invariants (revenue ⊆ closed, lost ⊆ closed, open ∩
closed = ∅) / frozenset immutability.

This file does NOT yet replace the eight call-sites. That
migration lands in Layer 1, after the picklist refresh job
exists. Callers swap incrementally.

Plan ref: tasks/pebble-search-spec.md §0.9
Memory refs: feedback_sf_stages_sacred, project_stage_schema_drift

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Layer 1.1 + 1.2 + 1.3 of the Pebble 1.0 search system. The
durable schema for cross-entity, permission-aware,
audit-logged search.

bedrock.search_doc — denormalized one-row-per-entity index:
  * tsvector + GIN (lexical match)
  * pg_trgm + GIN on search_text (typo tolerance: "ed millr" →
    "Ed Miller")
  * pgvector halfvec(768) + HNSW reserved for v1.1 semantic
    overlay (column defined now so backfill never ALTER's a
    200k-row hot table)
  * Permission columns FK-shaped (owner_sf_id, owner_email,
    account_sf_id, visibility) — pre-filter via accessible-id
    subquery, never post-filter, never denormalize ACL into
    rows
  * Soft-delete via partial GIN — tombstones in heap, never in
    posting list
  * search_doc_compose_vector() trigger sets relevance weights
    in one place (A: title, B: subtitle, C: search_text)
  * Multi-tenant org_id outer guard from day 1

bedrock.search_index_queue — durable hand-off:
  * UNIQUE(entity_type, entity_id, op) coalesces dup enqueues;
    ON CONFLICT bumps enqueued_at so the worker still drains
    in FIFO without missing any
  * Generic enqueue_search_index() trigger function takes
    entity_type via TG_ARGV[0]; source tables get one CREATE
    TRIGGER apiece in subsequent migrations
  * pg_notify('bedrock_search_index_queue', et||':'||eid) wakes
    the asyncio worker (drain pattern: FOR UPDATE SKIP LOCKED
    LIMIT 100)

bedrock.search_audit — every query (Find / Ask / past_research):
  * query_id correlates query → results → click; request_id is
    Phase 0.7 idempotency key with UNIQUE replay defense
  * Two attribution columns: user_email (bearer; e.g.
    pebble@internal) + originating_user_email (delegated human)
  * query_text stored FULL at v1.0; query_text_hash is the
    default dashboard join key (no PII leak); raw text reads
    gated on manage_users_roles and self-audited via
    search_audit_access_log
  * Per-backend latency breakdown (perm_resolution_ms,
    backend_latency_ms) for SLO drill-down
  * result_count vs result_count_redacted exposes the
    permission-redaction leak surface
  * Cost columns mirror pebble_daily_usage shape so budget
    enforcement reads from one source

All three migrations idempotent. Apply as bedrock owner.

Plan refs:
  tasks/pebble-search-spec-backend.md §2-§3
  tasks/pebble-search-spec-security.md §2

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Layer 1.8 of the Pebble 1.0 search system. The cross-entity Find
endpoint that GlobalSearch.tsx Find mode and Pebble's Ask-mode
tool calls both query against. Replaces the SOSL passthrough at
routes/salesforce_search.py incrementally (dual-read in Phase 2).

services/search_service.py:
  * SearchPrincipal — the effective principal whose permissions
    filter the query. Service callers (Pebble, is_service=True)
    resolve to their X-Originating-User; Pebble is delegated, not
    god-principal.
  * resolve_principal — reads bedrock.org_users +
    permission_profiles to compose the principal. Non-existent
    user → maximally restrictive (no rows visible).
  * _compose_permission_predicate — the OR-chain that the
    backend spec §4 argues for: pre-filter via accessible-id
    subquery, never post-filter, never denormalize ACL into rows.
    Multi-tenant org_id is the OUTERMOST predicate, always.
  * search() — composes the WITH-CTE query, applies recency decay
    (ts_rank_cd × exp(-Δs / 30-day-half-life)), returns
    rank-ordered SearchHits.
  * Strict input validation: type list rejects unknown
    entity_type; limit clamping to MAX_LIMIT=100; empty query
    short-circuits without DB hit.

routes/search.py:
  * GET /api/search — happy path returns flat + grouped views
    so the frontend picks shape it wants.
  * POST /api/search/click — click attribution for ranking
    quality. Fenced on user_email match (one user can't poison
    another's history).
  * Permission gate: check_permission_or_internal("view_opportunities")
    via a stable module-level reference (require_search_perm)
    so tests can override.
  * Audit row INSERT runs as a BackgroundTask — never blocks the
    response. ON CONFLICT (request_id) DO NOTHING idempotency.
    Audit failure is logged, never propagates to the user.
  * X-Request-Id used as audit row request_id. Malformed header
    → mint UUID, log warning. Empty header → mint UUID.
  * Long queries truncated at 256 chars in audit_text (defense
    against bypass of the 256 max_length validator).

56 tests across both files:
  * test_search_service: 35 — covers the OR-chain composition
    cell-by-cell (admin path, view-all path, restricted path,
    each branch of the OR), org_id always present invariant,
    type validation, limit clamping, service-spoof protection,
    query_text_hash stability + case-insensitivity, e2e against
    a mocked connection.
  * test_search_route: 21 — happy path, empty results, unknown
    entity_type → 400, missing/empty/oversized inputs → 422,
    audit BackgroundTask invoked, audit failure swallowed,
    click endpoint validation, header parsing helpers.

Plan refs:
  tasks/pebble-search-spec-backend.md §2-§5
  tasks/pebble-search-spec-security.md §1, §2
  tasks/pebble-search-spec.md decisions §2, §4, §6

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Layer 1.6 of the Pebble 1.0 search system. Drains
bedrock.search_index_queue and upserts the denormalized
projection into bedrock.search_doc.

services/search_indexer.py:
  * Composer registry — one async fn per entity_type that reads
    the source row and produces a SearchDocRow projection.
    Composers can return None to signal "row is gone or
    shouldn't be indexed"; indexer soft-deletes in search_doc.
  * Built-in composers for bedrock_project, bedrock_saved_view,
    pebble_profile (sourced from bedrock.pebble_research_sessions
    where status='completed' — has explicit name/org/tier columns
    vs pebble_profiles' opaque profile_json).
  * drain_once(pool) — claims a batch with FOR UPDATE SKIP
    LOCKED, dispatches per row, removes successes, bumps
    attempt_count on errors. Single transaction per row so a
    poison pill doesn't block the queue head.
  * MAX_ATTEMPT_COUNT=5 hard cap; attempt-exhausted rows stay
    in queue with last_error populated for the periodic
    reconciliation job.
  * No-composer rows leave the queue intact (no attempt bump) —
    a future worker with the composer registered processes
    them without restart. This is how the bedrock_award trigger
    (wired below) gracefully waits for its composer.
  * run_worker(pool, stop_event) — long-running drain. LISTEN
    on bedrock_search_index_queue + 2s polling fallback.
    Exponential backoff to 60s on consecutive failures; never
    crashes the FastAPI process.
  * backfill(pool, entity_type) — one-shot reindex from source.
    Used after schema changes or for first-deploy population.

db/migrations/2026-05-06-search-source-triggers.sql:
  * AFTER INSERT/UPDATE/DELETE triggers on bedrock.project,
    bedrock.saved_view, bedrock.award.
  * Conditional trigger on bedrock.pebble_research_sessions
    (only fires when status='completed') + a separate DELETE
    trigger (NEW is null in AFTER DELETE so WHEN can't reference
    NEW.status).
  * bedrock.award trigger ships ahead of its composer — by the
    time the composer lands (depends on Layer 1.4 SF mirrors),
    the queue has every award row already enqueued, no
    separate backfill needed.

19 tests in test_search_indexer.py:
  * Registry: register/get/clear, default registrations, overwrites.
  * Composer correctness for all 3 built-ins (happy / deleted /
    missing / wrong-status / invalid JSON).
  * drain_once: upsert + dequeue, soft-delete on op='delete',
    no-composer leaves queue intact, composer error bumps
    attempt_count + records last_error, MAX_ATTEMPT_COUNT
    enforced in SELECT.
  * backfill: enqueues all source rows, rejects unregistered
    entity_type.

This module is callable + testable in isolation. Wire-up to
main.py lifespan is a separate change.

Plan refs: tasks/pebble-search-spec-backend.md §3
           tasks/pebble-search-spec.md Layer 1.6

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wires the search read path and indexer worker into the
production FastAPI process.

main.py changes:
  * Import routes/search.py and mount /api/search +
    /api/search/click endpoints.
  * In startup_event: read the asyncpg pool, create a
    stop_event, kick off run_search_indexer_worker as a
    long-lived asyncio task. Stop_event + task stored on
    _services dict so shutdown can stop them cleanly.
  * In shutdown_event: set stop_event, then asyncio.wait_for
    the task with a 5-second drain timeout. Cancel-hard if it
    doesn't shut down — production restarts shouldn't be
    blocked by a stuck indexer.
  * Indexer failure is non-critical at startup: search remains
    available (existing search_doc rows still queryable), it
    just won't reflect new writes until restart. Logged
    loudly via logger.exception.

main.py smoke-tested: imports clean, /api/search and
/api/search/click both registered.

Plan refs: tasks/pebble-search-spec.md Layer 1.6, 1.8

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Layer 3.1 of the Pebble 1.0 plan. Forwards conversational queries
from the frontend's Ask mode to Pebble (separate process, port
8001) and streams the response back as SSE.

Why a Bedrock-side proxy instead of direct frontend → Pebble:
  * Single auth boundary. Frontend has Bedrock JWT; doesn't have
    Pebble's X-Api-Key. Proxy swaps creds.
  * Centralized cost / rate gating. One place to enforce
    per-user budget + audit row.
  * Single CORS surface — FE talks to one host.
  * Trace propagation gateway: X-Trace-Id minted at ingress,
    forwarded to Pebble + back to FE so FE → Bedrock → Pebble
    → Bedrock(/api/search) shares one correlation id.

routes/pebble_proxy.py:
  * POST /api/pebble/ask {query, conversation_id?, context?}
    streams text/event-stream to FE. Read-only at v1.0 — no
    write tools in Pebble's tool budget. Suggested-action cards
    require user confirmation via separate JWT-authenticated
    routes (/api/opportunities/update-stage, etc).
  * Permission gate: check_permission_or_internal("use_pebble_chat").
  * Service callers (Pebble calling itself in nested flows)
    propagate originating_user_email as Pebble's X-User-Email
    so Pebble grounds in the human's permissions, not the
    service principal's.
  * Pebble status >= 400 → SSE error frame (type=error, status).
  * Timeout (60s read) → SSE error frame (reason=timeout, 504).
  * Other httpx errors → SSE error frame (reason=upstream, 502).
  * Audit row written via BackgroundTask to bedrock.search_audit
    with mode='ask' — same table as Find queries, single
    "what did Pebble see for this user" surface.
  * Singleton httpx.AsyncClient with 60s read timeout, 3s connect.
    close() called from main.py shutdown.

13 tests covering: happy path streaming, Pebble error
surfacing, timeout / connection error fallback, query length
validation (1-2000 chars), audit BackgroundTask invocation,
trace_id propagation (kept when valid, minted when malformed),
service-caller originating-user delegation to Pebble's
X-User-Email, close() singleton reset.

main.py wire-up:
  * Mount /api/pebble/ask router.
  * close_pebble_proxy() in shutdown_event before close_db().

Plan refs: tasks/pebble-search-spec.md Layer 3.1
           tasks/pebble-search-spec-security.md §6 (degradation order)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Layer 0.10 + Layer 2.2 of the Pebble 1.0 plan. Test infrastructure
established and the search modal becomes Find | Ask in one bar.

frontend-v2 test infrastructure (Layer 0.10):
  * vitest 3.2 + @testing-library/react 16 + jsdom 26 added.
  * vitest.config.ts wires alias resolution, jsdom env, coverage
    floor (lines/functions/branches/statements >= 60% on changed
    files in src/components, src/pages, src/lib, src/services).
  * src/test/setup.ts polyfills IntersectionObserver + matchMedia
    for jsdom; quiets React 19 act() noise from libraries that
    haven't migrated; auto-cleans up rendered trees after each test.
  * package.json scripts: test (single-shot), test:watch,
    test:coverage. CI gate flips advisory→required once coverage
    hits the floor (followup).

GlobalSearch dual-mode rebuild (Layer 2.2):
  * One bar, two modes: Find (default, /api/search typeahead) and
    Ask (Pebble streaming via /api/pebble/ask).
  * Mode triggers: segmented Find ⟷ Ask radiogroup at top, ?/
    prefix as first char hard-switches to Ask and consumes the
    prefix, Cmd/Ctrl+I toggles mid-query without losing it.
  * Permanent footer "Ask Pebble: <query>" chip when Find query
    >= 2 chars — closes the cmd-K-only discoverability gap UX
    adversary #3 flagged. Click chip → switch to Ask.
  * Find never silently routes to Ask. Wrong-Acme auto-promote
    to Ask is a trust killer; user always explicitly opts in.
  * Ask body streams SSE: parses `data: {...}\n\n` frames into
    {type: token|redirect|error|done}. Plaintext-only render —
    no rich HTML from LLM output, no <script>, no on-event.
    L0 redirect frames navigate via React Router. Errors
    surface as "Pebble had a problem" with try-again hint.
  * AbortController cancels in-flight Ask on mode switch / close
    so we never leak streams.
  * WAI-ARIA Editable Combobox: role=dialog/aria-modal=true,
    role=combobox/aria-expanded/aria-controls/aria-activedescendant
    on input, role=listbox/role=option for results, role=radiogroup
    with aria-checked on the mode toggle, aria-live=polite on the
    Ask response container. (Editable Combobox With List
    Autocomplete pattern — WAI-ARIA AP.)
  * Keyboard surface: ↑↓ nav (Find), Enter submit, Escape close,
    Cmd/Ctrl+I toggle mode, ?/ prefix.
  * sanitizeToken strips control characters from streamed tokens
    (defense-in-depth XSS guard).

AppShell sidebar:
  * New "Pebble" nav group with /pebble entry using the
    MessageSquarePlus icon. Permission gating handled at the
    route level; sidebar entry shown for all users so they
    discover the surface exists.

Tests (24 passing, 1.04s):
  * Open/closed: dialog renders only when open=true; aria-modal=true.
  * Mode toggle: default Find; clicking Ask radio switches.
  * Prefix detection: ? and / both switch to Ask and consume.
  * Cmd+I: toggles mode and preserves the typed query.
  * Find body: empty state hint, debounced /api/search call,
    no API call below 2 chars (validates the debounce).
  * Ask body: example prompts visible when query empty.
  * Ask chip: appears at >= 2 chars in Find mode, hides under,
    click switches to Ask.
  * Escape closes; backdrop click closes.
  * Helpers: detectModePrefix edges (empty / plain / ? / / / leading whitespace);
    sanitizeToken strips control chars.

Type-clean (npx tsc --noEmit). 24/24 tests green.

Plan refs: tasks/pebble-search-spec.md Layer 0.10, 2.2
           tasks/pebble-search-spec-ux.md §1, §5 (a11y), §6

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

PRODUCTION BUG. resolve_principal was reading from
``bedrock.org_users`` and ``bedrock.permission_profiles`` (plural).
Neither table exists. The canonical schema is:

  * ``public.org_users``  — identity; lives in the learning-platform
    parent app's schema. May not exist on local-dev DBs.
  * ``bedrock.user_config``  — joins org_users.id → profile_id.
  * ``bedrock.permission_profile`` (singular) — has the permissions
    JSONB.

The wrong query would have 500'd every search request the moment
it ran against a real database, with the test mock-conn passing
because we mocked the response shape instead of asserting against
real schema.

Fix: delegate to ``routes.permissions.get_user_permissions(email,
db)`` — the canonical resolver that already knows the live schema,
auto-provisions org_users rows on first Bedrock visit, and
defaults Admin profile keys to true. Lesson encoded in the
docstring: anything that looks like "user → profile → permissions"
must call the canonical resolver, never roll its own JOIN.

Behavior changes:
  * is_admin = (profile_name == "Admin") OR
    permissions.manage_users_roles. Earlier draft conflated
    is_admin with manage_users_roles only — Admin profile users
    without that flag were getting locked-out semantics.
  * has_view_all_* flags inherit from is_admin in addition to
    their respective edit_* permissions. Admin sees everything
    in their org regardless of granular permission state.
  * sf_user_id sourced from public.org_users.sf_user_id (added
    conditionally by init.sql migration step §B-1).
  * org_id hardcoded to "pursuit" — public.org_users has no
    org_id column today; multi-tenant retrofit is mechanical
    once that column exists. Reserved on bedrock.search_doc and
    SearchPrincipal so the schema doesn't need to change.

Fail-closed semantics: when get_user_permissions raises (DB
unreachable, schema migration in flight), return the
most-restrictive principal — search returns nothing, audit row
still logs the attempt for anomaly review.

Tests: 38 passing (was 35). New cases:
  * Admin profile name → all view_all flags true.
  * RM profile with partial perms → mixed view_all flags.
  * get_user_permissions failure → fail-closed restrictive principal.
  * Service caller resolves against originating_user_email,
    not pebble@internal (locks in delegated-principal contract).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
REGRESSION FIX. Commit f90099a made X-Originating-User mandatory
on internal-key calls to Bedrock. crm_bridge.py was never updated
to send it — every existing Pebble read/write path (search_all,
search_*, get_opportunities, create_*, update_*) was 401'ing
silently. The change is a breaking change disguised as a feature
unless the producer side is updated in lockstep.

Approach — context-var attribution propagation:

  pebble/request_context.py (new)
    * ContextVars for originating_user, request_id, trace_id.
    * set_originating_user(email, ...) context manager — populates
      the vars and restores on exit. Nested contexts honor
      LIFO restore so inner exits don't leak the outer state.
    * AUTONOMOUS_USER sentinel ("system:pebble-autonomous@pursuit.org")
      for cron / orchestrator runs with no human in the loop.
      Email-shaped so it passes Bedrock's format check; clearly
      labeled so audit dashboards distinguish it from real users.
    * attribution_headers() composes X-Originating-User +
      X-Request-Id (+ optional X-Trace-Id) from the current
      context. ALWAYS mints a UUID for X-Request-Id even when
      unset — Bedrock's UNIQUE(request_id) idempotency on the
      audit row depends on getting one.

  pebble/crm_bridge.py (12 call sites updated)
    * Every client.{get,post,put}() call now passes
      headers=_request_headers(). The static X-Internal-Key stays
      on the singleton client; per-request attribution merges
      via httpx's per-call headers kwarg.
    * _request_headers() logs a WARN when the originating user
      is unset — Bedrock will 401 the request, callers see
      None return (existing graceful-degradation contract).

  pebble/main.py (request middleware)
    * @app.middleware("http") attribute_request — reads
      X-User-Email (Pebble's existing header from Bedrock's
      proxy) or falls back to X-Originating-User. Wraps the
      request lifecycle in set_originating_user(...). All
      crm_bridge calls deeper in the stack auto-attribute via
      ContextVar inheritance across asyncio tasks.
    * CORS allow_headers extended with X-Request-Id + X-Trace-Id
      so browser preflight checks pass.

11 tests in pebble/tests/test_request_context.py:
  * Default state is unset.
  * set_originating_user populates + restores; with-block
    semantics verified.
  * Nested set_originating_user honors LIFO; outer state
    survives inner exit.
  * attribution_headers shape with / without ids; mints unique
    request_id per call.
  * AUTONOMOUS_USER passes Bedrock's email-shape validator
    (RFC-shaped, no whitespace, < 254 chars, "system:" prefix).
  * Concurrent asyncio.gather contexts do NOT cross-contaminate
    (locked in via 3 parallel workers each with its own email,
    each verified to observe only its own value).

29 pebble tests pass total (11 new + 18 existing bridge config).

Plan ref: tasks/pebble-search-spec.md decision §3 (delegated principal)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Phase 0.8. Closes the audit-table-but-no-writers gap I shipped
earlier — bedrock.pebble_write_audit existed since commit 2f2367f
but nothing wrote to it for the 11 existing internal-key write
routes. Pebble could create accounts/contacts/payments/opps with
no audit trail. The whole point of the audit table was missed.

services/pebble_audit.py:
  * PebbleWriteAuditMiddleware — Starlette middleware that
    transparently captures every successful internal-key write
    (POST/PUT/PATCH/DELETE) hitting a Pebble-relevant prefix
    (/api/salesforce/*, /api/opportunities/*, /api/payments,
    /api/payment-schedules, /api/projects, /api/awards). No per-
    route changes required; new write routes auto-audit on
    first deploy.
  * Reads request_id + originating_user from headers (set by
    Pebble's request_context middleware on the producer side).
    Idempotency via UNIQUE(request_id) on the audit row.
  * Captures path, method, response_status, latency_ms, side_effects.
    Skips body reading entirely — replay-into-handler fights with
    BaseHTTPMiddleware's receive-stream wrapper. Routes that
    need to capture sf_object_id (when it's in the body, not
    path) call record_sf_object(request, type, id). Routes that
    fire side effects call record_side_effect(request, key, value).
    update_opportunity_stage's auto-award handler will use this
    to attest award_created in the audit row.
  * _extract_sf_object_from_path uses an explicit
    PLURAL_TO_SINGULAR map — earlier rstrip("s").capitalize()
    naively turned "opportunities" into "Opportunitie" (caught
    by tests).
  * Skips audit for failed (>= 400) responses — attempt-rows are
    captured by upstream metrics + Cloud Logging instead.
  * Audit failure NEVER propagates to user response. asyncio
    create_task fire-and-forget; logger.exception on failure for
    Cloud Logging alerts.

main.py:
  * Mounts PebbleWriteAuditMiddleware via add_middleware.
  * pool_provider closure returns the asyncpg pool lazily so
    the audit module isn't coupled to main.py's import order.

31 tests in test_pebble_audit.py covering all 14 invariants:
  * is_audited_route gates: GET/HEAD/OPTIONS skipped, non-Pebble
    paths skipped, POST/PUT/PATCH/DELETE on prefix-matched routes
    audited.
  * is_internal_key_request: header presence required.
  * extract_sf_object_from_path: 8 path shapes including the
    "opportunities" plural fix.
  * record_side_effect / record_sf_object merge into request.state.
  * Middleware skips non-audited routes, JWT-only requests, and
    failed responses.
  * Successful internal-key write writes audit row with correct
    request_id / route / method / originating_user / status.
  * record_sf_object + record_side_effect propagate into the audit
    row (Opportunity 006XYZ + side_effects=award_created).
  * Audit insert exception swallowed; user response still 200.
  * Body NOT consumed by middleware — route receives the JSON.

Plan ref: tasks/pebble-search-spec.md decisions §3, §4
Adversary refs: pebble-adversary-security.md H3, M3
                pebble-adversary-architecture.md #6
                pebble-adversary-ux.md #4

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Real production runaway-cost guard. Earlier the proxy forwarded
to Pebble unconditionally — a single user could exhaust the org's
daily token budget in minutes. Closes the gap I'd flagged in my
own audit.

routes/pebble_proxy.py:
  * _DAILY_COST_LIMIT_USD env-driven default $5/user/day —
    matches pebble/main.py's same-named limit so users don't
    see different caps depending on which side throttles first.
  * _COST_DEGRADE_FRACTION = 0.80 — at 80%+ of cap, proxy adds
    X-Pebble-Force-Tier: L0 to upstream headers and X-Pebble-
    Degraded headers to the response. Pebble's router honors
    the hint to skip L1+ LLM calls and only emit deterministic
    L0 redirects (cheap nav). Even if Pebble doesn't honor it
    yet, the response header tells the FE to render a "running
    low" banner.
  * At 100% cap → 429 with structured payload (spent_usd,
    limit_usd, query_count_today) + Retry-After (seconds to
    midnight UTC, min 60).
  * 429 audit row emitted via asyncio.create_task — FastAPI's
    default HTTPException handler doesn't run the request's
    BackgroundTasks, so the fire-and-forget pattern is required
    here. Anomaly detection NEEDS to see 429s — they often
    signal spam.
  * _read_daily_cost is fail-open: DB outage returns (0, 0) so
    users aren't locked out by an unrelated incident. Sustained
    failures alert via the logger.exception call (Cloud
    Monitoring picks up warning rate).
  * _seconds_until_midnight_utc bounded [60, 86400] so an absurd
    Retry-After (e.g. clock skew) doesn't lock a client out
    for years.

Tests added (8, 23 total in file):
  * Cap exceeded → 429 with full payload shape + Retry-After.
  * Cap exact-equals → 429 (>= comparison).
  * Below cap → 200, request flows through normally.
  * 80%+ → X-Pebble-Force-Tier: L0 sent to upstream;
    X-Pebble-Degraded / X-Pebble-Cost-Today / X-Pebble-Cost-Limit
    in response.
  * Below 80% → no degradation headers anywhere.
  * 429 still emits audit row (via asyncio.create_task path).
  * _read_daily_cost: DB error → (0, 0); empty row → (0, 0);
    no email → (0, 0).
  * _seconds_until_midnight_utc bounded.

Plan ref: tasks/pebble-search-spec-security.md §3 (rate limiting)
          tasks/pebble-search-spec-architecture.md #13 (cost cap)
Adversary refs: pebble-adversary-architecture.md #13
                pebble-adversary-security.md M5

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three production bugs found by running the FULL test suite +
smoke-importing every module + auditing schema assumptions:

1. pebble.crm_bridge._request_headers was CALLED 12 times but
   NEVER DEFINED. The function definition got lost when an
   intermediate ``git checkout`` reverted my earlier edit; the
   regex pass that injected ``headers=_request_headers()`` into
   every call site landed AFTER the function disappeared. Pebble
   would have failed to import in production. Caught by a smoke
   test that exercised the import path; unit tests didn't catch
   it because they mock the request methods.

2. Test pollution in test_search_route.py — ``search_route.ss.X
   = fake_X`` raw assignment instead of monkeypatch.setattr
   leaked across the test session. test_search_service.py
   passed alone but failed in the full suite (9 tests). Caught
   by running the full pytest suite (1052 tests) instead of
   targeted file runs. Same pattern fixed in test_pebble_proxy.py
   for ``pebble_proxy._get_pebble_client``.

3. compose_bedrock_project read the wrong column for owner. It
   read created_by — the original author — not owner_email
   (added by the 2026-04-21 multi-owner support). The search
   filter would attribute ownership to the project creator
   instead of the current owner; ownership-transferred projects
   would be visible to the wrong RM. Now reads owner_email with
   created_by as fallback for legacy rows.

Fixes:
  * pebble/crm_bridge.py:1-83 — restore _request_headers helper
    + complete docstring / context-var integration explanation.
  * financial_forecasting/tests/test_search_route.py:74-130 —
    monkeypatch.setattr for module-level fakes; add test docstring
    explaining the cross-test-pollution failure mode.
  * financial_forecasting/tests/test_pebble_proxy.py:74-130 —
    same fix; convert raw lambda assignments to monkeypatch.setattr.
  * services/search_indexer.py:compose_bedrock_project — read
    owner_email; fall through to created_by for pre-2026-04-21
    rows; new test_compose_bedrock_project_falls_through_to_created_by
    locks in the legacy-project case.

Verification: 1052 backend + 390 pebble + 24 frontend tests all
pass. Full-suite run (not just per-file) confirms no
cross-pollution. main.py + pebble.main.py smoke-import clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Establishes the foundation for Pebble as a real BI assistant
using every agentic pattern from the Claude Certified Architect
curriculum. See tasks/pebble-bi-architect.md for the architecture
spec.

Patterns applied + where they live:

  * Orchestrator-worker — pebble/orchestrator/ package layout
    (planner, executor, evaluator forthcoming).
  * Bounded autonomy — pebble/orchestrator/budget.py with HARD
    caps on tool_calls / cost_usd / wall_seconds. Pre-flight
    `can_afford()` rejects plans whose estimates would exceed
    the cap before any tools fire. Exhaustion = clean halt with
    structured halt-reason.
  * Scratchpad / state externalization — pebble/orchestrator/
    scratchpad.py + bedrock.pebble_chat_scratchpad migration.
    Every step (plan, tool_call, tool_result, evaluation, render,
    conflict, checkpoint, error) persists. Tree-shaped via
    parent_step_id so re-plans branch without losing the
    original failed plan.
  * Tool use (formal) — pebble/orchestrator/tools.py with
    Anthropic-shape ToolSpec (name / description / input_schema
    / handler / cost_estimate / requires_human). ToolRegistry
    enforces unique names + async handlers + dispatches with
    exception-wrapping (a broken tool fails its step, never
    crashes the orchestrator).
  * Citation / provenance — schemas.Citation type; ToolResult
    carries citations tuple that the renderer wraps as
    <cite> spans.
  * Disambiguation — checkpoint step_type + SuggestedAction
    schema with diff_preview + record_label so the FE confirm-
    card can show "Acme · 006XYZ" anti-mistake guard.
  * Workflow vs agent — package layout reserves
    pebble/workflows/ for deterministic compositions; agent path
    only fires for novel queries.
  * Bounded recursion — Plan validator rejects forward references
    in depends_on; planner must topologically order steps.
  * Resource accounting — every ToolResult carries duration_ms,
    cost_usd, tokens_in, tokens_out so post-run telemetry has
    real numbers, not estimates.

bedrock.pebble_chat_scratchpad migration:
  * Tree-shaped (parent_step_id nullable, indexed when present).
  * step_type CHECK enum matches StepType Pydantic enum exactly
    — locked in via test_step_type_enum_values canary test.
  * Indexes for replay (conversation_id, step_number),
    per-user activity surface (user_email, created_at DESC),
    error queue (created_at WHERE step_type IN errors), cost
    rollup (conversation_id, cost_usd).
  * Append-only — no UPDATE/DELETE grants for bedrock_user.
    Reasoning chains are immutable; retention is a separate
    role's job.

Tests (71 total, 0.14s):
  * test_orchestrator_schemas.py (27) — Plan/PlanStep validators,
    frozen-immutability, depends_on forward-ref rejection,
    enum bounds, citation shape.
  * test_orchestrator_budget.py (19) — each cap trips
    independently; charge() rejects negatives; can_afford()
    pre-flight; monotonic time (not wall — DST-safe);
    snapshot shape; remaining clamps to zero.
  * test_orchestrator_tools.py (15) — registry isolation;
    async-only handler; unique name; dispatch wraps exceptions;
    Anthropic-shape export; insertion-order iteration;
    DEFAULT_REGISTRY separated from test registries.
  * test_orchestrator_scratchpad.py (10) — auto-mint UUID;
    monotonic step_number; INSERT shape + parameter order;
    JSONB pre-serialization (handles UUID via default=str);
    failure-soft (logs but doesn't raise); pool-less degraded
    mode for tests.

Plan ref: tasks/pebble-bi-architect.md
Standing memory: feedback_session2_architecture, feedback_agentic_principles

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…uator, renderer

Builds the agentic-pattern foundation for Ask-Pebble L1+ chat per the
Anthropic Architect curriculum's session-2 patterns:

  * planner.py — Sonnet emits a Plan; jsonschema-validates each step's
    args against the registered tool's input_schema; retry-once on
    malformed output with the validation error fed back; DI client
    so tests pass a stub.
  * executor.py — runs the Plan with hard budget enforcement, persists
    every step to the scratchpad, halts on checkpoints / tool failures
    with downstream deps / budget exhaustion / pre-flight rejection.
  * evaluator.py — Haiku-as-judge; scores factuality + completeness
    + harm; PASS / RETRY / ABORT verdict; failsafe PASS on judge
    outage so a transient eval failure doesn't block users.
  * renderer.py — deterministic per-tool templates (search_crm,
    get_record) into a FinalResponse; degraded-mode messages for
    pre-flight, budget, tool-failure, checkpoint outcomes; citation
    collection deduped across results.
  * builtin_tools.py — search_crm / get_record / request_human_review
    wired to /api/search and Bedrock REST routes, citations emitted
    in entity_type:entity_id format the renderer wraps.
  * chat_orchestrator.py — top-level loop: plan → execute → render →
    evaluate → optionally re-plan once → final. Streams ordered SSE
    events (plan_emitted, tool_call_started/finished, draft_emitted,
    eval_emitted, replan_started, response_final, error) the FE
    consumes for live agent-view rendering.

Package restructure: the legacy single-file ``pebble/orchestrator.py``
(prospect-research pipeline) moves to ``pebble/orchestrator/_pipeline.py``
with public symbols re-exported from the new package's __init__ so
``pebble.handlers.tier3``, ``pebble.clusters``, etc. keep working
without changes.

Tests: 117 new (27 executor + builtin_tools, 20 planner, 25 evaluator,
22 renderer, 13 chat_orchestrator + 10 already counted in executor);
all 497 pebble + 1052 backend tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This branch (feat/pebble-phase-0) is being pushed to origin so the
work is saved off-machine, but it is NOT ready to merge. See
tasks/pebble-phase-0-PARKED.md for what's done, what's missing, and
how to resume.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…, SSE, /pipeline workflow

Backend integration of the orchestrator package built in 45fbac6 with
production-grade pieces tying everything together.

What ships in this commit:

  * **pebble/llm/** — AsyncAnthropic-backed implementation of
    PlannerLLMClient + EvaluatorLLMClient protocols. Prompt caching
    on system prompts (~20% cost reduction on cache hits). Token-cost
    accounting in pebble/llm/cost.py keyed by model id with high-water
    fallback for unknown models. Singleton lifecycle managed via
    get_default_client / close_default_client. 54 tests + 2 cassettes.

  * **chat_orchestrator refactor** — extract post-planner pipeline into
    run_stream_with_plan(plan, allow_replan=False) so workflows can
    feed pre-baked Plans through executor + render + eval without a
    planner LLM call. Existing run_stream remains the agent path. +5 tests.

  * **generate_chart tool** — pure shape transformer (data + kind →
    ChartSpec). Auto-registers in DEFAULT_REGISTRY. JSON-schema enum
    on kind defends against planner inventing chart types. +14 tests.

  * **pebble/workflows/weekly_pipeline_review.py** — first worked
    example. Three views (at-risk renewals, stale opps, coverage by
    owner) sourced from one crm_bridge.get_opportunities call.
    Aggregations in pure Python — pre-mirror; swaps to SQL when
    sf_*_mirror tables ship. Plus three Recharts-shape ChartSpec
    dicts. Registered as `aggregate_pipeline_views` tool so the
    L1 planner can also invoke it for natural-language requests.
    +53 tests.

  * **/pipeline slash command** — router.classify_query detects
    `/pipeline` first-token, returns RouteResult(level=2,
    intent='workflow_weekly_pipeline_review'). Bypasses LLM
    classification entirely. +12 tests.

  * **pebble/orchestrator/sse.py** — canonical SSE encoder.
    {kind, payload} wire format with default=str for UUIDs and
    datetimes. encode_event / encode_error / encode_keepalive
    helpers. +11 tests.

  * **pebble/handlers/streaming.py** — orchestrator construction +
    dispatch entry. orchestrator_enabled() predicate gates level=1
    behind PEBBLE_USE_ORCHESTRATOR env flag for safe rollout.
    is_workflow_route() handles level=2. Clean error stream on
    Anthropic SDK failure or unknown workflow intent. +27 tests.

  * **pebble/main.py:chat_query** — switches between SSE streaming
    (level in {1+orch, 2}) and existing JSON ChatQueryResponse path
    (everything else). Honors X-Pebble-Force-Tier=L0 from the
    Bedrock-side proxy when daily-cost cap is at 80%+. Persists the
    streamed assistant message to chat history at end of stream.

  * **renderer.py** — _collect_charts() walks tool results harvesting
    ChartSpecs from both generate_chart and aggregate_pipeline_views
    shapes. Invalid specs skipped, never crash. Per-tool render
    branches for the new tools.

  * **routes/pebble_proxy.py** — error frame shape switched from
    {type:'error',...} to {kind:'error', payload:{phase, reason, ...}}
    matching the orchestrator's canonical event vocabulary.

Test totals: 1052 backend + 672 pebble = 1724 (was 1052 + 497 = 1549).
+175 new pebble tests; zero regressions on backend.

Branch stays parked. PR opens when JP says.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…agentic UX glass-box

Companion to a2d3c2f. Wires the orchestrator's {kind, payload} SSE
event stream into the Pursuit frontend.

What ships in this commit:

  * **types/pebble.ts** — discriminated union on `kind` for every
    OrchestratorEvent (plan_emitted, tool_call_started/finished,
    draft_emitted, eval_emitted, replan_started, response_final,
    error). Plus FinalResponse / Citation / ChartSpec /
    SuggestedAction matching the Pydantic schemas. Compile-time
    catch on missed cases when the server adds new event kinds.

  * **services/pebble.ts** — streamPebbleAsk async generator. Fetch +
    ReadableStream + SSE frame parser. Reassembles frames split
    across chunks, drops malformed JSON, skips :keepalive comment
    frames. Cancellation via AbortSignal swallows AbortError; other
    failures yield synthetic { kind: 'error' } events. +10 tests.

  * **context/PebbleConversationContext.tsx** — useReducer-driven
    conversation state. One PebbleTurn per user query. Reducer
    branches on event.kind via TypeScript discriminated union. Auto-
    cancels in-flight stream on unmount or conversation_id change.
    +14 tests.

  * **components/pebble/** — six reusable primitives:
      - ChartRenderer.tsx — switches on ChartSpec.kind to dispatch
        the right Recharts component. Pursuit's neutral palette.
        Empty data renders a "no data" caption rather than crashing.
        ARIA: figure[role=img] + aria-label.
      - CitationList.tsx — numbered footnote list. Internal nav
        via react-router-dom Link.
      - PlanTrace.tsx — plan-as-todos with status icons (pending /
        in_progress / done / failed). Live during streaming;
        collapses by default once final shipped. Shows eval verdict
        with color-coded factuality + completeness.
      - MessageInput.tsx — auto-grow textarea. Enter sends,
        Shift+Enter inserts newline, Cmd+Enter sends. Cancel button
        replaces send while streaming.
      - ConversationView.tsx — turn-by-turn renderer. User query as
        right-aligned bubble; PlanTrace card; assistant response with
        charts + citations. Auto-scroll to bottom on update.
        Empty-state with /pipeline hint.
      - (page) Pebble.tsx — single-conversation /pebble surface.
        3-column layout primitive (sidebar slot empty in L1, will
        host history list in L1.5). URL parameter ?conv=<uuid> ties
        page state to a specific conversation for shareability.

  * **App.tsx** — adds /pebble route inside AuthGate. Sidebar entry
    in AppShell already existed; this resolves the dead-link.

  * **GlobalSearch.tsx** — Ask mode rewritten to consume the new
    {kind, payload} stream via streamPebbleAsk. Inline modal renders
    just draft_emitted text + response_final + error (plan/tool/eval
    frames dropped — the deep-dive view is /pebble). Adds "Continue
    in Pebble →" CTA after answer arrives, navigating to
    /pebble?conv=<conversation_id> so the user lands on the same
    trace with the full agent view.

Test totals: 24 → 48 (+24 new). Full backend + pebble + FE: 1772
(was 1573 at parking point).

Branch stays parked. PR opens when JP says.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ld artifacts

  * tasks/pebble-phase-0-PARKED.md — reflect L1 scope shipped:
    Anthropic client, SSE, slash command, workflow, frontend panel.
    Test totals 1573 → 1772. Lists L2 follow-ups (propose_write,
    sf_*_mirror, additional workflows, history sidebar, omnibox).

  * frontend-v2/.gitignore — exclude *.tsbuildinfo + the
    vite.config.{d.ts,js} artifacts ``tsc -b`` emits during build.

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

Per pass-3 review of L1 wire-up: the streaming entry was constructing
a fresh AsyncAnthropic per request (via AnthropicLLMClient()),
discarding the httpx connection pool and the prompt cache benefit
between calls. Switch to get_default_client() — process-wide
singleton, lifespan-managed.

Tests: 672 pebble + 1052 backend stay green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…n view

Two paired UX additions on top of the L1 wire-up.

Running cost / token tally
--------------------------

Surfaces every LLM call's spend so users can see what their
conversation costs in real time.

  * **schemas.Evaluation** gains cost_usd / tokens_in / tokens_out
    (defaults preserve back-compat). Evaluator carries the LLM-call
    accounting from EvaluatorLLMResponse onto the Evaluation model.
  * **chat_orchestrator** event payloads:
      - tool_call_finished now includes tokens_in / tokens_out
        (already had cost_usd).
      - eval_emitted now includes cost_usd / tokens_in / tokens_out
        (the judge LLM call's spend was previously discarded).
  * **chat_query SSE body** sums cost + tokens across both event
    kinds; writes tokens_in / tokens_out into the persisted
    chat_messages.metadata for history surfaces.
  * **Frontend types** extend ToolCallFinishedPayload + EvalEmittedPayload
    + PebbleTurn (per-turn totals) + StepView (per-step tokens).
  * **PebbleConversationContext reducer** accumulates spend on every
    tool_call_finished and eval_emitted into the turn's totals; a
    useMemo aggregates across all turns into a `totals` value
    exposed on the context.
  * **PlanTrace** renders cost + tokens per step (e.g. "$0.0094 ·
    300↓120↑") plus turn totals in the collapsible header.
  * **PebblePage header** gets a running-total chip:
    "3 turns · $0.034 · 5,200 tok".

Collapse to deck-of-cards
-------------------------

Header toggle button switches the conversation column between full
chat flow and a stacked-card deck per JP's spec ("don't hide it when
collapsed").

  * **ConversationView** accepts a `collapsed` prop. When true,
    renders a DeckView: each turn becomes a 56px card stacked
    vertically with negative margins so the edge of every card peeks
    above the next. Newest card on top, z-index decreasing into the
    stack. Click any card to "draw" it — the selected card expands
    inline with the full TurnView; "Tuck back ↑" returns it to the
    deck. Cards keep their status icon + step count + cost preview
    in the compact state, so nothing is hidden.
  * **PebblePage** mounts a Layers/Maximize2 toggle button in the
    header. When collapsed=true the deck takes over the conversation
    area; the MessageInput stays visible at the bottom regardless,
    so the user can keep asking from either mode.

Tests
-----

  * Pebble: 672 → 678 (+6 new — evaluator cost-plumbing × 4,
    chat_orchestrator event payload × 2). Zero regressions.
  * Frontend: 48 → 51 (+3 — totals aggregator × 3).

Branch stays parked. PR opens when JP says.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`tsc -b --noEmit` errors on TS 5.7+ with composite project
references (TS6310: "Referenced project ... may not disable
emit"). Build mode handles emit per-referenced-project; the
global --noEmit flag is incompatible.

`tsc -b` alone produces .tsbuildinfo files but no .js output
for typecheck-only validation, which is what we want and what
the script was already trying to achieve.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Flip the PARKED doc to OPEN FOR REVIEW. Content preserved as
the design/scope reference for reviewers (covers Phase 0
foundation + L1 chat orchestrator wire-up).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…e-aware cost + new tables

Per the L2 Research Swarm plan (~/.claude/plans/glistening-crafting-matsumoto.md
§4.12, §5.1, §5.2, §6).

Migrations (idempotent, additive):
  * 2026-05-18-pebble-ledger-instrumentation.sql — extends
    pebble_harness_log with cache_creation_input_tokens,
    cache_read_input_tokens, provider, model_id, session_id,
    purpose, cluster, tier, redo_attempt. Creates pebble_tool_call_log
    for non-LLM tool invocations (FEC/ProPublica/OpenCorporates HTTP
    fetches) which currently bypass ModelClient and so are invisible
    to the cost surface.
  * 2026-05-18-pebble-swarm-runtime.sql — extends pebble_scratchpad
    with events_jsonl (append-only event log for SSE replay),
    last_event_seq, originating_user_email, tier, run_status.
    Creates pebble_meta_alerts (Meta-Observer interventions) and
    pebble_research_action_idempotency (X-Request-Id replay defense
    for abort / continue-to-T4 endpoints).
  * 2026-05-18-pebble-network-and-giving.sql — creates
    pebble_network_edges (Cluster D output: co_board / co_donor /
    family_candidate / professional_peer edges) and
    pebble_giving_history (Cluster E output: multi-year
    philanthropic / political / federal-received timeline).

model_client.py — cache-aware token capture in all three Anthropic
code paths (_complete_anthropic, complete_with_tools, __init__
default). _last_usage gains cache_create + cache_read fields
populated from response.usage.cache_creation_input_tokens and
cache_read_input_tokens (the Anthropic SDK fields that drive the
1.25× and 0.10× cache pricing). calculate_cost now delegates to
the canonical pebble/llm/cost.calculate_cost_usd so the math lives
in one place. Backwards-compatible: legacy two-field {input,
output} dicts still work via dict.get defaults.

tests — 9 new in test_model_client_cache.py covering the four-field
shape, cache create/read pricing, mixed sums, legacy compatibility,
OpenRouter $0 short-circuit, and the Plan §4.12 step-10 assertion
(4000 cache_read tokens × $0.30/Mtok = $0.0012). Full pebble suite:
687/687 pass (was 678; +9). Zero regressions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…, hidden from everyone else

Per JP's 2026-05-18 directive: land Pebble on main, but only
jp@pursuit.org can access it. Even other Admins (Jac) cannot see
the sidebar entry or hit any /api/pebble/* route. Anyone else
trying /pebble URL gets silently redirected to /dashboard — no
error toast; the route should feel like it doesn't exist.

The existing permission model has two facts that block a naïve gate:
  1. routes/permissions.py admin auto-fill (setdefault loop) would
     grant any newly-added key to every Admin profile.
  2. bedrock.user_config has no per-user override mechanism — only
     org_user_id → profile_id.

Solution:

DB migration (2026-05-18-pebble-access-launch-dark.sql, idempotent):
  * Adds pebble_access: false to all 4 profiles (Admin, RM, Exec, PM).
  * Adds bedrock.user_config.permission_overrides JSONB column,
    DEFAULT '{}'. Generalizes to any future per-user grant or deny.
  * Seeds permission_overrides = {"pebble_access": true} for
    jp@pursuit.org's user_config row, creating it if absent. Wraps
    in DO block with NOTICE-and-continue when public.org_users is
    unreachable (local dev).

routes/permissions.py:
  * PERMISSION_KEYS gains "pebble_access" (admin tooling validates).
  * NEW ADMIN_AUTOFILL_EXCLUDED frozenset — keys explicitly NOT
    granted to Admins by the setdefault loop. pebble_access is the
    first member. Without this, every Admin auto-receives the gate.
  * _admin_autofill() helper — honors the exclusion set.
  * _apply_overrides() helper — merges user_config.permission_overrides
    AFTER profile permissions + Admin auto-fill. Overrides win for
    both grant (true) and deny (false). Boolean-only — non-bool values
    are dropped (defense against malformed JSONB).
  * get_user_permissions() SQL query includes permission_overrides;
    all three resolution paths (existing user_config, bootstrap, new
    user) now run through autofill → overrides.
  * NEW check_pebble_permission(sub_perm) — composite gate that
    requires BOTH pebble_access AND a sub-permission. Distinct 403
    messages let the frontend tell "Pebble disabled" from "Pebble
    enabled, feature not allowed".
  * NEW require_pebble_access — standalone master-gate-only
    dependency for cockpit SSE / abort / continue endpoints.

routes/pebble_proxy.py:
  * /api/pebble/ask now uses check_pebble_permission("use_pebble_chat")
    instead of check_permission_or_internal("use_pebble_chat").
  * Pre-2026-05-18 behavior: Admin → instant access. Post-: master
    gate first. Service-account internal-key path unchanged.

pebble/main.py require_pebble_permission:
  * Checks pebble_access BEFORE the sub-permission so the Pebble
    service mirrors Bedrock's composite gate. Defense in depth —
    even if a Bedrock route somehow forgets the gate, the Pebble
    service won't honor the call.

Frontend (financial_forecasting/frontend-v2):
  * services/permissions.ts — new useStrictPerm + usePebbleAccess
    hooks. Strict semantics: false-while-loading so the Pebble nav
    entry does NOT flash visible to non-JP users during the brief
    permissions round-trip.
  * components/AppShell.tsx — Sidebar filters NAV_GROUPS to hide the
    "Pebble" group unless usePebbleAccess() returns true.
  * components/PebbleAccessGate.tsx (NEW) — route-level gate that
    redirects to /dashboard for non-JP. Defense in depth for
    bookmarks / manual URL entry / future deep links.
  * App.tsx — /pebble route wrapped in <PebbleAccessGate>.

Tests:
  * financial_forecasting/tests/test_pebble_access_gate.py — 18 tests
    pinning PERMISSION_KEYS membership, ADMIN_AUTOFILL_EXCLUDED set,
    override grant/deny semantics, malformed-value rejection,
    composite gate 403 messages, service-account bypass, and the
    full resolution chain (Jac=deny vs JP=grant).
  * frontend-v2 components/PebbleAccessGate.test.tsx — 6 tests
    covering loading state, grant, deny, missing key, non-boolean
    value rejection, and missing data.

Full test count after this change:
  * 687 pebble + 1070 bedrock = 1757 passing, +24 new for the gate.

Future widening (when Pebble opens beyond JP):
  * Flip pebble_access from false → true in the desired profile(s)
    via a follow-up migration. user_config overrides continue to
    win for any individual exception.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wave 0 (§4.12) of the L2 Research Swarm plan — the real-time cost
counter replacing estimate-based budget enforcement. Reads from
bedrock.pebble_harness_log (LLM calls, extended in 2026-05-18-pebble-
ledger-instrumentation.sql with cache_creation / cache_read columns)
and bedrock.pebble_tool_call_log (non-LLM tool calls).

pebble/orchestrator/ledger.py:
  * TokenEvent + ToolCallEvent + CacheHitMetrics + ClusterRollup +
    PurposeRollup + RunTotal + RunLedger Pydantic models. Lightweight
    enough to serialize on every SSE budget.consume event.
  * record_token_event(pool, ...) — superset of log_harness_outcome
    that captures the L2 swarm columns (session_id, purpose, cluster,
    tier, provider, model_id, redo_attempt) plus cache_create and
    cache_read tokens. Best-effort write — swallows DB exceptions to
    keep the swarm step running even if telemetry fails.
  * record_tool_call(pool, ...) — writer for the new pebble_tool_call
    _log table. Called by @with_ledger decorator on HTTP fetch helpers
    in pebble/data_sources/*.py (follow-up commit).
  * compute_run_rollup(pool, session_id) — materializes the live
    ledger by aggregating per-call rows by cluster + by purpose,
    rolling up the four-token-field cache metrics. Pure aggregation,
    no side effects. The cockpit's cost meter binds to the resulting
    RunLedger via SSE.
  * harness_result_to_event_kwargs(result, ...) — adapter from
    HarnessResult.tokens_used (the four-field dict from the cache-
    aware capture) to record_token_event kwargs. Handles legacy
    two-field dicts via dict.get defaults.

CacheHitMetrics math (per Anthropic prompt-caching billing):
  * cache_hit_ratio = cache_read / (cache_read + fresh_input) —
    drives the cockpit's "Cache: 74%" chip.
  * estimated_savings_usd = cache_read_tokens × 90% of input rate —
    advisory savings figure (not a billing line); uses Sonnet 4.6's
    $3/Mtok input rate as a reference midpoint.

pebble/tests/test_ledger.py — 15 tests pinning:
  A. Pydantic shapes accept four-field tokens; legacy two-field rows
     default cache fields to 0.
  B. CacheHitMetrics.cache_hit_ratio math (empty / pure-cache /
     pure-fresh / mixed) + estimated_savings proportional to
     cache_read.
  C. compute_run_rollup aggregations: empty session returns zeroed
     ledger; mixed LLM+tool rows sum costs correctly + populate
     by_cluster + by_purpose; error_count counts both LLM errors
     and tool failures.
  D. harness_result_to_event_kwargs adapter handles four-field and
     legacy two-field shapes.
  F. record_token_event + record_tool_call swallow DB exceptions
     (best-effort contract).

Test count after this change:
  * 702 pebble (was 687; +15 ledger). Zero regressions on existing
    suite. Bedrock count unchanged.

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

@claude claude 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.

⚠️ Code review skipped — your organization's overage spend limit has been reached.

Code review is billed via overage credits. To resume reviews, an organization admin can raise the monthly limit at claude.ai/admin-settings/claude-code.

Once credits are available, push a new commit or reopen this pull request to trigger a review.

JP and others added 3 commits May 18, 2026 14:27
…rve_tool_call

Wave 0 follow-on (§4.12 + §6 read-side). Gives JP a launch-dark-visible
surface to verify cache-aware cost capture without inspecting the DB
directly, plus the primitive Wave 1 clusters will use to record
tool-call events.

GET /api/pebble/ledger/{session_id}:
  * Returns RunLedger as JSON: run_total + cache_hit_ratio +
    estimated_savings_usd + by_cluster + by_purpose rollups.
  * Gated by require_pebble_access (master gate — JP-only on main).
  * Route ordering matters: /ledger/recent is declared BEFORE
    /ledger/{session_id} so FastAPI's path matcher doesn't treat
    "recent" as a session_id literal. Same trap I just hit in
    test_recent_sessions_returns_caller_runs.
  * Reads via pebble.orchestrator.ledger.compute_run_rollup (lazy
    import — pebble pkg isn't part of Bedrock startup path).

GET /api/pebble/ledger/recent?limit=N:
  * Lists recent distinct session_ids touched by the caller.
  * Defense-in-depth: filters by user_email so even with the master
    gate, a caller can't see other users' runs unless they happen
    to fall back to NULL user_email (legacy pre-2026-05-18 rows).
  * Limit clamped to [1, 100] regardless of query param.

pebble/orchestrator/ledger.observe_tool_call (NEW context manager):
  * Wraps a data-source dispatch so its outcome lands in
    pebble_tool_call_log. Captures start time, exceptions (records
    success=False + error_class then re-raises), and outcome
    metadata via a mutable dict yielded into the with-block:
      obs["bytes_returned"], obs["rate_limit_remaining"],
      obs["rate_limit_reset_at"], obs["cache_hit"], obs["cost_usd"].
  * Why a context manager and not a decorator: data sources are sync
    functions invoked via asyncio.to_thread from clusters. The
    cluster knows session_id + cluster name + originating user;
    data sources don't. Decorating them would thread context
    through every signature.
  * Wave 1 picks this up in cluster code — for now the primitive
    is unit-tested + ready.

Tests:
  * pebble/tests/test_ledger.py — 4 new observe_tool_call tests:
    success path records correct fields; failure path records
    error_class + re-raises; default cost_usd=0 path; cache_hit
    marker. Pebble suite: 706/706 (was 702; +4).
  * financial_forecasting/tests/test_pebble_ledger_routes.py — 6
    tests covering empty session zero-total, mixed LLM+tool
    aggregation, estimated_savings surfacing, recent sessions
    happy path, limit-clamping (>100 and <1). Bedrock suite:
    1076/1076 + 22 skipped (was 1070; +6).

Combined: 1782 backend tests pass, zero regressions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…end gate

Five issues found during the post-Wave-0 verification sweep, each one a
real bug that would have manifested at migration apply time, on a fresh
DB, or via a legacy frontend bypass.

1. CREATE INDEX with now() in WHERE predicate (P-pass 1, swarm-runtime)
   pebble_research_action_idempotency had:
       CREATE INDEX ... ON ... (occurred_at)
       WHERE occurred_at < now() - INTERVAL '24 hours'
   PostgreSQL rejects this with "functions in index predicate must be
   marked IMMUTABLE" because now() is STABLE. The partial index would
   have failed at apply time forcing a rollback + amend. Replaced with
   a plain non-partial index on occurred_at — same range-scan benefit
   for the cleanup query.

2. ADD COLUMN NOT NULL DEFAULT 'running' on existing rows (P-pass 1)
   pebble_scratchpad.run_status was being mass-marked 'running' on
   pre-existing rows that represent historical/legacy scratchpads.
   Made the column nullable with no DEFAULT; CHECK allows NULL plus
   the five real states. Cockpit treats NULL as legacy/unknown.

3. JP seed could create user_config with profile_id NULL (P-pass 2)
   On a fresh DB where JP's user_config row doesn't exist yet, the
   INSERT ... ON CONFLICT (org_user_id) path would create a new row
   with profile_id NULL, and get_user_permissions would fall through
   to the default profile (RM) at next login — silently demoting JP
   to RM with only the pebble_access override. Now pre-resolves the
   Admin profile id and passes it to INSERT. The ON CONFLICT branch
   doesn't touch profile_id, so existing rows keep their profile.

4. v1 frontend Pebble route only gated on sub-permissions (P-pass 3)
   frontend/src/App.tsx (legacy /pebble route) gated on
   use_pebble_chat || use_pebble_research, missing the pebble_access
   composite check. On main, any user with use_pebble_chat=true
   could still load the v1 Pebble UI even with pebble_access=false.
   Now requires pebble_access AND (use_pebble_chat || use_pebble_research)
   to match the backend require_pebble_permission gate in pebble/main.py
   and the frontend-v2 useUserPermissions hook.

5. CHECK ... IN (..., NULL) semantic bug (P-pass 4, network-and-giving)
   trend_direction and ideology_cluster CHECK constraints had NULL in
   the IN list. SQL semantics: x IN (a, NULL) = (x=a OR x=NULL); x=NULL
   evaluates to NULL not TRUE, so the constraint silently rejects NULL
   values even though both columns are nullable. Replaced with
   `col IS NULL OR col IN (...)`.

Pinned by tests/test_migration_lints.py — regex-based static lints
against db/migrations/*.sql that run in every CI:

  L1 — Reject now() / current_timestamp / current_date / etc. in
       CREATE INDEX ... WHERE predicates (catches bug #1 class)
  L2 — Warn on ADD COLUMN ... NOT NULL DEFAULT without an
       explanatory comment within 8 lines above (catches bug #2 class)
  L3 — Reject NULL inside CHECK ... IN (...) lists (catches bug #5 class)
  Smoke — Migrations dated 2026-05-18+ must declare their date in the
          first 15 lines as `-- YYYY-MM-DD:`; legacy grandfathered

96 lints pass (76 enforced + 20 grandfathered skips).
706 pebble tests pass, full backend suite passes — zero regressions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…rifier + retrieved_data guardrail

Implements the §4.3, §4.9, and §4.11 sections of the L2 swarm plan
(~/.claude/plans/glistening-crafting-matsumoto.md). All wiring is
end-to-end live — no stubs, no flags, no "later". Default behavior
changes; one env-var kill switch documents the safety hatch.

WHAT'S NEW

1. pebble/orchestrator/guardrails.py — Layer-1 guardrails
   * wrap_retrieved(content, origin): wraps external content in
     <retrieved_data origin="..."> tags that the system prompt tells
     the model to treat as DATA, not instructions. Escapes closing-
     tag sequences so a malicious 990 field cannot terminate the
     wrapper early. Sanitizes + truncates origin to 64 chars.
   * detect_injection_signatures: regex-scans for 10 documented
     prompt-injection signatures (ignore_previous, disregard_above,
     new_instructions, you_must, override, forget_everything,
     system_role, assistant_role, respond_with, approve_all).
   * GUARDRAIL_PREFIX_TEXT: stable Layer-1 + Layer-3 prompt prefix
     covering source_url requirement, no-fabrication rule, read-only
     constraint, and retrieved-data handling. Designed for Anthropic
     prompt-caching (§4.12 D18 — ~$0.10-0.20/T3 savings amortized
     across 30+ Doer/Verifier calls per run).

2. pebble/orchestrator/verifier.py — per-claim verifier loop (§4.3)
   * _call_verifier_once: raw harness call. Returns _RawVerdict with
     six outcome classes (approve/reject/redo/parse_failed/
     unavailable/unknown_outcome).
   * verify_claim_once: one-shot path for template-emitted claims
     (FEC rows, 990 officers, OpenCorporates records). FAIL-CLOSED
     on parse failure — admit at low confidence with verifier_note,
     not auto-approve (§4.11) and not auto-drop (an adversary who
     breaks JSON must not get to either).
   * verify_claim_loop: Doer→Verifier loop with bounded redos.
     T1 max_loops=0, T2=1, T3=2, T4=2 per §4.5. Redo budget exhausted
     → admit_low_confidence with verifier_note=redo_exhausted:<reason>.
     Doer returns None on redo → admit current claim at low
     confidence (doer_gave_up). Parse failure mid-loop STOPS
     immediately — adversary cannot drag us through paid retries by
     breaking JSON output.

3. pebble/harness.py — claim_verifier_singleclaim template + wrapping
   * New @register_template "claim_verifier_singleclaim" (WORKER tier
     Haiku). Takes one claim + source_url + optional source_excerpt
     + optional redo_hint. Returns {outcome, reason, confidence_hint}.
   * philanthropy_agent now wraps wiki_data extract, propublica_data,
     and edgar_data in <retrieved_data> tags.
   * wealth_indicator_agent now wraps fec_data, oc_data, usa_data.
   * Lazy import of wrap_retrieved breaks the harness↔orchestrator
     circular import — first call pays import cost; subsequent hits
     the Python module cache.

4. pebble/model_client.py — agent→tier mapping
   * "claim_verifier_singleclaim" → ModelTier.WORKER (Haiku, $1/$5).

5. pebble/orchestrator/_pipeline.py — fail-closed fix + per-claim wiring
   * quorum_verify_claims._run_verifier: PARSE FAILURE NOW RETURNS
     EMPTY SET (was: full index set, which approved every claim).
     This is the §4.11 fix. Verifier-internal errors (timeout,
     retries, schema) also return empty set. The 2-of-3 majority is
     intact — a single fail-closed verifier doesn't starve the
     quorum; two simultaneous fail-closeds correctly drop everything.
     pebble_harness_log gets a killed_schema_fail row on parse failure
     so cockpit retros can spot pattern.
   * NEW _prefilter_claims_per_claim: runs BEFORE the run-level
     quorum. Per-claim Haiku verifier filters reject/admit/approve.
     Budget-aware (re-checks INSIDE asyncio.Semaphore to avoid the
     race where N parallel tasks all see not-yet-spent budget).
     Budget exhausted → pass-through (run-level quorum is the second
     line of defense). Costs roll into ProspectBudgetTracker; outcomes
     persist to harness_log under agent_name="claim_prefilter" so
     ops can grep prefilter vs quorum activity.
   * PEBBLE_PER_CLAIM_VERIFIER_DISABLED env kill switch (default OFF
     = prefilter ENABLED). Falsy values ("", "false") keep prefilter
     on; truthy ("1", "true", "yes", "on") disable it. Documents the
     safety hatch without making the feature optional.

TESTS PINNED (53 new tests, 767 pebble total, was 706 + 53 + 8)

* test_orchestrator_guardrails.py (24): wrap_retrieved shape +
  closing-tag escape + origin sanitization + injection-signature
  pattern coverage + GUARDRAIL_PREFIX_TEXT semantics + cache-
  stability invariant.
* test_orchestrator_verifier.py (20): parse helpers + outcome
  coercion + verify_claim_once approve/reject/redo/parse-fail/
  unavailable/unknown_outcome paths + verify_claim_loop redo-then-
  approve / redo-exhausted / doer-gives-up / reject-early / parse-
  fail-stops / zero-max-loops / negative-max-loops.
* test_pipeline_verifier_fail_closed.py (9): §4.11 quorum fail-
  closed (parse / unavailable / all-3-down) + prefilter drops/keeps
  routing + parse-fail-admits-low / budget-exhausted-passes-through
  + kill-switch env var.
* test_harness_templates_wrap_retrieved.py (8): philanthropy_agent +
  wealth_indicator_agent + retrieved_data wrapping per data source +
  injection-payload-stays-inside-wrapper end-to-end check.

PINNED INVARIANTS

* Parse-broken verifier output NEVER approves a claim (§4.11 fix).
* Single fail-closed verifier does NOT starve the 2-of-3 quorum.
* Per-claim prefilter parse failure admits at low confidence —
  adversary cannot force a drop by breaking JSON.
* Budget exhaustion during prefilter PASSES claims through; they
  reach the quorum unverified rather than silently dropping.
* Forager retrieved data wrapped in <retrieved_data> tags;
  injection payloads end up inside the wrapper, not in instruction-
  space.

NO REGRESSIONS

767 pebble tests pass (was 706). Full backend suite passes. All
existing template + quorum behavior preserved when prefilter env
disabled.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@jpb33333
jpb33333 requested a review from jacrev-pursuit as a code owner May 18, 2026 21:06
JP and others added 2 commits May 18, 2026 17:15
The Wave 0 migration created bedrock.pebble_meta_alerts but nothing
wrote to it. This slice closes the loop: deterministic anomaly
thresholds in the per-claim prefilter now emit meta_alert rows
during the run, so cockpit retros / the Meta-Observer feed have
real data the moment Wave 3 (SSE + cockpit) lands.

Three signals wired:

  off_rails (severity=warn)
    ≥50% of verified claims rejected by the per-claim verifier.
    Doer outputs may be off-rails OR the verifier may be over-
    rejecting; either way the human reviewer needs to know.

  low_novelty (severity=warn)
    ≥60% of verified claims admitted at low_confidence (verifier
    couldn't decide either way). Signals source-data weakness; not
    a bug but worth flagging.

  injection_signature (severity=warn or throttle)
    Known prompt-injection phrases ("ignore previous instructions",
    "approve all claims", "system:" line-start, etc — 10 patterns
    from pebble.orchestrator.guardrails.INJECTION_SIGNATURE_PATTERNS)
    appearing in claim text or source_url. 1-2 hits = warn; 3+ hits
    in one run = throttle (Wave 2 orchestrator will act on it; for
    now the persistence is the action).

New surface:

  pebble.storage.db.save_meta_alert(...)
    Best-effort INSERT into bedrock.pebble_meta_alerts. Validates
    alert_kind + severity against the schema CHECK constraint
    client-side so typos raise ValueError, not SQL errors. Rejects
    empty originating_user_email (audit attribution mandatory per
    §4.10). DB failure is swallowed with a warning — Meta-Observer
    is advisory; research correctness MUST NOT depend on it.

Wiring changes:

  research_single_prospect now mints session_id at the top of the
  pipeline and threads it through _prefilter_claims_per_claim and
  _save_session_for_prospect. Same id is used by the meta_alert
  rows and the eventual session_history row, so cockpit + ops
  retro tools can join them. Backward-compat: _save_session_for_
  prospect's session_id param is optional and defaults to a fresh
  UUID if not passed (legacy callers unaffected).

  _prefilter_claims_per_claim now accepts session_id and emits
  meta_alerts at the documented thresholds. Skips alert emission
  when session_id or user_email is missing (audit invariant; can't
  satisfy attribution → don't persist).

Tests (13 new — 780 pebble total, was 767 + 13):

  save_meta_alert direct: invalid alert_kind / severity / empty
  email all raise; happy path INSERTs the right columns;
  DB-error swallowed best-effort.

  Threshold wiring: off_rails fires at 60% reject; not at 20%
  reject; low_novelty fires at 70% admit_low; injection_signature
  fires at 1-2 hits (warn) and 3+ hits (throttle); happy path
  emits zero alerts; no session_id or no user_email → no alerts.

NO REGRESSIONS

780 pebble tests pass. Full backend suite passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
detect_conflicts() + save_conflicts() existed pre-Wave-1 but were
only used by the tier3 handler. The main research pipeline now
invokes them after the run-level quorum, persists detected conflicts
to bedrock.pebble_conflict_log, emits a divergence / conflict_spike
meta-alert, and exposes the conflict list on the returned profile
so the cockpit can render it.

Wiring:

  research_single_prospect, post-quorum stage:
    1. Run detect_conflicts(verified_claims, person_name) — the
       deterministic regex/heuristic detector from
       pebble.clusters.conflict_detector. Returns role / financial /
       temporal contradictions as structured dicts.
    2. Persist via save_conflicts(session_id, contact_id, conflicts)
       to bedrock.pebble_conflict_log (existing table). Same
       session_id as the meta_alerts and harness_log rows so cockpit
       + retros can join them.
    3. Emit a meta_alert:
         conflict_count < 3 → divergence (severity=warn)
         conflict_count >= 3 → conflict_spike (severity=warn)
       The §4.4 throttle severity is reserved for Wave 2 when the
       orchestrator can act on it; for now persistence is the action.
    4. Attach the conflict list to the saved profile under the
       "conflicts" key per §5.4 structured-output schema.

  detect_conflicts proxy in _pipeline.py:
    Lazy-imports pebble.clusters.conflict_detector.detect_conflicts
    at call time to break the orchestrator↔clusters circular import
    (clusters/__init__.py imports ProspectBudgetTracker from
    orchestrator). Same lazy-import pattern as wrap_retrieved in
    harness.py.

Best-effort:
  detect_conflicts is deterministic regex, won't raise.
  save_conflicts can fail if DB is unhappy — wrapped in try/except,
  log + continue rather than crash the research path.
  save_meta_alert is already best-effort (Wave 1 commit).

Tests (7 new — 787 pebble total, was 780 + 7):

  test_pipeline_conflict_wiring.py
    detect_conflicts called with (verified_claims, person_name);
    save_conflicts persists with run session_id + contact_id;
    empty conflicts → no DB writes, no alert;
    1-2 conflicts → divergence warn;
    3+ conflicts → conflict_spike (not divergence);
    profile dict carries conflicts list under "conflicts" key;
    no user_email → conflicts still persist but meta-alert skipped
    (audit attribution invariant).

NO REGRESSIONS — 787 pebble tests pass; full backend passes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
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