arch-scaffold-entity · git:20261014.7748a1f · 2026-10-14 · sha256 1c442c51d1ab9c16
arch-scaffold-entity git:20261014.7748a1fA
Immutable. This exact content never changes and is served at /api/v1/blob/1c442c51d1ab9c16.
---
name: arch-scaffold-entity
description: "Add one entity to an object-model namespace: the frozen type, the table and its migration with its policy, storage in Postgres and memory, manager operations, wire types, routes, a portal screen, and tests, in the shape of the scaffold's own. Python and TypeScript."
allowed-tools: Read, Grep, Glob, Write, Edit, Bash(make check), Bash(make infra-up), Bash(make migrate), Bash(make migrate-check), Bash(make openapi), Bash(docker info:*), Bash(uv run:*), Bash(git status:*)
---
# arch-scaffold-entity
A path that starts with `../` is read from this skill's folder
as `realpath` resolves it.
Conventions: `../_shared/scaffold-conventions.md`.
Sections of `../../architecture.md`: Naming
Entities (Identifiers), The Business Layer (Shape of an Operation), The
Storage Layer (Namespace Shape, Defining ORM Classes, Translation, A
Storage Impl, Database Roles, The Second Fence, Migrations), The Network
Layer (Service Interfaces and Impls, Public Types, Realtime at the
Edge), Documentation as Code (A README at Every Level), Cross-Cutting
Conventions (Exceptions). A section is read with its own introduction.
## Input
`<namespace> <EntityName> [field:type ...] [--role core|activity|queue|admin] [--scope system|org|identity|both] [--person-column <col>] [--no-api]`
Example: `inventory Warehouse address:str timezone:str`. The role
defaults to `core`, the scope to `org`. Ask in one message for the
fields not given and their types, and for the list below; with no one
to answer, decide them as the conventions' step 4 says:
- the mixins: `Named`? `Trackable`? `SoftDeletable`? A mixin is
composed only when an operation exercises it: `Trackable` needs an
update, `SoftDeletable` a delete. "Append-only" means neither: the
record is `Identifiable` and carries its time in a field of its own,
`created_at` or a name of its own like the change event's `produced_at`
(`om/src/<name>/om/change_events/types/change_event.py`), never read out of its id.
Unless `--role` says otherwise, its role is `activity`;
- whether concurrent edits of a `Trackable` entity matter. When they
do, the entity carries a `version`;
- the fields the manager owns and a caller never writes (a status its
transitions own, a position, a derived key);
- any unique key besides the id.
## Created
The shape of each file is its sibling for the `File` entity of the
`media` namespace, unless the row names another.
| File | Holds |
|------|-------|
| `om/src/<name>/om/<ns>/types/<entity>.py` | the frozen entity, mixins in house-style order, `MANAGER_OWNED_FIELDS` (with `version` in it on a versioned entity) |
| `om/src/<name>/om/<ns>/storage/tables/<entities>.py` | the table over the matching mixins; the sibling by scope is below |
| `om/migrations/sql/<role>/<stamp>_<entities>.up.sql`, `.down.sql` | the table with its mixin header first, then its policy with `ENABLE` and `FORCE ROW LEVEL SECURITY`; the down drops the policy and the table |
| `om/migrations/versions/<role>/<stamp>_<entities>.py` | `revision = "<stamp>"`, `down_revision` the role's current head, `run_sql(DatabaseRole.<ROLE>, ...)` |
| `om/tests/contracts/<entity>_storage.py` | the storage contract cases, their `CROSS_TENANT_CASES` set, and `INTERFACE`, the storage interface they cover, shape `om/tests/contracts/media_storage.py` |
| `om/tests/unit/test_<entity>_storage.py`, `om/tests/integration/test_<entity>_storage_postgres.py` | those cases over memory, and over Postgres under the `integration` marker |
| `om/tests/unit/test_<entity>_manager.py` | every operation over the memory storage |
| `services/api/tests/test_<ns>_<entity>_api.py` (unless `--no-api`) | the routes over the in-process app, shape `services/api/tests/test_media_api.py` |
| `apps/portal/src/features/<entities>/` (when a portal exists and unless `--no-api`) | `<Entities>Page.tsx`, `use<Entities>Vm.ts`, `<entities>Model.ts`, and its test, shape `apps/portal/src/features/settings/` |
`<stamp>` is the minute the migration is written, `YYYYMMDDHHMM`. The
table's sibling by scope, with its policy in its role's first migration:
| Scope | Sibling | The policy |
|-------|---------|------------|
| `org` | `media/storage/tables/files.py` | on `org_id`, with the system login's clause |
| `both` | `tenancy/storage/tables/api_keys.py` | on `org_id`, narrowed by the person column, `user_id` unless `--person-column` names another |
| `system` | `tenancy/storage/tables/identities.py` | none: no row-level security, and every storage method is declared `@tenantless` |
| `identity` | none; `ScopeKind.IDENTITY` in `storage/scopes.py` | on the identity column against `app.identity_id`; every storage method takes `identity_id` in place of `org_id` and is declared `@tenantless` |
## Changed
| File | Change |
|------|--------|
| `om/src/<name>/om/<ns>/storage/__init__.py`, `impl/postgres.py`, `impl/memory.py` | the storage operations of the conventions' Names, each with its tenant first by the scope, and `purge_tenant(org_id, limit)` when the namespace gains its first table, which deletes at most `limit` of the tenant's rows from each table the namespace holds, the new one included, as the leases storage's does over its three |
| `om/src/<name>/om/<ns>/manager.py`, `impl/manager.py` | the manager operations, or, when the manager delegates the entity's duty, those operations in the delegate's `<duty>.py` and `impl/<duty>.py`; and `purge_tenant(ctx)` over the storage's, always on the manager itself, which the maintenance worker calls, its batch from the namespace's options, as `OrchestrationsOptions` carries it |
| `om/src/<name>/om/root.py`, `workers/maintenance/src/<name>/workers/maintenance/container.py` (first table) | the namespace's options taken by `build_managers`, and the worker's purge batch passed in them, as `orchestrations_options` is |
| `om/src/<name>/om/storage/roles.py`, `scopes.py` | the table in `TABLE_ROLES` and in `TABLE_SCOPES` |
| `om/src/<name>/om/<ns>/README.md`, `om/README.md` | the noun, what can happen to it, the rules that hold; in `om/README.md`, the noun in a paragraph of its own, linked to the namespace's README where it is first named, as the file is |
| `om/src/<name>/om/exceptions.py` (when a leaf is needed) | `<Ns>Exception(OmException)` once, then leaves that multiply-inherit a shape (`NotFound`, `Conflict`) |
| `workers/maintenance/src/<name>/workers/maintenance/main.py` | the namespace in `purges`, and, for a `SoftDeletable` entity, its purge past retention in `across`, as `media` is |
| `services/api/src/<name>/services/api/types/<ns>.py`, `services/<ns>.py`, `services/impl/<ns>.py`, `routers/<ns>.py` (unless `--no-api`) | the views and requests, the service interface, its impl, and the routes, shape `media`: every route tagged `<ns>`, and the module's docstring the routes by area. An operator route goes in the module's `operator` router under `/admin`, as `routers/work.py` holds, and calls the namespace's own service, so the admin service stays as it is; the operation is the namespace's manager's, which requires `OperatorPermission.READ` or `WRITE` of the `OperatorContext` and reads the org the route names through the tenancy operator manager's `get_org`, `NotFound` for an org that is not there |
| `services/api/src/<name>/services/api/services/__init__.py`, `services/impl/root.py`, `gateway/resolve.py` (when `<ns>` is new to the API) | the service interface's name in the `__all__ +=` list and its getter on the root, each below its marker, its impl built over the managers, and its `<Ns>Service` alias |
| `services/api/src/<name>/services/api/routers/__init__.py` (when `<ns>` is new to the API, or its `operator` router is) | the namespace's entry in `HOSTED`, naming each router `routers/<ns>.py` holds: its `router`, and its `operator` beside it |
| `clients/typescript/src/types/<ns>.ts`, `src/index.ts` (with the portal screen) | the facade type, which the screen names as `<ns>.<Name>`; a new module's `export type * as <ns>` line, in order |
| `apps/portal/src/queries/keys.ts`, `queries/<ns>.ts`, `app/routes.tsx`, `realtime/router.ts`, `realtime/router.test.ts` (with the portal screen) | a key whose first element is `<entity>`, the query hooks, the route, and, only when the entity pushes (its writes carry an outbox row, as a `core`-role entity's do and an `activity`-role entity's never do), `<entity>` in `PUSHED_ENTITIES` and its kinds in `SERVER_KINDS` |
| `apps/portal/src/app/AppNav.tsx` or `features/home/HomePage.tsx` (with the portal screen) | a link to the new screen, where the portal links its screens |
## Procedure
1. Write the type, the table and its migration, storage, the manager,
then the wire types and the routes, so each step has what it needs.
2. The migration grants nothing: the role's first migration grants
every later table by default privilege. A table whose reads all lead
with `org_id` declares its compound indexes and sets
`__org_id_index__ = False`, as `files.py` does; a feed's is
`(org_id, id)`. A unique key on a
`SoftDeletable` entity is a partial unique index
`WHERE deleted_at IS NULL`, and the memory impl refuses a duplicate
only among the living rows.
3. A create returns `bool`, `False` when the id is already written. With
a second unique key it returns `InsertOutcome` instead, as
`create_item` in `om/src/<name>/om/work/storage/` does. The manager
reads the row back by the key that collided, and returns it only when
its id is the id the create carried: a retry. A row under another id
holds a business key another request took, and the create raises the
namespace's leaf of `Conflict` (409), `<Entity><Key>Taken`. The work
manager returns the row on either, since its second key is an
idempotency key, which only a retry repeats.
4. Manager operations follow authorize, verify, copy, write, and return
the copy it wrote. Reads take `Permission.READ`, writes
`Permission.WRITE`. A `core`-role write carries
`outbox_row(ctx, "<ns>.<entity>.<created|updated|deleted>", <entity>.id, {})`,
and a second row when work follows. A versioned entity's row is
`versioned_row(ctx, <kind>, <entity>.id, <entity>.version)` instead,
with the version the write left, so the push carries it, as
`om/src/<name>/om/orchestrations/steps.py` writes it. An `activity`-role entity is
appended by `append_<entity>` and carries no outbox row. A list is a
page, `after` and a clamped `limit`, like `get_files`. A get or a
list leaves out a soft-deleted row, in both impls.
5. On a versioned entity the write is a compare-and-set against the
caller's expected version, as the orchestrations storage writes
against `expected_version`. The copy sets `version` to the expected
version plus one, and a mismatch raises `PreconditionFailed` (412).
At the edge, the get answers the version as a strong `ETag`,
`"<version>"`, set by a response type of `types/<ns>.py` that the
service impl returns, as `FileContentResponse` in `types/media.py`
sets its headers; the route declares the view as its
`response_model` and returns the response as it is, never setting a
header itself. The `PATCH` takes the expected version from `If-Match`
or the body's `expected_version`, and refuses a request with neither,
or with both and different, as `ValidationFailed`. The service impl
passes the caller's version through and never fills it from the row
it read, which would make the compare-and-set last writer wins.
6. The service impl translates and never decides. A partial update reads
the current entity through `get_<entity>`, copies the request's set
fields onto it (an absent field unchanged, an explicit null cleared
where the field is optional), and hands the whole entity to
`update_<entity>`. The create builds the entity with the id the
`Idem` dependency minted. `delete_<entity>` is a soft delete; the
hard delete is the sweep's purge, never a route's.
7. The tests hold, beyond one case per operation: for every storage
method, the case that passes another tenant's id (another person's
for `both`, another identity's for `identity`); for each unique key,
a second row refused by both impls; for a named atomic method, two
callers at once of which exactly one wins, over memory and over
Postgres; an update sent with another `created_by`, a cleared
`deleted_at`, or another value of a manager-owned field, which leaves
each as stored; on a versioned entity, a stale version refused and the
row left as it stood, and `412` at the edge; and the other tenant
naming this tenant's row on every route that takes one, answered as
an id that never existed. The row-level security test reads the scope
map, so it holds the new table once its scope is declared.
8. After the routes, `make openapi`, so the TypeScript and the Python
clients carry them, then the screen. A namespace new to the API gets
its own document and generated files, each a new file to commit.
## Output
As `../_shared/scaffold-conventions.md` states.