Closes#76. Adding a filter from a message reported success while the script on the server never held more than two rules. Rules were being destroyed, and the confirmation was a lie — which is why this jumped the queue.
The chain
Three links, each defensible on its own:
load() catch → contents[id] = "" a failed blob fetch looks like an empty script
sieveToRules("") → [] "no rules", indistinguishable from "unreadable"
saveRules([] + new) → whole script every existing rule deleted; the write succeeded,
so the UI reported success
No fetch failure was even required.rules() did contents[id] ?? "", so a script whose content simply hadn't loaded yet read as empty. That explains bitbln's "sometimes the last rule shows, sometimes earlier ones but not the last, never more than two" — it's a race with the content load, and the count oscillates around one or two.
And saveScript cached the content it had just written, then called load(), which replaced the whole map — discarding it whenever the refetch came back short.
The fix: keep "unknown" and "empty" apart
a failed fetch leaves the key absent rather than storing ""
load()merges rather than replacing, so a reload can't throw away what saveScript just wrote
rules() returns null for content it doesn't have — which every caller already treats as "don't touch this script"
saveRulesrefuses outright when the baseline is unknown
That last one is the real safety net. Refusing is recoverable; overwriting isn't.
rules() now also reports whether the script was actually read, because "written by hand" and "couldn't be read" deserve different advice — one is permanent, the other is a reload away. Telling someone the wrong one sends them hunting for a problem they don't have.
Ruled out on the way
The rule codec round-trips cleanly — eight rules in, eight out. sieveToRules reads the # rule: JSON comments rather than parsing Sieve, so the generated script's shape was never the issue. Worth recording, since it was the obvious first suspect.
Tests
Seven new, pinning the distinction rather than the symptom: an unreadable script reports its rules as unknown and refuses to save; a genuinely empty one is distinguishable from it; a script that was read hands back every rule; and a reload doesn't discard content already held.
272 web + 77 server tests, typecheck and build clean.
Closes #76. Adding a filter from a message reported success while the script on the server never held more than two rules. **Rules were being destroyed, and the confirmation was a lie** — which is why this jumped the queue.
## The chain
Three links, each defensible on its own:
```
load() catch → contents[id] = "" a failed blob fetch looks like an empty script
sieveToRules("") → [] "no rules", indistinguishable from "unreadable"
saveRules([] + new) → whole script every existing rule deleted; the write succeeded,
so the UI reported success
```
**No fetch failure was even required.** `rules()` did `contents[id] ?? ""`, so a script whose content simply hadn't loaded yet read as empty. That explains bitbln's "sometimes the last rule shows, sometimes earlier ones but not the last, never more than two" — it's a race with the content load, and the count oscillates around one or two.
And `saveScript` cached the content it had just written, then called `load()`, which **replaced** the whole map — discarding it whenever the refetch came back short.
## The fix: keep "unknown" and "empty" apart
- a failed fetch leaves the key **absent** rather than storing `""`
- `load()` **merges** rather than replacing, so a reload can't throw away what `saveScript` just wrote
- `rules()` returns `null` for content it doesn't have — which every caller already treats as "don't touch this script"
- `saveRules` **refuses outright** when the baseline is unknown
That last one is the real safety net. Refusing is recoverable; overwriting isn't.
`rules()` now also reports whether the script was actually read, because *"written by hand"* and *"couldn't be read"* deserve different advice — one is permanent, the other is a reload away. Telling someone the wrong one sends them hunting for a problem they don't have.
## Ruled out on the way
The rule codec round-trips cleanly — eight rules in, eight out. `sieveToRules` reads the `# rule:` JSON comments rather than parsing Sieve, so the generated script's shape was never the issue. Worth recording, since it was the obvious first suspect.
## Tests
Seven new, pinning the distinction rather than the symptom: an unreadable script reports its rules as unknown and refuses to save; a genuinely empty one is distinguishable from it; a script that was read hands back every rule; and a reload doesn't discard content already held.
272 web + 77 server tests, typecheck and build clean.
**Merged** 2026-08-26 as coffey-labs/ihasmail@c647744470e9
<sub>Rebuilt from: git history, session transcript.</sub>
This repo is archived. You cannot comment on issues.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Closes #76. Adding a filter from a message reported success while the script on the server never held more than two rules. Rules were being destroyed, and the confirmation was a lie — which is why this jumped the queue.
The chain
Three links, each defensible on its own:
No fetch failure was even required.
rules()didcontents[id] ?? "", so a script whose content simply hadn't loaded yet read as empty. That explains bitbln's "sometimes the last rule shows, sometimes earlier ones but not the last, never more than two" — it's a race with the content load, and the count oscillates around one or two.And
saveScriptcached the content it had just written, then calledload(), which replaced the whole map — discarding it whenever the refetch came back short.The fix: keep "unknown" and "empty" apart
""load()merges rather than replacing, so a reload can't throw away whatsaveScriptjust wroterules()returnsnullfor content it doesn't have — which every caller already treats as "don't touch this script"saveRulesrefuses outright when the baseline is unknownThat last one is the real safety net. Refusing is recoverable; overwriting isn't.
rules()now also reports whether the script was actually read, because "written by hand" and "couldn't be read" deserve different advice — one is permanent, the other is a reload away. Telling someone the wrong one sends them hunting for a problem they don't have.Ruled out on the way
The rule codec round-trips cleanly — eight rules in, eight out.
sieveToRulesreads the# rule:JSON comments rather than parsing Sieve, so the generated script's shape was never the issue. Worth recording, since it was the obvious first suspect.Tests
Seven new, pinning the distinction rather than the symptom: an unreadable script reports its rules as unknown and refuses to save; a genuinely empty one is distinguishable from it; a script that was read hands back every rule; and a reload doesn't discard content already held.
272 web + 77 server tests, typecheck and build clean.
Merged 2026-08-26 as coffey-labs/ihasmail@c647744470
Rebuilt from: git history, session transcript.