What
filterPayloadForConsent (frontend/src/consent.ts, mirrored server-side) keeps any payload key that isn't listed in KEY_MIN_LEVEL:
const min = KEY_MIN_LEVEL[key];
if (min && consentRank(min) > rank) continue;
kept[key] = value;
So the gate is an opt-in denylist. A content-bearing field ships in full at the usage default unless somebody remembered to register its name.
Why it matters
usage is DEFAULT_CONSENT_LEVEL — it's what nearly every user is on, and its label promises "No document text and no AI suggestions are stored." A new payload field named brief, excerpt, selection, snippet, or title would break that promise silently. Nothing would fail: no type error, no test, no runtime warning. The only defense today is a docstring in frontend/src/api/logging.ts asking call sites to "use names the consent gate recognizes," and LogEvent is declared [key: string]: any, so TypeScript can't help.
This is a privacy-policy-adjacent invariant defended by naming convention.
Suggested fix
Invert the default: classify every key explicitly, and treat unknown keys as the most restrictive level rather than the least.
- Keep an explicit set of usage-safe keys (
generationType, feature, features, selected, isOverall, source, responseLength, field, hasContent, tool, sharedDoc, scopes, error, code, found, attempts, durationMs, plus the envelope fields), and drop anything unrecognized above usage.
- A dropped field is a lost metric; an unclassified field that ships is an incident. The asymmetry should be encoded in the default.
- Optionally back it with types: give each event helper in
api/logging.ts a declared payload type instead of [key: string]: any, so a new field has to be classified at the point it's added.
- Add a test asserting an unregistered key does not survive at
usage.
Both copies (frontend/src/consent.ts and backend/src/consent.ts) need the change, since the server re-applies the rules authoritatively.
Notes
Related to #503 (consent UI and open consent decisions), but distinct: that issue is about what we ask users, this one is about whether the code can keep the promise once they've answered.
What
filterPayloadForConsent(frontend/src/consent.ts, mirrored server-side) keeps any payload key that isn't listed inKEY_MIN_LEVEL:So the gate is an opt-in denylist. A content-bearing field ships in full at the
usagedefault unless somebody remembered to register its name.Why it matters
usageisDEFAULT_CONSENT_LEVEL— it's what nearly every user is on, and its label promises "No document text and no AI suggestions are stored." A new payload field namedbrief,excerpt,selection,snippet, ortitlewould break that promise silently. Nothing would fail: no type error, no test, no runtime warning. The only defense today is a docstring infrontend/src/api/logging.tsasking call sites to "use names the consent gate recognizes," andLogEventis declared[key: string]: any, so TypeScript can't help.This is a privacy-policy-adjacent invariant defended by naming convention.
Suggested fix
Invert the default: classify every key explicitly, and treat unknown keys as the most restrictive level rather than the least.
generationType,feature,features,selected,isOverall,source,responseLength,field,hasContent,tool,sharedDoc,scopes,error,code,found,attempts,durationMs, plus the envelope fields), and drop anything unrecognized aboveusage.api/logging.tsa declared payload type instead of[key: string]: any, so a new field has to be classified at the point it's added.usage.Both copies (
frontend/src/consent.tsandbackend/src/consent.ts) need the change, since the server re-applies the rules authoritatively.Notes
Related to #503 (consent UI and open consent decisions), but distinct: that issue is about what we ask users, this one is about whether the code can keep the promise once they've answered.