Skip to content

Latest commit

 

History

History
720 lines (613 loc) · 40 KB

ARCHITECTURE.md

File metadata and controls

720 lines (613 loc) · 40 KB

GoFastr framework package layout

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.


The shape in one paragraph

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.


The blueprint is a generator, not a source of truth

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 as gen/ output or the clean-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, Angular ng 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.

The four moves (and the one that is deleted)

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.

The IR is the hub

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.)

Schema migration is a separate action, not part of the blueprint's authority

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 is one-way by policy, lossy by maturity

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.

No gen/: generated code lands where a hand-written app would put it

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:

  1. One-shot refuse, --force to overwrite (replaces clean). There is no quarantine dir and no merge machinery. generate writes 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 --force to overwrite the whole set. The emitted code is yours the moment it lands.
  2. No manifest, no DO NOT EDIT headers 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 moment entities/product.go carries // 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.

Why this is the better story to tell

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.


The package map

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 as framework.X by external code
  • Doc: doc.go

Layering rules (top imports bottom, reverse is forbidden)

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.


Why those things are still in framework root

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.

Process-isolated third-party modules (#37)

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/moduleproto carries 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). CrossOwnerRead is 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. Ready lives 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 handleRemoteToggle fail-open. Revoke = a desired_generation bump the restart circuit keys to.
  • The module never emits markup. It returns a ui.node.v1 tree the host validates (core-ui/uinodev1) and renders (framework/uihost/uinoderender). See core-ui/ARCHITECTURE.md.
  • Sandbox = observable outcomes, not syscalls. processmodule_probe.go defines 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.


Cycle-breaking interfaces

When a subpackage needed something from framework root, the answer was never to back-import. Three patterns kept the graph acyclic:

entity.Registry interface

// 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.Registry field

db.Executor interface

// 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.

db.Beginner + db.WithTx/db.TxFromContext

// 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.


Conventions established along the way

When the framework can't do what the app intended, it screams

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.

Pre-rename to avoid package shadowing

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.

Field-key collisions in struct literals

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.

Test placement

  • 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 import framework as an external test package, which only works for tests that don't need internal access to the package they're testing.

Promotions across new boundaries

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().

Agent-onboarding inventory (agents.md per package)

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/gofastr blank-imports the batteries it inventories, then walks agentsinv.All() to write AGENTS.md (see framework/agentsinv/inventory.go).
  • An empty agents.md panics at init: the file is missing or the //go:embed directive 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.

Naming collisions with core/

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.


How to extract a new subpackage (recipe)

  1. 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.

  2. 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
  3. Move with git mv and 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
  4. Qualify every caller with gofmt -r per 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
  5. Undo struct-field-key collisions (see convention above).

  6. 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 in framework/reexports_<pkg>.go.
  7. Keep tests that compose App at framework root. They use the facade re-exports; that's the whole point of the facade.

  8. Run the full suite, including external consumers: go test ./framework/... ./cmd/... ./kiln/... ./examples/.... The examples/site chromedp suite is the slow one (~2.5 min) and the only thing that exercises the full SSR + island + widget stack end-to-end.


What I recommend NOT doing

  • 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 *App are 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.Executor interfaces 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.