diff --git a/.gitignore b/.gitignore index d3323fe..8f32fb1 100644 --- a/.gitignore +++ b/.gitignore @@ -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/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..899ed57 --- /dev/null +++ b/AGENTS.md @@ -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=` + 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=`). +- 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. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7c291a3 --- /dev/null +++ b/CLAUDE.md @@ -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. diff --git a/README.md b/README.md index c93c59f..f8d9e1f 100644 --- a/README.md +++ b/README.md @@ -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 ``` diff --git a/docs/api/submissions.md b/docs/api/submissions.md index d0c6acf..816dfba 100644 --- a/docs/api/submissions.md +++ b/docs/api/submissions.md @@ -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. diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md index 8aff1e8..bf7d2ce 100644 --- a/docs/architecture/overview.md +++ b/docs/architecture/overview.md @@ -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 ``` diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index fb75a7c..d0b9c8e 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -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. diff --git a/docs/getting-started/quick-start.md b/docs/getting-started/quick-start.md index 9d6c80f..6437e0c 100644 --- a/docs/getting-started/quick-start.md +++ b/docs/getting-started/quick-start.md @@ -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 | diff --git a/docs/operations/migrations.md b/docs/operations/migrations.md index 58c5a9b..0ad1367 100644 --- a/docs/operations/migrations.md +++ b/docs/operations/migrations.md @@ -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 diff --git a/docs/reference/limitations.md b/docs/reference/limitations.md index 62b50c4..e11361e 100644 --- a/docs/reference/limitations.md +++ b/docs/reference/limitations.md @@ -138,9 +138,16 @@ deploy it, and it is kept complete rather than flattering. absorb and the ingestion path never pays because it is not authenticated. A failure to write it is logged and ignored rather than allowed to turn a valid credential into a `401`. -- **SQLite is not a production target.** It backs the test suite and local - experimentation. It has no row locking, which several parts of this service rely - on. See [Concurrency](../architecture/concurrency.md). +- **SQLite is not a production target.** It backs the test suite. It has no row + locking, which several parts of this service rely on. See + [Concurrency](../architecture/concurrency.md). +- **A fresh SQLite database cannot be migrated to the current schema.** Revision + `0005` alters two tables with foreign keys between them directly rather than + through Alembic's batch mode, which is what SQLite needs to change a column at + all, so `alembic upgrade head` reaches `0004` on SQLite and then fails. It is + written for PostgreSQL, which does not have this restriction. The test suite is + unaffected: it builds its SQLite schema from the models rather than migrating + it. See [Database migrations](../operations/migrations.md#sqlite). ## Not implemented at all diff --git a/docs/releases/v0.2.0.md b/docs/releases/v0.2.0.md new file mode 100644 index 0000000..7d8f40f --- /dev/null +++ b/docs/releases/v0.2.0.md @@ -0,0 +1,130 @@ +# v0.2.0 + +The first release meant to be evaluated by someone other than its own author. +**This is not a claim that the service is production-ready.** It is a maturity +pass over what had already been built: version consistency, a landing-page +README, one architecture diagram, an end-to-end demo, a security and +operational review, and a documentation audit, all against the system as it +actually stands. + +## What this release is + +Reliable form ingestion and signed webhook delivery, self-hostable and +PostgreSQL-backed. + +- **Form ingestion and validation.** A public `POST /f/{endpoint_id}` route that + accepts a plain HTML form submission, with explicit limits on body size, field + count, field name and value length. No credential, because the URL sits in a + form's `action` attribute. +- **PostgreSQL persistence.** Every submission is stored, together with the + durable obligation to deliver it if its endpoint has a webhook. +- **Idempotency.** An `Idempotency-Key` header makes a retried submission + resolve to the original rather than storing the form twice, scoped per + endpoint and enforced by a database constraint. +- **A transactional webhook outbox.** The submission and its delivery job are + written in one database transaction. A `202` means the delivery is already + promised; a crash between accepting a form and queuing its delivery cannot + happen. +- **Signed webhook delivery.** HMAC-SHA256 over the exact bytes transmitted, a + destination allow-list that rejects loopback, private, link-local and + multicast literals, and no redirects followed. +- **A separate worker**, with leases, exponential backoff and crash recovery. + It claims due deliveries with `SELECT ... FOR UPDATE SKIP LOCKED` on + PostgreSQL, so any number of workers can run against the same queue without + coordinating. +- **Management API keys**, minted by an operator CLI and stored only as a + SHA-256 digest. The credential exists in the process for exactly as long as + the request that carried it. +- **Endpoint and delivery management.** Create, list, inspect and reconfigure + endpoints; list deliveries and their attempt history; replay a failed + delivery, which resumes the same logical delivery rather than starting a new + one. +- **Distributed rate limiting** on public ingestion, per source address and per + endpoint, enforced through an atomic database counter so it holds across + every API process rather than per replica. +- **Alembic migrations**, with a startup check that refuses to serve against a + schema this build was not written for, and a test asserting the migrations + and the models describe the same schema. +- **Real PostgreSQL integration testing**, including genuine concurrent-claim + and concurrent-replay behaviour against independent connections. +- **Submission retrieval and export.** Browse and filter stored submissions by + endpoint and time, read one back in full, and export a filtered range as + streamed JSON or as CSV with formula-injection escaping. +- **Retention cleanup**, run deliberately by an operator, which never removes a + submission a delivery could still need. +- **A documentation site**, covering installation, every guide, the full API + and error reference, operations, and the architecture. + +## What changed in this release + +This was a release-quality pass, not a feature interval, so the list above is +what the previous eleven intervals had already built. What this release adds on +top of that: + +- The package version is `0.2.0` everywhere it appears: `pyproject.toml` + (dynamic from `__init__.py`), the `/health` response, OpenAPI metadata, the + webhook `User-Agent`, and the documentation. +- One architecture diagram, shown identically on the README and in the + documentation, covering public ingestion, the authenticated management + boundary, the transactional outbox, the PostgreSQL-backed queue, and signed + outbound delivery. +- An end-to-end demo added to [Quick Start](../getting-started/quick-start.md): + install, configure PostgreSQL, migrate, create a key, run the API and the + worker, register an endpoint, submit a form, inspect the delivery, and + optionally force a delivery to fail and replay it. +- A security review across API key handling, the management authentication + boundary, webhook signing and SSRF guardrails, rate limit accounting, export + and CSV formula escaping, idempotency, error responses and retention + semantics. No exploitable issue was found; see + [Security](../architecture/security.md) for what was checked and what remains + a known, documented gap rather than a defect. +- A correction to the documented SQLite story: a fresh SQLite database can no + longer reach the current schema through `alembic upgrade head`, because + revision `0005` alters two mutually-referencing tables directly rather than + through Alembic's batch mode, which is what SQLite needs to change a column at + all. That revision is written for PostgreSQL, which does not share the + restriction, and PostgreSQL migrates and runs exactly as before. SQLite still + backs the test suite, which builds its schema from the models rather than + migrating it. See [Database migrations](../operations/migrations.md#sqlite). +- `.claude/settings.local.json`, a local tool-permission file that is not part + of this project, is now excluded from the repository and from the built + source distribution. + +## Verified before this release + +- The fast test suite (in-memory SQLite) and the PostgreSQL integration suite + both pass in full. +- Ruff lint, Ruff format check, and mypy in strict mode all pass with no + findings. +- `mkdocs build --strict` succeeds. +- The wheel and the source distribution both build from a clean tree, the wheel + carries every migration including the Alembic script template, and installing + the wheel into a fresh virtual environment is enough to run the CLI, the API + and the worker against PostgreSQL end to end. + +## Limitations worth knowing before you deploy this + +- **Delivery is at-least-once, never exactly-once.** Deduplicate on the + submission `id` in the signed payload. +- **A failed delivery is never retried on its own.** It stays `failed` until an + operator replays it; there is no alerting and no automatic sweep. +- **SSRF protection is partial.** Webhook hostnames are checked as literals and + never resolved, so a name that resolves to a private address still passes. +- **There is no user, account or role model.** Every valid management key can + do everything a management key can do. +- **Rate limiting bounds volume, not content.** There is no CAPTCHA, no spam + classification and no email verification. +- **Retention is never automatic.** Nothing is deleted until an operator runs + the cleanup command, and it never removes a submission a delivery could still + need. +- **A fresh SQLite database cannot reach this schema.** Use PostgreSQL; SQLite + is a test-suite backend only. See above. + +The full, honest list is in [Limitations](../reference/limitations.md), and it +is worth reading in full before you point this at real traffic. + +## Upgrading from a pre-release checkout + +There is no prior tagged release, so there is no upgrade path to document yet. +A database already migrated through the previous revisions reaches this one +with an ordinary `alembic upgrade head`. diff --git a/mkdocs.yml b/mkdocs.yml index ebec788..64130a2 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -121,3 +121,5 @@ nav: - Reference: - Data Handling: reference/data-handling.md - Limitations: reference/limitations.md + - Release Notes: + - v0.2.0: releases/v0.2.0.md diff --git a/src/hymical_forms/__init__.py b/src/hymical_forms/__init__.py index 1a58e53..7e2b365 100644 --- a/src/hymical_forms/__init__.py +++ b/src/hymical_forms/__init__.py @@ -2,6 +2,6 @@ hymical forms: reliable form ingestion and webhook delivery for developers """ -__version__ = "0.1.0" +__version__ = "0.2.0" __all__ = ["__version__"]