If a question is still open inside the text, the text isn't ready to be handed over.
๐ Hi โ I'm Anton, a software engineer working mostly in PHP/Symfony and Go, currently carving a
live PHP monolith into Go services. This part is about the thing I hand over when I hand over
work: the text itself. It's one habit from one codebase โ maybe it's useful to you, maybe you
already do it better, maybe you read it differently. Notes:
github.com/brilliant-almazov.
The difference
An assignment describes what should come out and leaves the executor to go and get the facts.
A specification carries the facts inside itself.
That isn't a difference of style. It decides who pays for a fact, and how many times.
ASSIGNMENT SPECIFICATION
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
"wire the reads onto the create: .../methods/search.go
shared core" plugs into: Reader[In, Out]
โ fields: executor, statement,
โโโโถ which core? scanner
โโโโถ which file? values: ErrEntityNotFound
โโโโถ which error name? accept: go test ./... -race
โโโโถ which test command?
nothing left to look up
four searches, per executor one text, written once
The reason the difference bites is the execution model I work in, which I'd summarise as done
and forgotten. An executor gets the whole task in one message and runs it without reading
anything else. Nothing carries over: not the conversation, not the neighbouring iteration, not
what was discovered ten minutes ago. One executor, one task, one context. A follow-up isn't a
reply in a thread โ it's a new task with a new complete text.
Under that model an address is worthless. "See how the neighbour does it" assumes a reader who can
go and look and who will still remember tomorrow. There is no such reader. There is a reader who
resolves the address by reading whatever it finds, once, at your expense, and then evaporates.
The rule that follows
An open question inside the text means the text isn't ready.
Not "should be tightened up later". Not ready. The text gets written after every fact is
collected โ not while collecting, not with the rest to be gathered along the way. A TBD, a
"decide for yourself", a "check how the neighbour does it" โ each one is a question I chose not to
answer, handed to someone with less context than me.
It's a checkable condition, which is why I like it: read the text, look for a place where a
decision hasn't been made. Found one? The text goes back on my desk, not out.
The trap is that the open question rarely looks like a question. It looks like a considerate
phrase. "By analogy with the neighbouring domain" is a search. "Adjust if needed" is a decision I
didn't make. "Clarify the approach first" is work that belongs before the text exists, not inside
it. Each one reads as flexibility and executes as a hole.
What the text has to carry
I keep one section at the top of every set of tasks, and it always has the same five items:
- Exact paths โ the package and every file that will be created or changed.
- Full signatures of the types and interfaces the work plugs into: name, type parameters, methods, package, import alias.
- The generics and shared primitives that already exist, each with its path and signature, and each marked don't make a copy of this.
- Values, not descriptions โ sentinel error names, variable names, queue names, metric names, table names, written out as the literal strings they are.
- One acceptance command, on one line, that runs with no substitution.
the facts section
paths internal/repository/entity/methods/search.go
signature Reader[In, Out] ยท Query(ctx, in) (Out, error)
reuse internal/repository/rows โ do not copy
values ErrEntityNotFound
acceptance go test ./internal/repository/entity/... -race -count=1
The wording of each of those five carries more weight than it looks like it does, so it's worth
being concrete about what each one costs when it's written loosely.
A path is not a package. "The entity repository" is an address; it resolves to a directory
someone has to open and then choose inside. internal/repository/entity/methods/search.go is a
fact, and it also happens to be checkable afterwards โ a diff that touched a different file is
visibly outside the specification, without anyone reading the code.
A signature is not a name. "Plug into the reader type" leaves the type parameters, the field
names and the method shape to be discovered. The fact is the declaration: Reader[In, Out] with
fields executor, statement, scanner, and the method Query(ctx context.Context, in In), in package
(Out, error)internal/repository/rows, imported under a stated alias. That's four
lines that remove one file read.
The list of existing primitives is the highest-value line in the whole text, and it's the one
I most often find missing when something went wrong. The default failure of a fast executor isn't
laziness โ it's writing a fresh, locally reasonable version of something that already exists in
the repository. Naming it, with a path, and marking it don't make a copy of this, is the only
thing that reliably prevents it.
Values are strings, not categories. "Use the usual error naming" is a description.
ErrEntityNotFound is a value. The same goes for queue names, metric names, environment variable
names and table names: written out as literals, they're either used or visibly not used.
One acceptance command, and it has to run with no substitution. No <package> placeholder, no
"and run the linter too". If I can't paste the line into a terminal and get a verdict, then
"done" is an opinion, and an opinion can't close an iteration.
Who collects the facts
Someone has to read the code to produce that section, and the whole point of the arrangement is
that it's exactly one person, once. In the way I run it, the lead doesn't write code at all. The
chain is: collect the facts โ the only place where reading around the repository is allowed โ
then hand out the work, then accept each iteration on its own package, then run one full pass at
the end of the stage, then close the executors, then audit the set against what was asked for.
That constraint is what makes the facts section honest. If reconnaissance were allowed downstream,
a gap in my text would quietly get filled by whoever hit it, differently each time, and I'd never
learn the text had a gap. Because it isn't allowed, a missing fact comes back to me as one line of
question โ which is a defect report about my file, and gets fixed in the file rather than in the
conversation.
Why values, and not addresses
Because of the arithmetic:
A fact written into the text is paid for once. The same fact, found by the executor, is paid for
on every executor.
When I tracked where the spend on a set of tasks actually goes, it sorted like this, largest
first: reconnaissance โ types, paths and signatures looked up again, neighbouring packages
read again; full re-runs โ every iteration running the entire suite and the linter over the
whole repository; rework โ an iteration that failed acceptance and got written twice; guard
trips โ a copy of an existing generic gets written, a check blocks it, and then someone hunts
for why; duplicated rules โ the shared style block pasted into every file of the set, riding
along in every context; and last, small but constant, re-reading files that were just written.
Only the first one looks like ignorance. The rest look like diligence, which is why they survive
so long.
One early set ran at roughly 350,000 tokens per iteration, for the plain reason that the
executor was assembling its own context. That number is what made me start writing the facts down
instead of the addresses.
written once found by the executor
โโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโ
โโโโโโโโ โโโโโโโโ โโโโโโโโ โโโโโโโโ
โ text โ ร1 โ exec โ โ exec โ โ exec โ
โโโโโโโโ โโโโโโโโ โโโโโโโโ โโโโโโโโ
ร1 ร1 ร1 โฆ รN
The same piece of work, twice
The assignment version:
Wire the read path of this entity onto the shared core.
The specification version:
create: internal/repository/entity/methods/search.go
plugs into:
Reader[In, Out] โ internal/repository/rows
fields: executor, statement, scanner
method: Query(ctx context.Context, in In) (Out, error)
already exists, do not copy:
internal/repository/rows โ the generic read core
values:
sentinel: ErrEntityNotFound
read only:
internal/repository/entity/methods/search.go
internal/repository/entity/methods/search_test.go
acceptance:
go test ./internal/repository/entity/... -race -count=1
Same work. The second one has nothing left to find, and nothing left to decide.
It's also worth noticing what the second version does not contain: the reason the shared core
exists, what the previous iteration did, how the neighbouring entity was converted, or any of the
discussion that led to the design. All of that is real context and all of it would be paid for
again on every executor, with no effect on the diff.
What a specification is not
Three things get proposed as substitutes for the facts section, and none of them works.
An architecture document. It describes intent across the whole system; the executor needs five
concrete values for two files. Handing over the document doesn't remove the search โ it widens it,
because now there's more to read before anything can be written.
A link to the neighbouring implementation. This is the most tempting one, because the
neighbour genuinely is the right shape. But "look at how it's done next door" is an address, and
resolving it costs a file read on every executor and carries a real chance of copying the wrong
neighbour โ the one that predates the current convention, or the one that was itself a shortcut.
The conversation that produced the design. It's the largest of the three and the least useful
per token. It contains options that were rejected, which an executor has no way to distinguish
from options that were chosen.
What replaces all three is the same thing: the decision, already made, written as a value.
What it costs
The work doesn't disappear. It moves: off every executor, onto one person, once. Whether that
trade pays depends entirely on how many times the text is executed โ which is why the format only
makes sense at volume, on sets that get run, repaired and run again.
Where it doesn't pay, honestly:
- A one-off fix in a file I already have open. Writing the specification takes longer than making the change. I just make the change.
- Exploratory work. If it isn't known what has to be done, there's nothing to specify. The investigation comes first and produces the facts; the text comes after.
- Unfamiliar code. The facts section has to come from somewhere, and if I haven't read the code, that somewhere is me reading it โ which costs more than it looks like it will.
- Work with no acceptance criterion. "Make it nicer" can't be accepted, so the iteration never closes, so it isn't an iteration.
Three checks for "this is still an assignment"
- There's a place in the text where a decision hasn't been made.
- There's an address where there should be a fact.
- There's an acceptance criterion that can't be run as one command.
Any one of the three, and it goes back to being a draft.
Human or model, same text
A specification whose correctness is visible without running anything works the same on a person
and on a model. The one difference that matters: a person tends to come back and ask when the text
has a hole in it, and a model tends to quietly pick something and keep going. That makes the hole
cheaper to find with a person and more expensive to find later with a model โ which is an argument
about the text, not about who's reading it.
That's my current habit and my current price for it. I'd rather hear how it's handled on your side:
if you do this better, if you've already been through it, or if you look at it differently โ what
does your version of the task text carry, and where has it broken on you?
Working with agents โ Part 4.
Next: the lead and the executors โ who cuts the work, who writes the code, and who accepts it.



Top comments (0)