Skip to content

M2-50 Reference app for ticket systems (Jira)

No Date Ruling Reason
D-M2-50-1 2026-10-25 plugins/jira-app is a Python project with the flat package jira_app and [tool.uv] package = false, for Python 3.11 and newer on Linux and macOS; entry uv run --directory plugins/jira-app --locked python -m jira_app --config <file>. Runtime dependencies: bothy-sdk as an editable path source ../../sdk/python, aiohttp (HTTP server), httpx (Jira client) and cryptography (the key type of the webhook keys), all already in the SDK's tree, as compatible ranges with the exact versions of sdk/python/uv.lock in uv.lock; no Jira client library. Dev tools at the SDK's exact versions; coverage fail_under at quality.coverage.python_min_percent; the in-memory store is the same SQL on an in-memory SQLite database. Place: plugins/jira-app/pyproject.toml, uv.lock. The fewest third-party packages in the security path (SEC-SC-1); the tests always see the SDK of the same tree; one implementation of the store rules.
D-M2-50-2 2026-10-25 Configuration in one JSON object jira_app with the twelve keys of 11 16 (place "Jira app", added to PLACES of tools/configdoc/configdoc.py); duplicate member names, unknown members, wrong types, bad durations, a lower-case or repeated project key and a credentials line without : abort the start with key and file, never a value; secret files must be regular files of the process user with mode 0400 or 0600; the webhook secret and the Jira credentials are read at each use, the app token once; one trailing line break is dropped; umask 0o077; state_dir is created with 0700, one with group or other access is refused. Reused keys (webhook.receiver_tolerance, sdk.http_timeout, sdk.retry_*) come from the SDK's SdkSettings. Place: plugins/jira-app/jira_app/config.py, __main__.py. SEC-SEC-1 applied to the app; rotation without restart; no new numbers.
D-M2-50-3 2026-10-25 Three routes on one aiohttp application, checked in this order: path (404), method (405), stopping or unauthenticated (503), Content-Type (415), body bound through client_max_size (413), signature (401), JSON with invalid UTF-8, NaN or duplicate members (400), duplicate, classification with seen identifier and job in one SQLite transaction (202 or 204); bodies empty except the autocomplete answer; plain HTTP behind the TLS proxy of the instance. Place: jira_app/web.py, jira_app/chat/receive.py. The app stays small; TLS and slow clients are the proxy's.
D-M2-50-4 2026-10-25 X-Hub-Signature exactly once; split at the first =; the method compared exactly with SUPPORTED_METHODS = {"sha256"} before any HMAC is computed, every other method (also SHA256 and correct digests of other methods) is unsupported_method; the signature 64 hexadecimal digits decoded with bytes.fromhex (upper case accepted); HMAC-SHA256 over the raw body compared only through hmac.compare_digest on 32 bytes; reasons missing, malformed, unsupported_method, mismatch, each 401 without body; test values of the GitHub documentation because Jira publishes none. Place: jira_app/jira/signature.py. 11 10.2: Jira may change the method (RK-83); guessing it would accept forged or reject valid deliveries.
D-M2-50-5 2026-10-25 Closed event list jira:issue_created, jira:issue_updated, comment_created; everything else 204 with unsupported, an unknown project 204 with project_not_configured, an update without changelog items 204 with no_change, a missing issue.id, issue.key, project key or comment ID 400 with invalid_payload; rich text read as a string or as an ADF document (text nodes, mention text, a line break after block nodes and for hardBreak), any other form gives no text; the job stores the parsed fields, never the payload. Place: jira_app/jira/events.py, jira_app/jira/adf.py. The list covers the life of an issue; the cited page does not fix the form of rich text, and the app must not drop events if it changes.
D-M2-50-6 2026-10-25 Every command and interaction is answered 202 and acted on through the API; requests of the server are verified with bothy_sdk.webhooks.verify_webhook and the keys of one WebhookKeyCache, loaded together with getMe and getInstance before the listener opens; no v1 secret. The cache uses a generated client without the app's token, because getWebhookKeys is public and no app scope covers it (as M2-49 found). Idempotency-Key values are passed through Client.idempotency_key, because the generated operations put a UUID object into the header, which httpx refuses (found by the adapter test). Place: jira_app/chat/receive.py, jira_app/chat/sdk_adapter.py, jira_app/app.py. In a forum the server has no reply thread for a 200 (M2-25 no_thread), and Jira calls do not fit into app.interaction_timeout.
D-M2-50-7 2026-10-25 The Python SDK gains bothy_sdk.webhooks with the behaviour of M2-30's TypeScript module (verify_webhook, VerifiedWebhook, WebhookVerificationError with the five codes, WebhookConfigError, parse_webhook_public_key, parse_webhook_secret, WebhookKeyCache that also takes a generated client without a token, its age from Cache-Control max-age, else agent.jwks_max_age, never below adapter.jwks_refresh_min_interval), bothy_sdk.testing.sign_webhook and webhook_public_key, and the SdkSettings fields webhook_receiver_tolerance and webhook_keys_max_age; every signature case of docs/api/webhook-test-vectors.json verifies. Place: sdk/python/src/bothy_sdk/webhooks.py, testing/webhooks.py, settings.py. M2-29 left the module out; R-EX-11 and ADR 0026 ask for one behaviour in both SDKs.
D-M2-50-8 2026-10-25 State in state_dir/state.sqlite3 (WAL, synchronous = FULL, one connection through asyncio.to_thread under one lock) with the thread map, drafts, created objects, seen identifiers and jobs; the lock file lock held with flock with LOCK_EX and LOCK_NB for the life of the process; one worker in creation order; every client_msg_id and Idempotency-Key stored in the job before the first attempt; a Jira write marked attempted before it is sent and never repeated; finished jobs deleted, seen identifiers, created objects and drafts pruned after jira_app.dedupe_window at each write; SIGTERM and SIGINT stop accepting, finish the running step, close the store and exit 0. Place: jira_app/store/, jira_app/worker/, jira_app/__main__.py. 04 5.1 and 5.2 make server writes repeatable; Jira's create calls are not, and a second issue is worse than a missing comment; the kernel releases a flock, so no stale-lock rule is needed.
D-M2-50-9 2026-10-25 Key of the thread map is Jira's issue.id; title <KEY> <summary>, never retitled; the latest key is kept for links; a 404 on a mapped thread drops the entry and opens one new post (thread_unreachable), a second 404 ends the job; English message templates of the brief, jira:issue_updated one line per changelog item with (none) for an empty value and description only as "changed". Place: jira_app/worker/jobs.py, jira_app/domain/render.py. A key changes when an issue moves; the ID stays (11 10.2 row "Output").
D-M2-50-10 2026-10-25 Foreign text: display names, field names and values in code spans, descriptions and comments in fenced blocks one backtick longer than the longest run; a link only to <jira_app.jira_base_url>/browse/<KEY> for a key of ^[A-Z][A-Z0-9_]*-[1-9][0-9]*$, never to a payload URL; messages cut in UTF-8 bytes at a code point with the fence closed and "(cut, full text in Jira)"; titles without Cc and bidi controls, cut in code points; a 400 on the title retries once with the key alone, a too_long reloads the limits once. Place: jira_app/domain/render.py, jira_app/worker/jobs.py. 03 content format rules 2 and 3, SEC-IN-4, SEC-OUT-1.
D-M2-50-11 2026-10-25 /issue has required_level trigger and always opens its own forum post with a single-use form (custom_id issue:<draft>, field description, and project when several projects share the forum); the issue gets the configured issue type of the project, the title as summary and an ADF description ending with "Created from chat.", nothing about the person; 3xx and 4xx answer failed with a new card; 5xx, a time-out, a transport error, an unreadable 201 or a restart after attempted answer failed with the check text and no second POST; a credentials file that fails its check sends nothing and answers failed with a new card; a 409 to a repeated respondToInteraction counts as done. Place: jira_app/worker/jobs.py. interaction.create needs trigger (05 4.10); a thread belongs to exactly one issue.
D-M2-50-12 2026-10-25 Mirroring only with messages:read, events:subscribe and a subscription; at receipt the app's own message, a non-human origin, a non-text kind, a deleted message and an unmapped thread are skipped in this order; the comment is an ADF document with the display name as a code text node, " wrote in chat:" and one codeBlock with the content (the full message loaded with getMessage when it was cut); the jira:issue_created and comment_created events of objects the app created are not posted, checked at receipt and again in the worker; any Jira outcome but 201 is logged and not retried. Place: jira_app/domain/mirror.py, jira_app/chat/receive.py, jira_app/worker/jobs.py. The loop protection of 11 10.1; the echo can arrive before the create call returned.
D-M2-50-13 2026-10-25 Answers of the server: 403 scope_missing ends the job with one Warn line; 401 ends the job and the endpoints answer 503 until getMe succeeds again (tried once per sdk.retry_backoff_cap); 403, 400, other 4xx end the job with their code; 422 idempotency_key_reuse is an Error line; 429, 5xx and transport errors keep the job queued after the SDK's retries, and the worker pauses sdk.retry_backoff_cap. Place: jira_app/worker/jobs.py, jira_app/app.py. 06 5, 07 2.10.
D-M2-50-14 2026-10-25 Jira client: POST /rest/api/3/issue and POST /rest/api/3/issue/<id>/comment on one httpx.AsyncClient with follow_redirects=False and sdk.http_timeout over the whole call; Accept and Content-Type application/json, Authorization: Basic of the credentials line, User-Agent: jira-app/<VERSION>; the body read only on 201; ADF built from paragraphs, text nodes and code blocks, never parsed as markup. Only Jira Cloud. Place: jira_app/jira/client.py, jira_app/jira/adf.py. A followed redirect would carry the credentials elsewhere; chat text must not create Jira markup.
D-M2-50-15 2026-10-25 Logs: JSON lines with time, level, a fixed msg code and only event, delivery_id, webhook_id, job_id, job_kind, step, operation, status, outcome, reason, code, issue_id, thread_id, channel_id; other fields are dropped; records of the SDK's logger bothy_sdk become sdk.client without their text. Place: jira_app/log.py. SEC-LOG-2, SEC-LOG-4, SEC-SEC-3; a canary test holds it.
D-M2-50-16 2026-10-25 Manifest: slug jira; required scopes exactly messages:write, commands:write, components:write; optional messages:read and events:subscribe with event message.created; delivery webhook with both addresses on one host; command issue; components [form]; one write grant request; no homepage_url. Place: plugins/jira-app/manifest.json. 11 10.2 takes over 10.1 as M2-49 corrected it.
D-M2-50-17 2026-10-25 Fixtures jira_issue_created.json, jira_issue_updated.json and comment_created.json are written by hand with the members of the example payloads of the page cited in 11 10.2 and values of this repository's world; no package publishes Jira's examples and the build has no network; tests/fixtures/README.md names the page and the date. Place: plugins/jira-app/tests/fixtures/. D-W-10, RK-83; the comparison with the live page stays open (report).
D-M2-50-18 2026-10-25 The live test tests/live/test_live.py runs in the default pytest run (so make test and make check run it) against sdkserver with M2-49's app_front {host: "jira-app.example.com"}, create_forum, app_backend and call, unchanged; it fails without Go or a Docker-compatible runtime and never skips; a change under sdk/python/ selects the plugins gates (tools/ci/components.py). Place: plugins/jira-app/tests/live/, tools/ci/components.py. PLAN 8 Exit criterion 9: the reference apps pass against the real server, inside make check.