Read this before adding, moving, or extracting any package under
framework/. The layout is intentional and non-obvious in places. The rules below explain why each subpackage exists, what's still in the root and why, and how to keep the layered shape intact when adding new code.
framework/ used to be a single 22k-LOC package. It now exposes the same
public API through a thin facade at framework/, with the actual
implementations split across ~44 runtime subpackages (plus a handful of
test-infra and experimental/ packages that are out-of-contract, see
the map). The facade re-exports
types/funcs via type aliases and var bindings so external callers
(framework.Entity, framework.NewApp, etc.) keep working unchanged.
Subpackages communicate via abstract interfaces defined in low-level
packages (entity.Registry, db.Executor, db.Beginner) so no
subpackage needs to back-import the framework root.
Read this before changing
cmd/gofastr(generate,migrate,pack), the generated-output layout, or how the project is positioned. This is what the code now does. Anything that still contradicts it, such asgen/output or theclean-rewrite, is legacy to remove, not progress toward it.
A declaration-first generator can follow one of two mutually exclusive philosophies:
- Canonical declaration (Wasp, Encore, Amplify). The declaration is the program. You live in it, regenerate from it, and the emitted code is the tool's property. You don't hand-edit it. "No drift" holds because there is one source. The cost: the declaration must eventually express everything, so it grows into a second, worse programming language.
- Scaffold-and-own (Rails
g scaffold, Angularng generate). The generator is a one-way accelerator. It emits code you then own and edit. Re-running it on the same thing would clobber you; you re-run it incrementally to add new things. The escape hatch to plain Go is the point, not a failure mode.
GoFastr is scaffold-and-own. The blueprint is an on-ramp, not an address. After generation the owned Go is the only source of truth. The blueprint never has standing to be diffed against, synced with, or treated as canonical for a running app.
| move | direction | standing |
|---|---|---|
generate |
YAML → owned Go | one-way on-ramp; scaffold then leave behind |
pack |
owned Go → YAML | one-way, lossy off-ramp; snapshot of shape |
migrate generate |
a schema → reviewable versioned SQL | a separate action; takes its schema from an opt-in source (blueprint --from, owned Go, or a live DB). Emits a migration file you review and commit. It does not treat any source as authoritative-over-the-world. |
migrate diff --from=<blueprint> |
— | deleted: it applied the blueprint onto a live DB, making the blueprint authoritative over the running database. |
generate (forward) and pack (backward) are not inverses you sync
between. There is no merge-back, no conflict resolution between YAML
and app. Two independent one-way moves, never a loop.
Note the distinction that keeps migrate diff deleted but migrate generate fine: code generation (blueprint → owned Go) and schema
migration (schema → SQL) are separate concerns. A migration action
that merely reads a schema to emit a reviewable file claims no
authority. migrate diff was deleted not because it read the blueprint,
but because it applied it, reconciling the live database to the
blueprint. Reading is fine; applying-as-source-of-truth is not.
generate and pack both pivot on one primitive: extract the
declarable shape (entities, screens, nav, access, seed) from a
compiled/live app → in-memory IR. generate goes IR → files (forward);
pack goes app → IR → YAML (backward). Build "app → IR" once and both
land. Kiln already maintains an in-memory world IR; that extraction
primitive is a reuse candidate, not net-new. (migrate generate is a
separate concern, covered below, though it could optionally take its
schema from that same IR one day; it is not load-bearing for this hub.)
Code generation (blueprint → owned Go) and schema migration (schema →
SQL) are different concerns. The migration engine (framework/migrate:
DiffSchema, Registry, Load/SaveSnapshot) already works on entity
declarations + a committed schema.snapshot.json. It never sees YAML.
At the CLI boundary, migrate generate takes its schema from an opt-in
source; today that source is --from=<blueprint>, which is perfectly
fine. Reading a schema to emit a reviewable migration file claims no
authority over anything. Owned-Go and live-DB sources are equally valid
inputs and can be added when wanted (e.g. the app's own binary, which has
its entities compiled in, running myapp migrate generate <name>, the
Ent/Django shape). None of this is a leftover to remove: there
is no contradiction with "delete the blueprint," because the running
app never needs the blueprint, and schema migration is simply a separate
action you point at whatever schema you have.
pack captures the declarable surface only: custom handlers, hooks, and
business logic do not serialize. That is acceptable: pack exports
shape (for sharing, templating, cheap agent-reading, re-scaffolding
elsewhere), never state for sync. Fidelity will improve over time; the
one-way rule will not. Even a perfect-fidelity pack has no standing to
sync back, because the owned Go wins by rule, not because the YAML is
too lossy to trust. Lossy-today is a maturity state; one-way is
permanent. Stating this is the firewall that stops pack → edit →
generate from quietly turning the blueprint back into a source of truth.
A directory named gen/, wiped and rewritten under a clean: true
flag on every run, is the giveaway of canonical-declaration output: you
cannot own a file the tool deletes on the next run. (An earlier
iteration of this generator worked exactly that way; nothing in the
tree does anymore: examples/ecommerce scaffolds into an owned app/
subpackage via output_dir, no gen/.) Scaffold-and-own emits an
idiomatic layout the user owns immediately:
main.go # wires App + batteries + Start() — yours
app.go # RegisterGenerated: screens + app wiring — yours
screens.go # generated screen components — yours
resource.go # server-render engine for entity screens — yours
stubs.go # endpoint/middleware/plugin/seed stubs — yours
entities/ # entity registration, models, typed repos — yours
migrations/ # versioned SQL (from `migrate generate`)
gofastr.yml # the scaffold input: OPTIONAL, deletable once the code is yours
The emitted app files are a flat package main at the root: no
blueprint/ subpackage, no importable "generator" namespace. main.go
calls RegisterGenerated(...) directly.
(--out=<dir> / app.output_dir scaffolds into a subpackage instead of
the module root, used by monorepo examples like examples/ecommerce,
which keeps its app in an owned app/ subpackage.)
generate is one-shot for the app: it scaffolds once, refuses to
overwrite an existing project (--force to insist), then you own the
code and edit it in place. Growth after that is additive, never a
merge: generate --add (partial yml) and the generate entity|screen <name> scaffold subcommands write only files that don't exist yet. An
owned file is never touched, so there is no re-run/merge loop and the
blueprint never becomes a canonical source you keep regenerating from.
Anything the additive path can't scaffold, you write in the owned Go.
Two rules fall out, and both are load-bearing:
- One-shot refuse,
--forceto overwrite (replacesclean). There is no quarantine dir and no merge machinery.generatewrites the whole app when the target is empty; if any target file already exists it refuses, listing the conflicts, rather than clobbering hand-edited code. Pass--forceto overwrite the whole set. The emitted code is yours the moment it lands. - No manifest, no
DO NOT EDITheaders on owned scaffold. Tracking "what I generated" re-introduces tool-ownership and slides back to a canonical declaration; use pure content-based conflict detection. The momententities/product.gocarries// Code generated — DO NOT EDIT, you have told the user it is not theirs. The header survives only on genuinely derived build artifacts (openapi.json, build-time route tables) that regenerate every build and may live in a build dir. Owned scaffold ≠ build output.
The AI-economics claim is two-sided. The README already argues the output should be inspectable because an agent wrote it; scaffold-and-own adds the input side: an agent does not need to learn the framework to author an app, only the blueprint schema, which is small, YAML, and fully documented. Low surface area to author, high surface area generated, one tool call → a multi-entity app. Two distinct AI surfaces follow: author-time → blueprint (write-mostly intent compression) and run-time → MCP into the live server (introspect routes/config/docs, drive the entity tools against the running app. The agent does not crack open the YAML to learn a running app). Kiln is the third, separate mode: live IR mutation that graduates to a blueprint, then to Go.
framework/
├── access/ Permission / Policy / RolePolicy / RBAC helpers
├── agentsinv/ Process-wide registry of agent-onboarding (agents.md) snippets
├── axecov/ Axe-coverage manifest (.gofastr/axe-coverage.json),
│ written by testkit/axetest scans, read by uihost strict
│ mode. Deliberately chromedp-free so production code can
│ read it without linking a browser.
├── contracts/ Semantic analysis: the rule catalog (stable GOFASTR
│ ids, each with Why/Fix + a bad/good example pair) and
│ the analyzer runner behind `gofastr verify`. The
│ analyzers self-register from `contracts/analyzers`.
├── cron/ CronJob / Scheduler: minimal in-process tick loop
├── crud/ HTTP CRUD layer, eager loading, includes, nested
│ filters, typed query, MCP tool generator,
│ JSONCase config, owner/tenant scoping, in-tx helper.
│ The biggest pkg.
├── datexport/ Process-wide registry of data-bearing tables that live
│ outside the entity registry (battery raw DDL), so
│ App.ExportData / ImportData / EraseUserData reach them.
├── db/ Executor + tx context primitives shared across
│ crud, slowquery, and the framework root tx wrapper
├── dev/ Dev-mode-only helpers (livereload, debug surfaces)
├── docs/ Embedded doc content (framework/docs/content/*.md) + `gofastr docs`
├── dsl/ ?dsl=… query string parser/builder
├── embed/ Embeddable screens/islands: a customer pastes one
│ <script> tag and gets a live, themed, authenticated
│ iframe of the app, gated by a per-surface origin list.
├── entity/ Entity + EntityConfig + Define, all column types
│ and operators (And/Or/Not/Condition/Order),
│ relations, validators, EntityDeclaration JSON
│ loader, and the shared Registry interface
├── event/ EventBus / Event / EventHandler
├── fanout/ Postgres LISTEN/NOTIFY implementation of the
│ core/fanout.Fanout interface: one channel, fallback
│ table for oversize payloads. Lossy best-effort by
│ design; durable delivery is outbox's job.
├── file/ FileField + Process/Delete/GeneratePath
├── filter/ Query-string filter & sort parsing
├── gallery/ Importable component catalog behind the /components
│ showcase: renders every design-system component
│ against an arbitrary theme. Composes framework/ui, so
│ like sdkdocs it is deliberately NOT a uihost option.
├── hook/ HookRegistry / HookType + lifecycle constants
│ (BeforeCreate, AfterCreate, etc.)
├── i18nui/ Translated default strings for framework UI surfaces
├── image/ Image decode/encode, variant pipeline, BlurHash encode
│ + decode. A zero-dependency leaf: imports nothing from
│ gofastr, so nothing below it may import IT either (see
│ imagefield/ and the framework/ui layering test).
├── imagefield/ Adapter making framework/image satisfy file.ImageDeriver,
│ so a schema.Image upload auto-produces renditions +
│ BlurHash. Separate package on purpose: file/ and crud/
│ must not link every image decoder, so the dependency is
│ inverted through an interface and only apps that opt in
│ (framework.WithImagePipeline) pay for the codecs.
├── internal/casing/ snake↔camel helpers (private to the framework
│ module, not part of the public API)
├── lifecycle/ Graceful shutdown contract: drain, flush, stop phases
├── migrate/ AutoMigrate / DiffSchema / Dialect / Bulk queries
├── openapi/ EntityOpenAPI spec generator + the entity-endpoint
│ URL builders (EntityEndpointPath etc.)
├── outbox/ Transactional outbox: Append writes the event row in
│ the caller's tx; a background Relay delivers each
│ committed row to declared durable consumers with
│ at-least-once semantics.
├── owner/ "Who owns this row" seam: OwnerField scoping for CRUD
├── pagination/ Cursor + offset pagination
├── pluginhost/ Heavy-JS plugin platform: sandboxed-iframe isolation host
│ (opaque origin), versioned postMessage broker (host/
│ pluginhost.js, go:embed'd, never in runtime.js budgets),
│ plugin manifest, same-origin AssetServer with framed-CSP
│ relaxation, data-fui-plugin* mount markers, capability
│ gate over battery/auth scopes. Extracted from the
│ gofastr-plugins wysiwyg build (#37 client mirror).
├── ratelimit/ Sliding-window + lockout HTTP rate limiter ("N per
│ period, then block"). battery/auth's login/register/
│ reset limiters build on it. The token-bucket sibling
│ is core/middleware.RateLimit.
├── routegroup/ Route groups: prefix, middleware, access, OpenAPI tags
├── sdk/ Shared contract between `gofastr generate sdk` and the
│ serving side (sdkdocs): artifact manifest schema, the
│ schema hash both compute for drift detection, and the
│ deterministic zip packer. Leaf: entity + core/ only.
├── sdkdocs/ The SDK docs site: sdkdocs.Mount registers public
│ screens (install/auth/errors + a live per-entity API
│ reference) on the core-ui app plus artifact download
│ routes. Composes framework/ui, which is exactly why it
│ is NOT a uihost option: uihost must never import
│ framework/ui (always-on styles would leak into every
│ host's CSS bundle).
├── semcov/ Semantic-coverage manifest: records which routes,
│ permissions, and entity endpoints a test run actually
│ exercised through the real router, as opposed to line
│ coverage's "did this statement run".
├── slowquery/ SlowQueryLogger: wraps any db.Executor
├── softdelete/ SoftDelete / Restore / ForceDelete + filter
├── tenant/ TenantConfig / Middleware / GetTenantID / etc.
├── static/ HTTP static-file serving
├── ui/ Server-rendered UI primitives (PageHeader, FormField,
│ DataTable, …). The largest UI surface: composes
│ core-ui/html + core-ui/patterns into intent-level
│ components; see core-ui/ARCHITECTURE.md for the rule
│ on when a primitive lives here vs in core-ui/.
└── uihost/ UI host + page renderer (SEO/Screen wiring)
Out-of-contract (NOT part of the layering rules below):
experimental/apiversions API versioning (URL prefix, deprecation
headers, projections). Experimental surface.
testkit/, testdata/ Public test helpers + fixtures for host apps.
factory/ Rails-style fixture/factory helpers (tests).
isolation/ Per-worktree local runtime resource resolution.
experimental/harness The GoFastr agent harness: a v0.1 experimental
subsystem (lives here so the /experimental/ stability
exemption applies). Not imported by the framework
root; enforced by harness/layering_test.go. Much of
its architecture doc is design intent, not shipped.
The root package framework/ itself contains:
- App spine:
app.go,plugin.go,battery.go,module.go,registry.go,typed_hooks.go,role.go,setup.go - App-method-tied helpers:
audit.go(App.WithAuditLog),tx.go(App.InTx),flags.go(App feature-flag store),health.go(App health/readiness probes),i18n.go(App.WithI18n / T),seed.go(App.WithSeed),metrics.go(App.Metrics accessor),export.go(App.ExportStatic),export_data.go/erase_data.go(App.ExportData / ImportData / EraseUserData),dbctx.go(WithDBContext / DBFromContext),agents.go(App agents-inventory wiring) - MCP surfaces:
mcp_introspection.go(App.WithMCPIntrospection),mcp_control.go(WithMCPControl / WithMCPGate),mcp_app.go(WithMCPApp),mcp_contracts.go+mcp_contracts_dev.go(contract tools),devmcp_bind.go(loopback guard for the dev MCP listener) - Agent-readiness endpoints:
wellknown.go,agent_extras.go,authmd.go,oauth_resource.go - Semantic coverage:
semanticcoverage.go(RecordSemanticCoverage) - Test harness:
testharness.go(TestApp wraps*App) - Facade re-exports:
reexports_*.go, one file per subpackage whose symbols are referenced asframework.Xby external code - Doc:
doc.go
L1 internal/casing (no internal deps)
L2 entity (imports core/ +
internal/casing only)
L3 hook, event, file, cron, access, db, (leaf packages, no framework-
pagination, filter, owner, agentsinv, internal imports at all)
axecov, datexport, fanout, i18nui,
image, lifecycle, ratelimit, semcov,
docs, dev, routegroup
dsl, tenant, softdelete, migrate, sdk (each imports entity)
slowquery, outbox, embed, imagefield, (intra-L3 edges, listed
contracts below)
L4 crud (uses entity, hook, event, db,
file, filter, pagination,
tenant, owner, access, embed,
internal/casing; softdelete
is inlined, not imported)
openapi (uses crud, entity,
internal/casing, and sits above
crud within L4)
L4+ ui, uihost, and the packages (the UI stack: same direction
composing them: ui/resource, static, rule; uihost never imports
gallery, sdkdocs, pluginhost ui. Edges listed below)
L5 framework/ (facade) (re-exports everything for
the public API surface)
(The map above is the real import graph as of v0.64.0. Verify with
go list -f '{{join .Imports "\n"}}' ./framework/<pkg> when in doubt.
The rule is direction: a package may import packages in lower layers,
never higher, and intra-layer edges should stay rare and deliberate.
Today's intra-L3 edges: slowquery → db, outbox → event + db,
embed → db + migrate + tenant, imagefield → file + image,
contracts → agentsinv, dsl → filter (the LIKE-escape helpers:
one canonical EscapeLikePattern/LikeEscapeSuffix, not a per-package
re-implementation). Within L4: openapi → crud. In the UI stack:
ui → i18nui + agentsinv, uihost → axecov + dev + embed + image + tenant, ui/resource → crud + filter + ui, static → ui + uihost,
gallery → ui + image + agentsinv, sdkdocs → ui + sdk + entity + internal/casing, and pluginhost → uihost. uihost/uinoderender → ui is a separate
package on purpose: the host itself never links framework/ui. The
node renderer that needs the component catalog sits above it.)
One cross-tree edge is deliberate: battery/auth → framework/access.
The token-scope matcher (scopeMatches, backing auth.HasScope and the
exported auth.ScopeMatch) delegates to access.ScopeMatch so the
resource:verb wildcard algebra has exactly one home. Batteries sit above
the framework tree, so the edge points downward and introduces no cycle;
framework/access stays a leaf (its only gofastr deps remain
core/handler + core/query). access.Can, the exact-match RBAC hot
path, is a different matcher on purpose and never learns wildcards.
One edge runs the other way and is tracked, not hidden:
framework/pluginhost → battery/auth. The capability gate
(pluginhost.Allow) needs both halves of the scope check:
access.ScopeMatch (reachable downward) and auth.HasScope, which reads
the caller's token out of the request context and is genuinely
battery/auth's. The import carries a //gofastr:allow(GOFASTR1301)
annotation in pluginhost/gate.go and is the only upward edge in the
tree.
No subpackage may import framework/. If a subpackage needs a type
defined in framework root (App, Registry, CrudHandler), one of three
patterns applies. See "Cycle-breaking interfaces" below. This invariant
is enforced mechanically by framework/layering_test.go, which also
asserts that uihost's dependency closure excludes framework/ui and
that no core-ui package imports framework. The test pins one
sanctioned carve-out: pluginhost carries the root in its closure
transitively, through the battery/auth edge above, and only there.
The remaining root files share one property: their public type or method
set is bound to *App (the spine type). Moving any of them out would
either require an API redesign or create an unbreakable import cycle.
| File | Why it stays |
|---|---|
app.go |
Defines App itself plus all (a *App) configuration methods. The package whose name is on the import path is by definition where App lives. |
plugin.go |
Plugin interface + PluginManager consumed by App.RegisterPlugin. Both Plugin and Battery use a single Init(*App) integration point; the router late-binds middleware so anything Init does wraps existing routes. |
battery.go |
Battery interface + BatteryManager consumed by App.RegisterBattery. Same single-Init shape as plugin, plus dependency-resolved init order and an optional BatteryLifecycle for OnStart/OnStop. |
module.go |
Module interface + ModuleManager consumed by App.RegisterModule. Module embeds Battery and adds a manifest; the manager holds enable/disable state and the attribution maps (route→module, tool→module) that the dispatch gates read on every request. Bound to App the same way battery.go is. |
registry.go |
Registry is concrete state (*sql.DB, entity map). Subpackages reference it through the entity.Registry interface instead. |
typed_hooks.go |
Generic OnBeforeCreate[T any](app *App, …) etc. Take *App directly; their type signatures live with App. |
audit.go |
(a *App) WithAuditLog(...) is the public entry; the rest of the file (table creation, hook closures) is intrinsically tied to it. Could be split if the closures move out, but the win is small. |
secret.go |
WithSecret + HKDF key derivation for the app-wide secret (GOFASTR_SECRET). Subsystem keys are derived per purpose: the uihost session-signing key is handed to a mounted host at Mount time through the duck-typed SetSessionKey([]byte) seam (same cycle-breaking shape as SetFanout: the framework never imports uihost). Fanout attached + no secret = boot panic, because stateless session tokens minted on one replica must verify on every other. |
tx.go |
(a *App) InTx(...) is the public entry. The lower-level context helpers already moved to framework/db; this file is just App's wrapper. |
testharness.go |
TestApp.App *App field is used by tests. Sixteen test files use TestApp / TestHarness unqualified inside package framework. Extracting requires either an interface refactor or qualifying every test. |
processmodule*.go |
Process-isolated third-party modules (#37). The supervisor, store, runner, sandbox, broker, and proxy are bound to *App (spawn under the app's lifecycle drainer, re-dispatch reverse calls through the app router, enforce the app's RolePolicy). RegisterProcessModule is the single (a *App) entry. See the process-module section below. |
role.go |
Role (all / serve / worker) + WithRole + resolveRole. The role decides at boot which responsibilities the binary assumes; App.Start consults it to skip worker-scoped consumers (cron, queues, the outbox relay) or the HTTP surface, so it is read directly by the spine. An unknown role fails loudly in NewApp. |
setup.go |
SetupRunner interface + WithSetup first-run setup. framework defines the interface rather than importing battery/setup to avoid a layering cycle; App.Start owns the lifecycle (incomplete → wizard or headless run → atomic swap to the real handler). |
export_data.go, erase_data.go |
(a *App) ExportData / ImportData / EraseUserData: data portability and right-to-be-forgotten. Both walk the App's entity registry plus the datexport registry, and all raw table access funnels through the SafeIdent-guarded path here, so they need the App's DB handle and migrate dialect. |
wellknown.go, agent_extras.go, authmd.go, oauth_resource.go |
The agent-readiness discovery endpoints (/.well-known/api-catalog, the MCP server card, Web Bot Auth JWKS, UCP/ACP docs, /auth.md, RFC 9728 protected-resource metadata). Each auto-serves when its precondition holds: entities registered, WithMCP mounted, or a host opt-in config, all of which is App state. |
mcp_contracts.go (+ mcp_contracts_dev.go) |
Contract-catalog MCP tools (contracts_list / _explain / _capabilities) registered alongside WithMCPIntrospection; the dev file adds the verify/fix tools that read the source tree and exist only in the dev loop. Registration hangs off the App's /mcp server. |
mcp_control.go |
WithMCPControl: the mutating tool set (app_module_enable / app_module_disable), deliberately separate from read-only introspection, plus WithMCPGate for caller gating. Outside the dev loop the tools require an authenticated caller and refuse embed-grant credentials; dev-implied tools are ungated but loopback-bound. Same App-bound registration shape as introspection. |
semanticcoverage.go |
RecordSemanticCoverage(t, app) wires the app's router serve hook and the access/hook/event observers into the semcov recorder; TestHarness calls it automatically, and GOFASTR_SEMANTIC_COVERAGE=1 enables it in a serving process. Needs app.router, so it stays with App. |
A process module is a third-party binary the host installs, upgrades,
crashes, and revokes without touching itself, so it runs out of process.
The trust boundary is the stdio pipe (core/moduleproto, a full-duplex
JSON-RPC-2.0 codec); the host↔module wire is a purpose-built dialect, NOT
MCP (MCP is an optional module surface only). The load-bearing rules:
- Crash isolation, not MCP transport.
core/moduleprotocarries the wire; a child crash yields a buffered 503 on the inbound call, never a truncated 200, and never takes the host down. - The module holds zero DB credentials. Every data op is a reverse
host.entity.*call the capability broker checks as module-grant ∩ caller-authority and re-dispatches through the CRUD chokepoint (crud.Redispatch), so owner/tenant/permission all re-run. The required permission is derived from the trusted method, never a child-supplied string (the confused-deputy control).CrossOwnerReadis non-grantable to a module and stripped on the reverse path even for a delegated caller who holds it. - Two-layer gate. Disabled → 404 (indistinguishable from uninstalled);
enabled-but-not-Ready → 503 +
Retry-After.Readylives in the proxy handler because the router gate can only 404. - Fail-closed everywhere. An untrusted module with no probe-passing
sandbox backend never reaches Ready (no silent downgrade). A replica that
cannot refresh desired state past its lease TTL drains its children, the
deliberate inversion of the in-process
handleRemoteTogglefail-open. Revoke = adesired_generationbump the restart circuit keys to. - The module never emits markup. It returns a
ui.node.v1tree the host validates (core-ui/uinodev1) and renders (framework/uihost/uinoderender). Seecore-ui/ARCHITECTURE.md. - Sandbox = observable outcomes, not syscalls.
processmodule_probe.godefines the P1–P7 conformance suite; per-OS wrapper backends (bwrap/sandbox-exec/Job-Object, build-tagged linux-vs-not) each declare which probes they enforce on the host and fail closed on the rest. v1 is honest that P6 (resource caps) and stock macOS/Windows do not fully conform. Untrusted modules simply refuse to run there.
The full converged design (transport, capability model, sandbox contract,
migration/DDL isolation, lifecycle, UI validator, phasing, go/no-go gate)
is issue #37's design comment and framework/docs/content/process-modules.md.
When a subpackage needed something from framework root, the answer was never to back-import. Three patterns kept the graph acyclic:
// framework/entity/registry.go
type Registry interface {
All() map[string]*Entity
Get(name string) (*Entity, error)
}*framework.Registry satisfies this implicitly. Used by:
migrate.AutoMigrate(db, registry entity.Registry)migrate.DiffSchema(ctx, db, registry entity.Registry)dsl.BuildDSLQuery(registry entity.Registry, input string)crud.CrudHandler.Registry entity.Registryfield
// framework/db/db.go
type Executor interface {
QueryContext(ctx, query, args...) (*sql.Rows, error)
QueryRowContext(ctx, query, args...) *sql.Row
ExecContext(ctx, query, args...) (sql.Result, error)
}Both *sql.DB and *sql.Tx satisfy this. framework.DBExecutor is
kept as a type alias (type DBExecutor = db.Executor) for back-compat.
Used by crud.CrudHandler, slowquery.SlowQueryLogger, the eager
loaders.
// framework/db/db.go
type Beginner interface {
BeginTx(ctx, opts) (*sql.Tx, error)
}
func WithTx(ctx, tx) context.Context
func TxFromContext(ctx) (*sql.Tx, bool)Both App.InTx (framework root) and CrudHandler.inTx (crud) call
into db.WithTx / db.TxFromContext. The previous private
contextWithTx helper would have created a cycle when the methods
ended up in different packages.
Configuration the framework accepts but cannot act on is a bug in the
framework's manners, not the app's problem. The Nexus retrospective (#78)
paid a debugging session each for a seed that swallowed its error, a
router param syntax that just 404'd, and grants persisted against
capabilities that never existed. The rule that came out of it: accepting
input that will silently have no effect requires a loud slog.Warn
naming the exact thing that won't work (with a nearest-match suggestion
where one exists, as in the capability registry's dead-grant warning), or
an error where the caller can act on one. Silent no-ops, best-effort
fallbacks that hide their fallback, and errors swallowed into defaults
all fail review. When in doubt, scream.
Local variables named entity and crud were renamed to ent / ch
across the codebase before moving the matching packages. Inside a
function with func f(entity *entity.Entity), Go resolves entity.X
in the body against the local var, not the package, so any
reference to a package-level symbol in the same function is impossible.
If you add a local var that would shadow a subpackage import, rename it first.
gofmt -r 'X -> pkg.X' rewrites struct field keys too, producing
invalid Go like Foo{pkg.Index: 0}. After every gofmt -r pass on a
package whose exports overlap with field names anywhere in the tree
(Entity, Index, Required, Unique, Relation, SoftDelete),
search for pkg.Sym: and undo the rewrite where it lands on a field
key. Do not undo case pkg.Sym:. Those are switch labels and
need to stay qualified.
- Per-feature internal tests that read unexported helpers stay in
the same package as the helper (
framework/cron/cron_test.go,framework/access/). - Cross-feature tests (e2e, integration, openapi conformance, typed-repo end-to-end) stay at the framework root and exercise the facade.
- Tests that compose the App spine (almost all of them, since they call
NewApp,WithDB,TestHarness) must stay at the framework root unless they're rewritten to importframeworkas an external test package, which only works for tests that don't need internal access to the package they're testing.
When a previously-unexported symbol crosses a new package boundary,
prefer promotion (rename to exported) over moving to internal/.
Promotion shows up in the public API but is documented in the comment;
internal/ would hide it but break the test surface for free.
Promotions made during the reorg:
cron.RunOnce, event.Snapshot, slowquery.TrimSQL,
migrate.SQLType/SQLDefault/ReadLiveColumns*/DetectDialect,
crud.NormalizePath, crud.BatchResponse,
crud.ApplyTenantScope/Count/Update/Delete, crud.InjectTenant,
crud.ApplySoftDeleteFilter/Count,
entity.Condition.SQL(), entity.Condition.Args().
Each battery/* and every framework subpackage worth surfacing to an
agent ships an agents.md next to its source files plus an agents.go
that does:
package mybattery
import (
_ "embed"
"github.com/DonaldMurillo/gofastr/framework/agentsinv"
)
//go:embed agents.md
var agentsMarkdown string
func init() {
agentsinv.Register(agentsinv.Entry{
Name: "mybattery", // dir name
Kind: agentsinv.KindBattery, // or KindFramework
ImportPath: "github.com/.../battery/mybattery",
Markdown: agentsMarkdown,
})
}Why a registry instead of file globs:
- Importing the battery = its snippet is in scope. No central allow-list to update on every new battery.
cmd/gofastrblank-imports the batteries it inventories, then walksagentsinv.All()to writeAGENTS.md(seeframework/agentsinv/inventory.go).- An empty
agents.mdpanics at init: the file is missing or the//go:embeddirective is stale.
Per-battery agents.md content lives next to the code it documents.
Treat it as a short "use this when / shape / don't reinvent" reference
for AI agents, not as a user manual.
Several core/ packages share names with what subpackages would want:
| Subpackage rename | Why |
|---|---|
framework/entity (not framework/schema) |
core/schema is imported as schema in 46 framework files |
framework/migrate aliases core/migrate as coremig inside its own files |
self-name collision |
framework/openapi forces app.go to alias core/openapi as coreoa |
same as above |
If you add a subpackage, grep core/ for name conflicts first.
-
Confirm it's actually a leaf. Grep the file for references to
*App,(a *App), or App methods. If any of those exist, the file stays in framework root or needs an API redesign first. -
Pre-rename shadowing locals. If the future package name (e.g.
tenant) appears as a local variable name anywhere in the tree, rename to a non-conflicting form before moving anything:for f in framework/*.go; do gofmt -r 'tenant -> tenantID' -w "$f"; done
-
Move with
git mvand rewrite the package declaration:mkdir -p framework/<pkg> && \ git mv framework/<file>.go framework/<pkg>/<file>.go && \ sed -i '' '1s/^package framework$/package <pkg>/' framework/<pkg>/<file>.go
-
Qualify every caller with
gofmt -rper exported symbol:for sym in Sym1 Sym2 …; do for f in framework/*.go; do gofmt -r "${sym} -> <pkg>.${sym}" -w "$f" done done goimports -w framework/*.go
-
Undo struct-field-key collisions (see convention above).
-
Build and fix. Common categories of failure:
- Test access to unexported helpers → promote in the new package.
- Methods on a moved type referenced from framework root → the method must travel with the type (Go's package-locality rule); either add a wrapper at the call site or move the calling file too.
- External callers using
framework.Sym→ add a re-export inframework/reexports_<pkg>.go.
-
Keep tests that compose
Appat framework root. They use the facade re-exports; that's the whole point of the facade. -
Run the full suite, including external consumers:
go test ./framework/... ./cmd/... ./kiln/... ./examples/.... Theexamples/sitechromedp suite is the slow one (~2.5 min) and the only thing that exercises the full SSR + island + widget stack end-to-end.
-
Do not add a new subpackage just to make
framework/smaller. Each subpackage justified its own move: pure data/helpers, or a cluster of related types. Single-file extractions of methods on*Appare not worth the API churn. -
Do not delete the facade re-export files. They are the seam that lets external code keep using
framework.X. Removing them is a breaking change for every consumer. -
Do not bypass the
entity.Registry/db.Executorinterfaces by adding a back-import to framework root. If a subpackage needs more than the interface offers, extend the interface. Don't reach for the concrete type.