Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -222,3 +222,9 @@ __marimo__/

# Streamlit
.streamlit/secrets.toml

# Claude Code local settings
.claude/settings.local.json

# Temporary agent working context, not tracked project documentation
.context/
208 changes: 208 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,208 @@
# Agent Instructions

Canonical repository instructions for any coding agent (Claude Code, Codex, or
otherwise). Read this before changing code. It states durable invariants, not
current task state.

## Project

Hymical Forms is a self-hostable service that accepts HTML form submissions
over HTTP, validates and stores them, and delivers them to a webhook with an
HMAC signature and a bounded retry schedule. It is preparing its first public
release and is not yet production-hardened; treat it as a maturing codebase
with real architectural guarantees, not a prototype.

## Architecture

These are current, load-bearing properties of the system. Preserve them unless
you have a specific, discussed reason to change one, and update the docs in
the same change if you do.

- FastAPI (`app.py`, `api/`) handles all HTTP. Nothing outside `api/` should
know about requests or responses.
- Two security boundaries exist: **public** (`POST /f/{endpoint_id}`,
`GET /health`, no credential, reachable from a raw HTML form) and
**management** (everything else, a `hym_live_...` bearer key). Do not blur
them, and do not add a new route without deciding which side it is on.
- PostgreSQL is the only supported production database. SQLite backs the fast
test suite and local experimentation only.
- All persistence goes through `models.py` (schema) and `storage.py`
(queries). No other module issues SQL.
- Alembic owns schema evolution. The API, the worker and the CLI never create
or alter a table; each checks on startup that the database is at the
revision the build expects and refuses to serve otherwise (`schema.py`).
- A submission and the obligation to deliver it are written in one database
transaction (the transactional outbox). The API never makes an outbound
HTTP request; `delivery.py` is the only module that does, and only the
worker calls it.
- The worker (`worker.py`) claims due deliveries from PostgreSQL (`SELECT ...
FOR UPDATE SKIP LOCKED` where supported) and sends them. PostgreSQL is the
queue: there is no broker.
- Delivery is at-least-once, never exactly-once. Do not add logic that assumes
a webhook receiver sees an event only once.
- The webhook payload shape and the `Hymical-Signature: v1=<hex hmac-sha256>`
header are a public contract (`webhooks.py`: `build_payload`,
`serialize_payload`, `sign`). Changing either breaks every existing
receiver; if you must, version it.
- Idempotency (`Idempotency-Key` header) is enforced by a database unique
constraint on `(endpoint_id, idempotency_key)`, not by an in-process check.
The lookup-then-insert race is resolved by catching the constraint
violation, not by locking ahead of time.
- Public-ingestion rate limiting is enforced by an atomic database upsert
(`storage.consume_rate_limit`), shared across every API process. It is not
per-process, in-memory, or best-effort.
- Submitted field values (form data) are sensitive. They are never logged.
Only the authenticated submission-detail and export routes return them.
- Retention deletion (`retention.py`) must never remove a submission whose
delivery is `pending`, `processing`, or `failed` (replayable). Only a
submission with no webhook, or one already `delivered`, is eligible.
- Every management route depends on the single authentication dependency in
`api/security.py` (`ManagementKeyDep`). Do not read the `Authorization`
header directly in a route handler.

## Code boundaries

| Module | Owns | Must not contain |
| --- | --- | --- |
| `api/health.py` | Liveness endpoint | Business logic |
| `api/submissions.py` | Public ingestion (`POST /f/{endpoint_id}`) | SQL, webhook sending |
| `api/endpoints.py` | Endpoint create/list/get/update (management) | SQL |
| `api/deliveries.py` | Delivery list/get/replay (management) | SQL, outbound HTTP |
| `api/submission_management.py` | Submission list/get/export (management) | SQL |
| `api/security.py` | The management auth dependency | Route-specific logic |
| `api/pagination.py` | The shared cursor design | Table-specific queries |
| `ingestion.py` | Endpoint ID and submission validation | HTTP, database |
| `webhooks.py` | URL/SSRF validation, payload, signing, retry policy | HTTP, database |
| `delivery.py` | The one outbound HTTP request | Retry scheduling, storage |
| `apikeys.py` | Management key format, minting, digesting | HTTP, database |
| `ratelimit.py` | Windows, subjects, client-address trust | Database, HTTP |
| `retention.py` | The retention eligibility rule | Queries |
| `export.py` | JSON/CSV rendering, formula escaping | Database, HTTP |
| `models.py` | The persisted schema (SQLAlchemy models) | Queries |
| `storage.py` | Every query and write | HTTP, domain validation |
| `schema.py` | The Alembic/app startup boundary | Migrations themselves |
| `worker.py` | The delivery process: claim, send, retry | HTTP route logic |
| `cli.py` | Operator commands: keys, retention cleanup | HTTP |
| `config.py` | Typed settings from `FORMS_*` env vars | Defaults used nowhere |
| `errors.py` | The shared JSON error envelope | Route-specific messages |

If you find yourself writing SQL in `api/`, or importing FastAPI into
`ingestion.py`, `webhooks.py`, `ratelimit.py`, or `apikeys.py`, stop and move
it to the right layer instead.

## Database and migrations

- Never edit a migration that has already been merged. Create a new Alembic
revision (`alembic revision --autogenerate -m "what changed"`) and read what
it produces before committing it.
- A migration must not import application code. Write out the SQLAlchemy type
directly (for example `sa.DateTime(timezone=True)`, not the app's
`UtcDateTime` decorator) so the migration stays a frozen record.
- Every constraint and index needs an explicit name (the naming convention in
`models.py` gives you one) so a later migration can reference it.
- Migration/model drift must stay at zero. A PostgreSQL integration test
asserts the migrations and the models describe the same schema
(`compare_metadata`); any schema change must keep it passing.
- A schema change needs PostgreSQL integration coverage under
`tests/integration/`, not just the fast SQLite suite, for anything touching
locking, constraints, or a migration.
- **SQLite migration caveat**: revisions through `0004` replay against SQLite
in Alembic batch mode. Revision `0005` does not (it alters two
mutually-referencing tables directly) and a fresh SQLite database cannot
reach `head` via `alembic upgrade head`. This is documented, not a bug to
silently work around; do not assume `alembic upgrade head` works on SQLite
when writing docs or scripts. PostgreSQL is unaffected. The fast test suite
builds its SQLite schema from the models with `create_all` instead of
replaying migrations.

## Security invariants

- A management API key is 256 random bits, prefixed `hym_live_`, and stored
**only** as a SHA-256 digest. It is shown once, at creation, by the CLI, and
never over HTTP.
- Digest comparison uses `hmac.compare_digest`. Malformed, unknown, and
revoked keys all produce the same `401 invalid_api_key`, with no detail
about which.
- A webhook signing secret (`whsec_...`) is server-generated and returned only
once, in the response of the mutation that created it.
- Outbound webhook bodies are signed HMAC-SHA256 over the exact transmitted
bytes (`Hymical-Signature: v1=<hex>`).
- Webhook destinations must be `http`/`https` and must not be a literal
loopback, private, link-local, multicast, reserved, or unspecified address.
Hostnames are **not** resolved, so this is a guardrail against mistakes, not
a complete SSRF defense. Do not describe it as more than that.
- Client addresses used for rate limiting are stored as a SHA-256 digest
(optionally HMAC-keyed by `FORMS_RATE_LIMIT_IP_SECRET`), never raw.
- Submitted form field values are never logged. The only routes that return
them are the authenticated submission-detail and export routes.
- CSV exports escape formula-injection leaders (`= + - @` and leading tab/CR)
with a text marker; do not remove this when touching `export.py`.
- Error responses never leak database/driver internals, stack traces, or file
paths. A storage failure is an opaque `503`; an unhandled exception is an
opaque `500`.
- `POST /f/{endpoint_id}` and `GET /health` are the only unauthenticated
routes. Every other route requires a valid management key.

## Repository style

Docstrings follow this exact shape:

```python
def example_function(value):
"""
description of function
:param value: description of parameter
:returns: description of return value
"""
```

- Descriptions start lowercase and carry no trailing punctuation.
- Use `:param name:`, `:returns:`, and `:raises:` when a function can raise
something the caller should know about.
- Never use `:return:`, `:rtype:`, or `:arg:`.
- A test whose name already says what it proves does not need a docstring.
Do not add one just to have one.
- No em dashes anywhere in the repository: not in code, comments, docstrings,
Markdown, YAML, TOML, migrations, tests, or error messages.
- Comments explain *why*, not what the code already says.

## Verification

```bash
pytest # fast suite, in-memory SQLite, no services needed
ruff check . # lint
ruff format --check . # formatting check
mypy # strict type check, over src and tests
```

PostgreSQL integration suite (needs a real, disposable database):

```bash
export HYMICAL_TEST_POSTGRES_URL=postgresql+psycopg://forms:forms@localhost:5432/forms_test
pytest tests/integration -m postgres
```

Documentation, only if you touched `docs/` or `mkdocs.yml`:

```bash
pip install -e ".[docs]"
mkdocs build --strict
```

All of the above run in CI. Run the ones relevant to what you changed; you do
not need to run the PostgreSQL suite for a documentation-only change, and you
do not need `mkdocs build` for a code-only change.

## Working rules

- Inspect the existing implementation before modifying it. Do not assume; grep
and read.
- Prefer extending an established pattern over introducing a new abstraction.
- Do not refactor unrelated working code while making a change.
- Do not weaken or delete a test to make a change pass. Fix the change.
- Add a test for behavior that needs coverage, not to inflate a count.
- Keep documentation synchronized with behavior. A stale doc is a bug.
- Report limitations honestly. Do not call something production-ready or
complete when it is not.
- Never commit, push, merge, tag, release, or publish unless explicitly
instructed to in the current conversation.
19 changes: 19 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
# Claude Code Instructions

Read `AGENTS.md` before making changes to this repository.

`AGENTS.md` is the canonical source for project architecture, security
invariants, repository conventions, testing requirements, and working rules.
Do not duplicate it here and do not treat this file as a second copy of it.

Also read the relevant MkDocs pages under `docs/` before changing behavior in
an area they document, so a change and its documentation do not drift apart.

If `.context/context.md` exists, read it for current task context. Treat it as
temporary working context, not as authoritative project documentation: it may
be stale, and it is not a substitute for reading the code.

When instructions conflict, current user instructions take precedence over
temporary context. Do not silently override an established invariant in
`AGENTS.md` because of something written in `.context/context.md`; surface the
conflict instead.
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,11 +72,11 @@ crash cannot lose work the service already acknowledged.

```mermaid
flowchart TD
Form["HTML form"] -->|"public submission"| API["FastAPI API process"]
Operator["Operator"] -->|"authenticated management routes"| API
API --> DB[("PostgreSQL")]
DB --> Worker["Delivery worker process"]
Worker -->|"signed HTTP POST"| Receiver["Your webhook receiver"]
Form["Browser / HTML form"] -->|"POST /f/{endpoint_id}, public"| API["FastAPI API"]
Operator["Operator"] -->|"authenticated management API"| API
API -->|"submission + delivery job, one transaction"| DB[("PostgreSQL")]
DB --> Worker["Delivery worker"]
Worker -->|"HMAC signed webhook"| Receiver["Developer endpoint"]
Worker --> DB
```

Expand Down
2 changes: 1 addition & 1 deletion docs/api/submissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ Every request that reaches it spends budget, whether or not it is accepted. See
Reports that the API process is running.

```json
{ "status": "ok", "service": "hymical-forms", "version": "0.1.0" }
{ "status": "ok", "service": "hymical-forms", "version": "0.2.0" }
```

**Public**, and not rate limited.
Expand Down
11 changes: 5 additions & 6 deletions docs/architecture/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,11 @@ Two processes, one database, no broker.

```mermaid
flowchart TD
Form["HTML form"] -->|"POST /f/{endpoint_id}"| API["FastAPI API process"]
Operator["Operator"] -->|"Authenticated management routes"| API
API --> DB[("PostgreSQL")]
DB --- Rows["submission + delivery job, one transaction"]
DB --> Worker["Delivery worker process"]
Worker -->|"Signed HTTP POST"| Receiver["Your webhook receiver"]
Form["Browser / HTML form"] -->|"POST /f/{endpoint_id}, public"| API["FastAPI API"]
Operator["Operator"] -->|"authenticated management API"| API
API -->|"submission + delivery job, one transaction"| DB[("PostgreSQL")]
DB --> Worker["Delivery worker"]
Worker -->|"HMAC signed webhook"| Receiver["Developer endpoint"]
Worker --> DB
```

Expand Down
6 changes: 5 additions & 1 deletion docs/getting-started/installation.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,11 @@ mkdocs serve
export FORMS_DATABASE_URL=sqlite:///./forms.db
```

Fine for trying the service out. Not a production target.
Backs the test suite and is not a production target. It is also not usable
for this walkthrough: a fresh SQLite database cannot reach the current
migration, `0005`, through `alembic upgrade head`. See
[Database migrations](../operations/migrations.md#sqlite). Use PostgreSQL to
actually run the service.

The PostgreSQL driver (`psycopg`) is a runtime dependency, so nothing extra needs
installing for either backend.
Expand Down
43 changes: 43 additions & 0 deletions docs/getting-started/quick-start.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,49 @@ The worker will have claimed it, attempted it, and either marked it `delivered`
or scheduled a retry. `GET /deliveries/{delivery_id}` shows the full attempt
history.

## 8. See a failed delivery, and replay it (optional)

Point an endpoint at a destination nothing is listening on, and give it a single
attempt, so it reaches `failed` right away instead of retrying for an hour first.
Stop the API and worker, export two more variables, and start them again:

```bash
export FORMS_ALLOW_PRIVATE_WEBHOOK_TARGETS=true # local demo only, never in production
export FORMS_WEBHOOK_MAX_ATTEMPTS=1
```

```bash
curl -X POST http://127.0.0.1:8000/endpoints \
-H "Authorization: Bearer $HYMICAL_KEY" \
-H 'Content-Type: application/json' \
-d '{"id": "broken-demo", "name": "Broken demo",
"webhook_url": "http://127.0.0.1:9/nothing-here"}'
```

```bash
curl -X POST http://127.0.0.1:8000/f/broken-demo -d hello=world
```

Give the worker a moment to claim and attempt it, then look for it:

```bash
curl "http://127.0.0.1:8000/deliveries?endpoint_id=broken-demo&state=failed" \
-H "Authorization: Bearer $HYMICAL_KEY"
```

Take the `id` from the response and replay it:

```bash
curl -X POST http://127.0.0.1:8000/deliveries/whd_REPLACE_WITH_THE_ID/replay \
-H "Authorization: Bearer $HYMICAL_KEY"
```

It goes back to `pending`, and the worker attempts it again on its next poll. Against
this same broken destination it fails again, which is expected: the point of the
exercise is the state transition, `failed` to `pending` to `failed`, not a
successful send. Point `webhook_url` somewhere real to see a replay succeed. Full
detail: [Delivery replay](../guides/delivery-replay.md).

## Where to go next

| You want to | Read |
Expand Down
39 changes: 27 additions & 12 deletions docs/operations/migrations.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,18 +69,33 @@ the DDL before anything touches it.

## SQLite

Migrations run against SQLite too, so local experimentation works the same way:

```bash
export FORMS_DATABASE_URL=sqlite:///./forms.db
alembic upgrade head
```

Migrations that alter a column are written in batch mode, because SQLite cannot
`ALTER` in place and has to rebuild the table instead. This is configured already;
it is not something a migration author has to remember.

SQLite remains unsupported as a production target.
Migrations through `0004` replay against SQLite too, in batch mode, because
SQLite cannot `ALTER` a column in place and has to rebuild the table instead.

!!! warning "A fresh SQLite database cannot reach `head`"

Revision `0005` alters `webhook_deliveries` and `delivery_attempts` directly
rather than through batch mode, because both tables carry foreign keys
between them and rebuilding one while SQLite enforces the other's key is not
a rebuild batch mode can do safely. That revision's own docstring explains the
reasoning: it is written for PostgreSQL, which performs every one of its
operations in place, and SQLite is not its migration target.

So `alembic upgrade head` against a fresh SQLite database applies `0001`
through `0004` and then fails on `0005` with a plain SQL syntax error, not
with a schema this build can serve. There is no workaround short of
PostgreSQL: a database stopped at `0004` is a schema this build's startup
check refuses to run against, the same as any other outdated revision.

This is why the test suite does not migrate its SQLite database at all: it
builds the schema straight from the models with `create_all` and stamps it as
fully migrated, and a PostgreSQL-only test asserts that what that produces and
what the real migrations produce are the same schema. See
[Drift is a build failure](#drift-is-a-build-failure) and
[Limitations](../reference/limitations.md).

SQLite remains unsupported as a production target, and is no longer usable for
trying the service out end to end either. Use PostgreSQL.

## Writing a migration

Expand Down
Loading
Loading