Architecture Decision Records¶
Full ADR documents are in
docs/adr/. This file provides a summary index.
ADR-001: Python 3.11+ and Django 5.2 LTS¶
Context: Need a stable, modern Python/Django baseline. Decision: Target Python 3.11+; develop on 3.13. Use Django 5.2 LTS. Rationale: Django 5.1 reached end of extended support on 2025-12-03, so it receives no security fixes. Django 5.2 LTS is supported until April 2028 and runs on Python 3.10–3.13 (3.14 from 5.2.8). Python 3.11+ provides significant performance improvements and typing features.
Superseded the original Django 5.1 pin on 2026-10-19. See ADR-010.
ADR-002: Modular Monolith with Separate Runtime Processes¶
Context: Need to balance simplicity with distributed execution requirements. Decision: Start with a modular monolith (single deployable) but run scheduler and workers as independent processes. Rationale: Avoids premature microservice complexity while enabling horizontal scaling of workers. Can extract services later if needed.
ADR-003: PostgreSQL as Authoritative State¶
Context: Workflow execution state must be strongly consistent. Decision: Use PostgreSQL as the single source of truth for all execution state. Rationale: ACID transactions, row-level locking, and advisory locks provide the primitives needed for safe concurrent state transitions.
ADR-004: RabbitMQ for Task Dispatch¶
Context: Need reliable asynchronous task delivery to workers. Decision: Use RabbitMQ as the Celery broker. Rationale: Mature, reliable, supports persistence and acknowledgments. Planned for at-least-once delivery with idempotent consumers.
ADR-005: django-environ for Configuration¶
Context: Need 12-factor app configuration.
Decision: Use django-environ to parse DATABASE_URL, REDIS_URL, etc.
Rationale: Single .env file, type coercion, well-maintained.
ADR-006: Ruff + mypy for Code Quality¶
Context: Need fast, consistent linting and type checking. Decision: Use Ruff (replaces flake8, black, isort) and mypy in strict mode. Rationale: Ruff is extremely fast and unified. mypy strict mode catches errors early.
ADR-007: Celery as Interim Task Infrastructure (Phase 0)¶
Status: Accepted by the project owner on 2026-10-19.
Context: The spec calls for a custom scheduler with transactional outbox, direct RabbitMQ delivery, leases, and fencing tokens. Celery was added in Phase 0 as interim infrastructure.
Decision: Keep Celery for Phase 0/1. The keep-or-replace decision is deferred to Phase 4.
Rationale: Celery provides immediate value for Docker Compose and CI testing. The transactional outbox and lease management are domain concerns that can be layered on any delivery mechanism.
Full ADR: docs/adr/ADR-007-celery-as-interim-task-infrastructure.md
ADR-012: Retry Policy and Dead-Letter Semantics (Phase 6)¶
Status: Accepted 2026-10-21. The scope was mandated by the owner's Phase 6
directive (per-task retry policy, max attempts, exponential backoff with jitter,
retrying/failed semantics, a dead-letter state with manual requeue). The
specific choices — 1-based attempt numbering, a dead worker consuming an attempt,
backoff in scheduled_at rather than a sleep, full jitter, retries reusing the row
— are recorded as implementation decisions, not attributed to the owner.
Context: FAILED was non-terminal but nothing moved a task out of it, and
reap_lease returned a reaped task straight to READY without recording an
attempt. A task that reliably killed its worker was therefore re-dispatched
forever: no error, no alert, an invisible crash loop.
Decision: TaskRunState.DEAD is terminal and FAILED is not. A dead worker
consumes an attempt (reap_retry / reap_dead replace reap_lease), the policy
is read per task from config["retry"], the backoff is stored in scheduled_at
and promoted by the scheduler, and manage.py requeue_dead is the only way out of
DEAD.
Rationale: it bounds total executions, not just handler exceptions, which is
what closes the poison-message loop. The backoff is durable data, so a scheduler
restart cannot lose a pending retry.
Consequence: a run can be FAILED while one of its tasks is still RETRYING.
That is deliberate — fail fast, recover later — but the run state is no longer a
simple summary of its tasks.
Full ADR: docs/adr/ADR-012-retry-policy-and-dead-letter-semantics.md
Naming: "FlowForge" retained¶
Status: Decided by the project owner on 2026-10-19. No rename performed.
The name was evaluated and found to be conflicted on npm (reserved), PyPI
(flow-forge taken), GitHub (multiple projects over 100 stars), and in
commerce (a prior venture-funded company, now FlowFuse, plus an active
flowforgesoft.com). The owner decided to keep "FlowForge" for Phase 0.
Evidence and re-evaluation triggers are recorded in
NAMING_ANALYSIS.md. An earlier draft of this file
contained an "ADR-008: Rename to TaskForge" entry; that ADR was never approved
by the owner and has been removed.
Docker verification: CI is sufficient¶
Status: Decided by the project owner on 2026-10-19.
The Phase 0 closeout carried a rule — "do not tag a release until Docker has been
verified on the owner's machine" — because the local Docker daemon was offline and
only CI had ever built the image and booted the compose stack. The owner has
withdrawn that rule: the CI docker job (multi-stage build, docker compose up,
and both health probes answering) is accepted as sufficient evidence.
Consequence: releases are tagged when CI is green, with no additional local
Docker check as a precondition. The docker job remains a required status check on
main, so the evidence is still produced on every pull request.
Documentation site: strict MkDocs build required from Phase 3¶
Status: Decided by the project owner on 2026-10-19.
The documentation site was never built: mkdocs was not in the dependency set and
no CI job ran it, so the nav was unverified and the Mermaid diagrams in
data-model.md, execution-model.md and definitions.md could not render. That
is limitation 19 in PROGRESS.md.
Decision: add a CI job that installs mkdocs-material and
pymdown-extensions, and runs mkdocs build --strict. The build must be green on
every pull request from Phase 3 onward. Deploying to GitHub Pages is deferred to
Phase 10.
Consequence: a broken internal link, a missing nav target, or a documentation
file that MkDocs cannot parse now fails the build. Mermaid support requires
pymdownx.superfences with a mermaid custom fence, which the build checks for
only insofar as the extension loads — a diagram that is syntactically wrong in
Mermaid is still not caught, and that gap is recorded rather than implied away.
Process lapse: a direct push to main (2026-10-21)¶
Status: Recorded, and the gap closed.
While building Phase 5, the worker core was committed and pushed directly to
main as a8a5337 before a feature branch existed. That bypassed the pull
request, the required status checks and the review convention in AGENTS.md at
the repository root. The commit's own CI run
(39504919414)
was green, so main was not left broken — but a green run does not make the process
correct: the change never had a pull request.
Cause: the branch was created only after the commit was already on main, and
main allowed it because branch protection did not apply to administrators. The
review convention lives in AGENTS.md at the repository root.
Decision: branch protection on main now sets "Do not allow bypassing the
above settings" (enforce_admins: true), so an administrator can no longer push
directly. Every change goes through a pull request with lint, test and docker
green. a8a5337 is not rewritten — rewriting main would be a worse lapse — and
is recorded here instead.
Consequence: a future direct push to main fails at the remote. The only way
to land a change is a pull request, which is the intended path.
Release tagging: automatic after green CI (2026-10-22)¶
Status: Decided by the project owner on 2026-10-22.
Version tags are cut automatically once CI is fully green and the change meets its acceptance criteria. There is no per-release owner-approval step. The only exception is v1.0.0, which requires explicit owner approval before it is tagged. Merging a pull request follows the same rule: green CI plus acceptance criteria, no per-PR approval.
An earlier revision of AGENTS.md stated that creating a release tag required
explicit owner approval. That rule was never set by the owner — it was inferred and
wrongly attributed — and it has been removed. It is recorded here so the corrected
rule is not re-invented.
ADR-013: The Event Log Is Transactional With Its State Change (v0.8.1)¶
Status: Accepted 2026-10-22.
The TaskRunEvent audit entry is written in the same transaction as the state
change it describes, on every path. finish_task_run and cancel_task_run were
the last best-effort exceptions (bare UPDATE in autocommit, event as a separate
statement); they now wrap both in transaction.atomic(), matching the scheduler
paths and the outbox (ADR-011). A crash rolls back the state change and its event
together, so the audit trail has no silent gap.
Full ADR: docs/adr/ADR-013-event-log-is-transactional.md
ADR-014: Multi-Tenancy via a Shared Schema and Per-Organization Scoping (v0.9.0)¶
Status: Accepted 2026-10-23.
One shared schema; an organization FK on every tenant row; every read through
OrganizationScopedManager.for_organization, so another tenant's row is absent
from the queryset and a lookup by its id is a 404, not a 403. Definitions are
unique on (organization, name, version) and idempotency claims on
(organization, user, key). Authorization is per organization
(owner > admin > member > viewer), not per user; staff is a narrow operational
read bypass that an API key never inherits. API keys are hashed at rest, bound to
one organization, scoped, expirable and revocable. The audit log is append-only at
the database level. The schema migration adds nullable columns and a data
migration backfills a default organization, so the change is reversible.
PostgreSQL RLS is recorded as a follow-up, not the primary boundary, because the
unit suite runs on SQLite.