Type the files whose shape you are still deciding.
“Always type” is a rule that gets broken and then ignored. The rule these examples come from splits the work honestly, and the split is worth having before the directory is.
TYPE a file whose shape you are still deciding
a file in a language you are learning
anything you would otherwise accept without reading
the first of a kind — a first adapter, a first use case
COPY generated code
vendored code
the second adapter that mirrors the first
anything where reading it again teaches nothing A file you do not want to type is often a file you do not want, and noticing that is most of the value. Friction on something you already understand is only cost, and a practice that pretends otherwise becomes something to route around.
Type it, or copy it?
Decide before you compare. The friction of typing is the filter, not the goal.
The first adapter for a service this codebase has never called
One file worth typing, and the reason it earns it.
Mirror the destination, and let nothing depend on the draft.
A draft’s path is its destination with drafts/ in front. drafts/src/lib/app/return-to.ts becomes src/lib/app/return-to.ts. That is not tidiness. It makes the question “where
does this go?” get answered before the file exists, and that is the question most often
answered badly and last.
The directory is ignored by git, and nothing imports it, builds it, or tests it. A draft that something depends on has become part of the code without anyone deciding it should. This is the ignore rule from the template the examples use:
# A draft is not an artefact — DRAFTS.md. One that outlives its session wanted to
# be a decision record or a note.
/drafts/
A file at drafts/ plus its destination, and nothing in the build that can see it.
A draft compiles, is formatted, and passes its tests before it lands.
This is the difference between a draft and a sketch. A file that already works is one you can read for whether it is right. A file that does not is one you read for whether it runs, and that is a shallower kind of attention.
Here is that difference in a real tree. On 6 September, a project’s drafts/ held a logger package. It built, and go vet passed. In two of its files, logger.go and console.go, 25 functions were panic("not implemented").
Placed over a copy of the tree, the draft fails the package’s own tests on the first call
to New. The typed file passes them. It is 147 lines where
the draft was 77.
The draft had a shape and no behavior. Whoever typed it spent the typing making it run, not reading something that already did.
The rungo 1.27.1 · the draft, then the typed file
# go go1.27.1 · Darwin 25.3.0 · recorded 2026-09-12T20:45:47Z
# overwatch: drafts/overwatch-backend/pkg/logger (6 Sep 2026 09:40-09:48) placed over a copy of the tree, tests unchanged
$ grep -c "panic(\"not implemented\")" pkg/logger/{logger,console}.go # the draft
pkg/logger/logger.go:10
pkg/logger/console.go:15
$ go build ./pkg/logger/ # the draft
build ok
$ go vet ./pkg/logger/ # the draft
vet ok
$ go test -count=1 ./pkg/logger/ # the draft, against the tests the typed file ships with
--- FAIL: TestLevelIsAFloorNotAThresholdToExceed (0.00s)
--- FAIL: TestLevelIsAFloorNotAThresholdToExceed/a_record_at_the_configured_level_is_emitted (0.00s)
panic: not implemented [recovered, repanicked]
goroutine 36 [running]:
testing.tRunner.func1.2({0x100a3efe8, 0x100a0c410})
/opt/homebrew/Cellar/go/1.27.1/libexec/src/testing/testing.go:2123 +0x1a0
testing.tRunner.func1()
/opt/homebrew/Cellar/go/1.27.1/libexec/src/testing/testing.go:2126 +0x2c8
panic({0x100a3efe8?, 0x100a0c410?})
/opt/homebrew/Cellar/go/1.27.1/libexec/src/runtime/panic.go:859 +0x120
github.com/0xsj/overwatch-backend/pkg/logger.New(...)
<run>/sketch/pkg/logger/logger.go:46
github.com/0xsj/overwatch-backend/pkg/logger_test.console(0x3272adc09708?, 0x10078e6d0?, 0x3d?)
<run>/sketch/pkg/logger/logger_test.go:27 +0x60
github.com/0xsj/overwatch-backend/pkg/logger_test.TestLevelIsAFloorNotAThresholdToExceed.func6(0x3272adc5c488)
<run>/sketch/pkg/logger/logger_test.go:48 +0x3c
testing.tRunner(0x3272adc5c488, 0x3272adc1c6c0)
/opt/homebrew/Cellar/go/1.27.1/libexec/src/testing/testing.go:2193 +0xc4
created by testing.(*T).Run in goroutine 35
/opt/homebrew/Cellar/go/1.27.1/libexec/src/testing/testing.go:2258 +0x3b8
FAIL github.com/0xsj/overwatch-backend/pkg/logger 0.328s
FAIL
$ go test -count=1 ./pkg/logger/ # the typed file
ok github.com/0xsj/overwatch-backend/pkg/logger 0.319s
The draft and the typed filelogger.go, both versions
package logger
import (
"context"
"io"
"log/slog"
"time"
)
const (
FieldError = "error"
FieldErrKind = "err_kind"
)
type Clock interface {
Now() time.Time
}
type ContextAttrs func(ctx context.Context) []slog.Attr
type Format uint8
const (
FormatConsole Format = iota
FormatJSON
)
type ColorMode uint8
const (
ColorAuto ColorMode = iota
ColorNever
ColorAlways
)
type Config struct {
Level slog.Level
Format Format
Output io.Writer
Color ColorMode
Source bool
Clock Clock
Context ContextAttrs
}
func New(cfg Config) *slog.Logger { panic("not implemented") }
func Nop() *slog.Logger { panic("not implemented") }
func ParseLevel(s string) (slog.Level, error) { panic("not implemented") }
func WithContext(h slog.Handler, attrs ContextAttrs) slog.Handler { panic("not implemented") }
func errorPair(a slog.Attr) ([]slog.Attr, bool) { panic("not implemented") }
func replaceAttr(clk Clock) func(groups []string, a slog.Attr) slog.Attr {
panic("not implemented")
}
type contextHandler struct {
inner slog.Handler
attrs ContextAttrs
}
var _ slog.Handler = contextHandler{}
func (h contextHandler) Enabled(ctx context.Context, l slog.Level) bool {
panic("not implemented")
}
func (h contextHandler) Handle(ctx context.Context, r slog.Record) error {
panic("not implemented")
}
func (h contextHandler) WithAttrs(attrs []slog.Attr) slog.Handler { panic("not implemented") }
func (h contextHandler) WithGroup(name string) slog.Handler { panic("not implemented") }
package logger
import (
"context"
"io"
"log/slog"
"os"
"strings"
"time"
"github.com/0xsj/overwatch-backend/pkg/errors"
)
const (
FieldError = "error"
FieldErrKind = "err_kind"
)
type Clock interface {
Now() time.Time
}
type ContextAttrs func(ctx context.Context) []slog.Attr
type Format uint8
const (
FormatConsole Format = iota
FormatJSON
)
type ColorMode uint8
const (
ColorAuto ColorMode = iota
ColorNever
ColorAlways
)
type Config struct {
Level slog.Level
Format Format
Output io.Writer
Color ColorMode
Source bool
Clock Clock
Context ContextAttrs
}
func New(cfg Config) *slog.Logger {
if cfg.Clock == nil {
panic("logger: New with a nil Clock")
}
if cfg.Output == nil {
cfg.Output = os.Stdout
}
var h slog.Handler
switch cfg.Format {
case FormatJSON:
h = slog.NewJSONHandler(cfg.Output, &slog.HandlerOptions{
Level: cfg.Level,
AddSource: cfg.Source,
ReplaceAttr: replaceAttr(cfg.Clock),
})
default:
h = newConsole(cfg)
}
return slog.New(WithContext(h, cfg.Context))
}
func Nop() *slog.Logger { return slog.New(slog.DiscardHandler) }
func ParseLevel(s string) (slog.Level, error) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "debug":
return slog.LevelDebug, nil
case "info", "":
return slog.LevelInfo, nil
case "warn", "warning":
return slog.LevelWarn, nil
case "error":
return slog.LevelError, nil
}
return 0, errors.Newf(errors.Invalid, "unknown log level %q", s)
}
func WithContext(h slog.Handler, attrs ContextAttrs) slog.Handler {
if attrs == nil {
return h
}
return contextHandler{inner: h, attrs: attrs}
}
func errorPair(a slog.Attr) ([]slog.Attr, bool) {
if a.Value.Kind() != slog.KindAny {
return nil, false
}
err, ok := a.Value.Any().(error)
if !ok || err == nil {
return nil, false
}
return []slog.Attr{
slog.String(FieldError, err.Error()),
slog.String(FieldErrKind, errors.KindOf(err).String()),
}, true
}
func replaceAttr(clk Clock) func(groups []string, a slog.Attr) slog.Attr {
return func(groups []string, a slog.Attr) slog.Attr {
if len(groups) == 0 && a.Key == slog.TimeKey {
return slog.Time(slog.TimeKey, clk.Now())
}
if pair, ok := errorPair(a); ok {
return slog.Group("", pair[0], pair[1])
}
return a
}
}
type contextHandler struct {
inner slog.Handler
attrs ContextAttrs
}
var _ slog.Handler = contextHandler{}
func (h contextHandler) Enabled(ctx context.Context, l slog.Level) bool {
return h.inner.Enabled(ctx, l)
}
func (h contextHandler) Handle(ctx context.Context, r slog.Record) error {
if attrs := h.attrs(ctx); len(attrs) > 0 {
r.AddAttrs(attrs...)
}
return h.inner.Handle(ctx, r)
}
func (h contextHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
h.inner = h.inner.WithAttrs(attrs)
return h
}
func (h contextHandler) WithGroup(name string) slog.Handler {
h.inner = h.inner.WithGroup(name)
return h
}
When an agent hands you stubs, it has handed you a sketch. A sketch is fine; it is not this. Call it what it is, or send it back with the tests it has to pass. What to avoid is typing a sketch as if it were a draft.
A draft that has already passed, somewhere else, the tests it will face in the tree.
One line at the top: authored, written against something, or copied.
Three different things arrive in drafts/, and they look identical in a
directory listing. Only the first line tells them apart, and only the author knows which it
is.
DRAFT · authored — no existing version; written against this tree's decisions
DRAFT · against <path or repo> — read, not copied; <what differs and why>
DRAFT · copied from <path or repo> — unchanged The copied one is the one to watch. A file lifted from another project carries that project’s decisions: its error vocabulary, its conventions, its shape. Typed into a tree that made none of those decisions, the tree now has them, and nothing says so.
A rule does not apply itself, though. The provenance line was added to this rule on 4 September. Every code draft in the two directories that hold code was checked for it: 24 files, none with the line, not even the 10 written after the rule gained it.
The survey24 files, one command
# recorded 2026-09-12T20:46:56Z · every code file in the two drafts/ directories that hold code
# DRAFTS.md §3 (the provenance line) was committed to conduit on 2026-09-04
$ for f in $(find overwatch/drafts archive/overwatch-v1/drafts -type f \( -name "*.go" -o -name "*.ts" \) | sort); do
head -3 "$f" | grep -q "DRAFT ·" && echo "line $f" || echo "none $f"; done
none 2026-08-29 archive/overwatch-v1/drafts/overwatch-backend/pkg/clock/clock.go
none 2026-08-29 archive/overwatch-v1/drafts/overwatch-backend/pkg/clock/clock_test.go
none 2026-08-29 archive/overwatch-v1/drafts/overwatch-backend/pkg/clock/wait.go
none 2026-08-29 archive/overwatch-v1/drafts/overwatch-backend/pkg/clock/wait_test.go
none 2026-08-30 archive/overwatch-v1/drafts/overwatch-backend/pkg/id/generator_test.go
none 2026-08-30 archive/overwatch-v1/drafts/overwatch-backend/pkg/id/id_test.go
none 2026-08-30 archive/overwatch-v1/drafts/overwatch-backend/pkg/pagination/cursor.go
none 2026-08-30 archive/overwatch-v1/drafts/overwatch-backend/pkg/pagination/cursor_test.go
none 2026-08-30 archive/overwatch-v1/drafts/overwatch-backend/pkg/pagination/pagination.go
none 2026-08-30 archive/overwatch-v1/drafts/overwatch-backend/pkg/pagination/pagination_test.go
none 2026-08-30 archive/overwatch-v1/drafts/overwatch-backend/pkg/random/derive_test.go
none 2026-08-30 archive/overwatch-v1/drafts/overwatch-backend/pkg/random/predictable.go
none 2026-08-30 archive/overwatch-v1/drafts/overwatch-backend/pkg/random/random.go
none 2026-08-30 archive/overwatch-v1/drafts/overwatch-backend/pkg/random/random_test.go
none 2026-09-06 overwatch/drafts/overwatch-backend/cmd/server/main.go
none 2026-09-06 overwatch/drafts/overwatch-backend/pkg/logger/console.go
none 2026-09-06 overwatch/drafts/overwatch-backend/pkg/logger/doc.go
none 2026-09-06 overwatch/drafts/overwatch-backend/pkg/logger/logger.go
none 2026-09-06 overwatch/drafts/provenance/actor.go
none 2026-09-06 overwatch/drafts/provenance/context.go
none 2026-09-06 overwatch/drafts/provenance/identifier.go
none 2026-09-06 overwatch/drafts/provenance/marshal.go
none 2026-09-06 overwatch/drafts/provenance/origin.go
none 2026-09-06 overwatch/drafts/provenance/provenance.go
24 files · 0 with a provenance line
The draft in the next step says what it is. It is this site’s return-path check, written after reading a starter template’s version, and the line names the two ways it differs.
A first line that says which of the three the file is, and, when it was written against something, what differs.
The typing is the point, so do it for real.
Open the draft beside the destination and type. Do not paste. The places you would have skimmed are the places you now have to stop at: why the protocol-relative check comes first, why the fallback is a slash and not the page you were on. Try to answer both before you read on.
Type the draft into the tree, then compare.
This is this site’s real return-path check, as a draft. Type it into the box without pasting, then compare. Tab moves focus out of the box, so indent with spaces; the comparison counts spacing apart from changed lines, so that difference is expected. Nothing is saved.
// DRAFT · against blueprints/flover-svelte/src/lib/app/return-to.ts — read, not copied; falls back to '/' and refuses /auth and /api, where that one allows only /app and /cookbook
const FALLBACK = '/';
export function safeReturnTo(value: unknown): string {
if (typeof value !== 'string' || !value.startsWith('/') || value.startsWith('//'))
return FALLBACK;
try {
const target = new URL(value, 'https://heyrian.invalid');
if (target.origin !== 'https://heyrian.invalid') return FALLBACK;
const path = decodeURIComponent(target.pathname);
if (/^\/(?:auth|api)(?:\/|$)/.test(path)) return FALLBACK;
if (/[\\\s]/.test(path)) return FALLBACK;
return `${target.pathname}${target.search}${target.hash}`;
} catch {
return FALLBACK;
}
}
against· blueprints/flover-svelte/src/lib/app/return-to.ts
A new file informed by blueprints/flover-svelte/src/lib/app/return-to.ts. Check that what differs is what the line says differs.
Type the file, then compare.
It compares text. It cannot tell you whether a changed line is an improvement, or what the typing taught you.
The answers the typing should have raised: //example.com starts with a slash,
so a check that only asked for a leading slash would pass it, and the URL parser would then
send the reader to another site. Refusing // first means the parser only ever
sees a local path. The fallback is / because the rejected value was the page offered, and a check that has refused it has nothing else it can trust; the site root
always exists. On this site the caller that finishes sign-in now turns that / into /account, outside the lines you typed.
What does a clean comparison establish? Less than it seems. Here is a real pair from 29
August: a clock package, drafted at 21:08 and typed in by 21:49, going by file times. Both
versions pass their tests with the race detector on. In clock.go the comparison
finds 0 changed lines and
4 spacing differences: a pair of one-line functions realigned, blank lines
dropped and added. The same recording shows one more added blank line in wait.go, and nothing else.
That is the trace hands leave, and a copy would leave none. It is also everything a diff can show. It cannot show what the reading taught. If the typing taught you something, that belongs in a note, not in the draft.
The clock, draft against typeddiff, gofmt, and both test runs
# go go1.27.1 · Darwin 25.3.0 · recorded 2026-09-12T20:45:37Z
# archive/overwatch-v1: drafts/overwatch-backend/pkg/clock (29 Aug 2026 21:08) vs overwatch-backend/pkg/clock (typed 21:47 and 21:49)
$ diff -u draft/pkg/clock/clock.go typed/pkg/clock/clock.go
--- draft/pkg/clock/clock.go
+++ typed/pkg/clock/clock.go
@@ -14,8 +14,7 @@
type System struct{}
-func (System) Now() time.Time { return time.Now().UTC() }
-
+func (System) Now() time.Time { return time.Now().UTC() }
func (System) Elapsed() time.Duration { return time.Since(origin) }
type Fake struct {
@@ -44,6 +43,7 @@
func (f *Fake) Advance(d time.Duration) time.Time {
if d < 0 {
panic("clock: Fake.Advance with a negative duration; use Set to move the wall clock backwards")
+
}
f.mu.Lock()
defer f.mu.Unlock()
@@ -60,7 +60,6 @@
}
func Since(c Clock, t time.Time) time.Duration { return c.Now().Sub(t) }
-
func Until(c Clock, t time.Time) time.Duration { return t.Sub(c.Now()) }
var (
$ gofmt -l draft/pkg/clock typed/pkg/clock # files gofmt would change
$ diff <(gofmt draft/pkg/clock/clock.go) <(gofmt typed/pkg/clock/clock.go) && echo identical-after-gofmt
17,18c17
< func (System) Now() time.Time { return time.Now().UTC() }
<
---
> func (System) Now() time.Time { return time.Now().UTC() }
46a46
>
63d62
<
$ diff <(gofmt draft/pkg/clock/wait.go) <(gofmt typed/pkg/clock/wait.go) && echo identical-after-gofmt
55a56
>
$ (cd draft && go test -count=1 -race ./pkg/clock/)
ok github.com/0xsj/overwatch-backend/pkg/clock 1.476s
$ (cd typed && go test -count=1 -race ./pkg/clock/)
ok github.com/0xsj/overwatch-backend/pkg/clock 1.400s
The file in the tree, typed, and anything it taught you written down.
Once the file exists, the draft goes.
The directory that held that clock kept a README. Its status column says: “Typed out. Draft kept for reference.”
# drafts
**Nothing here is real.** Files mirror their destination path under
`overwatch-backend/`, so `drafts/overwatch-backend/pkg/clock/clock.go` is typed
out at `overwatch-backend/pkg/clock/clock.go`.
Drafts are never committed and are usually deleted once typed. The point is to
type the file, not to copy it.
Every draft here was built, vetted, gofmt'd and tested in a scratch module before
being placed — so it compiles as written, but it is a proposal, not an artifact.
| Draft | Status |
| --- | --- |
| `overwatch-backend/pkg/clock/` | Typed out. Draft kept for reference. |
| `overwatch-backend/pkg/id/` | Typed out. Draft kept for reference. |
| `overwatch-backend/pkg/random/` | `random.go` + `derive.go` + two test files. 23 tests, 100% coverage, race-clean. No `doc.go` and no comments yet. |
The rule written a few days later says the opposite, and its reason is worth keeping: two copies of a file, one of which nothing checks, and no way to tell which is current. Keeping a typed draft for reference is how the directory rots.
A small command in the owner’s workspace, conduit draft, holds the order: it
seeds the first line, lists what is waiting, and refuses to remove a draft while its
destination does not exist, because a draft deleted before the file is typed is an hour
traded for nothing. You will not have that command; a shell alias or a short script can do
the same three things.
# conduit 72c06e3 2026-09-05 · bash 5.3.15(1)-release · recorded 2026-09-12T20:50:03Z
# a scratch workspace with an empty drafts/; ANSI colour removed
$ conduit draft src/lib/app/return-to.ts
drafts/src/lib/app/return-to.ts
say where it came from on line 1 — authored, against, or copied
vetted before it is placed: it compiles, it is formatted, its tests pass
$ head -2 drafts/src/lib/app/return-to.ts
// DRAFT · authored | against <ref> | copied from <ref>
// Delete this line when the file is typed in. DRAFTS.md §3.
$ conduit draft --done src/lib/app/return-to.ts # before the file exists
src/lib/app/return-to.ts does not exist yet — type it first
[exit 1]
# stand-in for typing: a three-line file written with printf, so that the destination exists
$ conduit draft --list
waiting to be typed
~ src/lib/app/return-to.ts destination exists — typed?
conduit draft --done <path> once it is typed
$ conduit draft --done src/lib/app/return-to.ts
typed · removed drafts/src/lib/app/return-to.ts
[exit 0]
The typing in this recording is a stand-in: a three-line file written by a script so that the destination exists. The seeded line, the refusal, and the removal are real output.
A draft that outlives the session that wrote it is a signal, not a state. It wanted to be a decision record or a note, and it should become one.
The smallest drafts directory worth having.
- Ignore it
/drafts/in.gitignore, and nothing imports from it.- Path
- The destination, with
drafts/in front. - Line 1
- Authored, against a reference, or copied, and what differs.
- Before it lands
- It compiles, it is formatted, and its tests pass somewhere else.
- Then
- Type it, delete the draft, and note what the typing taught.
Where each quote comes from: the rule and both packages are copies of the originals, dated as they were written (the clock on 29–30 August, the logger on 6 September); the runs against them and the command recording were made on 12 September 2026. One thing is a stand-in, and its caption says so: the typing inside the command recording. The lab’s draft is this site’s own return-path check, read from the file at build time.
Explain the habit without saying “draft directory.”
“The agent writes the whole file somewhere nothing can import it. I make it pass there, then type it into its real place and throw the other copy away.” That is the practice. The name is what you call the folder.
Before moving on, explain three things without the name: why a file full of stubs is not worth typing, what the first line of a draft is for, and why a typed draft is deleted rather than kept for reference.
Connections to follow nextRelated lessons
- Notes protocol keeps what the typing taught you, as one sentence you could be wrong about.
- Spec before code gives the draft’s tests a source of truth the agent did not write.
- Enforcement layer is the check that says no when nobody is reading the diff.