feat(#193 follow-up): provenance graph filter by Lifebook wing#257
Merged
Conversation
There was a problem hiding this comment.
Pull request overview
This PR completes the deferred #193 follow-up by adding end-to-end support for filtering the provenance graph to a specific Lifebook wing, starting from DB storage through API filtering and up to the frontend consumer.
Changes:
- Add
wing_idtocapability_provenance_nodes(plus a partial index) and populate it at write-time via best-effort Lifebook lookup frompayload.registryId. - Extend
GET /api/capabilities/provenance-graphwith an optionalwing=<uuid>filter and includewingIdon returned nodes. - Update the provenance graph frontend to read
wingfrom the hash query string and provide a “Show all wings” clear-filter affordance; add API tests for the new filter and response shape.
Reviewed changes
Copilot reviewed 6 out of 6 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/db/src/repositories/provenance-repository.ts | Adds wing_id plumbing to reads/writes and auto-derivation from payload registryId via Lifebook lookup. |
| packages/db/src/migrations/041-provenance-wing-id.sql | Adds nullable wing_id column and a partial index for wing-scoped queries. |
| apps/api/src/routes/capabilities.ts | Adds wing query param validation + SQL WHERE filter; returns wingId per node. |
| apps/web/public/js/pages/provenance-graph.js | Reads wing from hash query string, forwards it to API, and adds UI to clear wing scoping. |
| apps/api/src/tests/provenance-graph-routes.test.ts | Adds vitest coverage for wing filtering, invalid UUID handling, and wingId presence in responses. |
| CHANGELOG.md | Documents the new wing-filter capability across migration/API/frontend and testing notes. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
Comment on lines
+72
to
+80
| // Strip the `?wing=` query from the hash so subsequent renders | ||
| // load the un-scoped graph. Re-render explicitly so we don't | ||
| // depend on the hashchange listener firing. | ||
| const newHash = '#/provenance'; | ||
| if (window.location.hash !== newHash) { | ||
| window.location.hash = newHash; | ||
| } else { | ||
| renderProvenanceGraph(document.getElementById('page-content'), getCurrentUserId(), { wing: '' }); | ||
| } |
Comment on lines
+122
to
+124
| * - The payload is null / not an object / has no `registryId`. | ||
| * - No lifebook claims that registryId. | ||
| * - The matching lifebook hasn't been bound to a wing yet. |
7 tasks
jayzalowitz
added a commit
that referenced
this pull request
May 12, 2026
…ngIdFromPayload) Two docstring accuracy nits from Copilot round 2: 1. pg-clear-wing-filter handler: previous comment claimed it "re-renders explicitly so it doesn't depend on the hashchange listener" but the wing-actually-cleared branch DOES rely on hashchange. Comment now describes both branches accurately (hashchange path when wing was set; explicit-render path for the unusual double-click case). 2. resolveWingIdFromPayload: docstring claimed null is returned when payload is "not an object", but the signature already excludes that case at compile time. Docstring now lists the actual runtime cases (undefined payload, missing/non-string registryId, no lifebook match, lifebook with no wing). Test plan: db builds clean; web syntax clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Owner
Author
|
Round-2 review reply: both findings addressed in 048ec85.
|
Closes the "provenance graph wing-filter consumer" item PR #242 (#193 Child 1) explicitly deferred. The lifebook page has been linking to page couldn't honor that filter because nodes had no wing linkage. This PR closes the gap end-to-end. Migration 041: - Adds nullable wing_id UUID column to capability_provenance_nodes - Partial index (user_id, wing_id, occurred_at DESC) WHERE wing_id IS NOT NULL keeps per-wing graph queries indexed without bloating the index with the NULL long tail - No FK to lifebooks (would block lifebook hard-delete); the frontend filter naturally excludes NULL rows Repository: - provenanceRepository.writeNode() auto-derives wing_id when the caller doesn't pass one: if the payload carries a registryId, look up the matching lifebook and stamp its wing_id. Best-effort — unknown registryId stays NULL. Explicit wingId always wins. API: - GET /api/capabilities/provenance-graph accepts wing=<uuid> query param. UUID-validated (400 on bad shape). Each node response carries wingId: string | null. Frontend: - provenance-graph.js reads the wing param off the hash query string, passes it through fetchProvenanceGraph, and renders a scoped-state indicator with a "Show all wings" button. UUID validated client-side too. Test plan: 3 new vitest cases for the API (wing filter active, invalid wing → 400, node response includes wingId). api 547/547, workspace 70/70 turbo tasks green. Out of scope (follow-ups): per-domain briefing prose (the other migration nodes. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ngIdFromPayload) Two docstring accuracy nits from Copilot round 2: 1. pg-clear-wing-filter handler: previous comment claimed it "re-renders explicitly so it doesn't depend on the hashchange listener" but the wing-actually-cleared branch DOES rely on hashchange. Comment now describes both branches accurately (hashchange path when wing was set; explicit-render path for the unusual double-click case). 2. resolveWingIdFromPayload: docstring claimed null is returned when payload is "not an object", but the signature already excludes that case at compile time. Docstring now lists the actual runtime cases (undefined payload, missing/non-string registryId, no lifebook match, lifebook with no wing). Test plan: db builds clean; web syntax clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
048ec85 to
71f0c1a
Compare
jayzalowitz
added a commit
that referenced
this pull request
May 12, 2026
Closes the last of the three #193 Child 1 follow-ups deferred by PR filter shipped in #257, this one). The weekly briefing worker now emits, in addition to the existing global briefing, one per-Lifebook briefing for each visible domain with activity in the window. The lifebook page renders the per-domain briefing when one exists. Migration 042: - Adds nullable domain_name STRING to twin_briefings. NULL preserves the historical global-briefing semantic. A string scopes the briefing to that lifebook's domain. - Partial index (user_id, domain_name, generated_at DESC) WHERE domain_name IS NOT NULL keeps per-domain queries fast without bloating storage for global rows. Repository: - create() accepts optional domainName. - getLatestForUser() now explicitly scopes to domain_name IS NULL (the historical implicit semantic, made explicit). - New getLatestForUserDomain(userId, domain, cadence?). Worker: - After writing the global briefing, fetch visible lifebooks and emit one per-domain briefing per lifebook with matching events. Events filtered by registry_id ∈ lifebook.suggested_capabilities. - Empty filtered sets skipped (no "nothing happened in Health" rows). - Per-lifebook failures caught and logged so one broken domain doesn't take out the rest. - Prompt carries optional {{domain}} input; the template's {{#if domain}} block adds a one-sentence framing for the scoped domain. Deterministic fallback unchanged. API: - New GET /api/twin-briefings/lifebook/:domain/latest?cadence= returns { briefing: TwinBriefingRow | null }. Frontend: - Lifebook page fetches the per-domain briefing best-effort and renders a briefing card. Friendly empty state when none exists yet. Test plan: 3 new vitest cases in briefing-generator.test.ts (per- Lifebook write, no-events skip, per-lifebook failure resilience). worker 86/86 (+3 new), api 547/547, db 189/189. Workspace 70/70 turbo tasks green; build clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
jayzalowitz
added a commit
that referenced
this pull request
May 12, 2026
* feat(#193 follow-up): per-Lifebook briefing prose Closes the last of the three #193 Child 1 follow-ups deferred by PR filter shipped in #257, this one). The weekly briefing worker now emits, in addition to the existing global briefing, one per-Lifebook briefing for each visible domain with activity in the window. The lifebook page renders the per-domain briefing when one exists. Migration 042: - Adds nullable domain_name STRING to twin_briefings. NULL preserves the historical global-briefing semantic. A string scopes the briefing to that lifebook's domain. - Partial index (user_id, domain_name, generated_at DESC) WHERE domain_name IS NOT NULL keeps per-domain queries fast without bloating storage for global rows. Repository: - create() accepts optional domainName. - getLatestForUser() now explicitly scopes to domain_name IS NULL (the historical implicit semantic, made explicit). - New getLatestForUserDomain(userId, domain, cadence?). Worker: - After writing the global briefing, fetch visible lifebooks and emit one per-domain briefing per lifebook with matching events. Events filtered by registry_id ∈ lifebook.suggested_capabilities. - Empty filtered sets skipped (no "nothing happened in Health" rows). - Per-lifebook failures caught and logged so one broken domain doesn't take out the rest. - Prompt carries optional {{domain}} input; the template's {{#if domain}} block adds a one-sentence framing for the scoped domain. Deterministic fallback unchanged. API: - New GET /api/twin-briefings/lifebook/:domain/latest?cadence= returns { briefing: TwinBriefingRow | null }. Frontend: - Lifebook page fetches the per-domain briefing best-effort and renders a briefing card. Friendly empty state when none exists yet. Test plan: 3 new vitest cases in briefing-generator.test.ts (per- Lifebook write, no-events skip, per-lifebook failure resilience). worker 86/86 (+3 new), api 547/547, db 189/189. Workspace 70/70 turbo tasks green; build clean. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(#258 post-review): tier-promotion attribution, N+1 fix, empty-allowlist footgun, listForUser scope, +6 route tests Five substantive Copilot findings on PR #258 addressed: 1. tier_promotion rows now flow into per-Lifebook briefings. The prior filter checked payload.registryId, but tier_promotion payloads carry { from, to, reason } and reference the server via server_id. gatherBriefingData now computes a server_id → registry_id map and filterDataByLifebook uses it to attribute promotions to the right lifebook. 2. N+1 query pattern eliminated. The orchestrator calls gatherBriefingData ONCE per user; the bundle is shared across the global briefing and every per-Lifebook briefing via a new `preGathered` parameter on generateBriefingProse. Per-lifebook DB cost dropped from 2 repo calls + 1 query per lifebook to "filter the in-memory bundle." 3. Empty-allowlist footgun fixed. filterDataByLifebook now returns an EMPTY bundle when the lifebook has no suggested_capabilities, not the unfiltered global bundle. Previously a scoped call with an empty allowlist would have written a global briefing under the domain's label. 4. listForUser() defaults to global (domain_name IS NULL) — adding the column would otherwise have silently interleaved per-Lifebook rows into existing twin-briefings history surfaces. New opts.includeDomainScoped lets callers opt in. New listForUserDomain() mirror serves the per-domain history. 5. 6 new route-level tests for GET /lifebook/:domain/latest. Test plan: worker 86/86 still green; api 550/550 (+6 new tests). Build clean across db, worker, api. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
3 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
Closes the "provenance graph wing-filter consumer" item that PR #242 (#193 Child 1) explicitly deferred. The lifebook page has been linking to
#/provenance?wing=<wingId>since #242 shipped, but the provenance page couldn't honor that filter because nodes had no wing linkage. This PR adds the wing-id column + write-time population + API filter + frontend consumer end-to-end.Two of three #193 follow-ups now closed (capabilities filter via PR #256; this one). One remaining: per-Lifebook briefing prose.
What changed
Migration
packages/db/src/migrations/041-provenance-wing-id.sql— adds nullablewing_id UUIDtocapability_provenance_nodesplus a partial index(user_id, wing_id, occurred_at DESC) WHERE wing_id IS NOT NULL. Partial keeps the index small — only populated rows — while still serving per-wing hot-path queries. No FK tolifebooks(would block lifebook hard-delete); the frontend filter naturally excludes NULL rows.Write-time population
provenanceRepository.writeNode()auto-deriveswing_idwhen the caller doesn't pass one explicitly. Logic: if the payload carries aregistryId, look up the lifebook whosesuggested_capabilitiescontains that id and stamp itswing_idon the node. Best-effort — a registryId not in any lifebook stays NULL. ExplicitwingIdargument always wins over the auto-derivation, so future call sites with their own wing context (e.g. an action invoked from inside a Lifebook page) can bypass the lookup.API
GET /api/capabilities/provenance-graphaccepts a new optionalwing=<uuid>query param. UUID-validated (returns 400 on bad shape, mirroring the existingserverIdvalidation). Each node returned to the client includeswingId: string | null.Frontend
apps/web/public/js/pages/provenance-graph.jsreads thewingparam off the hash query string (the lifebook page's#/provenance?wing=<uuid>link), passes it throughfetchProvenanceGraph, and renders a clearly-visible scoped-state indicator above the graph with a "Show all wings" button to clear the filter. UUID-validated client-side too so a malformed hash doesn't reach the API.What this PR deliberately does NOT do
wing_idfor pre-migration provenance rows. Older nodes stay NULL forever unless a future utility runs. The wing filter shows post-migration nodes; that's the right MVP for "Lifebook scoping" since the lifebook itself is a recent concept and the most relevant nodes are recent.Test plan
provenance-graph-routes.test.ts:wingfilter is forwarded aswing_idin the SQL params + WHERE clause.wingUUID returns 400 with awing-mentioning error.wingIdper node.pnpm build --concurrency=1clean.node --checkclean on the JS file.Notes for reviewers
resolveWingIdFromPayloadhelper runs one extra SELECT per provenance-node write. On install/action node creation that's negligible (these aren't hot paths). For high-volume node writers (signal/entity), the payload typically doesn't have aregistryId, so the helper short-circuits and never hits the DB.capability_provenance_nodestable grows with NULL-wing rows. CockroachDB supports partial indexes natively.wingIdif a caller passed one and a different lifebook later claimed the same registryId. The current contract — explicit wins — means the explicit value is authoritative. Worth flagging if you read it differently.🤖 Generated with Claude Code