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

Open the map full screen · source decisions/0007-screens-are-derived-from-schemas in the record

Context

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) or Permission(resource.Write), the resource’s own, so the boot gate sees them and the request-time middleware enforces them; the navigation asks the same httpx.Authorizer the kernel enforces with, so a link that is shown is a link that works.

  • The shell is composed last: kit/app calls each module’s Routes in composition order, so a module mounted after admin registers 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 assigneeId is a person, so it renders the identifier and says there is no picker for it; it cannot know that Assign is 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.gocrud.Schema, the closed crud.FieldType set, and crud.Fields[T], which derives an entity’s fields once per type from the json, gorm, enum, validate, default, doc and ui tags, marking what Base contributes readOnly.

  • kit/rest/rest.goSpec.Schema() and Spec.Mount, which calls api.RegisterResource for the entity in the same breath as its five routes.

  • kit/httpx/schemas.gohttpx.Resource, the register behind RegisterResource and Resources(), and the guard that puts the five closures behind the resource’s own Read and Write permissions.

  • ui/screens/screens.goscreens.Mount: the seven operations of one resource, each a renderer behind page.Serve, with the path, the guards, the columns and the controls all taken from the resource.

  • ui/screens/render.goList, Detail, Form and Control, pure functions of a resource and what a handler read; Control follows the tag before the type.

  • ui/screens/catalog.goscreens.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.Mount for every resource in api.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 catalog TestCatalogGolden keeps, 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.