Skip to content

feat(#193 follow-up): provenance graph filter by Lifebook wing#257

Merged
jayzalowitz merged 2 commits into
mainfrom
jayzalowitz/issue-193-provenance-wing-filter
May 12, 2026
Merged

feat(#193 follow-up): provenance graph filter by Lifebook wing#257
jayzalowitz merged 2 commits into
mainfrom
jayzalowitz/issue-193-provenance-wing-filter

Conversation

@jayzalowitz

Copy link
Copy Markdown
Owner

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 nullable wing_id UUID to capability_provenance_nodes plus 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 to lifebooks (would block lifebook hard-delete); the frontend filter naturally excludes NULL rows.

Write-time population

provenanceRepository.writeNode() auto-derives wing_id when the caller doesn't pass one explicitly. Logic: if the payload carries a registryId, look up the lifebook whose suggested_capabilities contains that id and stamp its wing_id on the node. Best-effort — a registryId not in any lifebook stays NULL. Explicit wingId argument 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-graph accepts a new optional wing=<uuid> query param. UUID-validated (returns 400 on bad shape, mirroring the existing serverId validation). Each node returned to the client includes wingId: string | null.

Frontend

apps/web/public/js/pages/provenance-graph.js reads the wing param off the hash query string (the lifebook page's #/provenance?wing=<uuid> link), passes it through fetchProvenanceGraph, 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

Test plan

  • 3 new vitest cases in provenance-graph-routes.test.ts:
    • wing filter is forwarded as wing_id in the SQL params + WHERE clause.
    • Malformed wing UUID returns 400 with a wing-mentioning error.
    • Response nodes include wingId per node.
  • Full api suite: 547/547 passing (+3 new tests).
  • Full workspace: 70/70 turbo tasks green.
  • pnpm build --concurrency=1 clean.
  • node --check clean on the JS file.

Notes for reviewers

  • The resolveWingIdFromPayload helper 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 a registryId, so the helper short-circuits and never hits the DB.
  • The partial index strategy means the per-wing query cost stays bounded even as the capability_provenance_nodes table grows with NULL-wing rows. CockroachDB supports partial indexes natively.
  • Auto-derivation could disagree with explicit wingId if 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

Copilot AI review requested due to automatic review settings May 11, 2026 23:37

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_id to capability_provenance_nodes (plus a partial index) and populate it at write-time via best-effort Lifebook lookup from payload.registryId.
  • Extend GET /api/capabilities/provenance-graph with an optional wing=<uuid> filter and include wingId on returned nodes.
  • Update the provenance graph frontend to read wing from 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.
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>
@jayzalowitz

Copy link
Copy Markdown
Owner Author

Round-2 review reply: both findings addressed in 048ec85.

  • pg-clear-wing-filter re-render claim: comment now describes both branches accurately. The hash-actually-changed branch DOES rely on the SPA router's hashchange path; the unusual already-on-base-hash branch falls through to an explicit renderProvenanceGraph call.
  • resolveWingIdFromPayload docstring: rewrote the "returns null when" list. The signature already excludes "not an object" at compile time, so the runtime checks are payload=undefined or registryId missing/non-string.

jayzalowitz and others added 2 commits May 12, 2026 00:38
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>
@jayzalowitz jayzalowitz force-pushed the jayzalowitz/issue-193-provenance-wing-filter branch from 048ec85 to 71f0c1a Compare May 12, 2026 04:38
@jayzalowitz jayzalowitz merged commit 1ab3716 into main May 12, 2026
7 checks passed
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants