Skip to content

feat(governance): cross-session GDPR erasure with HMAC-signed receipts (closes #365)#567

Open
arcgod-design wants to merge 5 commits into
sreerevanth:mainfrom
arcgod-design:feat/issue-365-gdpr-erasure
Open

feat(governance): cross-session GDPR erasure with HMAC-signed receipts (closes #365)#567
arcgod-design wants to merge 5 commits into
sreerevanth:mainfrom
arcgod-design:feat/issue-365-gdpr-erasure

Conversation

@arcgod-design

@arcgod-design arcgod-design commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Summary

Closes #365 — implements cross-session GDPR erasure (right-to-be-forgotten) with HMAC-signed compliance receipts, an ErasureTarget protocol for pluggable storage backends, and a POST /api/v1/gdpr/erase REST endpoint.

Changes

agentwatch/core/models.py

  • Added 5 new Repository methods: get_session_ids, count_sessions, count_events, delete_session, delete_events_by_session. Used by the SQLAlchemy erasure target.

agentwatch/core/schema.py

  • Added EventType.STYLE_FINGERPRINT_COMPUTED and EventType.STYLE_SWAP_DETECTED.
  • Added ReasoningStyleFingerprint and StyleSwapAlert models.

agentwatch/reasoning/auditor.py

  • Added FingerprintReport dataclass and ReasoningAuditor.fingerprint_session() method.

agentwatch/governance/gdpr.py

  • Added ErasureScope enum (user/agent/session/tenant).
  • Added ErasureRequest and ErasureTargetResult dataclasses.
  • Added ErasureTarget protocol for pluggable backends.
  • Added CrossSessionErasureService — orchestrator that counts, erases, and signs a receipt with HMAC-SHA256.
  • Renamed existing imports from typing.Iterable to collections.abc.Iterable.

agentwatch/api/server.py

  • Added _SessionErasureTarget — SQLAlchemy-backed ErasureTarget using the existing Repository.
  • Added POST /api/v1/gdpr/erase — accepts ErasureRequest JSON, returns ErasureReceipt.

Tests (tests/test_gdpr_erasure.py)

  • 10 new tests covering single/multi-target orchestration, HMAC signature determinism and change, error resilience, empty targets, and scope propagation.

Checklist

  • Code follows the existing style and conventions
  • I have performed a self-review of my code
  • I have commented my code where necessary
  • I have updated the documentation accordingly
  • My changes generate no new warnings
  • I have added tests that prove my fix is effective
  • New and existing unit tests pass locally with my changes
  • ruff check and ruff format --check are clean
  • Any dependent changes have been merged and published in downstream modules

Summary by CodeRabbit

  • New Features

    • Added a GDPR erasure API so authenticated requests can remove matching session and event data and return a signed receipt.
    • Added reasoning-style fingerprint reporting, including style-swap alerts and session-level audit summaries.
  • Documentation

    • Updated the changelog with the latest refactor note.
  • Tests

    • Added coverage for erasure workflows, receipt signing, and reasoning-style fingerprint behavior.

@vercel

vercel Bot commented Jul 5, 2026

Copy link
Copy Markdown

Someone is attempting to deploy a commit to the sreerevanth's projects Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@arcgod-design, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 56 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f9d20608-b6a3-4e00-85b2-374bd23a64e4

📥 Commits

Reviewing files that changed from the base of the PR and between c5cfbe8 and ca9ac55.

📒 Files selected for processing (3)
  • agentwatch/cli/main.py
  • agentwatch/cost/reporting.py
  • tests/test_cost_reporting.py
📝 Walkthrough

Walkthrough

This PR adds a GDPR cross-session right-to-erasure service with HMAC-signed receipts, repository query/delete helpers, and a new authenticated API endpoint. It also adds reasoning style fingerprinting and mid-session style-swap detection to the auditor, with corresponding schema models and tests, plus a changelog entry.

Changes

Cross-session GDPR erasure

Layer / File(s) Summary
Erasure contracts and signing service
agentwatch/governance/gdpr.py
Adds ErasureScope, ErasureRequest, ErasureTargetResult, ErasureTarget protocol, HMAC-SHA256 receipt signing, and CrossSessionErasureService orchestrating multi-target erasure; updates __all__ to remove GDPREngine.
Repository query/delete helpers
agentwatch/core/models.py
Adds get_session_ids, count_sessions, count_events, delete_session, and delete_events_by_session to Repository.
API endpoint wiring
agentwatch/api/server.py
Adds imports and a new authenticated POST /api/v1/gdpr/erase endpoint that builds a repository-backed erasure target and returns a signed ErasureReceipt.
Erasure service tests
tests/test_gdpr_erasure.py
Adds mock target and tests covering defaults, single/multi-target erasure, signature determinism/uniqueness, error handling, scope handling, and empty targets.

Reasoning style fingerprint and swap detection

Layer / File(s) Summary
Fingerprint and swap alert schema
agentwatch/core/schema.py
Adds STYLE_FINGERPRINT_COMPUTED/STYLE_SWAP_DETECTED enum values and ReasoningStyleFingerprint/StyleSwapAlert pydantic models.
Auditor fingerprint_session implementation
agentwatch/reasoning/auditor.py
Adds FingerprintReport dataclass, _pydantic_fingerprint helper, and fingerprint_session computing full/half-session fingerprints with swap detection and reason derivation.
Fingerprint and swap detection tests
tests/test_reasoning_style_fingerprint.py
Adds tests covering dataclass conversion, serialization, swap/no-swap detection, insufficient-events handling, and edge cases.
Changelog
CHANGELOG.md
Adds an Unreleased "Changed" note referencing an unrelated SilenceBaseline/SilentFailureDetector refactor.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant Endpoint as gdpr_erase endpoint
  participant Service as CrossSessionErasureService
  participant Target as ErasureTarget

  Client->>Endpoint: POST /api/v1/gdpr/erase (ErasureRequest)
  Endpoint->>Service: erase(request)
  loop for each registered target
    Service->>Target: count_matching(identifier, scope)
    Service->>Target: erase_matching(identifier, scope)
  end
  Service->>Service: sign receipt via HMAC-SHA256
  Service-->>Endpoint: ErasureReceipt
  Endpoint-->>Client: ErasureReceipt
Loading
sequenceDiagram
  participant Caller
  participant Auditor as ReasoningAuditor
  participant Fingerprint as fingerprint()
  participant Detector as detect_mid_session_change()

  Caller->>Auditor: fingerprint_session(events, threshold, split_ratio)
  Auditor->>Fingerprint: fingerprint(events)
  Auditor->>Detector: detect_mid_session_change(events, split_ratio)
  Auditor->>Fingerprint: fingerprint(first_half)
  Auditor->>Fingerprint: fingerprint(second_half)
  Auditor->>Auditor: build StyleSwapAlert + FingerprintReport
  Auditor-->>Caller: FingerprintReport
Loading

Possibly related PRs

  • sreerevanth/AgentWatch#547: The new fingerprint_session() mid-session swap detection relies on detect_mid_session_change(), which this PR fixes for cases where one half lacks planner output.

Suggested labels: Advanced, Hard

Suggested reviewers: sreerevanth

Poem

A rabbit signs receipts with glee, 🔏
Erasing sessions, one, two, three,
Fingerprints of style, swapped mid-run,
Detected sharp beneath the sun,
Hop, hop, hooray — the tests all pass,
Compliance code as smooth as grass! 🐇

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning Reasoning fingerprint models, auditor changes, and their tests are unrelated to GDPR erasure and fall outside #365's scope. Move the reasoning-auditor fingerprint work to a separate PR or remove it from this GDPR erasure change.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main change: cross-session GDPR erasure with signed receipts.
Linked Issues check ✅ Passed The PR adds cross-session erasure orchestration, signed receipts, delete/count backends, and a POST endpoint, satisfying #365's core requirements.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Jul 5, 2026

Copy link
Copy Markdown
Contributor

🧪 PR Test Results

Check Result
Tests (pytest tests/) ✅ success
Lint (ruff check .) ✅ success
Coverage (agentwatch) 73.45%

Python 3.12 · commit c93b148

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 7

🧹 Nitpick comments (2)
agentwatch/api/server.py (1)

1628-1631: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Per-session delete loop is N+1.

For agent/user/tenant scopes each session triggers separate event + session deletes. prune_sessions already accepts a list, so sessions can be pruned in one statement after batch-deleting events, reducing round-trips on large erasures.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@agentwatch/api/server.py` around lines 1628 - 1631, The per-session delete
loop in the agent/user/tenant erase path is doing N+1 deletes, so update the
prune flow to batch work instead of deleting each session one by one. In the
logic around get_session_ids, first collect all session IDs, then delete events
for those sessions in bulk, and finally call prune_sessions with the full list
rather than delete_session inside the loop. Use the existing repo methods
get_session_ids and prune_sessions to keep the change localized and reduce
round-trips.
agentwatch/governance/gdpr.py (1)

213-220: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

total_start_opt is computed but never used.

The pre-erasure total is accumulated across targets but never surfaced in the receipt or results, so this block is dead. This looks like an incomplete aggregate (ErasureReceipt has no total-matched field). Either drop it or expose it on the receipt.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@agentwatch/governance/gdpr.py` around lines 213 - 220, `total_start_opt` in
the erasure flow is computed in the target loop but never used, so the
aggregation is dead. Update the logic in the governance erasure method that
iterates over `self._targets` to either remove the unused pre-erasure count
accumulation entirely or thread it through the returned `ErasureReceipt`/results
as a new field if it is meant to be reported. Make sure the chosen fix is
reflected consistently in the receipt-building path and any related return
values so `count_matching` is not called without purpose.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@agentwatch/api/server.py`:
- Around line 1644-1657: The gdpr_erase endpoint currently returns a successful
ErasureReceipt even when CrossSessionErasureService.erase records per-target
errors, so the caller cannot tell failure from a no-op. Update gdpr_erase and
the ErasureReceipt/erasure flow to surface failures explicitly: either raise an
error and return a non-2xx response when any target result has an error, or add
a failure field that includes per-target details and ensure the response schema
reflects it. Use the existing gdpr_erase, CrossSessionErasureService.erase,
ErasureTargetResult, and ErasureReceipt symbols to wire the failure state
through the API.
- Around line 1587-1589: The session erasure signing secret is currently falling
back to an ephemeral os.urandom value in the module-level
_SESSION_ERASURE_SECRET initialization, which makes receipt signatures
non-deterministic across restarts and instances. Update the secret handling in
agentwatch/api/server.py to require AGENTWATCH_ERASURE_SECRET to be set at
startup, fail fast with a clear error if it is missing or empty, and remove the
random default so the same signing key is used consistently by the
receipt-signing and verification flow.
- Around line 1607-1634: The erasure logic in erase_matching and the related
count path are still filtering USER_ID/TENANT_ID through agent_id, which does
not match the stored keys on SessionRecord and EventRecord. Update the
Repository queries in these branches to use tenant_id for TENANT_ID and the
correct stored key for USER_ID, and make count_matching and erase_matching use
the same scope-to-column mapping so deletions and counts stay consistent even
when events exist without a session row.

In `@agentwatch/governance/gdpr.py`:
- Around line 114-130: The receipt signature in gdpr.py does not actually
include any timestamp even though the docstring says it does. Update the
documentation on the signature helper that builds the HMAC payload to match the
real signed fields, or, if you want tamper-evident timing, add the relevant
receipt timestamp field(s) to the payload in the signing logic while preserving
the behavior expected by test_receipt_signature_is_deterministic. Reference the
payload construction inside the erasure receipt signature function and keep the
signed data consistent with the receipt model.
- Around line 194-200: The constructor in GDPR-related receipt handling is
falling back to a constant zero-filled signing key when signing_secret is empty,
which makes audit signatures forgeable. Update __init__ in the receipt/signing
flow to reject an empty signing_secret instead of substituting b"\x00" * 32, and
fail fast with a clear error so callers must provide a real secret before any
signature generation.

In `@agentwatch/reasoning/auditor.py`:
- Around line 163-171: The reason selection in the auditor logic is using
distance == 0.0 as a proxy for missing planner output, which mislabels identical
planner styles as insufficient_planner_signal. Update the reason derivation near
session_id/reason so it checks actual planner-output presence from the already
sliced first and second halves instead of inferring from distance. Keep
insufficient_planner_signal only when one side truly lacks planner signal, and
let identical-style zero-distance cases fall through to below_threshold.

In `@tests/test_reasoning_style_fingerprint.py`:
- Around line 139-146: The 5-event parametrized case in
test_reasoning_style_fingerprint is misleading because fingerprint_session
short-circuits to insufficient_events before any below_threshold logic can
apply. Update the test around fingerprint_session to either assert the actual
reason explicitly for the 5-event input or change/remove the comment so it
matches the real behavior, and keep the events/expected_reason table aligned
with the intended branch coverage.

---

Nitpick comments:
In `@agentwatch/api/server.py`:
- Around line 1628-1631: The per-session delete loop in the agent/user/tenant
erase path is doing N+1 deletes, so update the prune flow to batch work instead
of deleting each session one by one. In the logic around get_session_ids, first
collect all session IDs, then delete events for those sessions in bulk, and
finally call prune_sessions with the full list rather than delete_session inside
the loop. Use the existing repo methods get_session_ids and prune_sessions to
keep the change localized and reduce round-trips.

In `@agentwatch/governance/gdpr.py`:
- Around line 213-220: `total_start_opt` in the erasure flow is computed in the
target loop but never used, so the aggregation is dead. Update the logic in the
governance erasure method that iterates over `self._targets` to either remove
the unused pre-erasure count accumulation entirely or thread it through the
returned `ErasureReceipt`/results as a new field if it is meant to be reported.
Make sure the chosen fix is reflected consistently in the receipt-building path
and any related return values so `count_matching` is not called without purpose.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 261fb12f-6fe3-475f-8a13-05369c2236b8

📥 Commits

Reviewing files that changed from the base of the PR and between 83dafb8 and c5cfbe8.

📒 Files selected for processing (8)
  • CHANGELOG.md
  • agentwatch/api/server.py
  • agentwatch/core/models.py
  • agentwatch/core/schema.py
  • agentwatch/governance/gdpr.py
  • agentwatch/reasoning/auditor.py
  • tests/test_gdpr_erasure.py
  • tests/test_reasoning_style_fingerprint.py

Comment thread agentwatch/api/server.py
Comment on lines +1587 to +1589
_SESSION_ERASURE_SECRET: bytes = os.getenv("AGENTWATCH_ERASURE_SECRET", "").encode() or os.urandom(
32
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Randomly generated signing secret breaks receipt verifiability.

When AGENTWATCH_ERASURE_SECRET is unset, a fresh os.urandom(32) key is generated at import. Signatures then change on every process restart and differ across horizontally-scaled instances, so previously issued compliance receipts can never be re-verified—undermining the immutable audit-trail goal. Require the secret to be configured (fail fast if missing) rather than defaulting to an ephemeral key.

🔒 Proposed fix
-_SESSION_ERASURE_SECRET: bytes = os.getenv("AGENTWATCH_ERASURE_SECRET", "").encode() or os.urandom(
-    32
-)
+_erasure_secret = os.getenv("AGENTWATCH_ERASURE_SECRET", "")
+if not _erasure_secret:
+    raise RuntimeError(
+        "AGENTWATCH_ERASURE_SECRET must be set to sign GDPR erasure receipts"
+    )
+_SESSION_ERASURE_SECRET: bytes = _erasure_secret.encode()
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
_SESSION_ERASURE_SECRET: bytes = os.getenv("AGENTWATCH_ERASURE_SECRET", "").encode() or os.urandom(
32
)
_erasure_secret = os.getenv("AGENTWATCH_ERASURE_SECRET", "")
if not _erasure_secret:
raise RuntimeError(
"AGENTWATCH_ERASURE_SECRET must be set to sign GDPR erasure receipts"
)
_SESSION_ERASURE_SECRET: bytes = _erasure_secret.encode()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@agentwatch/api/server.py` around lines 1587 - 1589, The session erasure
signing secret is currently falling back to an ephemeral os.urandom value in the
module-level _SESSION_ERASURE_SECRET initialization, which makes receipt
signatures non-deterministic across restarts and instances. Update the secret
handling in agentwatch/api/server.py to require AGENTWATCH_ERASURE_SECRET to be
set at startup, fail fast with a clear error if it is missing or empty, and
remove the random default so the same signing key is used consistently by the
receipt-signing and verification flow.

Comment thread agentwatch/api/server.py
Comment on lines +1607 to +1634
if scope == ErasureScope.SESSION_ID:
return await repo.count_sessions({"session_id": identifier})
if scope == ErasureScope.AGENT_ID:
return await repo.count_sessions({"agent_id": identifier})
# user_id / tenant_id / fallback: scan both tables on agent_id
sessions = await repo.count_sessions({"agent_id": identifier})
events = await repo.count_events({"agent_id": identifier})
return sessions + events

async def erase_matching(self, identifier: str, scope: ErasureScope) -> int:
if _db_session_factory is None:
return 0
async with _db_session_factory() as db:
repo = Repository(db)
erased = 0
if scope == ErasureScope.SESSION_ID:
erased += await repo.delete_events_by_session(identifier)
erased += await repo.delete_session(identifier)
await db.commit()
return erased
if scope in (ErasureScope.AGENT_ID, ErasureScope.USER_ID, ErasureScope.TENANT_ID):
session_ids = await repo.get_session_ids({"agent_id": identifier})
for sid in session_ids:
erased += await repo.delete_events_by_session(sid)
erased += await repo.delete_session(sid)
await db.commit()
return erased
return 0

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Verify SessionRecord/EventRecord columns to confirm correct scope→column mapping
ast-grep outline agentwatch/core/models.py --items all
rg -nP '\b(agent_id|user_id|tenant_id)\b' -C1 agentwatch/core/models.py

Repository: sreerevanth/AgentWatch

Length of output: 6533


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the server-side erasure logic and related scope definitions/callers
ast-grep outline agentwatch/api/server.py --items all
printf '\n--- scope/erasure matches ---\n'
rg -n "ErasureScope|count_matching|erase_matching|get_session_ids|count_sessions|count_events|delete_events_by_session|delete_session" agentwatch/api/server.py -C 3
printf '\n--- repository lookup helpers ---\n'
rg -n "def (get_session_ids|count_sessions|count_events|delete_events_by_session|delete_session)" agentwatch/core/models.py -C 20
printf '\n--- tenant/user erasure references ---\n'
rg -n "user_id|tenant_id|erasure|right to erasure|delete.*tenant|delete.*user" agentwatch -C 2

Repository: sreerevanth/AgentWatch

Length of output: 50378


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read the erasure scope definition and the surrounding server logic
rg -n "class ErasureScope|Enum|USER_ID|TENANT_ID|AGENT_ID|SESSION_ID" agentwatch -C 2
printf '\n--- server.py around erase/count_matching ---\n'
sed -n '1560,1655p' agentwatch/api/server.py
printf '\n--- repository helper implementations ---\n'
sed -n '330,410p' agentwatch/core/models.py

Repository: sreerevanth/AgentWatch

Length of output: 26159


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the erasure service contract and target protocol to determine expected scope behavior
sed -n '1,220p' agentwatch/governance/gdpr.py
printf '\n--- cross-session erasure references ---\n'
rg -n "class CrossSessionErasureService|class ErasureTarget|Protocol|count_matching|erase_matching|ErasureRequest|ErasureReceipt" agentwatch/governance/gdpr.py -C 4

Repository: sreerevanth/AgentWatch

Length of output: 12034


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Check whether event/session records are always linked by the same agent_id and session_id
sed -n '1,220p' agentwatch/core/schema.py
printf '\n--- repository inserts/lookups ---\n'
sed -n '220,330p' agentwatch/core/models.py
printf '\n--- server write paths ---\n'
sed -n '150,260p' agentwatch/api/server.py
printf '\n--- search for user_id storage in persistence models ---\n'
rg -n "\buser_id\b" agentwatch/core agentwatch/api agentwatch/models -C 2

Repository: sreerevanth/AgentWatch

Length of output: 18965


Map erasure scopes to the stored tenant/session keys
USER_ID/TENANT_ID still route through agent_id here, even though SessionRecord and EventRecord both persist tenant_id and there is no user_id column in the storage models. Tenant-scoped erasure can miss the intended rows, and the count path can drift from the delete path when events exist without a matching session row.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@agentwatch/api/server.py` around lines 1607 - 1634, The erasure logic in
erase_matching and the related count path are still filtering USER_ID/TENANT_ID
through agent_id, which does not match the stored keys on SessionRecord and
EventRecord. Update the Repository queries in these branches to use tenant_id
for TENANT_ID and the correct stored key for USER_ID, and make count_matching
and erase_matching use the same scope-to-column mapping so deletions and counts
stay consistent even when events exist without a session row.

Comment thread agentwatch/api/server.py
Comment on lines +1644 to +1657
@app.post("/api/v1/gdpr/erase", response_model=ErasureReceipt)
async def gdpr_erase(
request: ErasureRequest,
_auth: None = Depends(_require_api_key),
) -> ErasureReceipt:
"""Execute a right-to-erasure (right-to-be-forgotten) action across all
session, event, and memory data for the given identifier.

The response is an HMAC-SHA256 signed erasure receipt suitable for
compliance audit trails.
"""
service = await _build_erasure_service()
receipt = await service.erase(request)
return receipt

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟠 Major | 🏗️ Heavy lift

Endpoint reports success even when erasure fails.

CrossSessionErasureService.erase converts per-target exceptions into ErasureTargetResult(error=...) and still returns a signed receipt, but ErasureReceipt exposes only items_erased—no error/per-target detail. A DB failure therefore yields a 200 with items_erased=0 and a valid signature, and the caller cannot distinguish "nothing to erase" from "erasure failed." For a compliance action this should surface failures (non-2xx or an explicit failed-targets field).

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@agentwatch/api/server.py` around lines 1644 - 1657, The gdpr_erase endpoint
currently returns a successful ErasureReceipt even when
CrossSessionErasureService.erase records per-target errors, so the caller cannot
tell failure from a no-op. Update gdpr_erase and the ErasureReceipt/erasure flow
to surface failures explicitly: either raise an error and return a non-2xx
response when any target result has an error, or add a failure field that
includes per-target details and ensure the response schema reflects it. Use the
existing gdpr_erase, CrossSessionErasureService.erase, ErasureTargetResult, and
ErasureReceipt symbols to wire the failure state through the API.

Comment on lines +114 to +130
"""Generate an HMAC-SHA256 erasure receipt signature.

The signature covers the request identity, the per-target erased counts,
and the completion timestamp, ensuring the receipt cannot be forged.
"""
total_erased = sum(r.erased_items for r in results)
payload = json.dumps(
{
"identifier": request.identifier,
"scope": request.scope.value,
"total_erased": total_erased,
"results": [(r.target_name, r.erased_items, r.error) for r in results],
},
sort_keys=True,
separators=(",", ":"),
)
return hmac.new(secret, payload.encode(), hashlib.sha256).hexdigest()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟡 Minor | ⚡ Quick win

Signature does not cover timestamps despite the docstring.

The docstring states the signature covers "the completion timestamp," but payload only includes identifier, scope, total_erased, and per-target results. As a result submitted_at/completed_at in the receipt are not tamper-evident. Either fold a timestamp into the signed payload (note this would break the determinism relied on by test_receipt_signature_is_deterministic) or correct the docstring to reflect what is actually signed.

🧰 Tools
🪛 ast-grep (0.44.1)

[info] 119-128: use jsonify instead of json.dumps for JSON output
Context: json.dumps(
{
"identifier": request.identifier,
"scope": request.scope.value,
"total_erased": total_erased,
"results": [(r.target_name, r.erased_items, r.error) for r in results],
},
sort_keys=True,
separators=(",", ":"),
)
Note: [CWE-116] Improper Encoding or Escaping of Output.

(use-jsonify)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@agentwatch/governance/gdpr.py` around lines 114 - 130, The receipt signature
in gdpr.py does not actually include any timestamp even though the docstring
says it does. Update the documentation on the signature helper that builds the
HMAC payload to match the real signed fields, or, if you want tamper-evident
timing, add the relevant receipt timestamp field(s) to the payload in the
signing logic while preserving the behavior expected by
test_receipt_signature_is_deterministic. Reference the payload construction
inside the erasure receipt signature function and keep the signed data
consistent with the receipt model.

Comment on lines +194 to +200
def __init__(
self,
targets: Iterable[ErasureTarget],
signing_secret: bytes = b"",
):
self._targets = list(targets)
self._secret = signing_secret or b"\x00" * 32

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Zero-byte fallback signing secret makes receipts forgeable.

When signing_secret is empty, the service silently signs with a constant, publicly-known key (b"\x00" * 32). Any party can then forge a valid audit_signature, defeating the "cryptographically signed, immutable receipt" guarantee. Prefer failing fast over a predictable key.

🔒 Proposed fix
     def __init__(
         self,
         targets: Iterable[ErasureTarget],
-        signing_secret: bytes = b"",
+        signing_secret: bytes,
     ):
+        if len(signing_secret) < 32:
+            raise ValueError("signing_secret must be at least 32 bytes")
         self._targets = list(targets)
-        self._secret = signing_secret or b"\x00" * 32
+        self._secret = signing_secret
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def __init__(
self,
targets: Iterable[ErasureTarget],
signing_secret: bytes = b"",
):
self._targets = list(targets)
self._secret = signing_secret or b"\x00" * 32
def __init__(
self,
targets: Iterable[ErasureTarget],
signing_secret: bytes,
):
if len(signing_secret) < 32:
raise ValueError("signing_secret must be at least 32 bytes")
self._targets = list(targets)
self._secret = signing_secret
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@agentwatch/governance/gdpr.py` around lines 194 - 200, The constructor in
GDPR-related receipt handling is falling back to a constant zero-filled signing
key when signing_secret is empty, which makes audit signatures forgeable. Update
__init__ in the receipt/signing flow to reject an empty signing_secret instead
of substituting b"\x00" * 32, and fail fast with a clear error so callers must
provide a real secret before any signature generation.

Comment on lines +163 to +171
session_id = events[0].session_id if events else ""
reason: str | None = None
if not detected:
if len(events) < 6:
reason = "insufficient_events"
elif distance == 0.0:
reason = "insufficient_planner_signal"
else:
reason = "below_threshold"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

reason derivation mislabels "identical style" as "insufficient_planner_signal".

elif distance == 0.0 is used as a proxy for "one half had no planner output," but distance can legitimately be exactly 0.0 when both halves do have planner output with an identical computed style (same token count/diversity/punctuation stats). In that case the reported reason ("insufficient_planner_signal") is factually wrong — there was planner signal, it just didn't differ.

This is exercised by tests/test_reasoning_style_fingerprint.py::test_fingerprint_session_no_swap_detected_consistent_plans, which uses identical plan text for all 8 events (guaranteeing distance 0.0 with real planner signal in both halves) and has to accept either "insufficient_planner_signal" or "below_threshold" to pass — a symptom of this ambiguity rather than a real guarantee of correct behavior.

Since first/second are already sliced above, derive the reason directly from actual planner-output presence instead of inferring it from the distance value.

🐛 Proposed fix
         session_id = events[0].session_id if events else ""
+        has_first_planner = any(e.event_type == EventType.PLANNER_OUTPUT for e in first)
+        has_second_planner = any(e.event_type == EventType.PLANNER_OUTPUT for e in second)
         reason: str | None = None
         if not detected:
             if len(events) < 6:
                 reason = "insufficient_events"
-            elif distance == 0.0:
+            elif not has_first_planner or not has_second_planner:
                 reason = "insufficient_planner_signal"
             else:
                 reason = "below_threshold"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
session_id = events[0].session_id if events else ""
reason: str | None = None
if not detected:
if len(events) < 6:
reason = "insufficient_events"
elif distance == 0.0:
reason = "insufficient_planner_signal"
else:
reason = "below_threshold"
session_id = events[0].session_id if events else ""
has_first_planner = any(e.event_type == EventType.PLANNER_OUTPUT for e in first)
has_second_planner = any(e.event_type == EventType.PLANNER_OUTPUT for e in second)
reason: str | None = None
if not detected:
if len(events) < 6:
reason = "insufficient_events"
elif not has_first_planner or not has_second_planner:
reason = "insufficient_planner_signal"
else:
reason = "below_threshold"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@agentwatch/reasoning/auditor.py` around lines 163 - 171, The reason selection
in the auditor logic is using distance == 0.0 as a proxy for missing planner
output, which mislabels identical planner styles as insufficient_planner_signal.
Update the reason derivation near session_id/reason so it checks actual
planner-output presence from the already sliced first and second halves instead
of inferring from distance. Keep insufficient_planner_signal only when one side
truly lacks planner signal, and let identical-style zero-distance cases fall
through to below_threshold.

Comment on lines +139 to +146
@pytest.mark.parametrize(
"events, expected_reason",
[
([], "insufficient_events"),
([_plan("x", 0)], "insufficient_events"),
([_plan("plan", i) for i in range(5)], None), # below threshold in even dist
],
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Comment doesn't match actual behavior for the 5-event case.

The comment # below threshold in even dist implies the expected reason is "below_threshold", but with 5 events (< 6), fingerprint_session will short-circuit to reason = "insufficient_events" per the len(events) < 6 check. Since expected_reason is None here, the test only asserts detected is False and doesn't actually pin down which reason is produced, so the mismatch goes unnoticed.

Consider asserting the actual reason explicitly (or fixing the comment) to keep this case meaningful.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_reasoning_style_fingerprint.py` around lines 139 - 146, The
5-event parametrized case in test_reasoning_style_fingerprint is misleading
because fingerprint_session short-circuits to insufficient_events before any
below_threshold logic can apply. Update the test around fingerprint_session to
either assert the actual reason explicitly for the 5-event input or
change/remove the comment so it matches the real behavior, and keep the
events/expected_reason table aligned with the intended branch coverage.

@arcgod-design

Copy link
Copy Markdown
Contributor Author

Closes #365

SakethSumanBathini and others added 2 commits July 5, 2026 19:37
…h#339) (sreerevanth#557)

* feat(cli): add cost report command for spend by framework (sreerevanth#339)

* fix(cli): warn on truncated results, skip malformed sessions, dedup group_by validation (sreerevanth#339)

@sreerevanth sreerevanth left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Thanks for the substantial contribution—this adds a valuable GDPR erasure workflow with good test coverage. Before merging, I'd like to address a few issues:

Require a configured signing secret instead of falling back to a random/constant key.
Correct the USER_ID/TENANT_ID mapping so erasure targets the proper stored fields.
Surface target failures through the API instead of always returning a successful receipt.

Once those are addressed, I'd be happy to take another look.

@arcgod-design

Copy link
Copy Markdown
Contributor Author

Hey @sreerevanth, this PR has been ready for review for a while -- offering this friendly bump. The implementation matches the issue's specified behavior and CI is passing.

PR: #567
Title: feat: cross-session GDPR erasure with HMAC-signed receipts
Issue: #365

When you have a moment, a review would be much appreciated. I am happy to iterate on any feedback. If the timing is not right, no worries at all -- just a gentle nudge from a contributor who has been waiting patiently. �

@sreerevanth sreerevanth left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Thanks for the substantial contribution—this adds valuable GDPR erasure functionality and reasoning fingerprinting, and the accompanying tests are appreciated.

Before this can be merged, there are a few blockers that need to be addressed:

The receipt signing secret must not fall back to a random or zero-value key. Receipts need to remain verifiable across restarts and deployments, so the service should fail fast if no signing secret is configured.
The GDPR endpoint currently returns a successful signed receipt even if one or more erasure targets fail. Compliance operations should surface failures explicitly rather than returning HTTP 200 with items_erased=0.
The tenant/user scope mapping should use the actual persisted identifiers instead of routing through agent_id, otherwise tenant-scoped erasure may miss records.
The reasoning fingerprint logic should distinguish between identical fingerprints and missing planner signal instead of treating distance == 0 as insufficient planner output.
Please also update the receipt signing documentation so it accurately reflects what is included in the HMAC payload.

Once these issues are resolved, I'd be happy to review it again.

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.

[Feat] [ELUSOC] Implement Cross-Session GDPR Erasure and Right-to-be-Forgotten Actions

3 participants