Skip to content

feat(#251 follow-up): tier-aware privacy controls — pin / hide / hide-sender#270

Merged
jayzalowitz merged 2 commits into
mainfrom
jayzalowitz/251-exclude-ui
May 12, 2026
Merged

feat(#251 follow-up): tier-aware privacy controls — pin / hide / hide-sender#270
jayzalowitz merged 2 commits into
mainfrom
jayzalowitz/251-exclude-ui

Conversation

@jayzalowitz

Copy link
Copy Markdown
Owner

Summary

The privacy guardrail that gates Layer 2's default-on rollout. Users can now:

  • Pin an individual page (×2 RRF score boost).
  • Hide an individual page (dropped from search entirely).
  • Hide all from this sender — bulk action over every indexed page sharing a fromAddress.

All three composable with the tier multiplier already shipped in #259 + the rebalanced weights from #260.

Closes (partial): #251 privacy follow-up. With this and the ablation eval landed, Layer 2 default-on is no longer blocked on user-side disclaim controls.

Engine

  • EmbeddedGbrainMemoryPort.buildPageMetadata now stamps fromAddress (lower-cased bare address pulled from data.from) on every page that has one. Inlines a 3-line display-name stripper instead of pulling @skytwin/connectors into the memory layer's dep graph.
  • updatePageMetadata(userId, pageId, patch) (new repo helper): JSONB-merges a partial patch into brain_pages.metadata, scoped by user_id. A caller with another user's page id can't mutate that row. Returns affected count; 0 → 404 at the route.
  • hideAllPagesFromSender(userId, fromAddress) (new repo helper): bulk UPDATE setting metadata.userOverride='hidden' on every page where metadata->>'fromAddress' matches the lower-cased input.
  • Both have matching in-memory mirrors.

API

  • POST /api/memory-config/pages/:pageId/override body { override: 'pinned' \| 'hidden' \| null }. 404 on missing/foreign-owned pages — collapsed deliberately so the response shape can't be used to probe for foreign page-id existence.
  • POST /api/memory-config/senders/hide body { fromAddress: string }. Returns { ok, fromAddress, hidden: <count> }.
  • GET /api/memory-config/dashboard payload's pages.recent[] now includes fromAddress so the UI knows what to send to the sender route.

Web

New per-row action column on the Recent pages indexed table:

  • Pin / Unpin — swaps action based on current state.
  • Hide / Unhide — same.
  • Hide sender — only shown when the row has a fromAddress (i.e. email-derived). Surfaces a window.confirm before firing since one click can hide hundreds of rows.

All buttons use data-action attributes and the singleton click delegator from ensurePageListener, per CLAUDE.md "Frontend Event Handling."

Tests

  • 5 new in-memory-repository.test.ts cases: merge-preserves-keys, user-scoping for updatePageMetadata, 404 on missing/foreign, hideAllPagesFromSender matches case-insensitively + scopes by user + reports 0 when nothing matches.
  • 3 new embedded-port.test.ts cases: fromAddress lower-cases display-name addresses, handles bare addresses, omits when data.from is missing.
  • 6 new memory-config-routes.test.ts cases: per-page override rejects bogus values, accepts pinned/hidden/null, 404s on adapter's zero-row return; sender bulk-hide rejects missing fromAddress, lower-cases on the wire, surfaces affected count; dashboard payload includes userOverride + fromAddress.

Test plan

  • pnpm build --concurrency=1 → 35/35 packages.
  • pnpm test → 70/70 turbo tasks.
  • Specifically: pnpm --filter @skytwin/memory-gbrain-crdb-adapter test → 75 pass; pnpm --filter @skytwin/memory-gbrain test → 98 pass; pnpm --filter @skytwin/api test -- memory-config-routes → 25 pass.

🤖 Generated with Claude Code

…-sender

Adds the privacy guardrail that gates Layer 2's default-on rollout.
Users can now mark individual indexed pages as pinned (×2 score) or
hidden (dropped from search entirely), and bulk-hide every page from a
given sender in one click.

Engine:
- buildPageMetadata stamps lower-cased `fromAddress` on every page
  whose signal carries `data.from`. The connector inlines a 3-line
  display-name stripper to avoid pulling @skytwin/connectors into the
  memory layer's dep graph.
- `updatePageMetadata(userId, pageId, patch)` repository helper:
  JSONB-merges a partial patch into brain_pages.metadata, scoped by
  user_id so a guessable id can't be used to mutate another user's
  rows. Returns affected count; 0 → 404 at the route layer.
- `hideAllPagesFromSender(userId, fromAddress)` repository helper:
  bulk UPDATE on every page where `metadata->>'fromAddress'` matches.
  Lower-cases the input to match the stamped form.
- In-memory mirrors of both for tests.

API:
- POST /api/memory-config/pages/:pageId/override with body
  { override: 'pinned' | 'hidden' | null }. 404 on missing or
  foreign-owned page (deliberately collapsed so a caller can't probe).
- POST /api/memory-config/senders/hide with body { fromAddress }.
  Returns { ok, fromAddress, hidden: <count> }.
- /api/memory-config/dashboard `pages.recent[]` now includes
  `fromAddress` so the UI knows what to send to the sender route.

Web:
- Per-row actions on the Recent pages table: Pin/Unpin, Hide/Unhide,
  Hide sender. Buttons swap action based on current state. Sender
  button only appears when a fromAddress exists (i.e. email-derived).
  The bulk action confirms before firing.

Tests: +5 in-memory unit, +3 embedded-port, +6 routes. All green.

This unblocks the eventual Layer 2 default-on rollout — combined with
the labeled-retrieval eval guardrail from PR #260, users now have both
a working multiplier and a path to disclaim what they don't want
amplified.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Copilot AI review requested due to automatic review settings May 12, 2026 17:09

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

Adds tier-aware privacy controls to the gbrain memory dashboard so users can pin/hide individual pages and bulk-hide all pages from a given sender (by fromAddress), enabling Layer 2 tier-weighting to be default-on with user-side guardrails.

Changes:

  • Stamp fromAddress into brain_pages.metadata (normalized/lower-cased) during signal ingestion.
  • Add repository helpers + API routes for per-page override updates and per-sender bulk hide, and expose fromAddress in the dashboard payload.
  • Add dashboard UI actions (Pin/Hide/Hide sender) plus unit/route tests covering the new behavior.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
packages/memory-gbrain/src/embedded-port.ts Adds fromAddress extraction/normalization into page metadata.
packages/memory-gbrain/src/tests/embedded-port.test.ts Tests fromAddress stamping behavior for various data.from shapes.
packages/memory-gbrain-crdb-adapter/src/repository.ts Adds updatePageMetadata and hideAllPagesFromSender SQL helpers.
packages/memory-gbrain-crdb-adapter/src/index.ts Exports the new repository helpers from the adapter package.
packages/memory-gbrain-crdb-adapter/src/in-memory-repository.ts Adds in-memory mirrors of the new metadata update/bulk-hide helpers.
packages/memory-gbrain-crdb-adapter/src/tests/in-memory-repository.test.ts Adds tests for merge semantics and sender bulk-hide behavior.
apps/api/src/routes/memory-config.ts Adds new endpoints for per-page override and per-sender hide; extends dashboard payload.
apps/api/src/tests/memory-config-routes.test.ts Adds route tests for override validation, 404 behavior, sender hide, and dashboard projection.
apps/web/public/js/pages/memory-settings.js Adds action buttons + click handling for pin/hide/hide-sender in the dashboard table.
CHANGELOG.md Documents the new privacy controls, API routes, and tests.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread apps/api/src/routes/memory-config.ts Outdated
Comment on lines +200 to +201
// JSONB || merges; passing `userOverride: null` removes the key on
// PostgreSQL's strip-nulls behavior. CRDB matches PG here.

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Good catch — fixed the helper itself rather than just the comment. updatePageMetadata now splits the patch: non-null values go through jsonb ||, null values go through jsonb - 'key' so the column shape stays clean instead of accumulating {"userOverride": null}. In-memory mirror matches. New unit test verifies the cleared key is absent, not present-and-null. See cb51ed9.

Comment thread apps/api/src/routes/memory-config.ts Outdated
Comment on lines +235 to +241
try {
const hidden = await hideAllPagesFromSender(userId, body.fromAddress);
return res.json({ ok: true, fromAddress: body.fromAddress.toLowerCase(), hidden });
} catch (err) {
log.error('senders hide POST failed', {
userId,
fromAddress: body.fromAddress,

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Addressed in cb51ed9. Normalize once at the route boundary (trim().toLowerCase()) and use the canonical form for the adapter call, the response, and any logged context. Existing route test updated to use ' Spam@Vendor.Example.com ' as input and assert the adapter receives the trimmed/lowered form.

Two findings on the privacy/exclude UI, both valid:

1. JSONB null-clear comment was actually wrong. `jsonb_a || jsonb_b`
   does NOT strip null values — it stores them as JSON null. The
   pin/hide path used `{ userOverride: null }` and the resulting
   metadata column would have ended up with `{"userOverride": null}`
   instead of the key being absent. tierMultiplier treats both as
   "no override," so functionally it worked, but the column shape
   would have drifted.

   Fixed the helper itself rather than just the comment. `updatePageMetadata`
   now splits the patch into "set" (non-null values, applied via JSONB ||)
   and "drop" (null values, applied via JSONB - 'key'). In-memory mirror
   matches. New unit test verifies the cleared key is *absent*, not
   present-and-null.

2. fromAddress trimmed for validation but not normalized for the
   adapter call. Inputs like "  Spam@Vendor.Example.com  " passed
   validation but never matched the trimmed + lowered stored values,
   leading to a confusing `hidden: 0`. Normalize once at the route
   boundary (trim + lower) and use the canonical form for the adapter
   call, the response, and any logged context. Existing test updated
   to assert the trim-and-lower path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@jayzalowitz jayzalowitz merged commit 5877760 into main May 12, 2026
8 checks passed
jayzalowitz added a commit that referenced this pull request May 13, 2026
Cross-references the 12 PRs that landed across #251 Layer 1+2+follow-ups
(authoring tier, tier-weighted retrieval, pin/hide, backfill, real-
embedding ablation), #193 Lifebook follow-ups (capabilities filter,
provenance wing filter, per-Lifebook briefing), #179 mobile voice, and
#187 AC#4 (Piper TTS) against the project's user-facing docs.

README.md:
- Version badge 0.6.17.0 → 0.6.21.0
- Package/app count "14 packages and 6 apps" → "29 packages and 7 apps"
- Project Status reflects the v0.6 series (embedded LLM, tier-aware
  memory, per-Lifebook surfaces, voice loop)
- "What works today" adds mobile voice capture + the on-device
  embedded LLM stack (llama.cpp / whisper.cpp / Piper TTS) with the
  /api/voice/transcribe and /api/voice/synthesize endpoints

CLAUDE.md:
- llm-client row notes the `embedded` provider and the
  estimateLlmCostCents() helper
- New embedded-llm row covers llama.cpp / whisper.cpp / Piper TTS
- connectors row notes the AuthoringTier classifier (#251 Layer 1)
- memory-gbrain-crdb-adapter row notes Layer 2 tier-weighted RRF
  scoring, pin/hide controls (#270), and the backfill worker (#271)
- mobile app row notes voice capture via expo-audio + the desktop
  transcribe round-trip
- New twin-mcp-server app row

No CHANGELOG changes — each PR's entry was authored by /ship and
covers its own slice accurately. No TODOS.md changes — the two open
P3s (real production tour mode, multi-instance demo rate limiting)
remain blocked on the same product decisions; nothing in this sweep
closes them.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
jayzalowitz added a commit that referenced this pull request May 13, 2026
Cross-references the 12 PRs that landed across #251 Layer 1+2+follow-ups
(authoring tier, tier-weighted retrieval, pin/hide, backfill, real-
embedding ablation), #193 Lifebook follow-ups (capabilities filter,
provenance wing filter, per-Lifebook briefing), #179 mobile voice, and
#187 AC#4 (Piper TTS) against the project's user-facing docs.

README.md:
- Version badge 0.6.17.0 → 0.6.21.0
- Package/app count "14 packages and 6 apps" → "29 packages and 7 apps"
- Project Status reflects the v0.6 series (embedded LLM, tier-aware
  memory, per-Lifebook surfaces, voice loop)
- "What works today" adds mobile voice capture + the on-device
  embedded LLM stack (llama.cpp / whisper.cpp / Piper TTS) with the
  /api/voice/transcribe and /api/voice/synthesize endpoints

CLAUDE.md:
- llm-client row notes the `embedded` provider and the
  estimateLlmCostCents() helper
- New embedded-llm row covers llama.cpp / whisper.cpp / Piper TTS
- connectors row notes the AuthoringTier classifier (#251 Layer 1)
- memory-gbrain-crdb-adapter row notes Layer 2 tier-weighted RRF
  scoring, pin/hide controls (#270), and the backfill worker (#271)
- mobile app row notes voice capture via expo-audio + the desktop
  transcribe round-trip
- New twin-mcp-server app row

No CHANGELOG changes — each PR's entry was authored by /ship and
covers its own slice accurately. No TODOS.md changes — the two open
P3s (real production tour mode, multi-instance demo rate limiting)
remain blocked on the same product decisions; nothing in this sweep
closes them.

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