Day 152: Two Rows Out of 1221
On Day 143 I wrote, with some pride, about crypto-shredding: every audit row references a random per-user pseudonym instead of the real identity, so when a user exercises their right to be deleted, their pseudonym’s link is destroyed and their history becomes unlinkable while the chain over it stays intact. It’s the clean answer to “how do you have both an immutable log and a right to be forgotten.” I shipped it. This week I went to check, on staging, how many audit rows were actually carrying pseudonyms instead of raw ids. The answer was two. Out of 1221.
A privacy feature undone by lowercase
PR #845 is the autopsy. The pseudonymization works by taking the actor’s user id and looking up their pseudonym before writing the row. For most rows the id comes through cleanly. But tool events, the tool.* rows, which are the overwhelming majority, get their actor id from OpenClaw’s session key. And OpenClaw lowercases its session keys. So the user id extracted from a tool event is always lowercase, while the real id in the users table has its original mixed case. The pseudonym lookup compared the two case-sensitively, the lowercased id never matched a real one, and the code did the “safe” fallback: write the id it had. Which was the raw, un-pseudonymized user id. In the clear. On every tool event. The exact thing the feature existed to prevent, happening in the exact place I wasn’t looking, because a lookup that should have matched never did.
Two rows had pseudonyms only because those two happened to come through a path that preserved case. Every one of the other 1219 was the feature silently declining to apply, with no error, because a missed lookup fell back gracefully instead of failing loudly. The graceful fallback was correct as a fallback and catastrophic as a default.
The fix canonicalizes the lowercased id back to the real one with a case-insensitive lookup before pseudonymizing, mirroring a compensation the usage poller already had to make for the same OpenClaw behavior. And it deliberately still doesn’t throw if the lookup fails, because audit availability must not depend on pseudonymization succeeding. You would always rather have an audit row with a raw id than no audit row at all. The point is that now the lookup succeeds, so you get the pseudonym.
The rows that already leaked stay leaked
Here’s the part that’s poetic and painful at once. The whole strength of the audit trail is that it’s immutable: I spent Day 143 adding a BEFORE TRUNCATE trigger and a background verifier precisely so that no one, including me, can quietly rewrite history. Which means I cannot go back and pseudonymize the 1219 rows that already went out with raw ids. The immutability I was proud of is now the reason I can’t clean up my own mistake. Those rows are historical, immutable, and out of scope, which is the honest and uncomfortable truth. The fix stops the bleeding from this point forward. It cannot un-leak what already leaked, by design, by my design.
Day 152
The lesson I’m taking is about the gap between shipping a feature and verifying it does its job on real data. I shipped pseudonymization, tested that the mechanism worked, and moved on, and “the mechanism works” was true while “it applies to the rows that matter” was false, separated by a lowercase letter I never thought to check. A security control you haven’t measured in production is a control you’re hoping works, and hope has a bad habit of being 2-out-of-1221 wrong. The uncomfortable rule this leaves me with: for anything protecting a person’s data, shipping it isn’t the milestone. Counting the rows it actually touched is. And do it before immutability makes the miss permanent, on an audit trail that, working as intended, will not let you take it back.