LLP 0346: A client that stamps another client's client_name cannot be opted out; enforce it through the entrypoint its manifest claims
Type: Issue
Status: Draft
Extended-by: LLP 0356 (the request for the capture-side decision that retires the residual recorded in #local-agent-residual below)
Systems: Sinks, Usage-Policy, Cache
Author: Phil / Claude
Date: 2026-09-01
Related: LLP 0188 (#opt-out, #enforcement-scope: the decision this defect breaks the enforcement half of), LLP 0133 (#attribution: why Desktop rows carry client_name: "claude"), LLP 0140 (#manifest-declares-ownership: the entrypoint-ownership declaration this reuses), LLP 0192 (the sibling residual, null attribution), LLP 0175 (live-capture misattribution)
Extends LLP 0188 §opt-out: the store is keyed by picker source id and the seam tests those ids against the row's
client_name. For one shipped picker id those two key spaces are, by an earlier and still-correct decision, different values. This issue lands the seam-side enforcement; it changes nothing the decision settled.
The opt-out store holds picker source ids
(<state>/usage-policy/client-sync.json, LLP 0188 #opt-out). The export
seam tests them against the dataset's declared attribution_column,
which for ai_gateway_messages is client_name
(src/core/cache/source-withhold.js, src/core/cache/storage.js). The
rule is only sound where the two key spaces agree.
Across the shipped catalog they agree everywhere but one place:
| picker id | client_name on its ai_gateway_messages rows |
|---|---|
claude |
claude |
codex |
codex |
opencode |
opencode |
openclaw |
openclaw |
hermes |
hermes |
otel |
contributes no row to this dataset (its own datasets declare no attribution column, so LLP 0188 #enforcement-scope's dataset-scoped rule covers it) |
raw-anthropic, raw-openai |
null (the LLP 0192 residual, already fail-closed) |
claude-desktop |
claude on the live route |
claude-desktop is a real picker id with a real picker row, so
hyp privacy client claude-desktop local-only writes a real entry and
reports success. But Desktop delegates inference to its embedded CLI, so
its live rows deliberately land under client_name: "claude" with a
Desktop-owned entrypoint (LLP 0133 #attribution). The entry can never
match a row. The scheduled tick keeps forwarding Desktop conversations to
the org server while the machine owner believes the client is local-only,
and the failure is silent on both sides.
Only the live route was unenforceable. Desktop's backfilled rows
already carry client_name: "claude-desktop", by whichever of LLP 0140's
two admission rules applies to where the session was found: a session in
the shared ~/.claude/projects tree is attributed by
classifyTranscriptEntrypoint to the client whose manifest claims its
entrypoint (LLP 0140 #manifest-declares-ownership), and a session inside
the Claude-3p container is attributed by classifyContainerSession to
the container's root whatever its tag says (LLP 0140
#container-root-owns, DESKTOP_3P_CONTAINER_OWNER). Either way the
existing client_name test already withheld those.
Read the second attribution axis the
rows already carry. A row's entrypoint is matched against the
contributes.client.transcript_entrypoints declarations the catalog
already folds (LLP 0140 #manifest-declares-ownership, the same
declaration the backfill admission gate reads). When that value is
claimed by a client whose name is an opted-out picker source id, the
row is withheld, on the same drop-but-advance terms as every other seam
rule (LLP 0070 #incremental). The column is forced into the export scan
exactly as cwd and the attribution column are, so no caller's columns
projection can bypass it.
Two constraints make this a fix rather than a second defect:
-
Additive, never a relaxation. The
client_nametest is unchanged and runs first; the entrypoint test can only add withholding. Opting outclaudetherefore still withholds everyclient_name: "claude"row, Desktop's included, exactly as before. That is deliberately wider than "Claude Code", and it is the safe direction: it is what an existing opt-out already does, and narrowing it would start shipping rows a user had already been told stay local. -
Scoped to the clients that declare entrypoint ownership. The
entrypointvocabulary is per-client, not global: hermes stamps its session source, and its interactive values are literallycli,tui,cron, while the claude client claimscliandsdk-cli. Reading an entrypoint as an ownership claim on a row whose ownclient_namebelongs to a client that declares no ownership would withhold hermes rows becauseclaudeis opted out: a different broken promise, not a fix. The scope is therefore the map's value side (the set of declaring clients), which across the shipped catalog is exactly{claude, claude-desktop}. That pair is the whole blast radius.
Rejected: mapping picker ids to client_name values directly
(claude-desktop -> claude). It is the obvious repair and it is
wrong: claude and claude-desktop would become the same key, so
opting out one would silently withhold the other's rows.
Rejected: re-keying the store on client_name (LLP 0188's other
named option). It cannot represent claude-desktop at all, which is the
one case that needs representing, and it would silently rewrite a
privacy store whose entries a user authored.
Rejected: a new manifest field, config key, or row column naming the
alias. Nothing needed adding: entrypoint is an existing column and
transcript_entrypoints an existing declaration, and a third key space
would be a third thing to keep in agreement with the other two.
hyp privacy client claude-desktop local-onlynow withholds the live rows whoseentrypointis one Desktop's manifest claims (claude-desktop,claude-desktop-3p).hyp privacy client claude local-onlywithholds everything it withheld before; the container-drift note below is the one case where it can now withhold more.- The refinement does not reach the
current attached-Desktop build. Desktop app 1.13576.0 / embedded CLI
2.1.177 runs each conversation in a per-session sandbox home inside its
Claude-3pcontainer and tags the transcriptentrypoint: "local-agent"(LLP 0133 #attribution); the live gateway route copies that value onto the row (assignTranscriptIdentity). Desktop's manifest deliberately does not claimlocal-agent: it names a CLI mode, not a client, it drifted tolocal-agent-v2within a week, and LLP 0140 #container-root-owns rules that a container value cannot carry consent. So on that build aclaude-desktop-only opt-out still ships the live rows, for the same reason the no-entrypointresidual below does, and only the capture-side attribution fix LLP 0192 defers retires it. The values this issue does enforce areclaude-desktop-3p(LLP 0133's first live test) andclaude-desktop(un-attached Desktop, shared tree). What the pins actually catch:test/core/source-withhold-export-drop.test.jsfails if the seam starts withholding an unclaimed or absententrypoint, and the catalog pin intest/core/source-withhold-build.test.jsfails if a manifest ever claimslocal-agent. Neither fails on the capture-side fix itself, which changes what capture writes intoclient_namerather than anything the seam reads, so retiring the residual that way means deleting these pins deliberately. - The scoping set is
{claude, claude-desktop}, so a row carryingclient_name: "claude-desktop"also has itsentrypointread as an ownership claim. A container session whose tag drifts to a value theclaudeclient claims (cli,sdk-cli) would then be withheld by aclaude-only opt-out even though the row is Desktop's. That is over-withholding, the safe direction, and it is the price of deriving the scope from the manifests instead of hardcoding the one aliasing direction LLP 0133 documents. - A Desktop row that reached the cache with no
entrypoint(an exchange the projector could not correlate to a transcript) still ships under aclaude-desktop-only opt-out. It is indistinguishable from a Claude Code row at the seam, and the LLP 0192 fail-closed rule does not reach it because it is attributed, just not to Desktop. This residual is stated, not hidden; the capture-side attribution fix that would retire it is the same one LLP 0192 defers. - The scoping rule is load-bearing and invisible in the data: a future
client that declares
transcript_entrypointsjoins the namespace and its rows become subject to entrypoint reinterpretation. The pinned catalog test intest/core/source-withhold-build.test.jsfails when that set changes, so the decision gets revisited rather than drifting. - Not fixed here, and deliberately:
hyp sync --history's pre-send gate (src/core/commands/sync.js) reads the same store and presents it as an authoritative refusal. It leaks nothing the daemon was not already shipping, and now that the seam enforces, it is consistent with it.
- LLP 0188, LLP 0133, LLP 0140, LLP 0192, LLP 0070
src/core/cache/source-withhold.js(shouldWithholdEntrypoint,entrypointColumnFor),src/core/cache/storage.js(seam),src/core/runtime/source_withhold.js(clientEntrypointOwnersFromCatalog),src/core/backfill/entrypoint_owner.js(resolveEntrypointOwners, reused),test/core/source-withhold-export-drop.test.js,test/core/source-withhold-build.test.js