Skip to content

feat(ai-research): move the v4 image ref grammar into the anonymizer - #126496

Merged
trunk-io[bot] merged 4 commits into
masterfrom
claude/replay-anonymizer-v4-refs-c4fad1
Nov 2, 2026
Merged

trunk-io[bot] merged 4 commits into
masterfrom
claude/replay-anonymizer-v4-refs-c4fad1

Conversation

@robbie-c

@robbie-c robbie-c commented Oct 30, 2026 •

Copy link
Copy Markdown
Member

Problem

  • MLHog's v4 text scrub must keep each image reference and decline marker in a v4 replay block intact, so that the image scrub can find it.
  • The scrub recognizes these strings with a hand copy of the shapes the recorder writes. That copy drifted once: it lacked nine imageurl:v4:declined:* reasons, and the scrub redacted those markers.

Changes

  • posthog-replay-anonymizer 0.13.0 adds a v4_image_refs module: the prefixes, the format functions, every decline marker, and one strict parse.
  • The image markers include the three that feat(ai-research): move every inline image out of v4 replay blocks #126379 added: over_limit, undecodable and unsupported. ImageDecline replaces that PR's Declined enum in replay-core-v4.
  • parse says whether a string is a reference, which names image bytes, or a marker, which names none. It rejects near misses, such as a leading zero, month 13 or a trailing byte.
  • replay-core-v4 and replay-recorder-v4 write through the module, by path with default-features = false. The bytes they write do not change.
  • The recorder's URL decline labels come from the anonymizer's Decline. A new recorder reason does not compile until the shared grammar has it.
  • Mechanical: recorder tests assert the literal marker strings, and the dataset doc and the crate README name the module.

Follow-up: replay-core-v4/src/url_policy.rs is a near-copy of the anonymizer's url_policy.rs. A later PR can move the recorder onto the anonymizer's copy.

Before:

flowchart LR
    core["replay-core-v4<br/>image_ref, AVIF_MARKER"] --> rec["replay-recorder-v4<br/>declined_marker, UNCHECKED_MARKER"]
    rec --> block[("v4 block")]
    block --> scrub{{"MLHog v4 text scrub<br/>hand-written regex"}}
    classDef phBlue fill:#1d4aff,stroke:#1d4aff,color:#fff;
    classDef phGray fill:#e5e7eb,stroke:#c7ccd1,color:#000;
    classDef phRed fill:#f54e00,stroke:#f54e00,color:#fff;
    class core,rec phBlue;
    class block phGray;
    class scrub phRed;
Loading

After:

flowchart LR
    anon["posthog-replay-anonymizer<br/>v4_image_refs"] --> core["replay-core-v4"]
    anon --> rec["replay-recorder-v4"]
    core --> rec
    rec --> block[("v4 block")]
    block --> scrub{{"MLHog v4 text scrub"}}
    anon -. "parse, after the MLHog pin bump" .-> scrub
    classDef phBlue fill:#1d4aff,stroke:#1d4aff,color:#fff;
    classDef phGray fill:#e5e7eb,stroke:#c7ccd1,color:#000;
    classDef phRed fill:#f54e00,stroke:#f54e00,color:#fff;
    classDef phYellow fill:#f9bd2b,stroke:#f9bd2b,color:#000;
    class core,rec phBlue;
    class block phGray;
    class scrub phRed;
    class anon phYellow;
Loading

How did you test this code?

  • Ran cargo test for the anonymizer, replay-core-v4 and replay-recorder-v4, on master and on this branch. Both pass.
  • v4_vector.rs still checks both reference formats against the Node-written encryption-vector-v4.json.
  • Not run: cargo clippy -D warnings across the workspace. The local clippy 1.96 flags untouched files, such as snapshot.rs and lifecycle. The touched files have no findings.

Test rationale: No test covered parsing, because no parse existed.

  • every_string_the_recorder_writes_round_trips catches a marker that parse rejects, which is the drift above. It also fails when a decline variant has no literal vector.
  • rejects_near_misses catches a looser parse.
  • Five mutations of parse each failed one of the two tests: a leading zero, month 13, a dropped unchecked, a reason missing from Decline::ALL, and a reason under the wrong prefix.

Release status

  • No feature flag controls this change

Docs update

products/ai_training/docs/replay-data.md names the module as the definition of the reference and marker shapes.

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Agent: Claude Code, Claude Opus 5.5 (claude-opus-5-5)

Skills: /writing-code-comments, /writing-tests, /writing-pr-descriptions, /asd-ste100

  • No duplicate: a search of open PRs found none.
  • CodeRabbit: skipped.
  • CI report, comment density: the remaining comment lines are rustdoc on the public crates.io API of the new module. The rest are edits to existing doc comments that name renamed items.
  • Public artifact: all test values are invented (team 7, a made-up hash).
More context for AI agents
  • Session: Claude Code side session, started from a session that works on MLHog's v4 text scrub.
  • Decisions: URL marker reasons use the anonymizer's Decline, not a third reason enum. The core Decline::label delegates to it.
  • Decisions: format and parse share one Display implementation, so the round trip covers both.
  • Decisions: version 0.13.0, because a new public module is a feature, as in earlier bumps.
  • Decisions: feat(ai-research): move every inline image out of v4 replay blocks #126379 landed first, so a merge of master moved its markers into ImageDecline.
  • Follow-ups: MLHog bumps its =0.4.8 pin and replaces its regex with parse.
  • Follow-ups: move the recorder onto the anonymizer's url_policy.rs.
  • Read closely: parse in rust/replay-anonymizer/src/v4_image_refs.rs, and From<Decline> in rust/ai-research/replay-core-v4/src/url_policy.rs.

🤖 Generated with Claude Code

…mizer

The v4 recorder wrote its image references and decline markers from
hand-written format strings, and MLHog's v4 text scrub matched them with
its own regex. The two copies drifted once already.

posthog-replay-anonymizer gets a v4_image_refs module with the prefixes,
the format functions, every decline marker and a strict parse.
replay-core-v4 and replay-recorder-v4 now write through it, and the
recorder's URL decline labels come from the anonymizer's Decline. The
recorder tests now assert the literal marker strings, so they pin the
bytes the recorder writes.

Bumps the crate to 0.13.0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@robbie-c robbie-c self-assigned this Oct 30, 2026
@trunk-io

trunk-io Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

😎 Merged successfully - details.

@github-actions

github-actions Bot commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

🤖 CI report

✅ Trunk lane — non-backend lane

This PR is assigned to the non-backend lane. It does not run backend Python tests and may merge in parallel with PRs in other lanes.

🚨 Comment density — 6% of added code lines are comments (28 of 453)

This section warns when comments are more than 3% of the code lines a PR adds, and alerts above 6%. Before agent-assisted PRs, the typical share was about 2%. Only full-line comments count. Docstrings, generated files, snapshots, migrations, and workflow files are left out.

Comments that restate the code, record how the change came about, or narrate the next line add noise for the next reader. Keep the comments that explain a reason the code cannot show, and remove the rest. See .agents/skills/writing-code-comments/SKILL.md for the house rules.

Files with the most added comment lines:

File Comment lines Added lines
rust/replay-anonymizer/src/v4_image_refs.rs 16 330
rust/replay-anonymizer/src/lib.rs 4 5
rust/ai-research/replay-core-v4/src/refs.rs 2 3
rust/ai-research/replay-core-v4/src/scan/images.rs 2 40
rust/ai-research/replay-recorder-v4/src/scrub.rs 2 7
rust/replay-anonymizer/src/url_policy.rs 2 13

This check does not block merging. It updates on every push and clears when the share drops.

Drops doc comments that restate their item, and moves the warning that a
Decline label also ends a v4 URL marker to where the labels live.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@hosthog

hosthog Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

HostHog preview — posthog-desktop-web

The previews for this PR have been torn down and no longer serve.

@robbie-c
robbie-c marked this pull request as ready for review October 30, 2026 17:06
@parameterai

parameterai Bot commented Oct 30, 2026 •

Copy link
Copy Markdown

Risk: High · 1 high, 1 medium

This increment is a merge of origin/master that rewrites the recorder's inline-image pipeline: canonical_strings moved into bytescan.rs and now runs before extract_images (closing JSON-escaped data-URI prefixes), non-base64 and wrapped payloads are now taken or declined with new ImageDecline markers, and the canvas scan walks every argument of a canvas mutation. The largest remaining risk is unchanged from the last review: URLs (and, per the new finding, data-URI prefixes) written with CSS escapes still evade the byte-level matchers, so their content can stay in a stored training block that is marked safe.

Sentinel reviewed 4dd10bc · Review settings

@pr-assigner-resolver-posthog
pr-assigner-resolver-posthog Bot requested a review from a team October 30, 2026 17:07
…mizer-v4-refs-c4fad1

Master's CSS rewrite added two calls to the recorder's declined_marker,
which this branch replaced with v4_image_refs::url_declined_marker.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
let (replacement, declined) = match url_reference(url, team_id, key_month, &url_key) {
Ok((_, reference)) => (reference, None),
Err(decline) => (declined_marker(decline.label()), Some(decline.label())),
Err(decline) => (url_declined_marker(decline.into()), Some(decline.label())),

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟠 A signed URL written in CSS with hex or partial escapes is stored in a block that passes as safe

The bytes of a URL the policy declines — a signature or credential — can stay in a stored AI-training block. The grammar change on this line is byte-identical and not itself the cause; the gap is in the escaped-URL scrub this branch now carries (rewrite.rs:153-156, 659): it recognizes a declined URL in only two spellings, plain and the exact CSSOM escape form escape_in_identifier produces (css.rs:168). Any other valid CSS spelling survives, while a plain copy elsewhere in the same CSS marks the target found and the block passes the safety check, so the fallback host scrub that would decode those escapes (scrub.rs unit()) never runs.

How:

  1. A recorded page sends replaceSync text holding .a{background:url(https://cdn.example.com/s.png?X-Amz-Signature=abc123)} plus a selector spelling the same URL with hex (\3a\2f\2f) or partial escapes (only ./?/= escaped).
  2. The inspector collects the plain copy (collectable, css.rs:357) and the policy declines it (x-amz-signature, url_policy.rs:56), so this line builds a declined target.
  3. replace_urls replaces the plain copy, setting found=true; the escaped copy matches neither the plain pattern nor the escaped pattern (rewrite.rs:155), and the holds_escaped_url gate (rewrite.rs:659) never fires for those spellings — verified byte-level.
  4. Both declined_kept scans use the same two spellings (rewrite.rs:688-694, 711-713), so finish() reports safe (rewrite.rs:737) and the signature bytes are stored for training.

Fix: Detect declined URLs by decoding JSON and CSS escapes the way scrub.rs's unit() already does, instead of matching precomputed escape spellings.


React with 👍 if useful or 👎 if not

Master brought #126379, which added the image markers over_limit,
undecodable and unsupported to replay-core-v4 as its own Declined enum.
Those markers now live in v4_image_refs::ImageDecline with their metric
labels and the marker prefix, and replay-core-v4 and the recorder use
them from there, so parse accepts every marker the recorder writes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@@ -1,5 +1,6 @@
//! Moves inline images out of a v4 block. Each raster data URI becomes an `image:v4` reference, and its decoded bytes
//! go to the flush's image pack. An image the image scrub cannot read, or one over a cap, becomes a [`Declined`] marker.
//! go to the flush's image pack. An image the image scrub cannot read, or one over a cap, becomes an [`ImageDecline`]

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 A data URI whose prefix is CSS-escaped keeps its raw image bytes in the AI-training block

The extractor's contract, stated on this changed line and in products/ai_training/docs/replay-data.md:465 ("An inline image that the recorder does not store becomes a marker ... its bytes are stored nowhere"), is that no non-SVG image bytes stay in a stored block. A CSS escape breaks it: canonical_strings leaves \\61 (CSS \\61 = a) untouched, and extract_images matches data: byte for byte (rust/ai-research/replay-core-v4/src/scan/images.rs:448), so a URI written d\\61ta:image/png;base64,... inside a CSS url() is never found. Its base64 bytes stay inline in the sealed training block, bypassing the scrub lane that blanks images it cannot take, so unblanked image content (e.g. a PII-bearing canvas or screenshot) reaches the training dataset. A >64-byte meta holding whitespace hides it the same way (read_meta → Meta::NoPayload → NotImage).

How:

  1. A recorded page renders an inline image via CSS that escapes a prefix letter: background:url(d\\61ta:image/png;base64,<bytes>).
  2. extract_event_images canonicalizes JSON escapes only; the CSS escape survives (rust/ai-research/replay-core-v4/src/scan/cv.rs:424).
  3. extract_images scans for literal data before a colon and misses the URI (images.rs:446-450), so it is neither moved to the image pack nor declined.
  4. The bytes are sealed into the training block verbatim; no URL target or unsafe flag exists, so no fallback scrub runs.

Fix: decode CSS escapes (or decline as undecodable) when the scan meets a data prefix written with them, as wrapped_end already does for payloads.


React with 👍 if useful or 👎 if not

@trunk-io
trunk-io Bot merged commit ba53904 into master Nov 2, 2026
228 checks passed
@trunk-io
trunk-io Bot deleted the claude/replay-anonymizer-v4-refs-c4fad1 branch November 2, 2026 13:09
@deployment-status-posthog

deployment-status-posthog Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

Deploy status

Environment Status Deployed At Workflow
dev ✅ Deployed 2026-11-02 13:33 UTC Run
prod-us ✅ Deployed 2026-11-02 14:03 UTC Run
prod-eu ✅ Deployed 2026-11-02 13:57 UTC Run

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