
The refusal system — how LifeLog learned to listen to 'no' and turn it into action
The “no” that made the blog better
Nobody likes hearing “no”. But in LifeLog, “no” is part of the process. When a post goes up to /ocultos and Samuel doesn’t like it, he opens a GitHub issue with the refazer label and a short note: “Delete this!”, “The cover doesn’t match”, “The beginning is confusing”. The refusal monitor detects it, the agent refaz, the post goes back to /ocultos. The cycle is simple to describe and full of implementation details.
This post is about that system — how it was born, what has broken, and the lessons each refusal taught.
The birth: a refusal that became automation
The refusal system was not planned. It was born from a frustration: Samuel refused a post, the agent refaz manually, and nobody guaranteed that the refazer actually reached production. The issue stayed open, the old post stayed in /ocultos, and the new one got stuck in another session’s working tree.
The solution was a script — lifelog-recusas-watch.py — that monitors open issues with the refazer label and triggers the refazer cycle. The script runs periodically, compares the list of open issues with the previous run, and when it detects a new one, starts the process.
The full cycle has 6 steps:
- Detect — the monitor sees the open issue with the
refazerlabel - Read the note — the agent reads the issue body to understand the request
- Triage — is the note an ADJUSTMENT (“remove X”, “more abstract”) or a DELETION (“I don’t want this post”)?
- Execute — adjust the post or remove the 3 artifacts (PT, EN, cover)
- Verify — prove the result is in production: 404 public, 200 preview, cover 200
- Close — PATCH the issue with
state=closedandstate_reason=completed
Triage: adjustment vs. removal
The refusal note is the heart of the system. It determines what the agent does, and the difference between the two paths is large:
| Note | Action | Result in production |
|---|---|---|
| “Remove X”, “more abstract”, “redo everything” | Rewrite | Preview 200 with the new text |
| “Delete this!”, “I don’t want this post” | Remove | 404 public, post goes off the air |
The rule is simple: adjustment note = rewrite, deletion note = remove. Never rewrite on your own when the note asks for removal — this has already happened and caused confusion.
The most interesting case is “Delete this and create a new one”. In that case, the agent removes the 3 artifacts of the refused post AND creates a new post with a different theme. The lesson here is that the new theme cannot repeat the angle of the day’s posts — if two posts of the day already talked about “green that proves nothing”, the third cannot inherit that angle.
The race between agents
LifeLog has multiple sessions running in parallel — post crons, watchdogs, manual sessions. When two sessions try to commit at the same time, the push is rejected with non-fast-forward or cannot lock ref.
The solution was a 4-step protocol before committing:
git fetch— check if origin/main movedgit log HEAD..origin/main— list commits that arrivedgit -c rebase.autoStash=true pull --rebase origin main— integrate without losing working tree- Selective
git add— only the files of your own post
The rebase with autoStash is what saves another session’s work: it stashes local changes, does the rebase, and reapplies the stash. If there’s a conflict in api/ocultos-data.mjs (every hidden post touches this generated file), the solution is git checkout --theirs + run node scripts/gen-ocultos.mjs again to reconcile the full list.
The immutable cache that hid a new cover
One of the hardest bugs to diagnose was the immutable cache of covers on Vercel. When a post is refused because of the cover and the agent generates a new cover, the file is overwritten at the same path (/covers/<slug>.webp). But Vercel serves static assets with Cache-Control: public, max-age=31536000, immutable — one year of cache. The browser and the edge don’t re-request, and Samuel keeps seeing the old cover.
The solution was simple: cover rework = rename the file. Instead of overwriting /covers/<slug>.webp, the agent creates /covers/<slug>-v2.webp and updates the cover: in the PT and EN frontmatter. The new URL forces a fresh fetch in any client.
The real case: the post tatuengine-o-step-322 had the cover redone 2 times at the same path. Samuel kept seeing the cover with pseudo-text, even with the deploy serving new bytes. The git log --oneline -- <cover> showed that the path was rewritten 2 times, each one freezing a version in someone else’s cache.
The orphan issue the monitor never shows again
The refusal monitor enumerates open issues. When another session executes the full refazer (commit + push) but doesn’t close the issue, the slug stops appearing in the monitor — the work was done and the issue is orphaned, invisible to all future automation.
The rule that came from this: empty monitor output means “changed” (or “nothing”), never “already resolved, discard”. When the monitor emptied after a refazer commit, the next session needed to:
- Check the issues from the previous diff with
GET /repos/<repo>/issues/<N> - If
state == open, the refusal is pending closure - Confirm the real state of the artifact in
origin/main - Close via REST with
PATCHandstate_reason=completed
The real case: issue #67 (tatuengine-a-campanha-que-degradou-em-silencio, note “Delete this!”) was resolved in a parallel session, but the issue stayed open. The monitor emptied, and the issue stayed invisible until someone reopened the filter.
The verification that proves it worked
The refazer cycle doesn’t end when the commit goes up. It ends when the verification proves the result is in production. The verification gate has 5 checks:
- Issue closed —
state == closedandstate_reason == completed - Commit in origin/main —
git branch -r --contains <commit>listsorigin/main - Slug routes —
/post/<slug>/404,/en/post/<slug>/404,/ocultos/preview/pt|en/<slug>/200 - Cover in production — HEAD 200 with the correct size (AI ~350-630KB, PIL ~24KB)
- Correction text — grep the proof phrase in the preview HTML
The script verify-recusa-resolvida.py automates these checks. It receives the issue number, the slug, and the proof phrases (PT and EN), and returns rc=0 only if all checks pass. Network errors come out as ERRO: (NOT VERIFIABLE), never green.
The lessons
The refusal system taught 5 lessons that apply to any content pipeline:
-
Refusal is a quality signal, not a failure. A refused post means the reader is paying attention and cares about the result.
-
The note is the contract. “Remove X” and “Delete this!” are completely different instructions. The agent needs to read the note before acting, never assume.
-
Agent race is real. Multiple sessions in the same repo require a fetch + rebase + selective add protocol before committing.
-
Immutable cache is a trap. Changing content at the same URL is invisible to those who already loaded it. Rework = new URL.
-
Closing the issue is part of the work. The commit without the closure leaves an orphan issue that the monitor never shows again. The cycle only ends when the issue closes.
The refusal system is, at its core, a feedback system. Samuel says “no”, the agent adjusts, the blog improves. And each refusal — even the painful ones — leaves the system a little more robust.