Skip to main content
All posts

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)

FileLines added
db/migrations/20261106162040_create_customers.up.sql15
deploy/observability/dashboards/shop-overview.json217
frontends/web/src/app/customers/edit/page.tsx259
frontends/web/src/app/customers/new/page.tsx162
frontends/web/src/app/customers/page.tsx248
frontends/web/src/app/customers/view/page.tsx185
frontends/web/src/hooks/customers-service-hooks.test.tsx155
go.mod2
go.sum3
internal/app/compose.go1
internal/db/customer_repo_ext.go48
internal/handlers/customers/handlers_crud.go103
internal/handlers/customers/handlers_crud_test.go249
internal/handlers/customers/service.go6
proto/services/customers/v1/customers.proto105

Regenerated (44 files, 3,911 lines)

FileLines added
.forge/hashes/deploy/observability/alert-defaults_gen.json.sha2561
.forge/hashes/deploy/observability/spec/alert-service-customers_gen.json.sha2561
.forge/hashes/deploy/observability/spec/project_gen.json.sha2561
.forge/scaffolded/frontends/web/src/app/customers/edit/page.tsx.json1
.forge/scaffolded/frontends/web/src/app/customers/new/page.tsx.json1
.forge/scaffolded/frontends/web/src/app/customers/page.tsx.json1
.forge/scaffolded/frontends/web/src/app/customers/view/page.tsx.json1
.forge/scaffolded/frontends/web/src/hooks/customers-service-hooks.test.tsx.json1
.forge/scaffolded/internal/db/customer_repo_ext.go.json1
.forge/scaffolded/internal/handlers/customers/handlers_crud_test.go.json1
cmd/shop/cmd/roots_gen.go2
db/embed_gen.go13
db/grants/readonly/public.customers_gen.sql9
db/grants/readonly_gen.sql138
db/source_gen.go13
deploy/observability/alert-defaults_gen.json203
deploy/observability/spec/alert-service-customers_gen.json7
deploy/observability/spec/project_gen.json260
frontends/web/src/components/dashboard-tiles/customers_gen.tsx58
frontends/web/src/components/dashboard-tiles_gen.ts35
frontends/web/src/gen/services/customers/v1/customers_pb.ts343
frontends/web/src/hooks/customers-service-hooks_gen.ts160
frontends/web/src/hooks/index.ts5
frontends/web/src/lib/mock-transport_gen.ts52
frontends/web/src/lib/nav-routes/customers_gen.ts17
frontends/web/src/lib/nav-routes_gen.ts37
frontends/web/src/mocks/customers_gen.ts109
frontends/web/src/mocks/entities/customers_gen.ts41
frontends/web/src/mocks/fixture-freshness_gen.test.ts191
frontends/web/src/mocks/fixture-manifest_gen.ts38
frontends/web/src/mocks/scenario-rpcs_gen.ts15
gen/forge_service_catalog.json482
gen/services/customers/v1/customers.pb.go764
gen/services/customers/v1/customersv1connect/customers.connect.go166
gen/shop/config/v1/config.pb.go1
gen/shop/config/v1/web_config.pb.go1
internal/app/graph_gen.go3
internal/db/customer_orm_gen.go125
internal/db/customer_store_gen.go84
internal/db/store_gen.go33
internal/handlers/customers/factories_gen_test.go136
internal/handlers/customers/handlers_crud_ops_gen.go242
internal/handlers/customers/helpers_gen_test.go39
internal/handlers/mocks/customers_mock_gen.go79

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.

Proto on one side, migrations on the other, and the generated code where they overlap, with fields and columns matched by name.

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.

The lint's verdict on two hand-built Connect errors, with the svcerr call to use instead. Trimmed.
The path of a request, from the browser hook through the interceptor chain, with svcerr innermost, to the handler.

Other lint lanes mentioned in Part 1

  • A list filter such as email on ListCustomers that isn't optional: proto3 can't tell an empty string from no filter, so the generated filter silently matches everything.
  • A misspelled marker such as forge:immutible in 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, noctx and staticcheck still 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.