feat(chisel): Phase A — framework + 4 tools + 1 workflow migration - #211
Open
jpb33333 wants to merge 37 commits into
Open
feat(chisel): Phase A — framework + 4 tools + 1 workflow migration#211jpb33333 wants to merge 37 commits into
jpb33333 wants to merge 37 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>
…ing meta-system Scopes Chisel as the meta-tool that lowers Pebble's per-tool authoring cost from ~4–8h (8 stitching points across 6+ files, plus a silent-failure side-effect import in handlers/streaming.py) to ~30 min, and extends authoring to non-engineers via a GUI builder. Locked decisions (D1–D8) as of 2026-05-11: - Audience: engineers + non-engineers via GUI builder - Unit: tools + workflows - Authoring: hybrid manifest.yaml + handler.py per artifact; Pydantic → JSON Schema; Sprint-12 RBAC integration - External emit: none; forward-compat to internal platform stack only (segundo-db Postgres + asyncpg + JWT + React/Tailwind/shadcn) Delivery: three cumulative phases — A. Framework → B. Eval harness → C. GUI builder — ~6–7 weeks at production discipline. Doc includes: - File-by-file friction walkthrough for the existing authoring loop - Verification log for every load-bearing architectural claim - Implementation defaults pending plan-mode entry Plan mode + tasks/pebble-chisel-plan.md pending user approval of this scope. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ase A - tasks/pebble-chisel-plan.md (NEW): 635-line implementation plan resolving P1-P14 from scoping §7.5. Three PRs in order: Phase A (Framework ~2wk) → B (Eval ~1wk) → C (GUI ~3-4wk). Locks package layout, manifest schema, RBAC integration, migration sequencing, per-phase test surface + risk register. - tasks/pebble-chisel-scoping.md: 2026-05-12 verification pass corrects aspirational stack claims against reality — session-cookie auth (not JWT), custom UI primitives in frontend-v2/src/components/ui/ (not shadcn/ui), snake_case RBAC names matching use_pebble_research. Pulls handler/render code editing out of v1 GUI scope (PR review only). Workflow GUI authoring is declarative (workflow.yaml) — Python build_plan.py stays engineer-only. Phase A implementation begins next commit.
…chema strictness Phase A.1 of the chisel plan. Lays the framework down with empty tools/ and workflows/ dirs so autoload is a no-op until subsequent commits migrate the four built-in tools + weekly_pipeline_review. Resolves plan §11.8 step 1 (framework-first sequencing). No existing pebble files behaviourally affected; orchestrator tests still pass (112/112). New package: pebble/chisel/ - __init__.py public surface (autoload, snapshot, slash_command_map, dispatch_workflow) - manifest.py Pydantic ToolManifest + WorkflowManifest; FixedCost/VariableCost (P8) - schema.py pydantic_to_strict_schema injects additionalProperties:false at every object node (P1) + assert_strict invariant - handler_adapter.py HandlerContext + build_handler_wrapper — dict→pydantic, timing, exception→ToolResult, tool_version (P2, P11) - autoload.py directory walk; AutoloadReport surfaces per-unit errors instead of crashing; registry= arg for test isolation (P4) - rbac.py check_permission stub with PEBBLE_CHISEL_RBAC_BYPASS_USERS env (§11.9), Sprint-12-ready - lints.py AST checks: no bare httpx, async run required, no os.environ in run() - reload.py snapshot(registry) for snapshot-per-request safety (P5) - cli.py argparse: list, validate, scaffold (P10 test-stub generator) - tools/, workflows/ empty placeholders for migrations Adjacent changes: - ToolResult: optional tool_version field (P11 — Sprint-11 scratchpad forward-compat) - pebble/requirements.txt: PyYAML>=6.0 for manifest parsing
added 7 commits
May 22, 2026 09:24
Covers plan §8 framework surface end-to-end: - manifest: minimal-valid, bad-name rejection, extra-field rejection, variable-cost collapse, workflow steps-or-custom-plan invariant, slash-command format - schema (P1): strict invariant on nested model, $ref inlined, assert_strict flags permissive objects - handler adapter (P2, P11): happy path records tool_version + citations + duration_ms; input-validation, exception, and non-dict-return paths all produce ok=False with a typed error - autoload (P4): empty-root no-op, isolated registry, malformed manifest doesn't poison siblings, workflow populates slash + intent maps, DEFAULT_REGISTRY untouched by tests - rbac (§11.9): no-requirement always ok, bypass env list (case-insensitive), missing permission, PEBBLE_CHAT_ALLOWED_EMAILS fallback - lints: bare httpx, sync run, os.environ in run, overrides suppress - snapshot (P5): survives source mutation mid-request Full pebble suite green: 702 passed.
Phase A.2 — simplest tool first per plan §11.8. No I/O, no real handler logic, output_kind=checkpoint so the renderer's checkpoint pipeline handles surface presentation (P7 discriminator at work). Handler shrinks from ~40 lines of ToolResult boilerplate to ~10 lines of business logic — the adapter (P2) handles dict→Pydantic parsing, exception wrapping, timing, and ToolResult construction. requires_human flows from the manifest into the registered ToolSpec.
Phase A.3 — pure shape transformer, no I/O. Literal-typed ChartKind gives the planner exactly the same enum the legacy schema had, and the Pydantic model rejects non-object rows before the handler runs (the legacy code had to hand-iterate). output_kind=chart so the renderer's chart pipeline picks it up. Handler is 12 lines of business logic; the legacy was 60 lines of validation + ToolResult construction.
Phase A.4 — single-record fetch via ctx.http_client. Literal-typed EntityType replaces the legacy enum schema and gives Pydantic validation at parse time (used to be hand-checked inside the handler). Handler raises RecordNotFound / RecordFetchError on 404 / non-2xx; the adapter converts these to ToolResult ok=False with a clean type name in the error string. Boilerplate down from ~55 lines to ~25.
Phase A.5 — last individual tool. Literal type for SearchEntityType mirrors the legacy enum; Pydantic Field constraints (min/max length, ge/le for limit) replace inline validation. ctx.cite() per hit replaces the hand-rolled citation tuple construction. Adapter eliminates timeout / status-code / parse boilerplate from the handler — ~75 lines down to ~30.
Phase A.6 — the final migration commit. Brings aggregate_pipeline_views
across as a chisel tool and weekly_pipeline_review across as a chisel
workflow with a custom build_plan.py (the engineer escape hatch per
plan §3). Cutover wires production paths through chisel and deletes
the legacy modules — no coexistence with the old registration pattern.
Migrations:
- pebble/chisel/tools/aggregate_pipeline_views/
compute.py (pure helpers, isolated for testing) + handler.py
+ manifest + tests. PipelineFetchError replaces inline ToolResult(
ok=False).
- pebble/chisel/workflows/weekly_pipeline_review/
workflow.yaml (slash_command=/pipeline, dispatch_intent=
workflow_weekly_pipeline_review, has_custom_plan=true)
+ build_plan.py (factory replacing the legacy
build_weekly_pipeline_review_plan).
Framework extensions:
- autoload now loads build_plan.py for has_custom_plan workflows and
synthesizes a build_plan from declarative steps[] otherwise.
- chisel.build_workflow_plan(intent_or_name, **kwargs) → Plan | None
- chisel.slash_to_intent(slash) → dispatch_intent | None
- _import_module uses standard importlib for source-tree modules so
relative imports (from .compute import ...) resolve; tmp_path tests
still fall through to spec_from_file_location.
Cutover (replaces legacy side-effect imports per plan §11.8):
- pebble/handlers/streaming.py — drops `from ..orchestrator import
builtin_tools` and `from .. import workflows`; calls
`chisel.autoload()` at module load so DEFAULT_REGISTRY is populated
from manifests. `_build_workflow_plan_for_intent` becomes a one-liner
delegating to chisel.build_workflow_plan.
- pebble/router.py — drops the hand-rolled `_SLASH_COMMANDS` table;
`_check_slash_command` reads `chisel.slash_to_intent()` at request
time.
Deletions (no coexistence per §11.8):
- pebble/orchestrator/builtin_tools.py
- pebble/workflows/ (whole package)
- pebble/tests/test_orchestrator_builtin_tools.py
- pebble/tests/test_workflows_weekly_pipeline_review.py
Test isolation:
- conftest.py adds autouse _chisel_real_autoload fixture so framework
tests that autoload from tmp_path don't pollute the global maps that
router / streaming tests depend on.
Suite: 647 pass (702 pre-cutover − 60 legacy − a few replaced by
chisel-side equivalents + 28 new chisel tests).
Phase A.7 doc. Documents the chisel scaffold flow, the manifest + handler shape, the workflow modes (custom build_plan vs declarative steps), and the framework guarantees (strict schemas, no handler boilerplate, isolated autoload, snapshot-per-request). Cross-references tasks/pebble-chisel-plan.md for the full design.
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.
5 tasks
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
Lands Phase A of the Chisel plan: the tool/workflow authoring framework, plus migration of all four built-in tools and the
weekly_pipeline_reviewworkflow off the legacy side-effect-registration pattern.pebble/chisel/: manifest models, Pydantic→strict-JSON-schema (P1), handler adapter that eliminates per-tool ToolResult boilerplate (P2), autoload with per-unit error isolation (P4), snapshot-per-request reload safety (P5), RBAC stub (§11.9), AST lints, argparse CLI with scaffold subcommand (P10).request_human_review,generate_chart,get_record,search_crm, andaggregate_pipeline_viewsnow live underpebble/chisel/tools/.weekly_pipeline_reviewlives underpebble/chisel/workflows/withhas_custom_plan=true+build_plan.py.pebble/handlers/streaming.pycallschisel.autoload()at module load (replacesfrom ..orchestrator import builtin_tools+from .. import workflowsside-effect imports).pebble/router.pyreads the slash table fromchisel.slash_to_intent(). Legacy modules deleted — no coexistence per plan §11.8.tool_version: str | None, filled by the adapter from the manifest. Sprint-11 scratchpad will record it.Commits (in landing order)
424b72bdocs: chisel plan + scoping verification pass — locked for Phase Ae70fae9feat: scaffold framework — no-op autoload, manifest models, schema strictness3bdcbcbtest: Phase A.1 framework unit tests — 27 cases98461e9feat: migrate request_human_review to manifestd1118c5feat: migrate generate_chart to manifestcae2ffdfeat: migrate get_record to manifest60ec903feat: migrate search_crm to manifest1dfd4aafeat: migrate weekly_pipeline_review workflow + cutover8927199docs: pebble/README authoring sectionTest plan
test_orchestrator_builtin_tools.py+test_workflows_weekly_pipeline_review.pydeleted, replaced by chisel-side equivalents)conftest.pyautouse fixture re-runschisel.autoload()so tmp-path framework tests don't pollute router/streaming tests/pipelineend-to-end in a running Pebble instance (defer to reviewer or pre-merge)What's next
financial_forecasting/frontend-v2/src/pages/chisel/(3–4 wks)🤖 Generated with Claude Code