feat(#251 follow-up): tier-aware privacy controls — pin / hide / hide-sender#270
Conversation
…-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>
There was a problem hiding this comment.
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
fromAddressintobrain_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
fromAddressin 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.
| // JSONB || merges; passing `userOverride: null` removes the key on | ||
| // PostgreSQL's strip-nulls behavior. CRDB matches PG here. |
There was a problem hiding this comment.
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.
| 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, |
There was a problem hiding this comment.
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>
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>
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>
Summary
The privacy guardrail that gates Layer 2's default-on rollout. Users can now:
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.buildPageMetadatanow stampsfromAddress(lower-cased bare address pulled fromdata.from) on every page that has one. Inlines a 3-line display-name stripper instead of pulling@skytwin/connectorsinto the memory layer's dep graph.updatePageMetadata(userId, pageId, patch)(new repo helper): JSONB-merges a partial patch intobrain_pages.metadata, scoped byuser_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): bulkUPDATEsettingmetadata.userOverride='hidden'on every page wheremetadata->>'fromAddress'matches the lower-cased input.API
POST /api/memory-config/pages/:pageId/overridebody{ 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/hidebody{ fromAddress: string }. Returns{ ok, fromAddress, hidden: <count> }.GET /api/memory-config/dashboardpayload'spages.recent[]now includesfromAddressso the UI knows what to send to the sender route.Web
New per-row action column on the Recent pages indexed table:
fromAddress(i.e. email-derived). Surfaces awindow.confirmbefore firing since one click can hide hundreds of rows.All buttons use
data-actionattributes and the singleton click delegator fromensurePageListener, per CLAUDE.md "Frontend Event Handling."Tests
in-memory-repository.test.tscases: merge-preserves-keys, user-scoping forupdatePageMetadata, 404 on missing/foreign,hideAllPagesFromSendermatches case-insensitively + scopes by user + reports 0 when nothing matches.embedded-port.test.tscases:fromAddresslower-cases display-name addresses, handles bare addresses, omits whendata.fromis missing.memory-config-routes.test.tscases: per-page override rejects bogus values, accepts pinned/hidden/null, 404s on adapter's zero-row return; sender bulk-hide rejects missingfromAddress, lower-cases on the wire, surfaces affected count; dashboard payload includesuserOverride+fromAddress.Test plan
pnpm build --concurrency=1→ 35/35 packages.pnpm test→ 70/70 turbo tasks.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