Forge: technical notes for catching bugs in agent-written code
Technical notes on Forge's generated files, interceptor chain, lint rules and migration checks, accompanying our walkthrough of a customer app.
Shawn Nunoo
· 7 min read
Reference material for Forge: catching entire classes of bugs in agent-written code. Each section is written to be read alone.
Interceptor order
Every service gets the same interceptor chain in the same order, so there is nothing for an agent to forget.
Trace is outermost, so even a request that auth rejects has a span and every log line carries a trace id. Recovery turns a panic into a plain Internal. Request ids, logging, metrics and a deadline come before auth. Audit, rate limiting, protovalidate and svcerr come after, with svcerr innermost so it decides what leaves the building.
Auth fails closed. An rpc needs a valid token unless its proto says auth_required: false, and a server with no authenticator won't build. Making an rpc public is a choice written down in the proto, not something discovered three weeks later.
Every scaffolded package is also wrapped in a generated tracing decorator, so every call between packages gets a span and metrics without anyone writing tracing code. Dependencies are filled by type in internal/app, and a missing one fails at startup with the field name instead of a nil panic on the first request.
Suppressing a lint
Lints are opinions, and every rule has an exit. If forge-tracing-log-without-ctx fires in a tiny CLI where nobody traces anything, you can turn it off on one line, in one file, or in forge.yaml for the whole repo. Narrower is better, because a repo-wide switch also swallows the finding you'd have wanted next year.
// forge:lint-disable-next-line forgeconv-tx-lock-under-serializable: the lock is a hint, retries are fine here
tx, err := s.deps.DB.RunTx(ctx, orm.Serializable, fn)
lint:
rules:
forge-tracing-log-without-ctx: error # gate today
forge-closed-set: warn # stay a warning
forgeconv-handler-file-size: off # drop it
Overrides leave a trail. Suppressing an error-level rule needs a written reason, forge lint --show-suppressed lists every suppression in the repo, and a rule name that doesn't exist in forge.yaml is itself an error, so a typo can't quietly disable anything.
New rules warn before they gate
A new rule can find a couple hundred problems in code you never touched, and making all of them blocking on upgrade day is the fastest way to get the rule turned off for good. So a new rule warns for one release, says so in the finding ("new in v1.0.0, gates from v1.0.1"), and only then fails. A red gate after an upgrade stops every agent on every branch to fix the same thing. A release of warnings lets one PR do it properly.
Setting a rule to error in lint.rules opts in early, and warn extends the grace period.
Fingerprint files
We run a lot of agents on a lot of branches, and for a while generated files conflicted constantly on their fingerprint lines. Two agents each add an entity, invoices on one branch and quotes on another, and the merge fails on a hash nobody cares about.
Now every output keeps its fingerprint in its own small file under .forge/hashes/, and the store, mock data, grants and nav each get a file per entity. The two branches touch different files, and plain git merges them.
Generated files in the Customer example
One Customer message and one forge scaffold on forge v1.1.0 added 59 files and 5,669 lines. 44 files and 3,911 lines are regenerated, and 15 files and 1,758 lines are yours. A file is regenerated if its path is under gen/ or .forge/, or ends in _gen.*, .pb.go, _pb.ts or -hooks.ts, or its first five lines say Code generated or DO NOT EDIT. Everything else is yours.
Yours (15 files, 1,758 lines)
| File | Lines added |
|---|---|
db/migrations/20261106162040_create_customers.up.sql | 15 |
deploy/observability/dashboards/shop-overview.json | 217 |
frontends/web/src/app/customers/edit/page.tsx | 259 |
frontends/web/src/app/customers/new/page.tsx | 162 |
frontends/web/src/app/customers/page.tsx | 248 |
frontends/web/src/app/customers/view/page.tsx | 185 |
frontends/web/src/hooks/customers-service-hooks.test.tsx | 155 |
go.mod | 2 |
go.sum | 3 |
internal/app/compose.go | 1 |
internal/db/customer_repo_ext.go | 48 |
internal/handlers/customers/handlers_crud.go | 103 |
internal/handlers/customers/handlers_crud_test.go | 249 |
internal/handlers/customers/service.go | 6 |
proto/services/customers/v1/customers.proto | 105 |
Regenerated (44 files, 3,911 lines)
| File | Lines added |
|---|---|
.forge/hashes/deploy/observability/alert-defaults_gen.json.sha256 | 1 |
.forge/hashes/deploy/observability/spec/alert-service-customers_gen.json.sha256 | 1 |
.forge/hashes/deploy/observability/spec/project_gen.json.sha256 | 1 |
.forge/scaffolded/frontends/web/src/app/customers/edit/page.tsx.json | 1 |
.forge/scaffolded/frontends/web/src/app/customers/new/page.tsx.json | 1 |
.forge/scaffolded/frontends/web/src/app/customers/page.tsx.json | 1 |
.forge/scaffolded/frontends/web/src/app/customers/view/page.tsx.json | 1 |
.forge/scaffolded/frontends/web/src/hooks/customers-service-hooks.test.tsx.json | 1 |
.forge/scaffolded/internal/db/customer_repo_ext.go.json | 1 |
.forge/scaffolded/internal/handlers/customers/handlers_crud_test.go.json | 1 |
cmd/shop/cmd/roots_gen.go | 2 |
db/embed_gen.go | 13 |
db/grants/readonly/public.customers_gen.sql | 9 |
db/grants/readonly_gen.sql | 138 |
db/source_gen.go | 13 |
deploy/observability/alert-defaults_gen.json | 203 |
deploy/observability/spec/alert-service-customers_gen.json | 7 |
deploy/observability/spec/project_gen.json | 260 |
frontends/web/src/components/dashboard-tiles/customers_gen.tsx | 58 |
frontends/web/src/components/dashboard-tiles_gen.ts | 35 |
frontends/web/src/gen/services/customers/v1/customers_pb.ts | 343 |
frontends/web/src/hooks/customers-service-hooks_gen.ts | 160 |
frontends/web/src/hooks/index.ts | 5 |
frontends/web/src/lib/mock-transport_gen.ts | 52 |
frontends/web/src/lib/nav-routes/customers_gen.ts | 17 |
frontends/web/src/lib/nav-routes_gen.ts | 37 |
frontends/web/src/mocks/customers_gen.ts | 109 |
frontends/web/src/mocks/entities/customers_gen.ts | 41 |
frontends/web/src/mocks/fixture-freshness_gen.test.ts | 191 |
frontends/web/src/mocks/fixture-manifest_gen.ts | 38 |
frontends/web/src/mocks/scenario-rpcs_gen.ts | 15 |
gen/forge_service_catalog.json | 482 |
gen/services/customers/v1/customers.pb.go | 764 |
gen/services/customers/v1/customersv1connect/customers.connect.go | 166 |
gen/shop/config/v1/config.pb.go | 1 |
gen/shop/config/v1/web_config.pb.go | 1 |
internal/app/graph_gen.go | 3 |
internal/db/customer_orm_gen.go | 125 |
internal/db/customer_store_gen.go | 84 |
internal/db/store_gen.go | 33 |
internal/handlers/customers/factories_gen_test.go | 136 |
internal/handlers/customers/handlers_crud_ops_gen.go | 242 |
internal/handlers/customers/helpers_gen_test.go | 39 |
internal/handlers/mocks/customers_mock_gen.go | 79 |
Proto fields and columns are matched by name
Forge matches proto fields to columns by name, and only where both exist. Add an internal_notes column and it never reaches the API, because no proto field asks for it. Add a display_name field to the proto before there's a column and it never touches the database. The ORM comes from a throwaway Postgres: forge applies your migrations and reads the resulting tables back out of the catalog, so the structs match whatever Postgres actually ended up with.
File ownership and generated drift
Every file forge writes says who owns it in its first lines. A generated file starts with Code generated by forge. DO NOT EDIT. and is rewritten on every forge generate. One that's yours says forge scaffolded it once and won't return. An agent that opens a file can tell at a glance whether its edit will survive, and when it slips anyway forge lint --generated-drift catches it before merge.
Project layout
The layout is the same in every forge project. The API is a Protocol Buffer file under proto/services and the schema is SQL under db/migrations. Each service has a directory at internal/handlers/<svc>/ with one file per custom rpc. Logic that outgrows a handler moves to internal/<pkg>/ next to a contract.go, and wiring lives in internal/app/. Linters enforce all of it. forge project shapes --grep Customer lists every related symbol with a file and line.
Custom rpcs: auth and activity
rpc ArchiveCustomer(ArchiveCustomerRequest) returns (ArchiveCustomerResponse) {
option (forge.v1.method) = {
auth_required: true
activity: {
verb: "archive"
target: { kind: "customer", id: "id", label: "response.customer.name" }
}
};
}
auth_required: true means a caller needs a valid token. Forge fails closed unless the proto says otherwise. The activity block names the event the rpc records, and forge lints a mutating rpc that doesn't declare one. forge scaffold rpc customers ArchiveCustomer writes rpc_archive_customer.go with the right signature plus a placeholder test row that fails as soon as the handler is implemented.
The full handler from the recording:
func (s *Service) ArchiveCustomer(
ctx context.Context,
req *connect.Request[pb.ArchiveCustomerRequest],
) (*connect.Response[pb.ArchiveCustomerResponse], error) {
row, err := db.GetCustomerByID(ctx, s.deps.DB, req.Msg.GetId())
if err != nil {
return nil, svcerr.Wrap(err)
}
now := time.Now()
row.Archived, row.ArchivedAt = true, &now
fields := []string{db.CustomerFieldArchived, db.CustomerFieldArchivedAt}
if err := db.UpdateCustomerMasked(ctx, s.deps.DB, row, fields); err != nil {
return nil, svcerr.Wrap(err)
}
m, err := customerToProto(row)
if err != nil {
return nil, svcerr.Wrap(err)
}
return connect.NewResponse(&pb.ArchiveCustomerResponse{Customer: m}), nil
}
Contracts and generated test doubles
When a handler needs more than the database, the logic moves into a package under internal/ with a contract.go naming the calls it needs from its dependencies. This follows the Go guidance that "Go interfaces generally belong in the package that uses values of the interface type, not the package that implements those values." forge generate writes a test double for each interface in the contract. A test sets the generated <Method>Func to return an error and checks what the package does, with no real dependency running. A method the test never set up fails the test instead of quietly returning a zero.
Errors crossing the rpc boundary
A hand-built connect.NewError ships the error's text straight to the client, which is how a database driver's message ends up in someone's browser. With forge the handler returns svcerr.Wrap(err) and the innermost interceptor decides what leaves. Errors the app wrote go through. Anything else becomes internal server error, with the original in the log. A reason like no_active_subscription travels in a header, so the frontend branches on that instead of matching message text. The lint flags the hand-built version, and forge fix connect-errors rewrites it.
Other lint lanes mentioned in Part 1
- A list filter such as
emailonListCustomersthat isn'toptional: proto3 can't tell an empty string from no filter, so the generated filter silently matches everything. - A misspelled marker such as
forge:immutiblein a SQL comment: Postgres stores it and nothing happens. The lint checks every marker against the ones forge knows. - Incorrectly skipped tests.
- Forge runs golangci-lint alongside its own rules, so
errcheck,noctxandstaticcheckstill gate.
Migrations at deploy time
Migrations only roll forward. A bad one is fixed by the next one, not by rolling back. Each migration says whether the previous release survives it, and forge lint --migration-safety checks what it can see in the SQL. When several replicas start at once, one migrates and the rest wait, and a migration that can't get its lock gives up after five seconds instead of freezing every query queued behind it.
The full lint finding
The complete output from the recording, which the post shows as an excerpt:
❌ migration safety lint — 1 error
✗ db/migrations/20261110020927_archive_customers.up.sql:11 [unsafe-add-not-null-column] ADD COLUMN ... NOT NULL without a DEFAULT fails on populated tables; add nullable column, backfill, then SET NOT NULL
→ add the column nullable, backfill it with an UPDATE, then SET NOT NULL in the same migration; for a derived column prefer making the database compute it (ADD COLUMN ... GENERATED ALWAYS AS (<expr>) STORED NOT NULL, which this rule accepts); or — if you know the table is empty — mark the migration file with a "-- forge:allow-unsafe-not-null" comment
Error: forge lint: 1 gating linter(s) failed: migration safety lint. Fix: address the findings above under each ❌ lane; re-run 'forge lint' to confirm.
The hint's first path, nullable then backfill then SET NOT NULL, leaves the previous release inserting rows with no value for the column during a rolling deploy. DEFAULT FALSE avoids that. A bug is filed against the hint text.
- forge
Try Reliant
Agents that implement, verify and review in a loop until the work is done.


