Use and extend Agent Barn’s three FastAPI surfaces while preserving their distinct authentication, tenancy, authorization, and transaction boundaries.
API outcome
What you will be able to do
By the end of this guide, you will be able to:
- Inspect the live API contract
- Authenticate a client against the Product API
- Call an Organization-scoped endpoint
- Trace a request through the backend layers
- Add an API capability behind the correct boundary
- Verify the change against migrated PostgreSQL
Consume the API
Building an integration or another client.
Focus on the generated OpenAPI contract, bearer authentication, Organization-scoped URLs, pagination, and HTTP errors.
Extend the backend
Contributing to Agent Barn.
Follow the route, service, and repository boundary, enforce permissions, design transactions deliberately, and cover the behavior with the appropriate tests.
The generated OpenAPI schema is the endpoint-level contract: it documents the current endpoints and DTOs. This guide explains the development workflow and the security and implementation boundaries those contracts sit inside.
Overview
Agent Barn has three FastAPI composition roots. They share dependency-injection and infrastructure foundations, but expose different routes and use different authentication contracts.
Product API
http://localhost:8000/api/v1
Runtime Ingest API
http://localhost:8001/ingest/v1
Communications API
http://localhost:8002/communications/v1The Product API serves authenticated users, Organization members, and Platform Administrators. Runtime Ingest accepts Tool Call telemetry from running Agents. Communications owns provider ingress, durable delivery exchange with Agent Runtimes, provider-driver callbacks, and Connection-scoped webhooks.
A credential issued for one boundary must not be reused at another. User access tokens are for the Product API, per-Agent Ingest keys are for Runtime Ingest, and Communications credentials are distinct from both.
Dependency direction
The normal backend dependency direction is:
HTTP request
│
▼
FastAPI route
│
▼
Service
├──────────────► Infrastructure adapter
│
▼
Repository
│
▼
PostgreSQLRoutes stay thin. Services make business and authorization decisions. Repositories own queries and persistence. Infrastructure adapters own external concerns such as Kubernetes, Slack, email, LiteLLM, OpenRouter, and encryption.
Choose the API boundary
Select the boundary before adding a route or writing a client.
Product API
- Local address
http://localhost:8000/api/v1- Authentication
- User bearer access token
- Intended caller
- Web app, human-operated clients, and user-context integrations
- Responsibilities
- Composition root: api/api_app.py. Serves Organizations, members, Agents, Agent Settings, Connections, Conversations, Tool Calls, costs, Templates, Skills, credentials, integrations, and Platform Administration.
Platform routes
- Local address
http://localhost:8000/api/v1/platform- Authentication
- Verified Platform Administrator user session
- Intended caller
- Platform administration tools
- Responsibilities
- Platform routes are a Product API route family, not a separate FastAPI composition root.
Runtime Ingest API
- Local address
http://localhost:8001/ingest/v1- Authentication
- Per-Agent Ingest key in a bearer header
- Intended caller
- Running Agent telemetry plugins
- Responsibilities
- Composition root: api/ingest_app.py. Accepts Tool Call telemetry only.
Communications API
- Local address
http://localhost:8002/communications/v1- Authentication
- Route-family-specific Communications credentials
- Intended caller
- Agent runtimes, platform drivers, and provider webhook systems
- Responsibilities
- Composition root: api/communications_app.py. Owns provider ingress, durable delivery, Runtime claims and replies, driver callbacks, webhook verification, outbound processing, and ingress supervision.
Product API route families
/auth/organizations/organizations/{organization_id}/members/organizations/{organization_id}/agents/organizations/{organization_id}/agent-settings/organizations/{organization_id}/communication-platforms/organizations/{organization_id}/agents/{agent_id}/connections/organizations/{organization_id}/agents/{agent_id}/conversations/organizations/{organization_id}/agents/{agent_id}/web-chat/organizations/{organization_id}/templates/organizations/{organization_id}/skills/organizations/{organization_id}/shared-credentials/organizations/{organization_id}/costs/integrations/platform/users/platform/organizations/platform/templates/platform/skills/platform/stats/platform/costs/platform/event-deliveries
Organization Agent Settings
GET /api/v1/organizations/{organization_id}/agent-settings
PUT /api/v1/organizations/{organization_id}/agent-settings
Communication Connections
GET /api/v1/organizations/{organization_id}/communication-platforms
GET /api/v1/organizations/{organization_id}/agents/{agent_id}/connections
POST /api/v1/organizations/{organization_id}/agents/{agent_id}/connections
PATCH /api/v1/organizations/{organization_id}/agents/{agent_id}/connections/{connection_id}
DELETE /api/v1/organizations/{organization_id}/agents/{agent_id}/connections/{connection_id}
GET /api/v1/organizations/{organization_id}/agents/{agent_id}/connections/{connection_id}/directory/{kind}
POST /api/v1/organizations/{organization_id}/agents/{agent_id}/connection-directory-preview
GET /api/v1/organizations/{organization_id}/agents/{agent_id}/connections/{connection_id}/app-package
GET /api/v1/organizations/{organization_id}/agents/{agent_id}/connections/{connection_id}/summary
GET /api/v1/organizations/{organization_id}/agents/{agent_id}/connections/{connection_id}/journal
POST /api/v1/organizations/{organization_id}/agents/{agent_id}/connections/{connection_id}/reconnect
POST /api/v1/organizations/{organization_id}/agents/{agent_id}/connections/{connection_id}/deliveries/{delivery_id}/retry
Conversations
GET /api/v1/organizations/{organization_id}/agents/{agent_id}/conversations/channels
GET /api/v1/organizations/{organization_id}/agents/{agent_id}/conversations/connections/{connection_id}/channels/{channel_id}/messages
Skills
/api/v1/platform/skills
/api/v1/organizations/{organization_id}/skills
/api/v1/organizations/{organization_id}/agents/{agent_id}/skillsOrganization Agent Settings are Organization-owned defaults and allowed-model settings, not per-Agent Runtime routes. Communication Connection routes are authenticated Product API management and diagnostics routes; they are distinct from the non-user delivery and provider-ingress routes under /communications/v1/....
Conversation reads are Connection-scoped because one Agent can own multiple Communication Connections. Skills have Platform, Organization, and Agent scopes; their scoped route families cover applicable files, versions, drafts, publishing, deletion, forks, and source updates.
Web Chat Product API routes
GET /api/v1/organizations/{organization_id}/agents/{agent_id}/web-chat/threads
PATCH /api/v1/organizations/{organization_id}/agents/{agent_id}/web-chat/threads/{thread_id}
DELETE /api/v1/organizations/{organization_id}/agents/{agent_id}/web-chat/threads/{thread_id}
GET /api/v1/organizations/{organization_id}/agents/{agent_id}/web-chat/messages
POST /api/v1/organizations/{organization_id}/agents/{agent_id}/web-chat/messages
POST /api/v1/organizations/{organization_id}/agents/{agent_id}/web-chat/threads/{thread_id}/stop
GET /api/v1/organizations/{organization_id}/agents/{agent_id}/web-chat/streamWeb Chat history/stream reads require effective activity.read; sending, stopping, renaming, and deleting require agent.update. History is scoped to the requesting user within the Agent. Organization membership alone does not expose an otherwise inaccessible Agent. These are Product API user routes, not the runtime control stream. See the Web Chat guide for history bounds and cancellation behavior.
Platform Costs uses /api/v1/platform/costs. All Platform cost routes require Platform Administrator authority and do not grant Organization membership.
The Runtime Ingest surface
The Runtime Ingest surface is intentionally narrow:
POST /ingest/v1/agents/{agent_id}/eventsConversation messages do not enter through Runtime Ingest. Communications owns provider message ingress and Conversation persistence.
The Communications surface
GET /communications/v1/agents/{agent_id}/control
POST /communications/v1/agents/{agent_id}/deliveries/claim
POST /communications/v1/agents/{agent_id}/deliveries/{delivery_id}/renew
POST /communications/v1/agents/{agent_id}/deliveries/{delivery_id}/replies
POST /communications/v1/agents/{agent_id}/deliveries/{delivery_id}/complete
POST /communications/v1/agents/{agent_id}/messagesRuntime Communications calls use the Agent's Communications bearer credential and X-AgentBarn-Communications-Version. Versions 1 and 2 are accepted by the shared runtime authentication path; the persistent control stream specifically requires 2. Keep version-1 claim/reply/complete compatibility distinct from the current runtime's version-2 operation. These credentials are not Product API user tokens or Ingest keys.
| Method | Path | Purpose |
|---|---|---|
| GET | /communications/v1/agents/{agent_id}/control | Version-2 SSE control stream announcing durable work and control signals. |
| POST | /communications/v1/agents/{agent_id}/deliveries/claim | Claim an inbound Delivery; returns 204 when none is pending. |
| POST | /communications/v1/agents/{agent_id}/deliveries/{delivery_id}/renew | Renew a live claim, optionally reporting awaiting_input. |
| POST | /communications/v1/agents/{agent_id}/deliveries/{delivery_id}/replies | Queue a reply against the source inbound Delivery. |
| POST | /communications/v1/agents/{agent_id}/deliveries/{delivery_id}/complete | Complete runtime processing. |
| POST | /communications/v1/agents/{agent_id}/messages | Accept an execution-bound interactive send or scheduled submission; returns 202 with Delivery ID/status. |
The control stream provides wakeups, not token-by-token model output. PostgreSQL remains authoritative for durable Delivery state. Hermes uses asynchronous /v1/runs locally; OpenClaw uses its chat-completions path.
Waiting for human input
POST .../deliveries/{delivery_id}/renew?awaiting_input=true renews the live claim while reporting that its run is parked for a human answer. The flag defaults to false and is reported again on lease heartbeats. A waiting claim does not block the next inbound answer on the same ordering key. A newly claimed Delivery starts with the flag false. This is processing state on a claim, not a new terminal Delivery status, an authorization bypass, or a revival of an expired claim.
Initiated-message context
The initiated-message request contains text, idempotency_key, destination, and context. An interactive context carries the server-issued execution_token from its inbound claim and permits only an explicit target on that claim's Connection. Scheduled context carries a stable run_id and permits an origin or the configured default. The trusted runtime adapter records this context; the model must not invent an execution token, run identity, or Connection selection.
The destination is resolved and persisted before acceptance. Duplicate submission identity does not create a new message, and retries do not choose a new recipient. Current Connection policy is rechecked before provider delivery. Only Slack advertises initiated delivery at this baseline. A 202 response is acceptance, not provider confirmation. See the Slack and runtime guides for setup and limitations.
Provider ingress authentication
| Method/path | Authentication and addressing |
|---|---|
POST /communications/v1/connections/{connection_id}/events | Connection driver credential with X-AgentBarn-Driver-Version: 1. |
POST /communications/v1/webhooks/{connection_id} | Addressed plugin verifies provider authentication; Teams uses this Connection-specific route. |
POST /communications/v1/webhooks/email/inbound | Deployment EMAIL_INBOUND_SECRET bearer authentication; the recipient address resolves the Email Connection. |
Email's route is shared, not a per-Connection webhook URL. Do not apply the runtime Communications version header or driver credential requirements to Email ingress. User Product tokens, runtime keys, driver keys, and provider authentication are separate boundaries.
Run the API
Full local stack
Follow Run Agent Barn locally and start the stack from the repository root.
make setup
make runAPI-focused development
Start PostgreSQL, apply migrations, and run the Product, Runtime Ingest, and Communications applications.
make db-up
make migrate
make dev-apimake dev-api starts:
- Product API on port
8000 - Runtime Ingest API on port
8001 - Communications API on port
8002
It also supplies the host-facing Ingest URL used by Agents running in the local Kubernetes cluster.
make dev-communicationsVerify the API and its database connection
curl --fail-with-body \
http://localhost:8000/api/v1/healthA healthy response is:
{
"status": "ok",
"db": "connected"
}A 503 response means the API process is reachable but cannot query PostgreSQL.
Inspect the contract
FastAPI publishes interactive documentation and the machine-readable OpenAPI schema for each mounted application.
Swagger UI
Interactive documentation for each mounted application.
- Product API
http://localhost:8000/api/v1/docs- Runtime Ingest API
http://localhost:8001/ingest/v1/docs- Communications API
http://localhost:8002/communications/v1/docs
OpenAPI JSON
The machine-readable endpoint-level contract.
- Product API
http://localhost:8000/api/v1/openapi.json- Runtime Ingest API
http://localhost:8001/ingest/v1/openapi.json- Communications API
http://localhost:8002/communications/v1/openapi.json
Health
Operational health endpoints vary by application boundary.
- Product health
http://localhost:8000/api/v1/health- Communications health
http://localhost:8002/health
Metrics
Prometheus exposition on each application root.
- Product API
http://localhost:8000/metrics- Runtime Ingest API
http://localhost:8001/metrics- Communications API
http://localhost:8002/metrics
Use the OpenAPI document to confirm:
- HTTP method and path
- Required path and query parameters
- Request content type
- Request DTO
- Response DTO
- Validation constraints
- Status codes
- Whether bearer authentication is required
Treat the response DTO as the external contract. A database model may contain encrypted, internal, or operational fields that must never appear in an API response.
Contract conventions
| Concern | Convention |
|---|---|
| Identifiers | UUID strings |
| Timestamps | Timezone-aware ISO 8601 values |
| JSON fields | Python and Pydantic field names, normally snake_case |
| Collection pagination | page, page_size, total, and items |
| Page numbering | Starts at 1 |
| Partial updates | PATCH with only explicitly supplied fields applied |
| Standard errors | FastAPI detail response |
| Validation errors | 422 with structured field errors |
| Deletes without a response body | 204 No Content |
A paginated response has this general shape:
{
"page": 1,
"page_size": 15,
"total": 2,
"items": [
{
"id": "00000000-0000-0000-0000-000000000000"
}
]
}Do not infer writable fields from a read response. Inspect the endpoint’s *Create or *Update schema.
Authenticate requests
Product API calls normally use a user-session bearer access token.
-
Log in
The login endpoint accepts OAuth2 form data, not a JSON body. Supply the user’s email through the form field named
username.curl --request POST \ --url http://localhost:8000/api/v1/auth/login \ --header 'Content-Type: application/x-www-form-urlencoded' \ --data-urlencode 'username=developer@example.com' \ --data-urlencode 'password=replace-with-your-password'The response contains an access token, refresh token, and bearer token type:
{ "access_token": "replace-with-returned-access-token", "refresh_token": "replace-with-returned-refresh-token", "token_type": "bearer" }The server also writes the refresh token to an HttpOnly cookie. Local and test environments use local-compatible cookie settings; deployed environments use secure cookie settings.
-
Send the access token
Store the returned access token in a temporary environment variable:
export AGENT_BARN_ACCESS_TOKEN='replace-with-returned-access-token'Confirm the authenticated user:
curl --fail-with-body \ http://localhost:8000/api/v1/auth/me \ --header "Authorization: Bearer ${AGENT_BARN_ACCESS_TOKEN}"All protected Product API requests use the same header:
Authorization: Bearer <access-token> -
Refresh the session
A browser client can refresh using the HttpOnly cookie:
curl --request POST \ --url http://localhost:8000/api/v1/auth/refresh \ --cookie 'refresh_token=replace-with-refresh-token'A non-browser client can supply the refresh token in the request body:
curl --request POST \ --url http://localhost:8000/api/v1/auth/refresh \ --header 'Content-Type: application/json' \ --data '{ "refresh_token": "replace-with-refresh-token" }'Refresh tokens are rotated. Replace the stored access and refresh tokens with the values returned by the refresh request.
Scope requests
Authentication establishes who the caller is. It does not automatically grant access to every Organization or Agent.
Most Product API requests resolve access through this sequence:
- Authenticated user
A valid, unexpired, correctly typed bearer access token identifies the caller.
- Active Organization
The authentication dependency reads organization_id from the route and resolves a real persisted Membership.
- Organization Permission
The service requires the specific Permission its operation needs, resolved from the caller’s Organization Role.
- Agent visibility
Repository queries conceal Agents and subordinate resources the caller cannot see, before counting and pagination.
- Agent action Permission
Effective Agent permissions are resolved again on the server for every mutation.
Organization scope
Organization routes carry the Organization UUID in the URL:
/api/v1/organizations/{organization_id}/...For example:
export AGENT_BARN_ORGANIZATION_ID='replace-with-organization-id'
curl --fail-with-body \
"http://localhost:8000/api/v1/organizations/${AGENT_BARN_ORGANIZATION_ID}/agents?page=1&page_size=15" \
--header "Authorization: Bearer ${AGENT_BARN_ACCESS_TOKEN}"The authentication dependency reads organization_id from the route and resolves a real persisted Membership for the user.
Routes without an organization_id path parameter do not have an active Organization. Examples include login, the current-user endpoint, Organization creation, and Platform Administrator routes.
Platform scope
Platform administration routes live under:
/api/v1/platform/...These routes use the Platform Administrator dependency and do not synthesize an active Organization.
Agent scope
Agent authorization adds another layer beneath Organization Membership.
Organization Owners and Administrators receive the documented implicit Agent authority. Other members can receive access through:
- Explicit Agent Access
- An Agent Access Role
- Agent General Access, where applicable
- The union of effective Agent permissions
Agent and subordinate-resource queries must enforce visibility in the repository before counting or paginating results. This applies to:
- Communication Connections and their diagnostics
- Conversations
- Tool Calls
- Activity
- Logs
- Costs
- Secrets
- Skills assigned to an Agent
- Agent configuration
Agent and subordinate-resource visibility must be enforced in repository queries before counting and pagination. Mutations must resolve effective Agent permissions again on the server. Client-provided roles, permission flags, and allowed_actions values are never authorization proof.
HTTP security semantics
| Status | Meaning |
|---|---|
401 Unauthorized | The bearer token is absent, invalid, expired, revoked, or the wrong credential type |
403 Forbidden | The resource is visible, but the caller lacks the required action Permission |
404 Not Found | The resource does not exist or is concealed by Organization or Agent visibility |
409 Conflict | The request conflicts with lifecycle state, uniqueness, or another business invariant |
422 Unprocessable Entity | FastAPI or Pydantic rejected the request shape or field values |
Tenant-sensitive resources generally return 404 when an identifier belongs to another Organization. This prevents the response from confirming that the resource exists.
Organization administration can return 403 when the caller is known but lacks Organization authority. Always inspect the endpoint’s tested contract instead of converting every authorization failure to the same status.
Follow the request lifecycle
A normal Product API request follows this path:
- HTTP requestA client sends a bearer-authenticated request to a mounted application.
- FastAPI routePath, query, headers, and the request DTO are parsed and validated.
- Authentication and contextThe dependency resolves CurrentUserContext and, for Organization routes, the active Membership.
- ServiceBusiness rules, Organization and Agent permissions, orchestration, and domain-error translation.
- Repository or infrastructure adapterTenant-aware SQLModel queries, or Kubernetes, LiteLLM, OpenRouter, Slack, email, and encryption adapters.
- ResponseThe response DTO is serialized as the external contract.
Event-producing services branch here, before the response
- Domain-specific repository transactionOne session owns the business mutation and its event records.
- Outbox Message and Event DeliveriesAn immutable message plus one Event Delivery per intended handler.
- CommitBusiness state and delivery intent become durable together.
- Immediate dispatch or reconciliationHandlers run after the transaction boundary, at least once.
The business change and its event records must commit together. Event handling occurs after that transaction boundary.
The layer boundaries are deliberate. Each layer owns a distinct set of decisions.
Route
- Declare the HTTP method and path
- Declare request and response DTOs
- Resolve authentication dependencies
- Validate path and query parameters
- Receive injected services
- Call one service operation
- Return the result
A route must not contain a business workflow, SQL query, or database session.
Service
- Enforce business invariants
- Require Organization or Agent permissions
- Orchestrate repositories and infrastructure adapters
- Decide lifecycle and conflict behavior
- Translate domain failures into the appropriate API error
- Establish or call an explicit transaction boundary when required
Repository
- Own SQLModel and SQLAlchemy queries
- Scope tenant-sensitive records
- Apply Agent visibility before count and pagination
- Persist database state
- Expose explicitly typed public operations
- Own a multi-write transaction when the workflow requires one
Add an API capability
Start by reading the neighboring domain. New domains normally use:
api/domains/<domain>/
├── models.py
├── repository.py
├── service.py
└── routes.pyAdd another file only when it owns a distinct responsibility such as parsing, building, provider behavior, or domain exceptions.
The workflow below usually touches:
api/domains/<domain>/models.pyapi/domains/<domain>/repository.pyapi/domains/<domain>/service.pyapi/domains/<domain>/routes.pyapi/api_app.pyapi/ingest_app.py or api/communications_app.py when the boundary requires oneapi/migrations/versions/
Register the route in the correct composition root
- Product CRUD, read, administration, and user-context routes belong in
api/api_app.py. - Tool Call telemetry routes belong in
api/ingest_app.py. - Runtime Communications protocol, platform-driver, and provider-webhook routes belong in
api/communications_app.py. - The Communications application lifespan owns provider ingress supervision and outbound-delivery processing.
Do not register a route in the Product application merely because it ultimately reads or writes the same PostgreSQL database.
-
Define the contract
Create separate types for persistence and API input and output. Database models and API DTOs remain distinct types.
Use the established suffixes:
*Create*Update*Read*Filter
Keep encrypted and internal fields out of read DTOs. Use
default_factoryfor mutable defaults.For partial updates, distinguish an omitted field from a field explicitly set to
null:changes = update.model_dump(exclude_unset=True) -
Add repository behavior
Repositories own query composition.
An Organization-scoped lookup must include its tenant boundary in the query. Do not fetch a globally addressed row and filter it in the service afterward.
The Skill repository demonstrates the pattern:
def get_by_id_for_org(self, skill_id: UUID, org_id: UUID) -> Skill | None: with Session(self.delegate.engine) as session: return session.exec( select(Skill).where( col(Skill.id) == skill_id, or_( col(Skill.organization_id) == org_id, col(Skill.organization_id).is_(None), ), ) ).first()The
organization_id IS NULLbranch is specific to globally available built-in Skills. Do not copy that allowance into a domain whose records must always belong to one Organization.For Agent or subordinate-resource collections, reuse the existing accessible-Agent query pattern. Visibility must affect both the result query and the count query.
-
Add service behavior
Services derive the active Organization from
CurrentUserContext, enforce permissions, and coordinate the work.A representative Organization permission check is:
org_id = context.require_current_user_organization().organization_id self.permission_policy.require_organization( context, org_id, PermissionKey.SKILL_MANAGE, )For Agent resources, use the shared Agent authorization module rather than reproducing role checks:
agent = self.agent_authorization.require_action( context, agent_id, PermissionKey.AGENT_UPDATE, )Resolve grants on each request. Never trust a client-supplied role,
allowed_actionsvalue, or permission flag.Translate expected domain failures in the service:
Domain failure Status Invalid business input 400Missing or concealed record 404Visible but disallowed action 403Uniqueness or lifecycle conflict 409 -
Add a thin route
A representative route follows this shape:
skills_router = APIRouter( prefix="/organizations/{organization_id}/skills", tags=["skills"], ) @skills_router.post( "", response_model=SkillSummaryRead, status_code=status.HTTP_201_CREATED, ) def create_skill( data: SkillCreate, context: Annotated[ CurrentUserContext, Depends(get_current_user()), ], service: Annotated[ SkillService, Injected(SkillService), ], ): return service.create_skill(data, context)The route declares the contract and delegates the workflow. The authentication dependency resolves the Organization ID from the request path.
Use the established status conventions:
Operation Status Read 200Update returning a body 200Create 201Delete or bodyless action 204Prefer
204to custom success objects. -
Use dependency injection
Domain classes normally use
injector:@inject @singleton @dataclass class SkillService: repository: SkillRepository permission_policy: PermissionPolicyFastAPI routes receive services using
Injected(...).Do not construct repositories, services, infrastructure clients, or database engines inside route handlers.
Shared infrastructure providers are configured through
AppModuleand composed bycreate_injector(). Add a provider when construction requires configuration or a deliberately selected implementation. -
Register the router
Register a new Product API router in
api/api_app.pyusing the existing composition pattern:subapi.include_router(skills_router)Register runtime telemetry routes through
api/ingest_app.py. -
Synchronize consumers and documentation
When an API contract changes, check every consumer. Update, as applicable:
- Response and request DTOs
- UI Zod schemas
- UI query hooks and mutations
- Mock responses
- Playwright fixtures and expectations
- Generated or external clients
- Authoritative architecture or feature documentation
If a change revises a domain invariant, authorization boundary, lifecycle state, or operational contract, update the corresponding document in the same application-repository change.
Preserve atomicity
PostgresRepositoryDelegate is appropriate for ordinary persistence. Its operations open and commit a database session per operation.
That means this service workflow is not automatically atomic:
repository.save(first_record) → commit
repository.save(second_record) → commitIf the second operation fails, the first may already be durable.
Ordinary operation
Use the shared delegate when:
- One persisted operation is independently valid
- Partial completion is acceptable
- No outbox records must commit with the change
- The domain does not require a lock or concurrency-safe invariant
Explicit transaction
Use a repository-owned transaction when:
- Several writes form one invariant
- A quota or state transition must be concurrency-safe
- Rows must be locked
- An event must commit with the business mutation
- Partial persistence would leave invalid state
One transaction for business state and events
An event-producing mutation persists business state, an immutable Outbox Message, and the intended Event Deliveries in one transaction. The repository should own the session:
with Session(self.delegate.engine, expire_on_commit=False) as session:
session.add(resource)
session.flush()
outbox_repository.stage(
session=session,
registry=EVENT_REGISTRY,
event=event,
)
session.commit()The outbox staging operation must use the caller’s session. It must not open or commit another session.
After the transaction commits, enqueue immediate delivery or allow reconciliation to process the pending Event Deliveries.
Do not pass database sessions into routes. Avoid holding a database transaction open across a slow external network call unless the domain explicitly requires that failure behavior.
Evolve the schema
A database contract change is more than a model edit. Update all affected surfaces:
- Database model
- Request DTO
- Response DTO
- Repository query
- PostgreSQL constraints or indexes
- Alembic migration
- Integration-test setup and assertions
- UI Zod schema when the web app consumes the contract
- Authoritative feature or architecture documentation when an invariant changes
Create a migration from the repository root, review it, and apply it:
make makemigrations
make migrate
make check-migrationsReview the generated migration under api/migrations/versions/, then confirm the migration graph has one head with make check-migrations.
Exercise the immediate downgrade when rollback is supported:
make rollback
make migrateCheck PostgreSQL-specific behavior explicitly:
- Foreign-key ownership
- ON DELETE behavior
- Nullability
- Uniqueness
- Indexes
- Check constraints
- Enum creation and removal
- Server defaults
- Migration order
- Data backfills for existing rows
Test the contract
Choose the lowest test layer that proves the behavior reliably, then add integration coverage when the HTTP, authentication, tenancy, database, or composition contract is part of the change.
| Change | Minimum coverage |
|---|---|
| Service business rule | Focused unit or service test plus integration behavior |
| Route, request, response, or authentication contract | Integration test |
| Repository query or tenant visibility | Repository or integration test using PostgreSQL |
| Agent or subordinate-resource access | Agent RBAC and cross-Organization integration coverage |
| Database schema | Migration plus integration coverage |
| Infrastructure adapter | Focused unit test; integration test when wiring or real protocol behavior matters |
| Kubernetes behavior | Kubernetes integration target |
| UI-consumed response | API integration coverage plus UI schema and type verification |
How API integration tests run
API integration tests use:
- The real FastAPI application
- A migrated PostgreSQL database
- PostgreSQL Testcontainers
- Additive Injector overrides
- Reusable Given/When/Then steps
- PyHamcrest assertions
Test shape
A representative test uses Given/When/Then steps and PyHamcrest assertions:
def test_member_cannot_read_another_organizations_agent():
with given([
prepare_injector(),
prepare_api_server(),
create_test_client(),
there_is_a_user(),
there_is_an_access_token_for_user(),
there_is_an_agent_in_another_organization(),
]) as context:
with when("the member requests the other Organization's Agent"):
response = context.client.get(
context.agent_url,
headers={
"Authorization": f"Bearer {context.access_token}",
},
)
with then("the concealed Agent is not found"):
assert_that(
response.status_code,
equal_to(status.HTTP_404_NOT_FOUND),
)Keep shared setup in the existing test support structure instead of embedding large arrangements in every test.
What to cover
- Happy path
- Unauthenticated request
- Missing Organization Membership
- Insufficient Organization Permission
- Missing Agent access
- Visible Agent with insufficient action Permission
- Cross-Organization identifier
- Important validation failures
- Not-found behavior
- State or uniqueness conflicts
- Migration behavior when the schema changes
Verify the change
Run the API checks from the repository root:
make check-api
make test-api
make check-migrationsmake check-api runs:
- Ruff lint checks
- Ruff formatting checks
- Python type checking
Run Kubernetes integration only when the changed contract depends on Kubernetes behavior:
make test-api-k8sIf the API contract is consumed by the web app, also run:
make lint-ui
make check-ui
make test-uiBefore handing off the change, confirm
- The route appears in the Product, Runtime Ingest, or Communications OpenAPI document
- Request and response DTOs expose only intended fields
- Organization and Agent scoping occurs before results are returned
- The correct Permission is resolved on every mutation
- Cross-Organization identifiers cannot reveal resources
- All required writes share one transaction where necessary
- Schema changes include a reviewed migration
- Happy paths and important failures are covered
- Related UI schemas and documentation remain synchronized
- The change contains no unrelated refactor
Troubleshooting
404 conceals
A tenant-sensitive resource returns 404 when it belongs to another Organization or is hidden by Agent visibility. The response deliberately does not confirm that the resource exists, so a 404 is not proof of a bad identifier.
403 refuses
A 403 means the caller reached the scope and the resource is visible, but the required action Permission is missing. Fix the grant, not the identifier.
| Symptom | Likely cause | What to check |
|---|---|---|
| Route returns 404 even though the ID exists | Tenant or Agent visibility is intentionally concealing the resource | Confirm the Organization in the URL, persisted Membership, Agent Access, and repository scope |
| Route returns 403 | The caller can reach the scope but lacks the required action Permission | Check Organization Role, Agent Access Role, general access, and the Permission requested by the service |
| Every protected request returns 401 | Missing, expired, invalid, or incorrectly typed bearer token | Log in again, send the access token rather than the refresh or Ingest token, and check the Authorization header |
| Login returns 422 | Login was sent as JSON or omitted OAuth2 fields | Use application/x-www-form-urlencoded and put the email in the username field |
| New route is missing from Swagger UI | The router was not included by its composition root | Check api/api_app.py, api/ingest_app.py, or api/communications_app.py for the correct boundary. |
| Health returns 503 | PostgreSQL cannot be queried | Check DB_CONNECTION_URL, the database container, logs, and migration state |
| API starts but new columns are missing | The migration was not created or applied | Review Alembic heads and run make migrate |
| A multi-step operation leaves partial data | Several session-per-operation repository calls were treated as one transaction | Move the required writes into an explicit repository-owned session |
| Agents run but activity does not appear | Ingest is not running, or the Agent has the wrong Ingest URL or key | Use make dev-api, check port 8001, and inspect the Agent’s runtime configuration |
| Browser request is blocked by CORS | The browser origin is not in the API’s allowed local origins | Use the normal same-origin application path, or deliberately update the deployment’s CORS boundary |
| Integration tests cannot start PostgreSQL | Docker or Testcontainers is unavailable | Start Docker and verify that the configured PostgreSQL image can be pulled and run |
| A list count reveals more records than its items | Visibility was applied after counting or pagination | Apply the same accessible-resource scope to both the item and count queries |
| UI fails after a response change | UI schema and fixtures still describe the previous DTO | Update Zod schemas, hooks, mocks, and browser tests with the API contract |