Skip to content

feat(session): optional sessionKey resolver for advertiser-scoped state - #60

Open
goeric wants to merge 1 commit into
dupe-com:mainfrom
goeric:feat/session-key-resolver
Open

feat(session): optional sessionKey resolver for advertiser-scoped state#60
goeric wants to merge 1 commit into
dupe-com:mainfrom
goeric:feat/session-key-resolver

Conversation

@goeric

@goeric goeric commented Jul 26, 2026

Copy link
Copy Markdown

What

An opt-in sessionKey?: (url: string) => string | undefined on StanddownSession, controlling which key stand-down state is filed under, plus a resolveSessionKey(url) method so the read side can resolve the same key. Plumbed through the webext, content, and url adapters. Omitted, nothing changes.

Why

ingest derives its state key from the landing hostname (hostFromUrl(signals.url)) and the constructor exposes no override, so www.merchant.example, checkout.merchant.example, and a merchant's alternate ccTLD are three unrelated sessions. A stand-down that forms on the landing host does not survive the user's navigation to checkout.

Rewriting signals.url before ingest is not a workaround — detection matches against the real URL.

Approach

The hostname currently does two jobs. This separates them:

Job Where After
Detection matching — detection.advertiserHosts, referrer classification detect.ts:40 Unchanged, always the real hostname
State bucketing — which session/exemption record session.ts Resolver-controlled, defaults to the real hostname

SessionRecord.advertiserHost still holds the detected hostname; only the map key changes. upsertSessionRecord and recordSessionExemptions each take the key and the host as separate parameters, so classifyReferrer keeps suffix-matching against a real host.

One consequence worth stating plainly: Object.keys(state.sessions)[i] === state.sessions[k].advertiserHost no longer holds when a resolver is configured. The key is the bucket; the field is the most recent real host that touched it.

Why the resolver takes a URL, not Signals

An earlier revision took the full Signals. That is the wrong contract: on a read path only a URL exists — a tab id resolves to a URL and nothing more — so a resolver keying off selfPatterns, redirectChain, initiator, or signalCoverage is unimplementable on the read path by construction. With the wider type it would return one key on write and another on read, which is the write-here/read-there failure this option otherwise prevents. Narrowing to url: string makes that unrepresentable instead of merely documented, and the resolver must be deterministic (SPEC.md I7) because webext resolves once to cache and once via ingest.

The sharp edge, deliberately

One key groups both stand-down sessions and self-exemptions, and those have opposite safety directions:

  • Broader key → broader stand-down → more suppression. Safe, and consistent with the monotone rule.
  • Broader key → broader self-exemption → less suppression. Not safe if authored carelessly.

I kept one key rather than splitting them. A split (custom key for sessions, exact host for exemptions) looks safer but drops a granted exemption on navigation to the checkout host and stands the integrator down to their own traffic — the documented revenue-leak failure. So the contract is documented instead: return a key no broader than the advertiser whose attribution you control. Same posture as selfPatterns value-matching. The resolved key is normalised (normalizeHost) at the single resolution point, so a resolver returning Merchant.Example. cannot file state under one spelling and look it up under another.

A resolver that throws produces a fail-closed decision (session-key-error), matching how malformed-policy, invalid-url, and store-error are already handled — a user-supplied callback should not be the one path that can reject.

Note on the webext read side

The largest non-obvious hunk: tabHosts became tabStateKeys and caches the resolved key rather than the raw host, and hostForTab became urlForTab. Caching the host would guarantee a read-side miss the moment a resolver is configured, since ingest files state under the resolved key. The cache is cleared on onRemoved, on dispose, and whenever resolution yields undefined, so a failed resolution cannot leave a previous page's key answering for the current one.

Tests

tests/session-key.test.ts plus one end-to-end test per adapter: default behaviour unchanged; session and exemption shared across hosts under one key; a non-normalised key still shares correctly; undefined and '' fall back to the landing hostname; a throwing resolver fails closed rather than rejecting; resolveSessionKey agrees with ingest; the detected host stays on the record and in the audit log; and the stale-cache path.

184 tests pass (167 baseline + 17). The audit harness passes 30/30 against a fresh build.

Note

Proposal as much as patch — this is the shape that fits the gap cleanly, but if you would rather solve advertiser identity a different way, say so and I will rework or close it. The adapter plumbing is included because an option only reachable from the core class, when all three adapters construct the session for you, is half a feature — but it is easy to split out if you would rather review the core alone first.

Checklist

  • npm run typecheck, npm run lint, npm test, and npm run build pass
  • New behavior has tests
  • No policy-pack changes, so policies-cite-check is untouched
  • SPEC.md constructor options updated for sessionKey and resolveSessionKey; happy to add README coverage if you want it there too
  • No decision-path network call (I1) and no user-identity data in Signals (I2)

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need. Autofix is disabled.

ingest keys stand-down state by the landing hostname with no way to override
it, so a merchant's www, checkout and alternate-ccTLD hosts are unrelated
sessions and a stand-down opened on one does not survive navigation to
another.

Add an opt-in sessionKey resolver that controls only which bucket state is
filed under. Detection is untouched: advertiserHosts rules and referrer
classification still see the real hostname, and both SessionRecord and
ExemptionRecord keep holding it, so only the map key changes.
upsertSessionRecord and recordSessionExemptions each take the bucket key and
the real host as separate arguments so their records agree on what
advertiserHost means.

The resolver takes the URL rather than Signals. The read paths only ever have a
URL — a tab id resolves to a URL and nothing more — so a resolver keying off
any other Signals field (selfPatterns, redirectChain, initiator,
signalCoverage) is unimplementable on the read path by construction: it would
return one key on write and a different key on read. A URL argument makes that
unrepresentable rather than merely documented. The resolver must also be
deterministic (I7) — webext resolves once to cache the tab's key and ingest
resolves again to file state, so a varying key would desynchronise them.

The resolved key is normalized the way the write paths normalize hosts —
otherwise a mixed-case or trailing-dot key files an exemption under one string
and looks it up under another, standing the integrator down against its own
traffic. An empty return falls back to the landing hostname, and a resolver
that throws fails the navigation closed (session-key-error) rather than
rejecting ingest, keeping every error path a decision.

shouldStandDown deliberately does not run the resolver — it stays a lookup by
whatever key it is given — so resolveSessionKey(url) exposes the same
resolution for the read side. The content / webext / url adapters all accept
the option, and the webext and content read paths resolve through it, so a tab
or location read cannot land on a different bucket than the navigation wrote.
When a navigation resolves to no key at all, webext also drops the tab's cached
key, so the next read re-resolves and fails closed with it instead of answering
out of the previous page's bucket.

Default behaviour is unchanged when the option is omitted.
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.

1 participant