pour-api-contract
Pour HTTP API — Contract Specification
Status: RATIFIED on 2026-04-25. All sections are binding. Steps B–G implement against this contract; the inspector validates conformance; deviations require a contract amendment first.
1. Purpose & Scope
This document defines the HTTP interface exposed by pour serve for the pour - PWA companion. It is the single source of truth for:
- Request/response shapes
- Status codes
- Error envelope
- Auth
- Content negotiation
- Timing semantics (incl. offline-queue capture time)
- Idempotency
Both the Rust server (src/server/) and the PWA client (web/) MUST conform. New endpoints require a doc update first.
This contract is internal to Pour. It is not a public API. There is no compatibility guarantee outside the bundled PWA. Versioning exists to coordinate the server and the bundled PWA across Pour releases.
2. Versioning — LOCKED
All endpoints are namespaced under /api/v1/.
- Why v1 from day one: cheap. Pour itself is pre-1.0; the API contract may change shape independently of the binary version. Renaming routes later costs more than carrying
/v1now. - The PWA reads
/api/v1/healthon load and refuses to render if the server returns a major version mismatch (schema_versionfield in health response). - A future
/api/v2/may coexist with/api/v1/during transitions; the server can mount both. - No alias from
/api/healthto/api/v1/health— keep one true path. The PWA always calls the versioned path.
3. Authentication — LOCKED (Step A)
Every /api/* request MUST authenticate. Static assets at / and the manifest do NOT require auth (the PWA shell is fetched before login is possible; auth is enforced on the JSON API).
Token storage: ~/.pour/secrets.toml mobile_token field (UUIDv4 simple format, 122 bits hex). Override via POUR_MOBILE_TOKEN env var.
Two acceptance modes, header is authoritative:
Authorization: Bearer <token>— authoritative. If present and the suffix is non-empty, the server uses only this. A wrong header value returns401regardless of any query string.?token=<token>query parameter — bootstrap-only. Consulted only when theAuthorizationheader is absent or has an empty Bearer suffix. Used by the QR-code first-visit URL. After first contact, the PWA stores the token inlocalStorageand switches to the header.
Comparison: constant-time (subtle::ConstantTimeEq). Plain == is forbidden.
Failure response: 401 Unauthorized with the standard error envelope and code: "unauthorized". No WWW-Authenticate header (we are not implementing an auth challenge flow).
4. Transport, Content Negotiation, Encoding — LOCKED
- Bind:
0.0.0.0:<port>(LAN-only by design). Off-LAN is the user's responsibility (Tailscale/ZeroTier). - Scheme: HTTP. TLS deferred to Phase 3.
- Request
Content-Type:application/json; charset=utf-8for bodies. Empty body forGET/DELETE. Unknown content types →415 Unsupported Media Type. - Response
Content-Type:application/json; charset=utf-8for all/api/*responses (including errors). - Encoding: UTF-8 only. No
gzip/brin Phase 1; payloads are small. Revisit if/api/v1/historyexceeds ~50KB typical. - Trailing slashes: rejected.
/api/v1/health/returns 404. The PWA never appends them. - Methods: only those listed per endpoint. Unsupported methods on a known path return
405 Method Not Allowedwith anAllowheader listing accepted methods.
5. Standard Response Envelopes — LOCKED
5.1 Success
There is no wrapping envelope. Each endpoint returns its own typed body. Successful status codes:
| Method | Success status |
|---|---|
| GET | 200 OK |
| POST (create) | 201 Created (with Location header where applicable) |
| POST (action, no resource) | 200 OK |
| PUT | 200 OK (upsert) |
| DELETE | 204 No Content |
5.2 Error
Every non-2xx response uses this envelope:
{
"error": {
"code": "string-machine-readable-slug",
"message": "Human-readable single-line message.",
"details": { /* optional, endpoint-specific structured data */ }
}
}
Standard code values:
| Code | HTTP | Meaning |
|---|---|---|
unauthorized |
401 | Missing or wrong token |
not_found |
404 | Resource does not exist (unknown module, unknown preset, etc.) |
method_not_allowed |
405 | Wrong HTTP verb on a valid path |
validation_failed |
400 | Request body or path parameters violate the schema |
unsupported_media_type |
415 | Wrong Content-Type |
payload_too_large |
413 | Body exceeds limit (default: 1 MiB; configurable later) |
transport_error |
502 | API transport failed (Obsidian REST API unreachable AND filesystem fallback unconfigured) |
write_error |
500 | Filesystem or REST API write failed |
internal_error |
500 | Unhandled error |
Validation errors include a details.fields array describing each rejected field:
{
"error": {
"code": "validation_failed",
"message": "Submit rejected because required fields are empty.",
"details": {
"fields": [
{ "field": "bean", "code": "required" },
{ "field": "ratio", "code": "invalid_number", "value": "1::15" }
]
}
}
}
6. Endpoint Reference
6.1 GET /api/v1/health — LOCKED
Liveness + capabilities probe. Already implemented in Step A; this section documents the response shape.
Auth: required.
Response 200:
{
"ok": true,
"version": "0.2.2",
"schema_version": "1",
"transport_mode": "API",
"vault_base_path": "/path/to/vault",
"capabilities": [
"composite_array",
"create_template",
"post_create_command",
"show_when",
"presets",
"history",
"idempotency_key",
"captured_at"
]
}
version— Pour binary version (fromCARGO_PKG_VERSION).schema_version— API schema major version (currently"1"). Mismatch = client should refuse to render.transport_mode—"API"or"FileSystem". The PWA surfaces this in a status pill.vault_base_path— for display only. Phone never reads vault files directly.capabilities— feature flags so future PWA versions can detect older servers.
6.2 GET /api/v1/config — LOCKED
Returns the full module/field/template schema. The PWA renders forms from this.
Auth: required.
Response 200:
{
"modules": [
{
"key": "coffee",
"display_name": "Coffee",
"icon": "☕",
"mode": "create",
"fields": [ /* see §7 */ ],
"callout_type": null,
"append_under_header": null,
"append_template": null,
"append_shallow": false,
"daily_link": false
}
],
"module_order": ["coffee", "me", "music"],
"templates": {
"bean": {
"path": "Coffee/Beans/{{name}}.md",
"fields": [ /* template field defs, see §7.4 */ ]
}
},
"vault": {
"date_format": "%Y%m%d",
"transport_mode": "API"
},
"config_version": "0.3.0"
}
modulesis an array, not a map, ordered permodule_orderwith unlisted modules sorted alphabetically. Order matters for dashboard tile rendering.mobile_visibledefaults totrue. A module withmobile_visible = falseinconfig.tomlis omitted from this response entirely (not included with the flag set false). The PWA cannot reveal hidden modules. Note:mobile_visibleis NOT echoed in the module object — it controls inclusion, not data. Clients must not depend on its presence.module_orderechoes only the keys that survive themobile_visiblefilter. A hidden module is removed from bothmodulesANDmodule_order. This guarantees the rendering order has no phantom keys.field_typestrings are the lowercasesnake_caseenum names:"text","textarea","number","static_select","dynamic_select","composite_array".targetis"frontmatter"or"body"ornull(use field-type default).show_whenrules ship verbatim — the client evaluates them (see §8).
Caching: response is Cache-Control: no-store. Config can change at runtime via the TUI configurator; the PWA refreshes on every navigation to the dashboard.
6.3 GET /api/v1/options/{module}/{field} — LOCKED
Resolves the 3-tier fallback for a dynamic_select field. Used by the PWA when opening a dropdown.
Auth: required.
Path params:
module— module key, e.g.coffeefield— field name, e.g.bean
Query params: none.
Response 200:
{
"options": ["Ethiopia Guji", "Yirgacheffe", "Sumatra Mandheling"],
"source_path": "Coffee/Beans",
"tier": "transport"
}
tier— which of the 3 tiers served the result:"transport","cache", or"empty". Lets the PWA show "live" vs "cached" badge if desired.source_path— echoed back so the PWA can label "Beans / Coffee" in the dropdown header.
404 if the module or field does not exist. 400 with code: validation_failed if the field is not a dynamic_select.
6.4 POST /api/v1/submit/{module} — LOCKED (the keystone)
Executes a form submit. Reuses the engine's autocreate::run + output::write_create / output::write_append + History::record pipeline.
Auth: required.
Headers:
Content-Type: application/json; charset=utf-8(required)Idempotency-Key: <opaque-string>(optional) — see §9
Request body:
{
"field_values": {
"bean": "Ethiopia Guji",
"dose_g": "18",
"yield_g": "36",
"ratio": "1:2",
"method": "Espresso",
"notes": "Bright, blueberry, mid-body."
},
"composite_data": {
"ingredients": [
["flour", "200", "g"],
["water", "150", "ml"]
]
},
"callout_overrides": {
"notes": "tip"
},
"callout_titles": {
"notes": "First taste"
},
"auto_create_inputs": {
"bean": {
"roaster": "Onyx",
"origin": "Ethiopia",
"process": "Washed",
"roast_level": "Light",
"bag_weight_g": "250"
}
},
"captured_at": "2026-04-25T14:33:02Z",
"client_id": "phone-pwa"
}
Field semantics:
| Key | Type | Required | Meaning |
|---|---|---|---|
field_values |
map<string, string> |
yes | All values are strings. Numbers cross the wire as strings ("18", not 18) — this matches the engine's internal model and avoids JSON number/string coercion. Empty strings = field not filled. |
composite_data |
map<string, string[][]> |
when present | Outer key = composite_array field name; inner = rows of cell values, all strings. Empty rows are stripped server-side. |
callout_overrides |
map<string, string> |
optional | field_name → callout_type for textarea fields with runtime callout cycling. |
callout_titles |
map<string, string> |
optional | field_name → callout title. |
auto_create_inputs |
map<string, map<string, string>> |
optional | When a dynamic_select field has create_template and the user typed a novel value, this carries the sub-form values. Outer key = parent field name; inner = template field values. Single round-trip: the autocreate fires before the parent submit, atomically from the client's perspective. |
captured_at |
ISO 8601 string | null | optional | The instant the user tapped Submit on the phone. Used for offline-queue replays — the History entry, the file's date frontmatter, and any strftime tokens in path/append_template resolve against this time, not the server's now. If null/absent, server uses Local::now(). |
client_id |
string | optional | Opaque label to disambiguate clients in logs / future telemetry. Free-form. |
Response 201 Created:
{
"vault_path": "Coffee/2026/20260425 18-30-42.md",
"transport_mode": "API",
"auto_created": [
{
"field": "bean",
"value": "Ethiopia Guji",
"vault_path": "Coffee/Beans/Ethiopia Guji.md",
"templated": true
}
],
"post_create_commands": [
{ "field": "bean", "command": "templater:run", "fired": true }
],
"history_id": "20260425T183042-coffee",
"captured_at": "2026-04-25T14:33:02Z"
}
Location: /api/v1/history/<history_id>header on 201.auto_created[].templated— whethercreate_templatewas used (sub-form path) vs bare-stub.post_create_commands[].fired—trueonly on API transport. Filesystem transport returnsfired: falsewith no error (engine treats this as best-effort, mirroring TUI behavior).
Error cases:
| Status | Code | Trigger |
|---|---|---|
| 400 | validation_failed |
Required field empty (and visible per show_when); invalid number format if engine rejects; unknown field name. |
| 404 | not_found |
Unknown module key, OR a known module with mobile_visible = false (the server enforces visibility on submit too — clients cannot bypass the /api/v1/config filter by guessing module keys). |
| 409 | idempotency_replay_in_flight |
Same Idempotency-Key is currently being processed (rare; race). Client should retry after a short delay. |
| 500 | write_error |
Engine returned an error from write_create/write_append. details.engine_error carries the underlying message. |
| 502 | transport_error |
API + filesystem both unreachable. |
Synthetic 202 (PWA service worker only — round 6 amendment):
The HTTP status 202 Accepted is NOT produced by this server endpoint. It is a client-side construct emitted exclusively by the PWA service worker to its own page when a submit is queued for offline replay. No server-side handler ever returns 202 from /api/v1/submit/*.
Clients written directly against this contract — including a future MCP companion (Phase 4) that bypasses the service worker entirely — will never see 202. They receive 201 on success or 4xx/5xx on failure as documented above.
The synthetic 202 body from the service worker is: { "queued": true, "queue_id": <idb-id>, "captured_at": "<from-body>" }. The page treats 202 as a "Queued" summary state (not "Saved"). This status code is documented here for completeness; it is not part of the server contract.
Engine semantics preserved:
- Hidden fields (
show_whenfalse) are NOT validated forrequiredand are NOT written. Server runsvisible_field_indicesserver-side as belt-and-suspenders even though the client should already have done so. - Hidden modules (
mobile_visible = false) reject submits with 404not_found. The server is the source of truth on module visibility; the PWA-side filter is not authoritative. This mirrors the/api/v1/configfilter and prevents clients from bypassing visibility by guessing module keys. - Auto-create is best-effort. If it fails, the parent submit still proceeds;
auto_created[]carries success records only, and a failure is reflected indetails.autocreate_warningsof a separatewarningsarray (200/201 with warnings is preferred over 207 Multi-Status).
{
"vault_path": "...",
"transport_mode": "FileSystem",
"auto_created": [],
"warnings": [
{ "code": "autocreate_failed", "field": "bean", "message": "..." }
],
...
}
6.5 GET /api/v1/history — AMENDED 2026-04-26
Recent capture log for the mobile dashboard (heatmap, last-pour, streak).
Auth: required.
Query params:
since— ISO 8601, inclusive lower bound. Optional.until— ISO 8601, exclusive upper bound. Optional. Direct timestamp filter ("older than X date"). Do NOT useuntilas a pagination cursor — usecursorinstead.cursor— opaque string (thenext_cursorfrom the previous response). When present, returns entries whose id is lexicographically less than the cursor. This is the correct way to paginate;untilalone is incorrect for pagination because same-millisecond entries share a timestamp and would be silently dropped at the boundary.limit— integer, 1–1000. Default 100.module— module key. Optional. Filters to one module.
Response 200:
{
"entries": [
{
"id": "20260425T183042123-0-coffee",
"module_key": "coffee",
"timestamp": "2026-04-25T18:30:42Z",
"vault_path": "Coffee/2026/20260425 18-30-42.md",
"first_field": "Ethiopia Guji"
}
],
"summary": {
"version": 1,
"last_pour": { /* HistoryEntry */ },
"today_count": 3,
"week_count": 14,
"streak_days": 7,
"per_module_today": { "coffee": 2, "me": 1 }
},
"has_more": false,
"next_cursor": null
}
summaryis included only when neithersincenoruntilfilters are present (i.e. the dashboard call). For windowed queries, it's omitted to keep the response small.has_more+next_cursorprovide cursor pagination.next_cursoris the opaqueidof the last returned entry. Pass?cursor=<next_cursor>to retrieve the next page.- Why opaque cursor instead of timestamp: entry ids have the format
YYYYMMDDTHHmmSSsss-<counter>-<module>and are lexicographically sortable = chronologically + counter sortable. The PWA's offline-queue replay can submit multiple entries at the exact same millisecond; a timestamp-only cursor would silently drop entries that share the cursor timestamp. The id-based cursor is exact. - Backwards compatibility: the old
next_untilfield is removed. Clients that relied onnext_untilmust switch tonext_cursor+?cursor=.
Note: the trim feature (pour trim) does NOT have a mobile equivalent in Phase 1. Destructive operations stay on the desktop where the confirmation flow lives.
6.6 GET /api/v1/captures/{history_id} — LOCKED
Read back the rendered file content of a prior capture. Enables agent and PWA tasks like "summarize my last 5 coffee logs" without giving the client direct vault access.
Auth: required.
Path params:
history_id— the opaqueidreturned byPOST /api/v1/submit/{module}and listed in/api/v1/history.
Response 200:
{
"id": "20260425T183042-coffee",
"module_key": "coffee",
"timestamp": "2026-04-25T18:30:42Z",
"vault_path": "Coffee/2026/20260425 18-30-42.md",
"content": "---\nicon: ☕\nbean: \"[[Ethiopia Guji]]\"\ndose_g: 18\n...\n---\n\nBright, blueberry, mid-body.\n",
"transport_mode": "API"
}
contentis the full UTF-8 file content (frontmatter + body) as written.vault_pathis echoed for context.transport_modereflects which backend served the read at this moment (may differ from the write-time mode).
Error cases:
| Status | Code | Trigger |
|---|---|---|
| 404 | not_found |
Unknown history_id, or the history entry exists but the underlying vault file has been deleted |
| 502 | transport_error |
Both API and filesystem unreachable for the read |
| 500 | read_error |
Underlying read failed for another reason; details.engine_error carries the message |
Engine surface used: the existing Transport::list_directory does not cover single-file reads. Step C adds Transport::read_file(vault_path) -> Result<String> to src/transport/mod.rs (one-line wrapper over the filesystem read; API path uses GET /vault/{path}). This is the only engine addition Phase 1 introduces beyond what was already in lib.rs.
Note: body size is bounded by whatever Pour just wrote. Vault content is trusted user data; no sanitization is performed before returning.
6.7 GET /api/v1/presets/{module} — LOCKED
Response 200:
{
"presets": [
{
"name": "Morning Onyx",
"description": "default for weekday espresso",
"values": { "bean": "Ethiopia Guji", "dose_g": "18", "method": "Espresso" }
}
]
}
Order matches the on-disk order (which the TUI lets users reorder via Ctrl+Left/Right).
6.8 PUT /api/v1/presets/{module}/{name} — LOCKED
Upsert a single preset. Body is the preset entry (without name).
Request body:
{
"description": "default for weekday espresso",
"values": { "bean": "Ethiopia Guji", "dose_g": "18", "method": "Espresso" }
}
Response 200 (upsert) or 201 Created (new):
{ "preset": { "name": "Morning Onyx", "description": "...", "values": {...} } }
name URL-encoded in the path. Names containing / are rejected (404 not_found-style mismatch from axum's matcher).
Reserved names: the literal name "order" (case-insensitive) is reserved and MUST NOT be used as a preset name. The route /presets/{module}/order (§6.10) is a fixed path segment registered before /{name} in the router; a preset named "order" would be permanently unreachable via single-preset endpoints. Attempting PUT /presets/{module}/order with a preset body (wrong DTO for §6.10) returns 400 validation_failed. Attempting the same via percent-encoding (e.g. ord%65r) reaches put_handler and is rejected with 400 validation_failed { code: "reserved_name" }. Clients MUST reject the name "order" (case-insensitive) in name-input validation before sending the request. (Amendment round 8 — 2026-04-27)
6.9 DELETE /api/v1/presets/{module}/{name} — LOCKED
Response 204 on success, 404 if not found.
6.10 PUT /api/v1/presets/{module}/order — LOCKED
Reorder presets within a module.
Request body:
{ "names": ["Morning Onyx", "Aeropress quick", "Decaf evening"] }
The full list of preset names must be supplied. Missing or extra names → 400 validation_failed with details.missing / details.extra. Duplicate names in the request → 400 validation_failed with details.duplicates. The TUI's reorder semantics are preserved.
Response 200 with the resulting { presets: [...] }.
7. Field Schema Marshalling — LOCKED
7.1 Field types
The field_type enum on the wire matches the Rust FieldType enum (snake_case):
text | textarea | number | static_select | dynamic_select | composite_array
7.2 Field config object (in /api/v1/config)
{
"name": "bean",
"field_type": "dynamic_select",
"prompt": "Bean",
"required": true,
"default": null,
"options": null,
"source": "Coffee/Beans",
"target": null,
"callout": null,
"callout_title": null,
"allow_create": true,
"wikilink": true,
"create_template": "bean",
"post_create_command": "templater:run",
"show_when": null,
"icon": "🫘",
"preset_exclude": false,
"list": false,
"sub_fields": null
}
All optional fields are present and explicitly null (not omitted). This keeps client decoders simple.
7.3 show_when shape
{ "field": "method", "equals": "Espresso" }
Or:
{ "field": "method", "one_of": ["Espresso", "Moka"] }
Client-side evaluation (see §8). Server still validates show_when rules at config load (existing validator).
7.4 Template field config
{
"name": "roaster",
"field_type": "text",
"prompt": "Roaster",
"options": null,
"default": null,
"allow_create": false
}
field_type restricted to text | number | static_select.
8. show_when Evaluation — LOCKED (per plan)
Client-side evaluation. The server ships rules verbatim in /api/v1/config; the PWA evaluates them on every field-value change. Rationale:
- Avoids a round-trip per keystroke that touches a controlling field
- Logic is trivial (~20 lines of JS) —
equalsandone_ofonly, case-sensitive - Server still re-evaluates server-side at submit as a safety net (engine's
partition_fieldsalready callsvisible_field_indices)
The PWA's visibility.js mirrors src/visibility.rs semantics:
- Hidden fields: skipped in render, navigation, and validation
- A field whose controlling field is empty/absent is hidden
- Self-reference / circular chains: handled at config-load time on the server; the client trusts the rules it receives
9. Idempotency — LOCKED
POST /api/v1/submit/{module} accepts an optional Idempotency-Key header.
- Format: opaque string, 1–256 ASCII printable characters. Recommended: a UUIDv4 generated client-side.
- Server behavior: on receipt, the server records
(key, response)in an in-memory LRU (capacity ~1024, TTL 5 minutes). If the same key is replayed within the window:- If the prior request returned a cacheable response: replay the same status + body byte-for-byte. The replayed response carries an
Idempotency-Replay: trueheader. - If the prior request is still in flight: return
409 idempotency_replay_in_flight.
- If the prior request returned a cacheable response: replay the same status + body byte-for-byte. The replayed response carries an
- Cacheable responses: only 2xx terminal successes (typically 201) are stored in the cache. 4xx and 5xx responses are NOT cached — these represent recoverable conditions (validation_failed: fix the field and retry; transport_error / write_error: retry when the underlying issue clears). Caching errors would block the user from recovering within the form session. The client SHOULD reuse the same
Idempotency-Keyfor retries after a recoverable error so a duplicate-write race never opens. - Different body, same key (cached 2xx): returns the original cached response (NOT a fresh execution). The client is responsible for rotating keys per submit attempt after success; reusing a key for a different payload after a 2xx is a client bug.
- Different body, same key (no cached response): each retry executes fresh. The server cannot detect "different body, same key" before execution; that's a client-discipline issue.
- Storage: in-memory only. Restarting
pour serveclears the cache. Acceptable: the offline queue's retry windows are seconds-to-minutes, not hours.
The PWA persists Idempotency-Key across retries within a form session, rotates it after a successful 2xx, and rotates again on form reset. Service-worker retries reuse the same key.
10. captured_at Timing Semantics — LOCKED (critical for offline)
When captured_at is present in a submit body:
- The History entry's
timestampfield is set tocaptured_at, NOT the server's receipt time. - The output file's
datefrontmatter (and any{{date}}/ strftime tokens inpath/append_template) is rendered usingcaptured_atin the server's local timezone. - The autocreated note's
datefrontmatter (whenauto_create_inputsis present) is also rendered againstcaptured_at.
When absent or null: server uses chrono::Local::now() exactly as the TUI does today.
Validation: captured_at must be within the past 30 days and not more than 5 minutes in the future. Outside this range → 400 validation_failed with code: captured_at_out_of_range. The 5-minute future allowance accommodates clock skew.
Why this matters: a festival capture queued at 11pm on Friday and synced at 9am Monday would otherwise be dated Monday. That breaks the entire premise of offline capture.
11. CORS — LOCKED
Same-origin only.
- The PWA is served from the same
pour serveinstance. Origin matches. - The server does NOT emit
Access-Control-Allow-Originheaders. - Pre-flight
OPTIONSrequests on/api/*return405 Method Not Allowed(we do not support cross-origin). - This intentionally blocks browser-based exfiltration from other origins even if a token leaks into a malicious page's reach.
12. Cache Headers — LOCKED
| Resource | Cache-Control |
|---|---|
/api/* (all JSON) |
no-store |
/ (PWA shell HTML) |
no-cache, max-age=0, must-revalidate (always revalidate; service worker handles offline) |
/static/* (JS, CSS, icons) |
public, max-age=300, must-revalidate (5 min) — content-hashed filenames could allow immutable in a future iteration |
/manifest.json, /sw.js |
no-cache, max-age=0, must-revalidate |
ETag headers MAY be set by axum's static-file layer; the contract does not require them.
13. Body Size Limits — LOCKED
POST /api/v1/submit/*— 1 MiB max.composite_arraydata is the primary contributor; 1 MiB allows several thousand rows of typical fields.PUT /api/v1/presets/*— 256 KiB max.- All other endpoints: 16 KiB max.
Exceeding the limit → 413 payload_too_large.
14. Logging & Telemetry — LOCKED
- Tokens MUST NOT appear in logs. The auth middleware logs only the auth outcome (
accepted_via_header,accepted_via_query,rejected), never the token itself. - Request bodies for
POST /api/v1/submit/*MUST NOT be logged at info level — they contain user content. Debug level is acceptable for local dev only. - Errors include the
codeslug in logs but not user input that triggered them (to avoid PII spillage in case of crash dumps). - No external telemetry. Pour is local-first; no metrics leave the machine.
15. Future / Out of Scope
The contract leaves room for, but does not yet define:
/api/v1/events(Server-Sent Events) — live updates so an open browser session sees the TUI's writes in real time. Phase 3./api/v1/trim— destructive history trim. Stays desktop-only for now (the TUI's "type 'trim' to confirm" gate is manifesto-aligned).- Multi-tenant: not supported. One config, one vault, one token per server.
- TLS: deferred to Phase 3 with a setup guide.
- Service-side render of
show_when— if client-side proves problematic in practice, an opt-in?resolve_visibility=truequery on/api/v1/configcould ship a pre-filtered field list. Not built unless needed. pour mcpcompanion (Phase 4) — a Model Context Protocol server that wraps this API and exposespour_submit_<module>tools dynamically derived from/api/v1/config. Same binary or sibling crate; reuses the engine. Documented intent only — no implementation slot in Phases 1–3. The HTTP API contract is designed to be MCP-friendly: schema discovery, valid-choices endpoints, idempotent submits, time-preserving captures, read-back via/api/v1/captures/{id}.
15.1 OpenAPI 3.1 Spec — LOCKED
A machine-readable OpenAPI 3.1 document accompanies this human-readable contract:
- Phase 1: hand-written at
pour - docs/02 references/pour-openapi.yaml. The inspector audits every Step B–G endpoint against both this contract (markdown) and the OpenAPI spec (YAML); any divergence is a fix-before-merge. Optionally served at/api/v1/openapi.jsonso the PWA, agents, andcurl+jqusers can pull it at runtime. - Phase 2: replaced by
utoipa-generated output. Handlers gain#[utoipa::path(...)]annotations; request/response types gain#[derive(ToSchema)]. The hand-written YAML is deleted in the same PR; OpenAPI is now derived from the Rust types deterministically. The markdown contract (this file) stays as the human narrative — synced via inspector audit, not via codegen.
The "single source of truth" hierarchy: Rust types (Phase 2 onward) → OpenAPI (machine spec) → this markdown (human narrative). All three must agree; the inspector is the watchdog.
16. Ratification Log
All open questions raised during contract drafting were ratified on 2026-04-25:
| # | Decision | Locked answer |
|---|---|---|
| 1 | API namespace | /api/v1/... from day one |
| 2 | Wire types for field values | All strings, including numbers (matches engine's internal HashMap<String, String> — no JSON number ambiguity) |
| 3 | Idempotency-Key support |
Phase 1, in-memory LRU, 5-min TTL |
| 4 | captured_at window |
30 days past, 5 minutes future |
| 5 | mobile_visible per-module key |
Lands in Step B alongside /api/v1/config. Bumps config_version from 0.2.0 → 0.3.0. Default true; false omits the module from /api/v1/config entirely |
| 6 | Presets HTTP shape | Separated CRUD: GET /presets/{module}, PUT /presets/{module}/{name} upsert, DELETE /presets/{module}/{name}, PUT /presets/{module}/order reorder |
| 7 | History summary payload |
Included on the dashboard call (no since/until); omitted on windowed queries |
| 8 | capabilities array in /health |
Carried from day one |
| 9 | Read-back via GET /api/v1/captures/{history_id} |
Lands in Phase 1 (Step C alongside /api/v1/submit). Adds Transport::read_file as the only new engine method. Enables agent and PWA "summarize my last N" workflows |
| 10 | OpenAPI 3.1 spec | Hand-written in Phase 1 at pour - docs/02 references/pour-openapi.yaml; replaced in Phase 2 by utoipa-derived output. Markdown contract (this file) remains the human narrative |
| 11 | MCP companion in roadmap | Documented as Phase 4 intent in §15. No build commitment now. The HTTP API contract is intentionally MCP-friendly so the wrapper is thin when built |
All future deviations require an amendment to this contract committed before the implementation lands.
17. Change Log
- 2026-04-25 — initial draft.
- 2026-04-25 — ratified (round 1). Eight open questions resolved per §16 rows 1–8. Status: locked.
- 2026-04-25 — agent-surface amendment (round 2). Added
GET /api/v1/captures/{history_id}as §6.6, OpenAPI 3.1 Phase 1 hand-written + Phase 2utoipa(§15.1), MCP companion roadmap (§15). Three new ratified decisions per §16 rows 9–11. - 2026-04-26 — pagination cursor amendment (round 3). §6.5:
next_cursor: string|nullreplacesnext_until: timestamp|nullfor pagination;cursorquery param added;untilremains as a direct timestamp filter but must not be used as a pagination cursor. Rationale: same-millisecond entries (possible during PWA offline-queue replay) share a timestamp and would be silently dropped by a timestamp-only cursor; id-based cursor is exact. §6.10:details.duplicatesadded to reorder 400 response when the request list contains duplicate names. - 2026-04-26 — submit-side mobile_visible amendment (round 4). §6.4:
POST /api/v1/submit/{module}now explicitly returns 404not_foundfor modules withmobile_visible = false, mirroring the/api/v1/configfilter. Previously the contract only documented config-side filtering, leaving submit behavior implicit. Ratifies what the implementation already does: server is the source of truth on module visibility; clients cannot bypass the filter by guessing module keys. - 2026-04-26 — idempotency cacheability amendment (round 5). §9: only 2xx terminal successes are stored in the idempotency cache. 4xx and 5xx responses are NOT cached. Earlier wording said "final response" without disambiguating, which led to an implementation that cached every status — meaning a
400 validation_failedwould replay for 5 minutes, blocking users from fixing a field and retrying within the form session. The amendment makes recoverable errors retryable while preserving the duplicate-write protection that idempotency exists to provide. - 2026-04-27 — synthetic-202 clarification (round 6). §6.4: added a note that
202 Acceptedis a PWA service-worker–only construct emitted to the page when a submit is queued offline. The server never returns 202 from/api/v1/submit/*. Clients bypassing the service worker (future MCP companion, directcurl) see 201 on success or 4xx/5xx on failure only. No server-side change; documentation only. - 2026-04-27 — reserved name "order" (round 8). §6.8: the name
"order"(case-insensitive) is reserved and must not be used as a preset name. The/presets/{module}/orderfixed segment (§6.10) is registered before/{name}in the router, making a preset literally named "order" permanently unreachable via single-preset endpoints. Server rejects the name input_handlerwith400 validation_failed { code: "reserved_name" }as a belt-and-suspenders guard. Clients must also reject "order" (case-insensitive) in name-input validation before sending any request. Three regression tests added totests/server_presets.rs.