feat(pebble): L2 swarm Wave 0 — launch-dark gate + cost ledger (JP-only) - #191
Open
jpb33333 wants to merge 35 commits into
Open
feat(pebble): L2 swarm Wave 0 — launch-dark gate + cost ledger (JP-only)#191jpb33333 wants to merge 35 commits into
jpb33333 wants to merge 35 commits into
Conversation
…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>
There was a problem hiding this comment.
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.
…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>
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>
This was referenced May 19, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Wave 0 of the L2 Research Swarm plan. Lands on main as launch-dark — only
jp@pursuit.orgcan 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):a497ba8— Wave 0 foundation: 3 idempotent migrations for ledger instrumentation (extendspebble_harness_logwith cache + run context columns; newpebble_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 inmodel_client.py— capturescache_creation_input_tokens+cache_read_input_tokensfrom the Anthropic SDK and routes through the canonicalpebble.llm.cost.calculate_cost_usdfor the 1.25× / 0.10× rates.08ed7d9— JP-only launch-dark gate: newpebble_accesspermission key, defaultfalsein all 4 profiles.ADMIN_AUTOFILL_EXCLUDEDcarve-out keeps Admin auto-fill from granting it. Per-userpermission_overridesJSONB column onuser_config; seeded{"pebble_access": true}forjp@pursuit.org. Compositecheck_pebble_permission(sub)gate + standalonerequire_pebble_accessfor cockpit endpoints. FrontendusePebbleAccess()hook (false-while-loading),PebbleAccessGateroute component, sidebar nav filter.04c1c44— Token + cost ledger (pebble/orchestrator/ledger.py): Pydantic models forTokenEvent/ToolCallEvent/RunLedger.compute_run_rollup()aggregatespebble_harness_log+pebble_tool_call_loginto 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 fromHarnessResult.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_accesspermission defaults false everywhere; only JP'suser_config.permission_overridesgrants it. When Pebble is ready to widen, flip the profile bit in a follow-up migration.Tests
PebbleAccessGate(runs on CI).Migration order (ops)
Apply in this sequence as
bedrockowner against staging then prod:financial_forecasting/db/migrations/2026-05-18-pebble-ledger-instrumentation.sqlfinancial_forecasting/db/migrations/2026-05-18-pebble-swarm-runtime.sqlfinancial_forecasting/db/migrations/2026-05-18-pebble-network-and-giving.sqlfinancial_forecasting/db/migrations/2026-05-18-pebble-access-launch-dark.sqlAll 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 NOTICEifjp@pursuit.orgisn't yet inpublic.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)
cache_control: {"type": "ephemeral"}on stable guardrail prefix blocks (~$0.10-0.20/T3 savings).@with_ledgerdecorator onpebble/data_sources/*.pyHTTP fetches → writes topebble_tool_call_log.Follow-up commits to this branch will add these.
Test plan
/pebbleroute loads/pebbleURL redirects to/dashboardSELECT 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}bedrock.pebble_harness_logfor the new cache columns populated🤖 Generated with Claude Code