System architecture
AWS resources
Everything is declared in template.yaml and deployed as one stack per environment.
| Logical id | Physical name | Role |
|---|---|---|
|
|
API Gateway REST API. Two |
|
|
Handler |
|
|
Handler |
|
event source mapping |
Reads the |
|
EventBridge rule |
|
|
EventBridge rule |
|
Both functions share the Globals block: python3.12, 512 MB, 120 s timeout, the
same environment variables, and the VPC subnets and security group for that environment
(needed to reach the internal API; stage uses the dev VPC shared with Messaging and Documents).
Each function gets its own IAM
role generated from SAM policy templates: CRUD on the eight em_actions_* tables, read on the
reports cache table (API only), GetSecretValue on its own secret and on common/crm, and
lambda:InvokeFunction on the Worker.
| The DynamoDB tables are not part of the stack. They predate it, hold production data, and are referenced by name. Deleting the stack never deletes data. |
Why two functions
The split mirrors what Zappa ran as prod and prod2, without the second deployment:
-
The API must answer fast and scale with traffic.
-
Background work (Slack, emails, event log writes, counter recalculation) must not hold the HTTP response, and the stream consumer must not compete with API concurrency.
Both functions are built from the same artifact and updated by the same deploy. For the few seconds while CloudFormation updates them one after the other, a task queued by the old API code can run on the new Worker code (or vice versa), so keep task signatures backward compatible across one release.
Code layers
lambda_handlers.py Lambda entry points (api_handler, worker_handler)
run.py app = create_app("config"); setup_sentry()
actionstools_app/__init__.py Flask app, CORS, flask-dynamo, table definitions, create_app()
actionstools_app/api.py flask-restx Api, ApiResource base classes, shared header parsers
actionstools_app/<resource>/ views.py (HTTP) → services.py (DynamoDB, rules) → models.py (schemas)
actionstools_app/tools/ async_task, error handling, permissions helpers, Sentry setup
config.py every setting, from os.environ
secrets_loader.py Secrets Manager → os.environ (imported first)
<resource> is one of notes, tasks, comments, files, settings, notifications,
activity, reports; ddb_counts holds the counter machinery. Services extend the CRUD
classes from pyb_flask_utils.rest.services, which implement soft delete, timestamps,
pagination and owner permission checks.
Rules that hold it together
-
Import order matters.
secrets_loadermust run before anything importspyb_flask_utilsorconfig, because both reados.environat import time. It is the first import inconfig.pyand inactionstools_app/init.py. See Configuration & secrets. -
Task arguments are JSON. Anything passed to an
@async_taskfunction goes throughsimplejsoninto a Lambda payload (max 256 KB). Decimals from DynamoDB are converted first (seeparse_decimal_to_intintasks/services.py). -
Only
actionstools_app.functions decorated with@async_taskcan be run by the Worker.*worker_handlerrejects anything else. -
BASE_PREFIXis/actionstoolsin production and empty elsewhere, matching the custom domain path. See request-lifecycle.adoc#paths.