0007 — Screens are derived from schemas
Status: accepted, 2026-09-02. The record is
docs/adr/0007-screens-are-derived-from-schemas.md;
this page is its map and its summary and states nothing the record does not.
The map
decisions/0007-screens-are-derived-from-schemas in the recordContext
The previous codebase’s admin surface was 52 Stimulus controllers, a block
library, a descriptor pipeline that turned component manifests into delivery
graphs, and one hand-written set of list, detail and form templates per entity:
fifty-four modules meant fifty-four sets. A field added to an entity appeared in
the API immediately and on a screen when somebody remembered, so the two drifted
in one direction only — the screens were always behind, and the ones nobody
used were always wrong. The generic half of that work is the same for every
entity: a list is a page of rows with a sort and a filter, a detail is the
fields, a form is one control per writable field, decided by the field’s type.
What it needs is the entity’s shape, at runtime, in a process that did not
compile against it — and kit/crud already derived one, crud.Schema, from
the struct tags that were there for the API, while kit/rest already attached
one to every Spec. Nothing read it.
Decision
A screen is generated from a resource’s schema, and a hand-written screen is an
exception that has to earn itself. rest.Spec.Mount registers an
httpx.Resource beside its five routes — the names, the path, the two
permissions, the Immutable list, the schema, and the five operations bound to
the entity’s type as closures — and modules/admin reads that register in its
own Routes and generates seven pages per resource: list, detail, create form,
edit form, the two writes and delete. Everything on those pages comes from the
schema: a select exists because the struct says enum, a textarea because
the column is type:text; a field is required because it says
validate:"required", absent from the form because it is readOnly, absent
from the list because it says ui:"hide:list", and shown read-only because the
Spec named it Immutable. Five screens in the whole application are written
by hand, each saying why in its own comment — sign-in, the dashboard, the health
page, the tenant switcher and the component gallery — and a sixth arrives when
an interaction cannot be derived, the test of that being whether it is about
this entity rather than about entities.
Consequences
-
Adding an entity to the application adds seven screens and no code; adding a field to an entity adds it to every screen, in the same commit as the API.
-
The screens cannot outlive the API’s rules: they call the same closures the routes do, in the same request transaction, so a write from a form publishes the same events, refuses the same read-only fields and answers with the same three errors. There is no second implementation to keep honest.
-
The screens cannot be more permissive than the API: each declares
Permission(resource.Read)orPermission(resource.Write), the resource’s own, so the boot gate sees them and the request-time middleware enforces them; the navigation asks the samehttpx.Authorizerthe kernel enforces with, so a link that is shown is a link that works. -
The shell is composed last:
kit/appcalls each module’sRoutesin composition order, so a module mounted afteradminregisters a resource whose screens were already generated — which is to say, were not. -
The cost is that a generated screen is generic. It cannot know that
assigneeIdis a person, so it renders the identifier and says there is no picker for it; it cannot know thatAssignis the door that field belongs to, so it renders the field read-only and says a command owns it. Those are honest and not good, and when one of them matters the answer is a hand-written screen for that interaction, not a tag that makes the generator cleverer. -
A schema that describes nothing renders nothing: a field whose Go type is outside `crud.FieldType’s closed set is in the JSON and on no screen, which is the rule the sort and the filter already followed.
Where it lives
-
kit/crud/schema.go—crud.Schema, the closedcrud.FieldTypeset, andcrud.Fields[T], which derives an entity’s fields once per type from thejson,gorm,enum,validate,default,docanduitags, marking whatBasecontributesreadOnly. -
kit/rest/rest.go—Spec.Schema()andSpec.Mount, which callsapi.RegisterResourcefor the entity in the same breath as its five routes. -
kit/httpx/schemas.go—httpx.Resource, the register behindRegisterResourceandResources(), and the guard that puts the five closures behind the resource’s ownReadandWritepermissions. -
ui/screens/screens.go—screens.Mount: the seven operations of one resource, each a renderer behindpage.Serve, with the path, the guards, the columns and the controls all taken from the resource. -
ui/screens/render.go—List,Detail,FormandControl, pure functions of a resource and what a handler read;Controlfollows the tag before the type. -
ui/screens/catalog.go—screens.Describe, the same knowledge as a JSON document for a shell that is not a browser, omitting every resource the caller may not read. -
modules/admin/internal/mount.go— the shell:screens.Mountfor every resource inapi.Resources(), then the hand-written pages, then the catalog at/api/v1/admin/resources. -
modules/admin/internal/pages.go— the five pages no schema describes, each with its reason: sign-in, the dashboard, the health page, the component gallery and the tenant switcher. -
modules/admin/module.go— the manifest: no permissions, no events, no nav entry of its own; composed last, which is load-bearing. -
ui/screens/testdata/catalog.json— the golden catalogTestCatalogGoldenkeeps, copied verbatim into platformkit-mobile, whose parser test reads it.
Evidence
go test ./kit/rest -run 'TestMountRegistersTheEntityBesideItsRoutes|TestTheResourceOperationsAreTheRoutesWithoutTheHTTP|TestTheResourceClosuresCarryTheirOwnAuthorization'
go test ./ui/screens -run 'TestFormDerivesControlsFromTheSchema|TestControlFollowsTheTagBeforeTheType|TestCatalogGolden'
go test ./modules/admin -run 'TestTheScreensAreGeneratedFromTheSchema|TestTheCatalogIsTheSameKnowledgeAsJSON'
make e2e # e2e/admin-tasks.spec.ts: sign in, create a task through the generated form, find it, change it, delete it
The record names no command; the tests above are the ones in the files it
names. ui/screens renders a Note entity no module owns, so what is under
test is the generator and not an entity; modules/admin checks the seven
screens against an entity it has never heard of; and the e2e journey knows
nothing about a task beyond its title. The record is precise about the limit:
a generated screen is generic — there is no picker for assigneeId, and a
field a command owns is read-only — and a field whose Go type is outside
crud.FieldType is in the JSON and on no screen.