Framework reference for agents

A dense, scannable summary of Austial’s surface area, organised by the question an agent actually has while coding. Each row links to the full page. When this summary and a full page disagree, the full page wins.

Request pipeline#

middleware -> guards -> pipes -> interceptors (wrapping) -> handler -> exception filters

Guards run first, so unauthorised requests never pay for validation or reach handler code. Exceptions raised anywhere in the pipeline, including FastAPI’s own validation errors, flow through the exception filters.

Building blocks#

ConceptDeclare withRegistered inPage
Module@Module(imports=, controllers=, providers=, exports=)an imports=[...] chain reaching AppModuleModules
Controller@Controller("prefix") + @Get/@Post/@Put/@Patch/@Delete/@Options/@Heada module’s controllersControllers
Provider@Injectable()a module’s providersProviders
Guardsubclass CanActivate, can_activate(context) -> bool@UseGuards(...) or app.use_global_guards(...)Guards
Pipesubclass PipeTransform@UsePipes(...) or app.use_global_pipes(...)Pipes
Interceptorsubclass NestInterceptor, intercept(context, call_next)@UseInterceptors(...)Interceptors
Exception filtersubclass ExceptionFilter + @Catch(...)@UseFilters(...) or app.use_global_filters(...)Exception filters
Middlewaresubclass NestMiddlewareconfigure(consumer) on a moduleMiddleware
Custom provider{"provide": TOKEN, "useClass"/"useValue"/"useFactory": ..., "inject": [...]}a module’s providersCustom providers
Dynamic modulestatic method returning DynamicModuleimports=[X.for_root(...)]Dynamic modules

Handler parameters#

Python has no parameter decorators, so request data is bound with marker defaults and type hints:

You wantWrite
JSON body validated by a DTOdto: CreateCatDto = Body()
Path parameterid: int (matches :id in the route) or key: str = Param("key")
Catch-all path (matches /)route files/:key*, then key: str = Param("key")
Query string valuelimit: int = Query(default=10)
Headerx_request_id: str | None = Headers("x-request-id")
Authenticated principaluser=CurrentUser() (austial.auth)
Tenant contextinject TenantContext in the constructor
Status code / header@HttpCode(204), @Header("Cache-Control", "no-store")

Errors#

Raise these from anywhere; never build the response yourself. Every one takes (message: str | dict | None = None).

ExceptionStatusExceptionStatus
BadRequestException400ConflictException409
UnauthorizedException401UnprocessableEntityException422
ForbiddenException403TooManyRequestsException429
NotFoundException404InternalServerErrorException500
MethodNotAllowedException405ServiceUnavailableException503

Response shape: {"statusCode", "message", "error", "timestamp", "path"}.

Data access (austial.orm)#

TaskAPI
Define a table@Entity() class with PrimaryGeneratedColumn(), Column(), relation markers
Wire the connectionOrmModule.for_root(type_="postgres" | "sqlite", url=..., entities=[...]) or for_root_async(use_factory=..., inject=[ConfigService], entities=[...])
Wire a feature’s entitiesOrmModule.for_feature([Cat]) in the feature module
Get a repositoryrepo: Repository[Cat] = InjectRepository(Cat)
Readfind, find_one, find_by, find_one_by, count
Writesave (insert when PK unset, else update), insert (always insert), update(criteria, partial), delete(criteria), remove(entity)
FilterFindOptions(where=..., relations=[...], order_by=..., skip=, take=); wrap values in In([...]), NotIn([...]), IsNull(), NotNull()
Aggregaterepo.sum(column, criteria=, group_by=), repo.aggregate("SUM" | "AVG" | "MIN" | "MAX", ...)
Complex queryrepo.create_query_builder("alias") with .where(sql, params), .where_eq, .where_in, joins, .get_many()
Transactionawait data_source.transaction(async_callback)
Immutable rows@Entity(append_only=True) (audit, ledger)
Initialiseawait app.get(DataSource).initialize() at startup

Relations (OneToOne, OneToMany, ManyToOne, ManyToMany) take a class or a lambda: Class target, and pass join_column=JoinColumn(...) as a keyword argument. Full detail: Database.

Feature packages#

NeedPackageWire withPage
Environment configaustial.configConfigModule.for_root(), then ConfigService.get / get_or_throwConfiguration
Login, JWT, MFA, refresh tokensaustial.authAuthModule.for_root(AuthConfig(jwt_secret=..., issuer=...)), AuthGuard, @Public()Authentication
Roles and permissionsaustial.authzAuthzModule.for_root(), RolesGuard, @Roles, @Permissions, @AuthorizeObjectAuthorization
Tenant isolationaustial.tenancyTenancyModule.for_root(), TenantGuard, @RequireTenant(), Tenanted mixinMulti-tenancy
Headers, rate limit, idempotency, CSRF, PII, encryptionaustial.securitySecurityModule.for_root(SecurityConfig(...))Security
Who did whataustial.auditAuditModule.for_root(AuditConfig(...), store=OrmAuditStore(repo)), @AuditedAudit log
Background workaustial.jobsJobsModule.for_root(JobsConfig()), @Job, @Cron, JobQueue.enqueueBackground jobs
Filesaustial.storageStorageModule.for_root(StorageConfig(...)), StorageService, SignedUrlServiceFile storage
Schema changesaustial.migrationsMigrationsModule.for_root(...), austial-migrate CLIMigrations
Translationsaustial.i18nI18nModule.for_root(I18nConfig(...)), LocaleMiddlewarei18n
Liveness / readinessaustial.terminusHealthCheckService plus indicatorsHealth checks
Testsaustial.testingTest.create_testing_module(...).compile()Testing

Guard ordering#

When stacking guards, list them in the order they must run:

@UseGuards(AuthGuard, TenantGuard, RolesGuard)

TenantGuard and RolesGuard read the principal that AuthGuard publishes on request.state, so AuthGuard must come first. Rate limiting and idempotency guards usually go before AuthGuard so abusive traffic is rejected cheaply.

CLI#

austial new <name> [--skip-install] [--skip-git] [--link PATH]
austial g module|controller|service|resource <name>
austial serve [--host] [--port] [--reload/--no-reload]
austial-migrate init | revision -m "msg" --autogenerate | upgrade head | downgrade -1 | current | history
austial-worker run src.app_module:AppModule

Known limits#

Do not build on features that do not exist yet:

  • No Scope.REQUEST. Only singleton (DEFAULT) and TRANSIENT.
  • Module imports / exports are not enforced as a visibility boundary. Write them correctly anyway.
  • CallHandler.handle() is a plain async continuation, not an RxJS Observable.
  • DataSource.initialize() is a manual step. There is no async module-init hook.
  • Only PostgreSQL (asyncpg) and SQLite (aiosqlite) drivers exist.
  • The Tenanted mixin’s tenant_id is invisible to mypy’s synthesised __init__. Declare the column on the entity directly if strict typing matters more than reuse.

Gotchas that cost agents the most time#

SymptomCauseFix
Route returns 404 / not in /docsController or module not registeredAdd to controllers=[...] and import the module from AppModule
Cannot resolve DI errorProvider missing from any module’s providersRegister it; check the __init__ type hints are real classes
InjectRepository fails to resolveEntity not registeredOrmModule.for_feature([Entity]) in the owning module, and the entity in for_root(entities=[...])
save() on a UUID-keyed row updates nothingsave() treats a set PK as an UPDATEUse insert()
where={"col": None} matches nothingCompiles to = NULLUse IsNull()
App works with python src/main.py, tables missing under uvicornbootstrap() never runs under uvicorn src.main:appInitialise DataSource in a startup event handler
Rate limit keyed on the proxy’s IPX-Forwarded-For ignored by defaultSet SecurityConfig.trusted_proxy_hops
CurrentUser() is None on a protected routeNo AuthGuard ran for that routeAdd the guard, or register it globally