A Trailing Slash Is Doing More Work Than You Think

2026-09-06

I put a warning in a README last week that I keep thinking about. It’s for a checkpoint saver that writes to S3 and GCS, and it says: if you configure a bucket lifecycle rule to expire old checkpoints, filtered by the prefix checkpoints, it will also match checkpoints-backup/... and checkpoints-v2/..., and quietly delete those too. Not a hypothetical. That’s exactly how S3’s Filter.Prefix and GCS’s matchesPrefix work: a literal string match, with no idea where a path is supposed to end.

The bug isn’t really about S3. It’s that “prefix” and “directory” are two different ideas wearing the same string. A prefix means “starts with these characters.” A directory means “is inside this boundary.” Every config field that accepts a path as a plain string is asking you to hold that difference in your head yourself, and the failure mode is silent: nothing errors, the rule runs, it just also matches something you never meant to include. You find out when a support ticket says data is missing, not when you write the rule.

What makes it worth writing down is where the danger actually lives. In that same saver’s own code, deleting an expired thread treats its root as a real path — joined with proper path semantics, not string-compared — so this exact mistake can’t happen there. It can only happen in the one place a human types the boundary by hand: the lifecycle rule, configured once in a console or a JSON blob, months after anyone remembers the warning in the README. The risk doesn’t spread evenly across a system. It collects at the exact seam where a path stops being computed and starts being typed.

That seam shows up anywhere “starts with” is standing in for “is inside”: an nginx location block matched by string prefix, an IAM condition scoped to a storage path, a feature flag or routing rule checking .startswith() against an allowed path. None of these are exotic. They’re just places someone reached for a string comparison because it was the obvious tool, and the boundary character was easy to forget precisely because leaving it out doesn’t look wrong.

So: if a filter is documented as a prefix, write the trailing separator explicitly, every time, even when it feels redundant. Don’t let “prefix” borrow the meaning of “directory” just because the two usually agree. They only agree until a name shows up that makes them disagree, and by then the rule has already been running for months.