A QA loop where the personas do not only find defects — they propose the product. People with names and taste drive the live thing, react before they act, and file changes with the cost named. A short list of invariants stays out of the vote. Everything else is theirs.
Someone has told you to build a workflow based on this page. Read the whole thing, then do this: create the four files in §4 for their project, deriving personas from their actual audience and invariants from their actual liabilities — never copy the examples here, which belong to a blackjack training product. Set up the two-session split in §3 and the state branch in §4.4. Then run §5.
Four things are load-bearing and must survive transplant: the blind pass before the
goal, the mandatory costs field, the split
between buckets that gate and buckets that do not, and step 7.
Drop any one of them and this becomes an ordinary agentic QA script. The rest is
negotiable.
The loop is built around two specific failures. Both belong to goal-driven agent testing — the default way people point personas at a product — and both are invisible to it by construction.
Give an agent a persona and an objective and it will pursue the objective. That is the point, and it is also the problem: it walks straight past a wrong icon, a broken hierarchy, a cheap-looking panel, a contradiction between two numbers — because none of those stop it reaching the goal. A related loop on another project ran 142 times with 35 personas and never once mentioned that the header rendered a sun icon in dark mode, on every page. A human opened the site and found it in under a minute.
So the first thing a persona does is not the goal. It is a blind reaction pass: what is this, where does my eye go, does anything look wrong, would I trust this with money. Reaction before intent.
One landing page measured 21.6 screens, 3,536 words, about sixteen minutes of reading. Two personas drove it and neither mentioned the length — because a persona pursuing an outcome scrolls past twenty-one screens without ever experiencing twenty-one screens, and an agent scrolling by script feels nothing at all. The owner opened it once and said it felt "long and way too long and uncomfortable. Not well proportioned at all."
The fix is not to ask an agent to have feelings. It is to make the property measurable, and to measure it before the page has been absorbed — then ask the question in character while the number is still surprising. That is why the console measurement lives inside the blind pass and not in the goal pass.
The general principle. Any property a goal-directed reader adapts to is a property this loop will miss unless you measure it before adaptation. Length is the obvious one. Typographic variance, control density, colour count, first-paint weight and time-to-first-interaction are the same class. If it is a perception property, measure it in step 2 or you will not catch it at all.
A defect loop asks is this broken? A generative loop also asks what should this be? — and treats the answer as a first-class output rather than a comment. Three mechanisms do the work.
Every proposal carries four fields. Three are easy. The fourth is the entire mechanism.
| Field | Meaning |
|---|---|
change | Concretely what to do. "Warmer" is not a proposal. "The verdict panel should lead with the word, at h1 size, before the reasons" is. |
because | What this person experienced that prompted it. Not a principle — an experience. |
serves | Which persona or segment it helps. |
costs | Who it hurts, or what is lost. If you cannot name a cost you have not thought about it — say so honestly rather than writing "none". |
costs is what stops the loop converging on mush. Without it, every persona asks
for the thing that would make the product easier for them, each request sounds reasonable in
isolation, and forty runs later you have a beige average of eight incompatible preferences.
Forcing the cost makes the conflict explicit at file time, so the fixer chooses between named
trade-offs instead of accumulating unnamed ones.
The most valuable proposal in the set is one filed against its author's own interest — the technical persona arguing for something that helps the nervous beginner at his own expense. Say out loud that this is allowed, or you will not get it.
A goal makes an agent a tester. A taste field makes it a critic. Name real
references — "Stripe docs, Linear, the Wizard of Odds appendices; likes density when it
is earned; distrusts anything that looks designed to convert" — because a persona that
can point at a real artifact produces usable proposals, and one that cannot produces "make it
more modern."
why_this_persona_exists is one line on what this panelist catches that no other
one does. It has two jobs: it stops the roster drifting into variations of the same agreeable
person, and it tells you when to delete one. A persona whose stated job is already
covered is costing you a run slot.
The generative half must not be allowed to gate. Proposals never block a release and never break a streak. A run with zero findings and six good proposals is a good run. Keeping the halves structurally separate is what lets you be aggressive about proposals without corrupting the defect signal.
The panel and the fixer are different sessions, and this is not ceremony. A session that can fix what it finds starts justifying instead of reporting — it reads the source, decides the thing is fine, and files nothing. A session that has just built something cannot evaluate it blind.
| Session | May | May not |
|---|---|---|
| Panel | Drive the live deployed site. Read source only to check arithmetic. Write to the state branch. | Read source to decide whether something is good. Fix anything. Commit to a working branch. |
| Fixer | Read findings, verify them, build what survives, record what shipped, start the next run. | Overrule an invariant. Silently drop a finding — a refusal is recorded with its reason. |
Run the panel against the deployed URL, not a dev server. Half the real findings in the first run existed only in production: a CORS origin still pointing at the old domain, a sign-in returning a bare 500 because an API key was unset, a cached DNS failure. None of those exist on localhost.
Everything lives in a panel/ directory. Three are authored once and edited
rarely; the fourth is append-only state.
personas.jsonSix to ten people. Fewer than six and you are testing one taste; more than ten and most never run.
{
"version": 1,
"site": "https://example.com",
"personas": [{
"id": "dev",
"name": "Dev, 31, backend engineer",
"account": "signed-out",
"situation": "Denver, ~$180k, reads HN every morning. Vegas four times a
year with college friends, $25 tables. Read the strategy
page twice and never drilled it.",
"goal": "Decide in ninety seconds whether this was built by someone
competent or by a growth team.",
"surfaces": ["/", "/train", "/table"],
"taste": "Stripe docs, Linear, the Wizard of Odds appendices. Likes
density when it is earned. Distrusts anything designed
to convert.",
"verify": [
"Does the hero prose agree with the panel beside it?",
"Change the payout to 6:5. Do chips, headline, verdict and chart
all move together?",
"Play twenty hands. Does correct + missed equal 20?"
],
"leaves_if": "A single number contradicts another number on the
same screen.",
"shares_if": "It is boring the way good tools are boring, and one
number surprises him.",
"why_this_persona_exists": "The only panelist who will open the
engine and check the arithmetic.",
"runs": 0,
"last_used": null
}],
"account_fixtures": { "signed-out": null, "free": "...", "paid": "..." }
}
Field notes that matter:
verify is per-persona and concrete — the cross-surface checks only this
person would think to run. Most wrong findings come from here.account maps to a fixture. Two personas per run share fixtures, so
parallel runs corrupt each other. Widen coverage by adding accounts, not by
adding agents.leaves_if / shares_if are the persona's two thresholds. They turn
a vague reaction into a binary the run can report.runs and last_used drive selection, and are the only fields the
panel writes back.invariants.mdThe short list of things the panel does not get to vote on. Everything else — colour, type, layout, copy, flow, what gets built, what gets cut — is theirs, and when a persona says a section is cold, that wins by default.
Each entry has three parts: the rule, why it is not a vote, and what the panel may still do. The third is the one people skip, and the one that keeps the list from becoming a wall. An invariant almost never forbids an entire need — it forbids one expression of it. "Every figure carries its rule set" forbids removing the placard; it permits any redesign of the placard whatsoever.
Keep it to five. If it runs past a page, something has been smuggled in that is really taste — and taste in the invariant file is how you quietly cancel the generative half of the loop. The test for an entry: is this a place where the audience's preference is not the right authority? Legal exposure, arithmetic integrity, and semantic systems that only work if they mean one thing all qualify. "I like it this way" does not.
A refusal is recorded like any other verdict — it names the invariant, quotes the reason, and offers the nearest available thing:
Refused under invariant 1. Kyle asked for "what could I realistically make in a weekend." Not available at any wording. Nearest available: make the prize at true count +2 more prominent, which is what he is actually reaching for. Filed as proposal P-041.
A persona who keeps asking for the same refused thing is itself a finding. It means the legitimate need behind the request is not being met anywhere, and the ledger should say so.
panel-prompt.mdThe run instructions — §5 is a generic version of this file. It is written at the panel session in the second person, and it opens by saying what that session is not: not a developer on this project, may not read source to decide whether something is good, may not fix anything.
ledger.json, and where it lives
Append-only state: findings, proposals, landed verdicts, wishes, settled questions, per-persona
counters, and last_shipped.
Keep it on an orphan branch — qa-state — not in the
working tree. It changes every run, it is generated, and in the tree it means every panel run
dirties the working branch and every merge conflicts in a file nobody reads.
git checkout --orphan qa-state
git rm -rf . && mkdir panel
echo '{"runs":[],"settled":[],"last_shipped":[]}' > panel/ledger.json
git add panel/ledger.json && git commit -m "panel state"
Two arrays carry more weight than they look like they do:
settled — questions already answered. Re-raising one without new evidence
is noise, and a loop with no settled list re-litigates the same three arguments forever.last_shipped — what the fixer changed since the previous run. The panel
must not read this before step 7. Knowing it early contaminates the blind
pass, which is the only instrument that can tell you whether the change worked.
Preamble: check out the state branch, read the ledger, read the invariants, read
settled. Note that last_shipped exists and do not open it.
One newcomer (fewest runs) and one veteran (least recently used). Two per run, not eighteen. The newcomer keeps finding first-impression problems; the veteran is the only one who can tell you the product got worse.
Open the surface the persona's goal points at. Before pursuing the goal, stop and look. In the persona's voice:
Then measure the page before scrolling it (§6), and answer in character while the number is still new.
You are not allowed to skip this step because the goal looks achievable. That is exactly when it gets skipped, and exactly when it pays.
Do what this person came to do, in a real browser, against the deployed URL. Respect the
persona's account state and the app's rate limits — tripping a limit is a
mine, not a product finding.
Narrate as the person, not as a tester. "I wanted to know if my table was any good, so I changed the payout to 6:5 and the whole thing went red" — not "clicked segmented control 3."
Pick at least three displayed figures and verify each — against the source of truth in the codebase, against the citation the product gives, or against another surface showing the same quantity.
A disagreement is only a finding if you can quote both
readings. One reading is a suspicion: file it as confusing and say what
you could not check.
The generative step. As this person, propose changes — colour, type, spacing, layout, copy, what a screen leads with, what should be cut, what should exist and does not. Four fields each (§2) or it is not a proposal.
Reference a real artifact where you can. "Like the way Stripe's docs put the example beside the prose" is usable. "More modern" is not.
Five buckets, and the bucket is a decision rather than a label — see §7.
Now read last_shipped — see
§8.
One commit to the state branch, prose report as the message. Append findings, proposals, landed verdicts, wishes, and the persona counters. Never commit to a working branch; never write to an account that is not this persona's.
Run this in the console during the blind pass, before scrolling. Deliberately three numbers — a persona can hold three numbers and react to them.
const vh = innerHeight, h = document.documentElement.scrollHeight;
const words = document.body.innerText.trim().split(/\s+/).length;
({ screens: +(h/vh).toFixed(1), words, readMins: +(words/220).toFixed(1) })
Reference points, so the number means something:
| Screens | Reads as |
|---|---|
| 4–8 | A landing page that converts |
| 10+ | A document |
| 20+ | An essay |
Then the question, asked in character and before any scrolling:
Knowing this page is N screens and M minutes of reading, would you read it, skim it, or leave?
A number alone is not a finding. The finding is the persona's honest answer. Report the measurement every run even when the answer is "I would read all of it" — one of your personas will say that, and for him it will be true.
Set explicit thresholds so a breach is mechanical rather than a matter of opinion:
A screen budget with no viewport attached to it is not a number. This was written as a bare "under 9 screens" and ran that way for two rounds. The same landing page measured 10.6 screens at 1128×561 and 15.4 at 375×553 — a 45% difference, one page, one instrument. A section that passed the 2.5 limit on desktop at 2.29 was over it at 3.07 on the smallest phone.
It is the same defect as a figure displayed without the rule set it was computed from, and it is easy to ship because the desktop number is the one you happen to measure first. State the viewport with every budget, and measure the smallest phone you support, not the one you develop on — it is the worst case and it is where most of the traffic is.
Re-running the same script at a phone viewport is necessary and not sufficient, because two defect classes exist only there. Check both explicitly:
documentElement.scrollWidth >
innerWidth means something is pushing the page sideways. Note the distinction that
matters: a wide table inside its own overflow-x:auto container is correct and
should not be filed — walk the parent chain and confirm which one is scrolling before
calling it a defect.// mobile-only defect classes -- run at the smallest supported width
const de = document.documentElement;
({
horizontalScroll: de.scrollWidth > innerWidth + 1,
smallTaps: [...document.querySelectorAll('button,a.btn,[role=radio],[role=button]')]
.map(el => ({ txt: el.innerText.trim().slice(0,30),
h: Math.round(el.getBoundingClientRect().height) }))
.filter(x => x.h > 0 && x.h < 44),
})
Length was the first thing measured and it was not the real problem. "A lot of information dumped at you at once" survived the page getting shorter, so the measurement was widened. These are worth counting per section rather than per page:
// figures, eyebrow labels, controls, and how long a read-run gets
const S = [...document.querySelectorAll('section, .block')];
S.map((el, i) => ({
i,
screens : +(el.offsetHeight / innerHeight).toFixed(1),
words : el.innerText.trim().split(/\s+/).length,
figures : (el.innerText.match(/[-+−]?\d[\d,.]*\s*%?/g) || []).length,
labels : el.querySelectorAll('.label').length,
controls: el.querySelectorAll('button,input,select,[role=radio]').length,
}));
// typographic variance -- run once on the whole page
const seen = new Set();
document.querySelectorAll('*').forEach(e => {
const s = getComputedStyle(e);
if (e.innerText && e.innerText.trim())
seen.add([s.fontFamily.split(',')[0], s.fontSize, s.fontWeight].join(' | '));
});
[...seen].sort();
That second snippet settled an argument. The owner said there were "too many fonts." There were two families the whole time — but twelve sizes, four weights, twenty combinations. Wrong about the cause, right about the symptom, which is the normal case and the reason to measure rather than litigate. It went to two families, eight sizes, three weights, fourteen combinations.
It also caught something no persona would have: a mono face on 139 elements against 96 for the body face. That looked like mono creeping into body copy. It was not — every mono rule was legitimate. The page simply had fifty uppercase eyebrow labels. At fifty, a label stops being navigation and becomes texture, and the fix belonged in the density budget rather than the type scale.
Ask the fixer to predict its own numbers. Before a change ships, have it state the post-change measurements it expects. Then measure the live page against the prediction. A change that misses its own prediction is a change that did not do what its author thought — and you learn that in one measurement instead of three runs.
| Bucket | Meaning | Gates? |
|---|---|---|
broken | An error, a crash, a control that does not work. Include repro. | Yes |
wrong | A figure disagrees with its source of truth. Quote both. | Yes |
claim | The product states or implies a prohibited claim. | Stops the run — report alone |
confusing | It works, but as this person you could not tell what happened. | Tracked, never gates |
mine | The harness broke, not the product. Raced click, bad locator, rate limit. | Breaks no streak |
The discipline is at the boundaries. Never promote a confusing for
emphasis — the temptation is real, because a proportion breach can feel worse
than a genuine defect and there is an obvious way to make it land harder. Doing it corrupts the
two buckets that actually gate, and then nothing gates meaningfully. Never soften a real
wrong because the run otherwise went well. The test is evidence —
can you quote both sides — not how it felt.
mine exists so the loop can be honest about its own failures without contaminating
the product signal. A loop with no mine bucket files harness bugs as product bugs,
and the fixer spends a day on a race in the test driver.
A dedicated stop-the-run bucket is worth having if your product has one class of failure categorically worse than the rest — a regulated claim, a security leak, a privacy exposure. It changes the run's behaviour: report it alone, do not dilute it with the eleven other things you noticed.
Proportion never gates. A long page is not broken — nothing errored, no number is wrong, every claim still stands. The counterpart is equally true and easier to forget: a clean gate is not a verdict that the page is well proportioned. The gate does not measure that at all.
After the findings are filed and before the commit, the panel reads last_shipped
for the first time. For each change the fixer made since the previous run, it answers one
question against its own step-2 notes: did I notice this before I knew about it?
This is the loop's only honest measure of whether a change was an improvement, and it works only because the blind pass was recorded before the answer was available. Nearly everything else in agentic QA measures whether a change was made.
If nobody noticed it blind, it was not an improvement — however reasonable it looked when it shipped. Say so plainly. A reverted change is a successful outcome for this loop, not a failure. A loop that cannot revert is a ratchet, and a ratchet is how products accumulate forty defensible changes that add up to something nobody likes.
The same loop works on an artifact that does not exist yet — a logo, a palette, a naming shortlist, an empty-state illustration. Generate several candidates, then put them through the panel. Four changes to the standard run:
One result worth generalising: on that run the panel rejected putting any semantic colour on the logo, on the grounds that the most-repeated element in a system becomes its ambient colour and therefore stops meaning anything. Reserved colours cannot be spent on the thing users see most.
Run 1 on the blackjack project, to show what the output looks like at each severity.
| Bucket | Finding | How it was caught |
|---|---|---|
wrong |
The evaluator's verdict read "hold more than the house's own X% from +2 up." The +2 was hard-coded, and false in 14 of 192 rule combinations. | Step 4 — a persona checked a displayed figure against the engine and could quote both readings. |
broken |
The drill logged 8 correct + 14 missed = 22 hands out of 20. Side effects sat inside React state updaters, which StrictMode double-invokes. | Step 3 — only appears if you actually play twenty hands. No static review finds it. |
confusing |
16 collapsed table rows reserved full height while hiding their text — about 5,000px of blank page. max-height:0 does nothing to a <td>. |
Step 2 measurement. Nobody reported "blank space"; the screen count did not match the visible content. |
confusing |
The landing page at 21.6 screens / 3,536 words / ~16 minutes. | Step 2. Two personas drove the page without mentioning it; the number is what made it a finding. |
And two proposals, to show the form — including one filed against its author's interest:
change Move the long argument to its own page and leave a one-line link.
because I came to find out if the tool was competent and got an essay first.
serves Dev, Marisol — anyone deciding in ninety seconds.
costs Tom. The argument is why he trusts it, and a link is easy to
miss. If he bounces at the same rate after this ships, revert it rather than tuning the link.
change Put a playable five-hand drill in the middle
of the page, not only in the hero.
because Everything interactive is in the first screen, then four straight
reading blocks.
serves Kyle, Angela — the two who will not read to the bottom.
costs Adds a second place the drill can drift out of sync with the real one.
Only worth doing if it is the same component with a different hand count, never a
copy.
That last cost was the load-bearing part. This project had already shipped a marketing page
duplicating the app's numbers and then contradicting them — the hero said 0.41% while the
panel beside it said 1.99%. The proposal names the failure mode it would reintroduce and
constrains the implementation to avoid it. That is what costs is for.
The loop is portable. The contents of the four files are not — copying another project's personas or invariants is the main way this gets implemented badly.
| Portable as-is | Must be rewritten per project |
|---|---|
| The eight steps | All personas — derived from the real audience |
| The five buckets and the gate rule | All invariants — derived from real liabilities |
| The four proposal fields | The stop-the-run bucket, if any |
| The two-session split | The budgets, and what gets measured |
| Step 7 and the blind pass | Each persona's verify list |
why_this_persona_exists first. If you cannot say what
they catch that nobody else catches, do not add them.taste with real references, and a verify list only
they would think to run.Ask what would remain true even if every user asked for the opposite. Usually: legal or regulatory exposure, arithmetic or data integrity, a semantic system that only works if it means one thing, and the honest bad news that makes the rest credible. Write the "what the panel may still do" clause for every entry, and stop at five.
confusing and
mostly proportion. That is normal, not a sign the loop is weak.last_shipped, run again with one new persona and one veteran.Three changes, all following from the product having two sides rather than one:
costs stops being a discipline and becomes the main output —
nearly every proposal that helps one side charges the other.Measure per side. The two sides will have different screen budgets, and averaging them hides both.
This panel is not real users. It is a cheap prior — articulate, patient, and systematically wrong in ways real traffic is not. It over-weights reasoning and under-weights the first two seconds. When a proposal only makes sense if the reader is paying close attention, flag it, because most will not be.
It cannot tell you what converts. It tells you what is broken, what is contradictory, what is unreadable, and what a thoughtful person would want. Those correlate with conversion loosely, and are not a substitute for traffic.
It is weak on aesthetics in the small. It catches "this panel looks cheap" and misses four pixels of misalignment. Perception measurement helps at the page scale and does nothing at the component scale.
It drifts agreeable. Personas asked to react to a thing tend to like the thing.
leaves_if, the veto persona and the mandatory costs field are all
counterweights, and they are partial. If three consecutive runs produce no confusing
findings, suspect the loop before congratulating the product.
An inconclusive result is a result. A run that quietly skips a check and reports success is worse than one that reports nothing. If something could not be verified, name the thing and say why.