Skip to content

M2-50: Reference app for ticket systems (Jira)

Status: ready | Depends on: M2-49 | Milestone: M2

Goal

After this ticket plugins/jira-app is the reference app for ticket systems of 11 10.2: a Python program on the SDK of M2-29 that receives Jira webhooks at its own address, reads the method from the header X-Hub-Signature (format method=signature), accepts exactly sha256 and refuses every other method instead of assuming SHA-256, checks the HMAC over the raw body in constant time before it reads anything else, and posts the events jira:issue_created, jira:issue_updated and comment_created into a forum, one thread per Jira issue with the mapping kept in the app's own state, the same pattern M2-49 built for GitHub. A person with trigger on the forum runs /issue with a title; the app opens a forum post with a form, and when the form is submitted it creates the issue through the Jira REST API and answers in that thread. With the optional scopes for mirroring, replies of people in a linked thread go to Jira as comments, while the app's own messages are never mirrored back and its own Jira objects never come back into the chat (loop protection). The Python SDK of M2-29 left out the webhooks module of 11 11.2; this ticket adds it with the behaviour of M2-30's TypeScript verifier, proven against the test vectors of M2-26, so the app verifies the server's signed requests through the SDK exactly like the GitHub app does. The tests run against an in-process Fake-Jira API with fixtures after the Jira documentation, against an in-memory chat port, and inside make check against the real server through M2-29's sdkserver with the protocol parts M2-49 added; no test reaches a real Jira service, and the Python license gate stays green. Why: requirement 19 asks for working integrations (D-W-10), R-EX-11 asks for one real app per SDK as the proof that the Python SDK is enough for apps, and Jira announces that it may change the signature method, so an app that guessed the method would accept forged or reject valid deliveries (RK-83, T-PL-01, T-PL-02).

Read first

  • Dependencies: the header names M2-49, as the plan row does; D-W-102 reduced the row to the transitive reduction. Implied through M2-49 and therefore merged before this brief starts: M2-30 (the TypeScript webhooks module that note 8 mirrors, and its live files that drive sdkserver inside npm test), M2-29 (sdk/python: Client, SyncClient, Secret, Credentials, FileDpopKey, ProblemError and its classes, uuid7, SdkSettings, the components builders with form, bothy_sdk.testing, the live driver services/server/test/sdkserver and its protocol, sdk/python/tests/realserver/conftest.py, docs/sdk/python.md), M2-26 (getWebhookKeys, createWebhookSubscription, listWebhookDeliveries, docs/api/webhook-test-vectors.json, the host[:port] entries of webhook.allowed_hosts), M2-25 (requests to delivery.interactions_url from the job runs.app_dispatch, the answer forms 200 and 202, no_thread in a forum, the section on the interactions address in webhooks.md), M2-24 (AppManifest after 11 3.1, validateManifest, installApp, setAppScopes, createToken for apps with a scope subset), M2-23 (layout sdk/python/src/bothy_sdk/, distribution bothy-sdk, bothy_sdk.generated, bothy_sdk.tolerance, the coverage key quality.coverage.python_min_percent), M2-17, M2-16 (listChannelCommands, invokeCommand), M2-15 (cards, createInteraction, respondToInteraction), M2-10, M2-09, M2-08, M2-01, M1-71 (services/server/test/harness), M1-66, M1-65, M1-64, M1-63, M1-57 (getMessage, listThreadMessages), M1-56 (createMessage, createThread), M1-55 (listChannelThreads), M1-53 (createChannel), M1-48 (createGrant), M1-46 (getInstance, the test of the places with the product name), M1-44 (API tokens), M1-31 (getMe), M1-22 (egress client and its test seams) and the rest of their closure.
  • docs/PLAN.md: section 3 "Way of working per ticket"; section 5 "Test strategy" (row "Test doubles": "fake GitHub and fake Jira API (M2-49, M2-50)"); section 8 "M2 Agent platform, extensions and terminal": Goal ("Apps and webhooks use the same public API (P-18)"), Scope ("reference apps for code hosting and a ticket system (11, section 10)"), Exit criteria 9 ("The reference apps M2-49 and M2-50 pass their tests against the real server and the fake APIs") and 10, Test strategy ("fake APIs for GitHub and Jira with webhook fixtures from the vendors' documentation"), Red team ("exceeding the scopes of an app", "apps and webhook targets as boundaries"), Risks; block "agents and extensions" rows M2-24, M2-25, M2-26, M2-29, M2-30; block "reference integrations" (introduction: "No integration calls a real service in tests; the counterparts are fakes with fixtures from the vendor's public documentation"; row M2-49, the pattern; row M2-50, this ticket; no ticket depends on it); section 19 P-09, P-10, P-11, P-16, P-18.
  • docs/briefs/M2-49-github-app.md completely: this ticket copies its structure note by note (package, configuration, HTTP surface, signature, event list, requests of the server, state and worker, one thread per object, foreign text, /issue, mirroring and loops, server answers, logs, manifest, fake API and fixtures, no-network guard, live run with TLS front, threat model rows). Where this brief does not decide a point that M2-49 decided, M2-49's rule holds for Jira as well.
  • docs/spec/11-extensions.md (English): 1 "Principles" (points 1 to 4); 2 "Agent or app"; 3.1 "Format"; 3.2 "Validation rules"; 4.1 to 4.3; 5 "Scopes and grants"; 6 "Isolation and limits" (row "Never allowed"); 7 "Outgoing webhooks" (rows "Signature", "Key", "Check"); 9 "Commands and interactions for apps"; 10 "Ready-made integrations as examples" (introduction: tests never call a real service, own VERSION and CHANGELOG.md, no release component); 10.1 "Code hosting (GitHub)" as M2-49 changed it (rows "Scopes", "Loops"); 10.2 "Ticket system (Jira)" (normative for this ticket); 11.2 "Hand-written layer" (rows auth, retry, components, webhooks); 11.3 "Runtimes and dependencies"; 14 R-EX-01 to R-EX-04 and R-EX-11; 16 "Configuration values of this chapter" (the rows M2-49 added with Place "GitHub app").
  • docs/spec/09-agents.md: 10.1 "Built-in commands", 10.2 "Registration", 10.3 "Visibility and name conflicts", 10.4 "Execution" (last paragraph), 11.1 "Kinds (component.v1)" (form with fields), 11.2 "Who may interact", 11.3 "Single-use rule per message", 11.4 "Flow of an interaction", 16.1 "Test doubles".
  • docs/spec/05-actions.md (core contract, read only): 2.6 "Scopes for apps" (thread.create under messages:write, message.read under messages:read, interaction.respond under components:write, "Event subscriptions" under events:subscribe, self.read and instance.read without scope); 4.10 rows command.invoke, interaction.create (level trigger), interaction.respond; 5 "Agent invocation agent.invoke" (last paragraph: app commands without agent.invoke and I2).
  • docs/spec/03-data-model.md (core contract, read only): messages (origin, kind) and the content format content_format = 1 (CommonMark 0.31.2; rule 2: no mentions in inline code and code blocks; rule 3: only principals of kind user or agent are resolved). docs/spec/04-sync-protocol.md (core contract, read only): 2.2 "Envelope" (delivery field sync_epoch), 5.1 "Messages: client_msg_id", 5.2, 9 "Delivery guarantee".
  • docs/spec/07-permissions.md: 2.10 "Apps" (a missing scope with a grant is 403 with reason scope_missing), SR4 "Only humans trigger". docs/spec/06-api.md: 2 "Basic rules" ("No null", "Unknown values"), 5 "Errors", 7 "Idempotency", 12 "Webhooks". docs/spec/19-security.md: SEC-IN-4, SEC-OUT-1, SEC-OUT-3, SEC-LOG-2, SEC-LOG-4, SEC-SEC-1, SEC-SEC-3, SEC-SC-1. docs/spec/23-quality.md: 3 "Component contract" (row "Python project") with "Documentation in code" (row Python). docs/spec/02-architecture.md: 6 "Code layout", list "Places that carry the product name" (the SDK package names M2-23 added).
  • docs/api/openapi.yaml: operations getMe, getInstance, getWebhookKeys, createThread (its description: first_message.client_msg_id makes it idempotent), createMessage, getMessage, respondToInteraction, and for the live test createChannel, validateManifest, installApp, setAppScopes, createGrant, createToken, createWebhookSubscription, listWebhookDeliveries, listChannelCommands, invokeCommand, createInteraction, listChannelThreads, listThreadMessages; schemas AppManifest, AppInstall, AppScope, CommandRegister, CommandOption, CommandLevel, ThreadCreate, ThreadCreated, MessageCreate, Message, MessageOrigin, MessageKind, MessagePayload, MessageCreatedEvent, CommandInvokedEvent, CommandInvokedPayload, InteractionCreatedEvent, InteractionCreatedPayload, Interaction, InteractionRespond, InteractionResponse, ComponentCreate, ComponentRestriction, CommandAutocompleteRequestedEvent, AutocompleteAnswer, Me, PrincipalRef, InstanceInfo, InstanceLimits, WebhookKeys, WebhookKey, WebhookSubscriptionCreate, ApiTokenCreate, EventEnvelope, Problem (x-problem-codes), FieldError.
  • docs/api/webhooks.md: 1 "Subscribe", 2 "Delivery", 2.1 "Signing keys", 3 "Verification by the receiver", 5 "Order and duplicates", 7 (row message.created: messages:read in addition to events:subscribe), the section on the interactions address of apps that M2-25 added, and the sentence on the SDK verifiers that M2-30 added.
  • ADRs 0013, 0016, 0022, 0026 (and its "Confirmation": no own type for an object the core generates), 0044, 0052, 0053.
  • docs/DECISIONS.md: D-W-10, D-W-38 (language of files), D-W-100, D-W-102; the rows D-M2-23-<n>, D-M2-24-<n>, D-M2-25-<n>, D-M2-26-<n>, D-M2-29-<n>, D-M2-30-<n> and D-M2-49-<n> on the base branch; section 10 "Rulings of the build milestones" (row format D-M2-50-<n>).
  • docs/security/threat-model.md: 2 (TB6), 5.11 "Apps and webhooks" with the rows M2-49 added, T-PL-01, the register row T-PL-02 that M2-49 added, T-SC-01, 9 "Maintenance rule" point 2. docs/RISKS.md: RK-83 (the signature method of Jira may change; "fixtures with source and retrieval date"), RK-04, RK-20. docs/OPEN-QUESTIONS.md: OQ-17 (nothing published, no name reserved).
  • docs/operations/configuration.md: section 2 (formats), section 4 rows webhook.receiver_tolerance, webhook.retry_max_age, webhook.delivery_timeout, webhook.allowed_hosts, egress.allowed_hosts, app.interaction_timeout, agent.jwks_max_age, sdk.http_timeout, sdk.retry_max_attempts, sdk.retry_backoff_base, sdk.retry_backoff_cap, component.custom_id_max_chars, component.form_max_fields, component.default_ttl, command.description_max_chars, thread.title_max_chars, message.max_content_bytes, auth.dpop_required_for, and the github_app.* rows of M2-49.
  • docs/quality/gates.md, docs/quality/licenses.md (point 3: PackageOverrides in an osv-scanner.toml of the component), docs/quality/toolchain.md, docs/quality/push-discipline.md, docs/quality/red-team-gate.md; quality/licenses/allowed.txt; sonar-project.properties (sonar.python.coverage.reportPaths already lists plugins/*/coverage.xml); .gitignore; the root Makefile (PY_PROJECTS includes plugins/*pyproject.toml; the Python lines of fmt-check, lint with ruff and bandit, typecheck, test, licenses); tools/ci/components.py and its test; tools/ci/license_check.py; tools/docs/tests/test_config_values.py; plugins/README.md ("Rules for all programs here").
  • Briefs of the dependencies (merged names win, D-W-100): M2-49 (as above), M2-29 (docs/briefs/M2-29-python-sdk.md: Design notes 2 to 5, 12, 15 to 18; the hand-layer type lint test_hand_layer_types.py; the reference test test_reference_page_lists_every_public_module; Out of scope: the missing webhooks module), M2-30 (docs/briefs/M2-30-typescript-sdk.md: Design note 2 parity rule, Design note 10 the webhook verifier, Design note 12 live runs), M2-49 Design note 19 (the protocol parts of sdkserver), M2-26 (Design notes 9, 11 and 12: ValidateTarget, signing set and header order, whpk_ and whsec_, the vector file), M2-25 (Design notes 5 to 10), M2-24 (Design notes 2, 4, 5, 10), M2-23 (Design notes 1, 8, 10, 11).
  • Code on the base branch (merged names win): sdk/python/ (package, src/bothy_sdk/, pyproject.toml, uv.lock, tests/), plugins/github-app/ (the pattern), the live driver services/server/test/sdkserver with the protocol parts of M2-49 (member app_front of start, the TLS front, the ops app_backend, create_forum and call), sdk/python/tests/realserver/conftest.py, plugins/github-app/test/live/, services/server/internal/platform/egress (Deps{Resolver, RootCAs}), docs/sdk/python.md, plugins/README.md.

Fixed requirements that apply

Owner's fixed build rules (apply verbatim):

  1. No MVP. New from scratch and directly in the final state; stages are only the order.
  2. Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
  3. Documentation in code: every public module, class, function and interface with a docstring/doc comment (purpose, parameters, return value, errors, example where useful); comments explain the why, not the what. API documentation is generated from code (OpenAPI from the routes, dartdoc, Python API docs in MkDocs).
  4. Documentation in the repo: README per module/service, ADR for every architecture decision, CHANGELOG per release, keep the documentation site current. A change without matching documentation is not done.
  5. Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.

Rules for every brief:

  1. Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
  2. No en dash and no em dash in any file you write; the text-rules pre-commit hook rejects them. Use a hyphen or rephrase.
  3. White label: examples and test data use example.com and the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory.
  4. Limits, windows and thresholds only through named configuration keys listed in docs/operations/configuration.md; code comments and docs name the key, never the number.
  5. Specification first: a change to behaviour, API, event, right or configuration updates docs/spec/ or docs/api/ in the same change. The core contract (docs/spec/00-glossary.md, 03-data-model.md, 04-sync-protocol.md, 05-actions.md) is not edited by this ticket.

Note on rule 3: the app offers no API of its own; its docstrings are held by ruff D with convention google (23 "Documentation in code", row Python) in make lint, and the generated reference of the documentation site stays the SDK's (M2-29), extended by the new module bothy_sdk.webhooks. The app's README documents configuration, endpoints and operation.

Product requirements implemented here:

  1. The app is its own process outside the server and uses only the public API, through the Python SDK of M2-29 and never through server-internal packages (11 1 points 1 to 3, 11 10, R-EX-11, ADR 0022, ADR 0026, plugins/README.md).
  2. Jira webhooks go to the app's own address, never to an incoming webhook of the server; the app reads the method from X-Hub-Signature (method=signature), accepts exactly sha256, refuses every other method instead of assuming SHA-256, and checks the HMAC over the raw body in constant time before it parses or stores anything (11 10.2, RK-83).
  3. One thread per Jira issue in the forum of the issue's project, with the mapping in the app's state; messages per event of jira:issue_created, jira:issue_updated and comment_created (11 10.2 "Like 10.1", 10.1 row "Output").
  4. /issue with a form creates an issue through the Jira REST API and answers in the thread (11 10.1 row "Commands"; 09 10, 11; 11 9).
  5. Scopes exactly as 11 10.1 after M2-49: required messages:write, commands:write, components:write; optional messages:read and events:subscribe with event message.created; grant write on the forum. An action without its scope is 403 scope_missing (07 2.10, 05 2.6).
  6. The app never mirrors its own messages (author = app) back (11 10.1 row "Loops"); apps never trigger agents and never hold trigger or manage (11 6 row "Never allowed", R-EX-03, SR4).
  7. Commands and interactions reach the app as signed POST requests to delivery.interactions_url, answered within app.interaction_timeout with 202 and acted on through the API (11 9); requests of the server are verified with the SDK's webhooks module: raw body, webhook-timestamp within webhook.receiver_tolerance, v1a keys from getWebhookKeys only (webhooks.md 3, 11 11.2 row webhooks).
  8. The Python SDK has the webhooks module of 11 11.2 with the behaviour of M2-30's TypeScript module, and every signature case of docs/api/webhook-test-vectors.json verifies with it (R-EX-11, ADR 0026, M2-30 Design note 2).
  9. Duplicates are recognised by webhook-id, at least for webhook.retry_max_age (webhooks.md 3 point 4), and Jira deliveries by the digest of their body.
  10. Foreign text never resolves a mention or becomes a link in the chat (03 content format rules 2 and 3, SEC-OUT-1); titles carry no control or bidi characters (SEC-IN-4); text sent to Jira is structured document text, never markup.
  11. No test calls a real Jira service; the counterpart is a Fake-Jira API with webhook fixtures after the public Jira documentation (PLAN 8 "Test strategy", D-W-10, 11 10).
  12. Secrets (the app's API token, the Jira credentials, the Jira webhook secret) never appear in logs, error messages or test output, and their files have mode 0400 or 0600 (SEC-SEC-1 and SEC-SEC-3 applied to the app, SEC-LOG-2, SEC-LOG-4, plugins/README.md).
  13. Dependencies locked with hashes in uv.lock; the license gate for Python and OSV pass without a change to quality/licenses/allowed.txt (SEC-SC-1, licenses.md, PLAN row).
  14. The app's own names do not carry the product name; it appears only as the SDK distribution bothy-sdk and the import package bothy_sdk (02 6, ADR 0044).
  15. Own VERSION and CHANGELOG.md in the folder; the app is not a release component (11 10, D-W-38 for the English CHANGELOG).
  16. No JSON null in requests to the server (ADR 0053); unknown members of server events are tolerated through the SDK (06 2).
  17. The app passes its tests against the real server inside make check (PLAN 8 Exit criteria 9).

Scope

Paths are relative to the repository root. Names of M2-23 to M2-26, M2-29, M2-30 and M2-49 below are the expected ones; where the merged code names them differently, follow the code (Boundaries).

Create, under plugins/jira-app/:

  • pyproject.toml and uv.lock (Design note 2), VERSION (0.0.0), CHANGELOG.md (Keep a Changelog, English, like sdk/python/CHANGELOG.md), README.md (English, Design note 22), manifest.json (Design note 17), config.example.json (every key of Design note 3 with https://chat.example.com, https://jira.example.com and example paths outside a home directory).
  • The package jira_app/: __init__.py, __main__.py (CLI entry), app.py (start_app(config, deps) returning a handle with the listening address and async stop(); injectable HTTP transports, clock and logger), config.py (Design note 3), log.py (Design note 16), web.py (the aiohttp application, Design note 4), jira/signature.py (Design note 5), jira/events.py (Design note 6), jira/adf.py (Design notes 6 and 15), jira/client.py (Design note 15), chat/port.py (the app's port to the server, Design note 10), chat/sdk_adapter.py (the port on the SDK Client), chat/receive.py (Design note 7), domain/render.py (Design note 11), domain/mirror.py (Design note 13), store/base.py, store/sqlite_store.py, store/memory_store.py (Design note 9), worker/worker.py, worker/jobs.py (Design notes 9, 10, 12, 13).
  • tests/ with the tests of the section "Tests", tests/conftest.py (no-network guard, Design note 19), tests/fakes/jira_api.py (Design note 18), tests/fakes/chat.py (in-memory port), tests/fixtures/jira/*.json and tests/fixtures/README.md (Design note 18), tests/live/conftest.py and tests/live/test_live.py (Design note 20).
  • osv-scanner.toml only if the license gate needs an override (Design note 21).

Create, under sdk/python/, only if the base branch has no module bothy_sdk.webhooks (Design note 8):

  • src/bothy_sdk/webhooks.py; sign_webhook in bothy_sdk.testing; tests/test_webhooks.py and tests/test_webhooks_vectors.py.

Change:

  • sdk/python/src/bothy_sdk/settings.py (two fields of Design note 8), sdk/python/src/bothy_sdk/__init__.py (exports), docs/sdk/python.md (a block for every new public module), sdk/python/README.md (module webhooks built), sdk/python/CHANGELOG.md, sdk/README.md (if it lists the modules per SDK).
  • sdk/python/src/bothy_sdk/client.py or retry.py only where the merged SDK lacks the access to response headers of Design note 8 or lets a caller's Idempotency-Key be replaced (Design note 9), each with a test in sdk/python/tests/.
  • tools/ci/components.py and tools/ci/tests/test_components.py (Design note 21); tools/docs/tests/test_config_values.py only if its sources list Python projects one by one (Design note 21).
  • docs/spec/11-extensions.md 10.2, 11.2 and 16 (Design note 22), then make config-reference for docs/operations/configuration.md; docs/api/webhooks.md (the sentence on SDK verifiers).
  • docs/security/threat-model.md 5.11 and the register rows T-PL-01 and T-PL-02 (Design note 23).
  • services/server/test/sdkserver and its README.md only if M2-49's app_front does not take the host name (Design note 20); services/server/test/harness only if it cannot pass the egress test seams.
  • plugins/README.md (row jira-app/: status built, M2-50), root CHANGELOG.md under "Unreleased", docs/DECISIONS.md section 10 rows D-M2-50-1 and following (Design note 24).

This ticket adds no operation, event, action, problem code or server configuration key and creates no new top-level make target; its new configuration keys are values of the app (Place "Jira app").

Out of scope

  • The GitHub app (M2-49) beyond the host name of the TLS front of Design note 20; monitoring alarms and cron messages (M2-27, M7-20).
  • Server behaviour: manifests, installation and scopes (M2-24), app commands, interactions and the requests to interactions_url (M2-25), outgoing webhook subscriptions and deliveries (M2-26). Where the server deviates from its specification, stop with Verdict: BLOCKED <reason>; do not work around it in the app.
  • SDK changes beyond Design note 8 (the module webhooks, sign_webhook, two settings fields, access to response headers) and the caller's Idempotency-Key of Design note 9; if another needed SDK function is missing, stop with Verdict: BLOCKED dependency M2-29 incomplete: <function>.
  • Jira Data Center and Server, personal access tokens, OAuth 2.0 and Jira's own app frameworks; Jira events beyond the list of Design note 6 (deletions, comment edits, work logs); transitions, assignees, labels and edits of issues; closing or renaming chat threads (apps have no scope for thread.close or thread.title.update, 05 2.6).
  • Publishing to PyPI or a container image of the app (OQ-17; 11 10: operators build it from the repository); a release chain.
  • The console view of app installations (M6-08).
  • Changes to the core contract.

Boundaries

  • Work in a dedicated git worktree on the branch feat/M2-50-jira-app; one writer per worktree.
  • Conventional Commits with the component as scope (for example feat(jira-app): ..., feat(sdk-python): ..., test(server): ..., build(ci): ...) and a DCO sign-off on every commit (git commit -s); squash into meaningful commits before the merge (docs/quality/push-discipline.md).
  • Never push to main, never tag and never create remote repositories; the only remote steps are the branch push and the pull request of AGENTS.md. Never publish a package to PyPI and never reserve a name there (OQ-17).
  • Do not modify files outside the scope above, except the CHANGELOG, README and docs/DECISIONS.md entries named in this brief.
  • Follow the host rules supplied by the launcher (ports, machine rules).
  • Tests bind only 127.0.0.1 and port 0; PostgreSQL runs only as a throwaway container on a random port through the live driver (P-09, P-10). Without a Docker-compatible runtime, Go or uv the live test fails; it never skips.
  • Stop only processes you started, by PID. Temporary directories only via mktemp -d or pytest's tmp_path, removed at the end.
  • Never read or print secrets. No network calls except package downloads for pinned tools, Python packages and Go modules.
  • No real third-party service and no real DNS name in any test: Jira is the Fake-Jira API on 127.0.0.1, the server is the live driver's, names under example.com reach loopback only through the test resolver of the egress client.
  • Dependencies pinned exactly in uv.lock; dev tools at the exact versions of sdk/python/pyproject.toml. Do not extend quality/licenses/allowed.txt; if a needed package or its tree fails the gate, stop with Verdict: BLOCKED <package and license>.
  • Never lower a threshold, disable a gate or delete a test.
  • If the core contract lacks something this ticket needs, stop with Verdict: BLOCKED <reason> instead of building a deviation (P-11).
  • This brief was written before milestone M1 closed (D-W-100). Where the merged code of an M1 ticket names a package, type, function, file or make target differently from this brief, follow the code and list each difference in the report; where it behaves differently from the specification, stop with Verdict: BLOCKED <reason>.
  • The same holds for the merged code of M2-23, M2-24, M2-25, M2-26, M2-29, M2-30 and M2-49: follow its names and list each difference.
  • Never log, print or persist outside the app's state directory a summary, description, comment, form value, display name, e-mail address, Jira account ID, issue key, project key, credential, token, secret or signature, not even in test output; tests compare such values in memory.

Design notes

  1. Start check. Run make check on the fresh worktree; if it is red, stop with Verdict: BLOCKED base branch red and the failing gate. Confirm on the base branch: M2-49 (plugins/github-app with its live file test/live/github-app.live.test.ts inside npm test, and sdkserver with the member app_front of start and the ops app_backend, create_forum and call), M2-29 (services/server/test/sdkserver with its stdin protocol, sdk/python/tests/realserver/conftest.py, sdk/python with Client, Secret, FileDpopKey, ProblemError, uuid7, SdkSettings, bothy_sdk.components with a form builder, bothy_sdk.testing, docs/sdk/python.md), M2-26 (docs/api/webhook-test-vectors.json with signature cases, getWebhookKeys, createWebhookSubscription), M2-25 (requests to delivery.interactions_url from the job runs.app_dispatch), M2-24 (AppManifest with scopes.required, scopes.optional, delivery, requested_grants, data_egress), M1-57 (getMessage, listThreadMessages). A missing one ends with Verdict: BLOCKED dependency <ticket> missing. If bothy_sdk.webhooks already exists (the gap M2-29 reported was closed elsewhere), use it, skip the creation of Design note 8 and run its tests unchanged.

  2. Package, runtime and project. Ruling for docs/DECISIONS.md.

    • plugins/jira-app is a Python project with the flat package jira_app and [tool.uv] package = false like tools/pyproject.toml, for the requires-python of sdk/python/pyproject.toml, on Linux and macOS (the file modes and fcntl of Design notes 3 and 9 are POSIX). Entry: uv run --directory plugins/jira-app --locked python -m jira_app --config <file>; exit code 0 after a clean stop, non-zero on any start failure. pythonpath = ["."] in the pytest configuration so tests import the package.
    • Runtime dependencies: bothy-sdk from [tool.uv.sources] with path = "../../sdk/python" and editable = true (the tests always see the SDK of the same tree, the reason M2-49 builds the SDK in each script); aiohttp (the HTTP server; the library M2-29's FakeServer uses, already in the SDK's license tree); httpx (the Jira client; already a dependency of the SDK). Each as a compatible range from the version in sdk/python/uv.lock, exact versions with hashes in uv.lock. Everything else from the standard library: hmac and hashlib, sqlite3, json, fcntl, logging, asyncio. No Jira client library: the two Jira calls are written by hand (Design note 15). Reason: the fewest third-party packages in the security path (SEC-SC-1), and the license gate then sees the SDK's tree only.
    • Dev group: ruff, mypy, pytest, pytest-cov at exactly the versions sdk/python/pyproject.toml pins, bothy-sdk[testing] (for sign_webhook), and async tests with the anyio pytest plugin as in the SDK (no pytest-asyncio, M2-29 Design note 17).
    • Tool configuration in pyproject.toml: ruff with the rule set of sdk/python including D (convention google, tests excluded); mypy strict over jira_app and tests; pytest with testpaths = ["tests"], filterwarnings = ["error"] and no marker that deselects tests, so the default run of make test includes tests/live/ (Design note 20); coverage with branches, source = ["jira_app"] (so tests/live/ is never measured) and fail_under at the value of quality.coverage.python_min_percent (M2-23), named in a comment next to the value.
    • The product name: the app's names, paths, log codes and keys never contain it; it appears only as the dependency bothy-sdk and the import bothy_sdk, the kind M2-23 added to 02 6. If the product-name test of M1-46 flags these lines, extend its allowance to references of the listed SDK package names, never to a new name of the app.
    • Every .py file carries SPDX-License-Identifier: Apache-2.0 in its first lines (tools/ci/license_check.py).
  3. Configuration (new rows in 11 16 with Place "Jira app"; Ruling for docs/DECISIONS.md). A JSON file named by --config holds one object jira_app whose members are the keys without their prefix; duplicate member names are an error. For required keys the column "Default" uses the form M2-49 used for its required github_app.* keys.

    • jira_app.listen: required; host:port of the HTTP listener.
    • jira_app.server_url: required; base URL of the instance, https, http only for a loopback host.
    • jira_app.token_file: required; file with the app's API token (M2-24 createToken), read with the SDK's Secret.from_file.
    • jira_app.dpop_key_file: empty; key file for a DPoP-bound app token (auth.dpop_required_for), loaded with the SDK's FileDpopKey.load.
    • jira_app.webhook_secret_file: required; file with the secret of the Jira webhook.
    • jira_app.jira_base_url: required; base URL of the Jira site, https (http only for a loopback host), an optional path prefix, no query, no fragment, no user information.
    • jira_app.jira_credentials_file: required; file with one line <account e-mail>:<API token>, sent as HTTP Basic credentials (Design note 15); a line without : aborts the start.
    • jira_app.projects: required, not empty; list of {"project": "<KEY>", "channel_id": "<forum id>", "issue_type": "<name>"}; the key matches ^[A-Z][A-Z0-9_]*$ and appears once; issue_type is the name of the Jira issue type that /issue creates in that project, a display text without control or bidi characters; several projects may share one forum.
    • jira_app.state_dir: required; state directory of Design note 9.
    • jira_app.max_body_bytes: 1048576; largest request body on each endpoint.
    • jira_app.dedupe_window: 72h; how long body digests of Jira deliveries, webhook-id values, the Jira objects the app created and open drafts are kept. The default equals the default of webhook.retry_max_age; the README tells operators to keep it at least as long as that server key (webhooks.md 3 point 4).
    • jira_app.max_queued_jobs: 10000; open jobs, above which the endpoints answer 503.

    Reused keys, never new numbers: webhook.receiver_tolerance (the default of the SDK's verify_webhook), sdk.http_timeout (also the time limit of each Jira request), sdk.retry_max_attempts, sdk.retry_backoff_base and sdk.retry_backoff_cap (the SDK client; sdk.retry_backoff_cap is also the worker's pause after a transient failure), all through the SDK's SdkSettings defaults. Durations follow configuration.md 2. Unknown members, wrong types, bad durations and missing required keys abort the start with a message that names key and file and never a value. Secret files (token_file, dpop_key_file, webhook_secret_file, jira_credentials_file) must have mode 0400 or 0600 and belong to the process user, otherwise the start aborts (the rule of SEC-SEC-1); the webhook secret and the Jira credentials are read at each use with the same check, so rotation needs no restart (a failing check answers 503 or fails the job), the app token once at start. A secret file's content is used without one trailing line break. The process sets its umask to 0o077 at start; state_dir is created with mode 0700, an existing one with group or other access is refused. config.example.json parses with the loader (test).

  4. HTTP surface (Ruling for docs/DECISIONS.md). One aiohttp application with routes POST /jira/webhook, POST /chat/interactions, POST /chat/events; any other path gives 404, another method 405. Per request, in this order: Content-Type must be application/json (parameters allowed), else 415; the body is read as bytes up to jira_app.max_body_bytes (client_max_size), a larger body answers 413; signature check (Design notes 5 and 7), failure 401; JSON parse, where invalid UTF-8, invalid JSON and duplicate member names answer 400; duplicate check; classification and, in the same store transaction, the seen identifier and the new job; then 202 when a job was queued and 204 when there is nothing to do; 503 when the queue holds jira_app.max_queued_jobs jobs, the store fails, a secret file fails its check or the app is stopping. Bodies are empty except for the autocomplete answer of Design note 7. The app speaks plain HTTP on jira_app.listen behind the TLS-terminating proxy of the instance; delivery.interactions_url and delivery.events_url are https addresses of that proxy (11 3.2 rule 3), and the README shows the route. aiohttp keeps its default header limits; the proxy bounds slow clients.

  5. Jira signature (11 10.2; RK-83). Ruling for docs/DECISIONS.md.

    • The request must carry exactly one X-Hub-Signature header: none is missing, more than one is malformed.
    • The value is split at its first = into method and signature; no = or an empty method is malformed.
    • The method is compared exactly with the allow list SUPPORTED_METHODS = {"sha256"}, a constant next to the HMAC function. Every other method (sha1, sha384, sha512, md5, SHA256, anything else) is unsupported_method, decided before any HMAC is computed; there is no fallback to SHA-256, also not when the value would verify as SHA-256. A new method is a code change with its test values (RK-83).
    • The signature must be 64 hexadecimal digits; it is decoded with bytes.fromhex, so upper-case digits decode to the same 32 bytes (the chapter names no case; accepting both adds no forgery path); anything else is malformed.
    • The app computes HMAC-SHA256 with the secret over the raw body bytes and compares the 32-byte digest with the decoded 32 bytes through hmac.compare_digest, called through the hmac module so tests can replace it. No string comparison of a signature anywhere.
    • Every failure answers 401 without body and logs only the reason (missing, malformed, unsupported_method, mismatch). Only after a valid signature is the body parsed as JSON.
    • Test values: the Jira documentation publishes none, so the tests reuse the HMAC-SHA256 test values of the GitHub documentation cited in 11 10.1 (same algorithm, hex encoding): secret It's a Secret to Everybody, payload Hello, World!, header value sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17. For the method rule the tests use the correct HMACs of the same input under other methods, recomputed while this brief was written: sha1=01dc10d0c83e72ed246219cdd91669667fe2ca59 and sha512=11ed355a617e98134e842012a7944ccf59c10256cb182357bd7e3a42013ff07c376f8c14cf5cc1923da20b51d64256b2fb8ebbf100aa67a61326f61fea8111bc; both must be refused although they are correct. The test names the URL and the access date of 11 10.1 and of 11 10.2 in a comment.
  6. Jira events (closed list; Ruling for docs/DECISIONS.md).

    • After a valid signature the body must be a JSON object with a string member webhookEvent. Supported: jira:issue_created, jira:issue_updated, comment_created (11 10.2, PLAN row). Every other value answers 204 with log reason unsupported. Reason: the list of the plan covers the life of an issue in the chat; more events are a later change with their fixtures.
    • The project is issue.fields.project.key, compared exactly with the keys of jira_app.projects; an unknown project answers 204 with reason project_not_configured.
    • The parser keeps only the fields used: issue.id (decimal digits), issue.key (^[A-Z][A-Z0-9_]*-[1-9][0-9]*$), issue.fields.summary, the project key, the actor's display name (user.displayName for issue events, comment.author.displayName for comments; absent: "someone"), the description (issue.fields.description, for jira:issue_created), changelog.items[] with field, fromString and toString (for jira:issue_updated), and comment.id and comment.body (for comment_created). A missing or malformed issue.id, issue.key, project key or, for comments, comment.id answers 400 with reason invalid_payload. Unknown members are ignored. The job stores the parsed event, never the raw payload.
    • Rich text (description, comment body) is read in two forms: a string is taken as it is; an object of type doc (Atlassian Document Format, ADF) contributes the text of its text nodes in document order, the attrs.text of mention nodes, and a line break after each paragraph, heading, codeBlock, blockquote and listItem node and for each hardBreak; every other form gives no text. Reason: the page cited in 11 10.2 does not fix the form of rich text in payloads, and the app must not drop events if it changes (RK-83).
    • jira:issue_updated without changelog.items or with an empty list answers 204 with reason no_change, because only field changes are news (a comment arrives as comment_created).
  7. Requests of the server (11 9; webhooks.md 3 and the section of M2-25). Ruling for docs/DECISIONS.md.

    • Both /chat/ endpoints verify with the SDK: bothy_sdk.webhooks.verify_webhook over the raw body with the keys of one WebhookKeyCache of the app's client and the default tolerance webhook.receiver_tolerance; never an own verifier and never keys from the request. start_app loads the keys (together with getMe and getInstance) before it opens the listener; a failure aborts the start. A WebhookVerificationError answers 401 and logs its code. No v1 secret: an interactions address has no HMAC (M2-25), and the app's subscription is created without enable_hmac.
    • Duplicates: webhook-id (the event id) is remembered for jira_app.dedupe_window; a repeat answers as the first time (202 on /chat/interactions, 202 or 204 on /chat/events) without a second job.
    • /chat/interactions: command.invoked with command_name issue queues the job issue_command; interaction.created queues issue_form; both answer 202. command.autocomplete_requested answers 200 with {"choices": []} (AutocompleteAnswer; the app declares no option with autocomplete). Another type or command name answers 202 with Warn reason unknown_request. Reason: 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; the person sees the result through the API answers of Design note 12.
    • /chat/events: only message.created is handled (Design note 13); any other type answers 204. sync_epoch is ignored, because the app keeps no seq state.
  8. The SDK module bothy_sdk.webhooks (11 11.2 row webhooks; Ruling for docs/DECISIONS.md: M2-29 left the module out and M2-26 assigned the Python handling of the vectors to the SDK; this ticket is its first Python consumer). One behaviour with M2-30's TypeScript module (M2-30 Design note 2: function names are the snake_case forms, class names the same); where M2-30's merged behaviour and this note differ on a point the specification leaves open, follow M2-30 and record the difference. Public API, each with a google docstring and an example:

    • parse_webhook_public_key(value: str) -> Ed25519PublicKey: whpk_ plus standard base64 of the DER SubjectPublicKeyInfo of an Ed25519 key (D-M2-26-11), parsed with cryptography; another prefix, bad base64 or another key type raises WebhookConfigError. parse_webhook_secret(value: str) -> bytes: whsec_ plus standard base64; empty or malformed raises.
    • verify_webhook(body: bytes, headers: Mapping[str, str], *, keys, secrets=(), tolerance=None, clock=None) -> VerifiedWebhook: headers looked up case-insensitively; the three headers present, else missing_header; webhook-timestamp an integer of Unix seconds, else invalid_timestamp; its distance to the clock at most tolerance (default webhook.receiver_tolerance from SdkSettings) in both directions, else timestamp_out_of_tolerance; no key and no secret gives no_trusted_key; the header is split at spaces and each entry at its first comma, entries with an unknown scheme or bad base64 are ignored; a v1a entry is tried against every key over {webhook-id}.{webhook-timestamp}.{body}, a v1 entry against every secret with HMAC-SHA256 and hmac.compare_digest; one valid entry returns VerifiedWebhook(id, timestamp, scheme, envelope, sync_epoch) with the envelope decoded through M2-23's decode_event into the SDK's Envelope; none gives signature_mismatch. Errors are WebhookVerificationError with code from exactly this list; messages never contain the body, a signature or a secret.
    • WebhookKeyCache(client): await keys() fetches getWebhookKeys once, trusts every listed key (next, active, retiring), refreshes after the response's Cache-Control max-age (without the header: agent.jwks_max_age) and never because of an incoming request. It reads the header through the generated detailed call; if the merged Client offers no way to see response headers, add one with a docstring and a test.
    • bothy_sdk.testing.sign_webhook(*, webhook_id, timestamp, body, seeds=(), secrets=()) -> str: the webhook-signature value with v1a entries first in seed order, then v1 entries in the given order (D-M2-26-10).
    • SdkSettings gains the fields webhook_receiver_tolerance (webhook.receiver_tolerance) and webhook_keys_max_age (agent.jwks_max_age), held by M2-29's test_defaults_match_configuration_reference.
    • No class of the module carries the name of an openapi.yaml schema (M2-29's test_hand_layer_types.py; WebhookKey and WebhookKeys are schema names). Not in the SDK: storing seen webhook-id values (the app's duty, Design note 7).
  9. State, queue and worker (Ruling for docs/DECISIONS.md).

    • Port Store (store/base.py, a typing.Protocol) with SqliteStore in jira_app.state_dir: the file state.sqlite3 with PRAGMA journal_mode = WAL and PRAGMA synchronous = FULL, one connection used through asyncio.to_thread under one asyncio.Lock; tables for the thread map in both directions (issue ID to thread and forum, thread to issue ID), the drafts, the Jira objects the app created (issue or comment with their ID and time), the seen identifiers with their time (body_digest, webhook_id) and the jobs (increasing ID, kind, parsed input, progress, creation time). Files 0600 through the umask of Design note 3. MemoryStore serves unit tests; one contract test runs against both.
    • The lock file lock in the state directory is held with fcntl.flock(LOCK_EX | LOCK_NB) for the life of the process; a second process fails its start with a message naming the directory. The kernel releases the lock when the process ends, so no stale-lock rule is needed.
    • A job is written before its request is answered; the worker processes jobs strictly in creation order, one at a time, so events of one issue keep their order and a /issue job records its issue before Jira's jira:issue_created event of that issue is handled (Design note 13).
    • A job records its progress per step. Every write to the server carries an identifier stored in the job before the first attempt: client_msg_id (SDK uuid7) for createMessage and for createThread with first_message, an Idempotency-Key (SDK uuid7) for respondToInteraction, passed as the operation's header parameter; if the merged SDK replaces a key the caller gives, change its retry transport so that a given key wins, with a test (06 7 makes the key the client's). A rerun after a crash repeats the same request and the server answers the original (04 5.1 and 5.2). The SDK client retries transient failures within sdk.retry_max_attempts; when they are exhausted, the job stays queued and the worker pauses sdk.retry_backoff_cap. Permanent answers end the job (Design note 14).
    • A write to Jira is never repeated: the job marks the step attempted in the store before it sends; a rerun that finds attempted without a stored result treats the outcome as unknown (Design notes 12 and 13). Reason: Jira's create calls are not idempotent, and a second issue is worse than a missing comment.
    • Finished jobs are deleted; seen identifiers, created objects and drafts older than jira_app.dedupe_window are pruned at each write; the thread map stays.
    • SIGTERM and SIGINT (loop.add_signal_handler): stop accepting (requests after the signal get 503), let the running step finish, close the store, release the lock, exit 0.
  10. One thread per issue (11 10.1 row "Output" through 10.2; Ruling for docs/DECISIONS.md).

    • The key is Jira's issue.id, not the issue key, because a key changes when an issue moves to another project while the ID stays. The map holds issue ID to {thread_id, channel_id} and thread to issue ID; the issue key of the latest event is kept for links.
    • Port ChatPort (chat/port.py) with me(), limits(), create_forum_thread(...), post_message(...), get_message(...), respond_interaction(...), typed with the generated models of bothy_sdk.generated (no own type repeats a schema, ADR 0026 "Confirmation"; drafts are IssueDraft, because Draft is a schema name); chat/sdk_adapter.py implements it on the SDK Client (getMe, getInstance, createThread, createMessage, getMessage, respondToInteraction).
    • Known issue: createMessage into its thread. Unknown issue: createThread in the forum of its project with title and first_message (the event's message) in one call, idempotent through first_message.client_msg_id; the map entry is stored with the answer.
    • Title <KEY> <summary> (for example SUP-12 Login fails), cleaned and cut as Design note 11 says; a thread is never retitled when the key or summary changes.
    • 404 not_found when posting into a mapped thread (it moved out of reach or the grant there is gone): drop the entry, open a new forum post for the issue once, Warn reason thread_unreachable; a second 404 not_found ends the job.
    • Messages (English templates; Jira text rendered as Design note 11; "SUP-12" stands for the key, linked as Design note 11 allows): jira:issue_created "Issue SUP-12 created by Sam" plus the description; jira:issue_updated "SUP-12 updated by Sam:" followed by one line per changelog item, "<field>: <from> to <to>" with (none) for an empty value, except for the field description, whose line is "description changed" (the full text stays in Jira); comment_created "Sam commented on SUP-12:" plus the comment.
  11. Foreign text in the chat (03 content format, SEC-IN-4, SEC-OUT-1; Ruling for docs/DECISIONS.md).

    • Jira text never resolves a mention or becomes a link in the chat: display names, field names and field values inside messages go into a code span, descriptions and comments into a fenced code block; the delimiter of each is one backtick longer than the longest backtick run of its text (CommonMark 0.31.2, the version of 03), so the text cannot close it (03 content format rule 2: no mentions in code).
    • A link is written only to <jira_app.jira_base_url>/browse/<KEY>, built from the configuration and a key that matches the pattern of Design note 6; never to a URL of the payload (self members are REST addresses, and a payload URL is foreign text).
    • Messages are cut to limits.message_max_content_bytes of getInstance (UTF-8 bytes, at a code point boundary) by cutting the fenced text or the list of changes and adding the line "(cut, full text in Jira)", with the fence always closed; titles to limits.thread_title_max_chars code points.
    • Titles: Unicode control characters (category Cc) and the bidi controls U+200E, U+200F, U+061C, U+202A to U+202E and U+2066 to U+2069 are removed, line breaks become spaces, the result is trimmed; an empty summary leaves the key alone. If the server still answers 400 validation_failed on the title, the job retries once with the key alone.
    • Limits come from getInstance at start and once more after a 400 validation_failed with errors[].code too_long, followed by one retry.
    • Text the app writes itself (the confirmation of /issue, the form card) contains no mention.
  12. /issue with a form (11 10.1 row "Commands"; Ruling for docs/DECISIONS.md).

    • The manifest command issue: description "Create a Jira issue" within command.description_max_chars, option title (string, required, description "Issue title"), required_level trigger. Reason: the form is submitted with interaction.create, which needs trigger (05 4.10); with write a person could run the command but never submit its form.
    • Job issue_command: the payload's channel_id must be a forum of jira_app.projects, else Warn reason channel_not_mapped and end (the README tells operators to grant write only on configured forums, so the command is listed only there, 09 10.3). Then createThread in that forum with the option title cleaned and cut as the title (empty: "New issue") and the first message "New Jira issue. Fill in the form below.", then createMessage in the new thread with kind card and one component built with the SDK's form builder: custom_id issue:<draft id> (a UUIDv7, within component.custom_id_max_chars), single_use true, restricted_to any, fields description (textarea, label "Description", not required) and, only when more than one project maps to the forum, project (choice of those keys, required), within component.form_max_fields. Store the draft {draft id, thread id, card message id, title, projects}. Reason: a thread belongs to exactly one issue and an app cannot retitle a thread (05 2.6), so /issue always opens its own forum post, also when it was run inside a thread.
    • Job issue_form: the draft is found by interaction.custom_id, else by interaction.message_id; unknown or pruned: respondToInteraction with state rejected and text "This form expired. Run /issue again.". The project is the only one or the chosen one, checked against the draft. Mark attempted, then create the issue with summary = the title, issuetype.name = the configured issue_type and description = the form text as an ADF document (Design note 15) whose last paragraph is "Created from chat.". Ruling: nothing about the person (name, handle, ID) goes to Jira from /issue, and the text is not neutralized, because the person wrote it for Jira.
    • Jira answers 201: store id and key, record the issue as created by the app (Design note 13), map the issue ID to the draft's thread, respondToInteraction with state acknowledged and text "Created issue ", createMessage into the thread "Created Jira issue SUP-13." with the key linked as Design note 11 allows, delete the draft.
    • Jira answers 3xx or 4xx (not followed, so the issue was not created): respondToInteraction with state failed and text "Jira refused the issue (status ).", then a new form card in the same thread; the draft stays with the new card.
    • 5xx, a time-out after sdk.http_timeout, a transport error, or attempted without result after a restart (outcome unknown): respondToInteraction with state failed and text "Jira did not confirm the issue. Check the project before you try again."; no new card, the draft is deleted.
    • A 409 answer to a repeated respondToInteraction (rerun after a crash) counts as done.
  13. Mirroring and loop protection (11 10.1 rows "Scopes" and "Loops" through 10.2; Ruling for docs/DECISIONS.md).

    • Mirroring exists only when the owner granted messages:read and events:subscribe, added message.created to the installation's event_subscriptions and created a subscription to delivery.events_url for message.created (webhooks.md 1); otherwise /chat/events receives nothing.
    • Decision at receipt, in this order, each answering 204 without a job and logging its reason: the author is the app itself (message.author.id equals the principal ID from getMe; reason own_message, the loop protection of 10.1); origin is not human (not_human: agent, app, import and system messages are never mirrored, so two mirroring apps cannot feed each other and no agent output leaves the instance without a person); kind is not text (not_text); deleted_at is set (deleted); the thread is not mapped (thread_not_mapped). Otherwise the job mirror, answer 202.
    • Job mirror: if content_truncated is set, load the message with getMessage; the comment body is an ADF document with one paragraph holding the display name as a text node with the code mark followed by the text " wrote in chat:", and one codeBlock with the content, because chat mentions name chat people and the content must stay literal; mark attempted, send the comment to the issue ID of the thread; 201: record the comment ID as created by the app; any other outcome: log status or reason jira_outcome_unknown and end without retry.
    • Echo protection in the other direction: Jira's jira:issue_created event of an issue the app created through /issue and the comment_created event of a comment the app created are not posted (reason own_jira_object). The check runs at receipt (answer 204 when the object is already recorded) and again in the worker before posting, because Jira may deliver the event before the create call returned, and the FIFO worker finishes the creating job first. Later events of these objects (jira:issue_updated, comments of others) are posted. Reason: the chat already shows the original, and without the rule every mirrored reply would come back as an app message.
  14. Answers of the server (06 5, 07 2.10), as M2-49 Design note 13:

    • 403 scope_missing: end the job, one Warn line with operation, status and reason scope_missing, no retry; the Jira delivery was already answered 202.
    • 404 not_found: Design note 10 for threads; for respondToInteraction and getMessage end the job.
    • 403 forbidden and 403 channel_read_only: end the job and log the code and reason.
    • 400 validation_failed: the retries of Design note 11, otherwise end the job and log the errors[].code values.
    • 401 unauthenticated (token revoked, app suspended or uninstalled, M2-24): end the job with an Error line; the endpoints answer 503 until getMe succeeds again, tried once per sdk.retry_backoff_cap.
    • 422 idempotency_key_reuse: Error line (the same identifier with another body is a programming fault), end the job.
    • 429 rate_limited and 503 unavailable: retried by the SDK, then Design note 9. Unknown codes are classified by status through the SDK's ProblemError classes.
  15. Jira REST client (jira/client.py, jira/adf.py; Ruling for docs/DECISIONS.md). Two calls on one httpx.AsyncClient with follow_redirects=False (a 3xx is a failure) and the time limit sdk.http_timeout: POST <jira_base_url>/rest/api/3/issue with {"fields": {"project": {"key": ...}, "issuetype": {"name": ...}, "summary": ..., "description": <ADF>}}, and POST <jira_base_url>/rest/api/3/issue/<issue id>/comment with {"body": <ADF>}. Headers Accept: application/json, Content-Type: application/json, Authorization: Basic <standard base64 of the credentials line> and User-Agent: jira-app/<VERSION>. The response body is read only on 201, for id and key of an issue (the key checked against the pattern of Design note 6) or id of a comment; errors carry the status only, never a body. ADF: one function builds {"type": "doc", "version": 1, "content": [...]} from paragraph nodes (one per line of a text, an empty line as a paragraph without content), text nodes (never empty) with the code mark only where Design note 13 says, and codeBlock nodes; text is never parsed as markup, so chat text cannot create a Jira mention, link or macro. Only Jira Cloud as cited in 11 10.2. The README names the REST version 3 pages for creating issues and comments, ADF and basic authentication with an API token, and says that the account needs only the permissions to browse, create issues and add comments in the configured projects.

  16. Logs (SEC-LOG-2, SEC-LOG-4 and SEC-SEC-3 as the rule for this app). JSON lines on stdout through logging with one formatter: time, level, msg (a fixed code such as jira.delivery.accepted) and only these attributes: event, delivery_id (the first 16 hex digits of the SHA-256 of the body), webhook_id, job_id, job_kind, step, operation, status, outcome, reason, code, issue_id, thread_id, channel_id. Never summaries, descriptions, comments, form values, display names, e-mail addresses, Jira account IDs, issue keys, project keys, URLs, header values, tokens, secrets, signatures or file contents. The SDK's logger bothy_sdk goes through the same handler. Start errors name key and file, never a value.

  17. Manifest (plugins/jira-app/manifest.json, 11 3.1 as M2-24 built AppManifest). Members: schema_version 1; slug jira; name Jira; description (English, one sentence); publisher Reference integration; version equal to VERSION; min_api_version equal to major and minor of info.version of docs/api/openapi.yaml; scopes.required [messages:write, commands:write, components:write]; scopes.optional [messages:read, events:subscribe]; events [message.created]; delivery {mode: webhook, events_url: https://jira-app.example.com/chat/events, interactions_url: https://jira-app.example.com/chat/interactions}; commands with issue of Design note 12; components [form]; requested_grants one {target_kind: channel, level: write, capabilities: [], purpose: ...}; data_egress (English): issue and comment events of the configured Jira projects are posted into the forum; /issue sends title and description to the configured Jira site; with messages:read and events:subscribe, messages of people in linked threads with their display name go to Jira as comments. No homepage_url. The scopes follow 10.1 as M2-49 corrected it, which 10.2 takes over ("Like 10.1"). The README's installation steps follow M2-49's: the operator adds the host of the addresses to webhook.allowed_hosts; an owner uploads the manifest with installApp (required scopes; the optional ones only for mirroring), grants write on each configured forum with createGrant, creates the token with createToken, and for mirroring creates the subscription with createWebhookSubscription to events_url; a later scope needs a new token (M2-24). The Jira side: a webhook with the URL of /jira/webhook behind the proxy, a secret, the three events and, as the operator chooses, a filter on the configured projects.

  18. Fake-Jira API and fixtures (PLAN 8 "Test strategy", D-W-10, RK-83; Ruling for docs/DECISIONS.md).

    • tests/fakes/jira_api.py: an aiohttp server on 127.0.0.1 port 0 with the two endpoints of Design note 15. It checks the Basic credentials (401 without the test credentials), Accept, Content-Type and User-Agent; answers 400 for an unknown project, an empty summary, an issue type other than the configured one, and a description or comment body that is not an ADF document of type doc and version 1; 404 for a comment on an unknown issue; otherwise 201 with increasing IDs from 10001 and keys <PROJECT>-<n>. It records method, path, JSON body and header names in memory. Fault injection per call: status 500, status 400, a 302 to another loopback listener (which must never receive a request), an answer later than the client's time limit, a dropped connection.
    • deliver(fixture, *, overrides, signature, secret) renders a fixture with overrides (IDs, keys, project, texts, timestamp), signs it with HMAC-SHA256 in hex like Jira and posts it to the app with X-Hub-Signature and Content-Type: application/json; options omit, alter or truncate the signature, use another method, add a second header or repeat the identical body. With auto_echo, every issue created through the fake is followed by its jira:issue_created delivery, and every comment by its comment_created delivery and a jira:issue_updated delivery without changelog.
    • Fixtures tests/fixtures/jira/jira_issue_created.json, jira_issue_updated.json (changes of status, summary and description), comment_created.json: written by hand with the members of the example payloads on the page cited in 11 10.2 that Design note 6 reads, and values of this brief's world (site https://jira.example.com, projects SUP and WEB, Jira display names Sam and Robin, account IDs such as example-account-sam). No package publishes Jira's examples, and the build has no network; tests/fixtures/README.md names the page, the access date of 11 10.2, that the fixtures were reduced by hand without network, and their members. The comparison with the live page is named under "What is not proven".
  19. No real Jira service (PLAN row). tests/conftest.py has an autouse fixture that replaces socket.getaddrinfo, socket.socket.connect and socket.socket.connect_ex through monkeypatch: a host other than 127.0.0.1, ::1 or localhost and an address outside 127.0.0.0/8 and ::1 raise NetworkBlocked (Unix sockets pass); tests/live/conftest.py keeps the same guard. The test helper that builds configurations refuses a jira_base_url or server_url outside loopback. The app opens outgoing connections only through httpx (the SDK client and the Jira client).

  20. Live run against the real server (PLAN 8 Exit criteria 9; Ruling for docs/DECISIONS.md).

    • Mechanism: M2-29's real-server fixture with the protocol parts of M2-49, and no Go test. tests/live/conftest.py finds the repository root (the directory with docs/api), builds sdkserver once per session like sdk/python/tests/realserver/conftest.py (go test -c -tags sdkserver -o <tmp>/sdkserver.test ./test/sdkserver in services/server), runs it with -test.run '^TestSDKServer$' and drives it over its JSON-lines protocol on stdin: start with the members of M2-29's fixture plus app_front {host: "jira-app.example.com"}, then M2-49's create_forum, app_backend and call, at the end stop and then the PID. The live tests run in the default pytest run, so the root make test and make check run them; without Docker, Go or uv they fail and never skip.
    • TLS front: M2-49's app_front with the host jira-app.example.com; the driver sets webhook.allowed_hosts = jira-app.example.com:<front port> and egress.allowed_hosts = jira-app.example.com:<front port>;allow_private and wires Deps.RootCAs and the test resolver as M2-49 does; the server runs its jobs, so runs.app_dispatch and apps.webhook_deliver deliver. If the merged app_front serves only a fixed host name, make the host a parameter there; M2-49's live file stays green.
    • The ops app_backend {port} and call {as, operation_id, path_params, query, body} of M2-49 are used unchanged; the Python side performs installation, grant, token, scopes, subscription, command and form submission through call, so the live test runs the documented operator steps, and no token is ever printed.
    • World: the driver's world (owner Robin, Alex with trigger on space Support through G-1) plus the forum Support/Issues from create_forum {name: "issues"}. The app and the Fake-Jira run in the test process.
  21. Tool chain and gates.

    • Root Makefile: no change expected; its Python lines (ruff format, ruff check, bandit without tests, mypy, pytest with coverage, licenses) run on every tracked plugins/*/pyproject.toml. If a recipe breaks on a second Python project under plugins/ (for example a scanner reaching the project's .venv), fix it for all projects with a test in tools/ci/tests/; no new target.
    • tools/ci/components.py: a change under sdk/python/ also selects the gate component plugins, in the way M2-49 made sdk/typescript/ and services/server/ select it; tests in tools/ci/tests/test_components.py.
    • Coverage: tools/docs/tests/test_config_values.py must hold the fail_under of plugins/jira-app/pyproject.toml equal to quality.coverage.python_min_percent; if its sources name projects one by one, add this one.
    • Licenses: make licenses reads plugins/jira-app/uv.lock. If OSV-Scanner reports the local package bothy-sdk as UNKNOWN, add plugins/jira-app/osv-scanner.toml with one PackageOverrides entry and its reason ("local package of the repository under Apache-2.0, sdk/python"), as licenses.md point 3 says; never a change to quality/licenses/allowed.txt.
    • Documentation in code: tests/test_doc_gate.py copies pyproject.toml and jira_app/ into tmp_path, adds a public function without docstring to a module and runs ruff check with the copy's configuration: a non-zero exit naming D103 and the file; the unchanged copy passes (23 "Documentation in code": every gate has a test).
    • Sonar and .gitignore already cover plugins/*/coverage.xml; no change.
  22. Specification and documents (spec first; English in chapters, READMEs, CHANGELOGs and the manifest).

    • docs/spec/11-extensions.md 10.2: keep the paragraph with its sources and add a table in the form of 10.1 with the rows "Input" (header exactly once, method before the first =, allow list with exactly sha256, every other method 401 without computing, 64 hex digits, HMAC over the raw body in constant time before parsing, duplicates by the SHA-256 of the body within jira_app.dedupe_window, the event list of Design note 6), "Output" (key issue.id, title <KEY> <summary>, a thread out of reach is replaced once, jira:issue_updated only with field changes), "Commands" (/issue with option title and required_level trigger, own forum post with a form, answer 202, issue over the Jira API with the issue type of the project, answer in the thread, no data of the person to Jira), "Delivery to the app" (interactions_url; a webhook subscription to events_url only for mirroring), "Target API" (REST version 3, ADF, basic authentication with an API token, only Jira Cloud), "Foreign text" (code spans and fenced blocks, links only built from the configuration, text to Jira only as ADF text), "State" (SQLite, flock, one worker, one attempt at Jira). Facts about Jira that the chapter does not already prove (hex encoding of the signature, payload members, REST version 3, ADF, basic authentication) carry the marker [assumed], because the build has no network to check them.
    • docs/spec/11-extensions.md 11.2 row webhooks: both SDKs, Python bothy_sdk.webhooks (M2-50) and TypeScript (M2-30); 16: the twelve keys of Design note 3 with Place "Jira app"; then make config-reference.
    • docs/api/webhooks.md: the sentence on the SDK verifiers names the Python module as well.
    • docs/sdk/python.md: a block bothy_sdk.webhooks and one for every other new public module that M2-29's reference test requires; sdk/python/README.md (module webhooks built, example of a verification); sdk/README.md where it lists modules.
    • plugins/jira-app/README.md (English): purpose, layout, configuration with every key, endpoints and the TLS proxy, installation steps of Design note 17, the Jira webhook set-up, the Jira credentials and their permissions, mirroring, loop and echo rules, logs, state and lock, how to run the tests and the live test, and that the processor register of the operator lists Jira (docs/compliance/processors-template.md, row "App <name>").
    • plugins/README.md: row jira-app/ with status built in M2-50.
  23. Threat model (threat-model.md 9 point 2: a new data flow between Jira, the app and the chat). Add to 5.11 the rows S "Forged or replayed Jira delivery to the app" (X-Hub-Signature with an allow list of the method, comparison in constant time, duplicates through the digest of the body), T "Foreign text from Jira mentions people or sets links" (code spans and code blocks per the 03 content format, links only from the configuration), I "Chat content goes to Jira" (only with messages:read, events:subscribe and a subscription, only messages of people in linked threads, data_egress), D "Flood of Jira deliveries" (jira_app.max_body_bytes, jira_app.max_queued_jobs, one worker), E "Loop between chat and Jira" (author = app never mirrored, own Jira objects do not come back, only origin = human), each with register T-PL-02 except the I row (T-PL-01). In T-PL-02 add the test names of this ticket and extend the residual risk to Jira ("an intercepted Jira delivery can be replayed after jira_app.dedupe_window, because the signature carries no timestamp"); add plugins/jira-app/tests/live/test_live.py to the tests of T-PL-01. RK-83 needs no change: its mitigation already names the method read from the header and fixtures with source and date.

  24. Rulings for docs/DECISIONS.md (section 10, one row D-M2-50-<n> each with date, decision, reason and place): package, runtime and dependencies (note 2); configuration and secret files (3); HTTP surface behind the TLS proxy (4); signature rules and the method allow list (5); event list, no_change and the two rich-text forms (6); 202 for every command and interaction, SDK verifier, keys loaded at start, no v1 (7); the Python module webhooks and its parity with M2-30 (8); state in SQLite with flock, FIFO worker, idempotency identifiers, one attempt at Jira (9); issue ID as key, titles, unreachable thread, message templates (10); foreign text, links only from the configuration, cutting (11); required_level trigger, own forum post, issue type per project, no person data to Jira, failure handling (12); mirroring only human text, loop and echo protection checked again in the worker (13); handling of server answers (14); Jira client with REST version 3, ADF and basic authentication (15); log allowlist (16); manifest scopes (17); hand-written fixtures without network (18); live run in the default pytest run over sdkserver with M2-49's app_front, app_backend, create_forum and call (20).

Tests (write them first)

Unit and fake tests, plugins/jira-app/tests/ (pytest with the anyio plugin, run by the root make test; servers on 127.0.0.1 port 0; a fake clock where time matters; the no-network guard of Design note 19 in every test):

  • test_signature.py (PLAN row: "signature with sha256 valid, an unknown method rejected instead of assuming SHA-256 (11, 10.2), a wrong and a missing signature rejected"): test_sha256_valid (secret It's a Secret to Everybody, payload Hello, World!, sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17 verifies; the same digits in upper case verify); test_unknown_method_refused_instead_of_sha256 (sha1=01dc10d0c83e72ed246219cdd91669667fe2ca59, sha512=11ed355a617e98134e842012a7944ccf59c10256cb182357bd7e3a42013ff07c376f8c14cf5cc1923da20b51d64256b2fb8ebbf100aa67a61326f61fea8111bc, md5= and SHA256= each with the valid SHA-256 digits, and sha384= with 96 hex digits are unsupported_method; a spy on the module's HMAC function shows that no HMAC was computed for any of them); test_wrong_signature_refused (last hex digit changed, body changed by one byte, another secret: mismatch); test_missing_signature_refused (no header: missing); test_malformed_signature_refused (sha256, =757107ea..., sha256=, sha256=757107ea, 63 and 65 hex digits, a non-hex character, two X-Hub-Signature headers: malformed; none raises); test_constant_time_comparison (with hmac.compare_digest replaced through monkeypatch by a recording wrapper it is called exactly once with two bytes of length 32 for the valid and for a wrong signature of the same length; a replacement that returns True lets a wrong signature pass and one that returns False makes the valid one fail, which proves the decision goes only through compare_digest).
  • test_jira_endpoint.py: with the Fake-Jira deliver: a valid jira:issue_created gives 202 and one job; wrong, missing, truncated signatures and an unknown method give 401, no job and no call of the chat port; jira:issue_deleted gives 204; the identical body again gives 204 without a second job; an unknown project gives 204; a payload without issue.id and with a valid signature gives 400; text/plain gives 415; a body above jira_app.max_body_bytes gives 413; GET gives 405; an unknown path 404; a full queue (jira_app.max_queued_jobs set small) 503; a webhook secret file with mode 0644 gives 503.
  • test_jira_events.py: each fixture parses into the expected fields; a description and a comment as ADF documents read as text with line breaks, mentions as their text; changelog items of status, summary and description; a payload without issue.fields.project.key is invalid_payload; unknown members are ignored.
  • test_adf.py: built documents have type doc and version 1, no empty text node, one paragraph per line, the code mark only on the display name, the content of a codeBlock unchanged (backticks, [~accountid:x], {code} and @alex stay text).
  • test_threads.py (PLAN row: "jira:issue_created, jira:issue_updated and comment_created from fixtures yield a thread and messages as in M2-49"): jira:issue_created of SUP-12 creates one forum post in the forum of SUP titled SUP-12 <summary> with the event as first message and stores the map; comment_created on SUP-12 posts into the same thread and creates none; jira:issue_updated of SUP-12 with a status change posts the change line into the same thread; jira:issue_updated of SUP-12 without changelog posts nothing; jira:issue_created of SUP-13 creates a second post; after stop() and a new start_app on the same state directory an event of SUP-12 still lands in its thread; an update of the same issue ID whose key became WEB-3 lands in the same thread; 404 not_found on posting into SUP-12's thread drops the entry, creates one new post and logs thread_unreachable; a rerun of a job after a simulated crash between createThread and the map write sends the same first_message.client_msg_id.
  • test_render.py: @alex and [x](https://example.com) in Jira text end up only inside code; a description with backtick runs keeps its fence closed; a summary with a bidi override, a control character and a line break is cleaned; cutting respects limits.message_max_content_bytes at a code point boundary with the fence closed and the cut line; titles are cut to limits.thread_title_max_chars code points; a link appears only to https://jira.example.com/browse/SUP-12, never for a key like SUP-12) and never from a payload URL; a 400 validation_failed on the title leads to one retry with the key alone; a 400 validation_failed with errors[].code too_long reloads the limits and retries once.
  • test_issue_command.py (PLAN row: "form against the fake API"): a CommandInvokedEvent signed with the SDK's sign_webhook (a test key the app's key source trusts) gives 202; the job creates a forum post titled from title and a card whose form has description and no project field; with SUP and WEB on the forum the form has a project choice; a signed InteractionCreatedEvent with values.description gives 202; the Fake-Jira records exactly one POST /rest/api/3/issue with fields.project.key SUP, the configured issue type, the title as summary and an ADF description whose last paragraph is "Created from chat."; respondToInteraction with state acknowledged and the key; one message "Created Jira issue ..." in the draft's thread; the issue ID maps to that thread; the fake's echo jira:issue_created adds no message, also when it is delivered before the create call returned; a later comment_created by Sam posts into the thread. Variants: Fake-Jira 400 (and a 302) gives state failed and a new form card; 500 and a time-out give state failed with the check text, no new card and no second POST; a restart after attempted sends no second POST and answers failed; the same webhook-id twice gives one job; an unknown custom_id gives state rejected; command.autocomplete_requested gives 200 with an empty choices; a request signed by an unknown key, with a timestamp beyond webhook.receiver_tolerance or with a changed body gives 401 and no job.
  • test_loop.py (PLAN row: "loop protection"): a signed MessageCreatedEvent whose author is the app's own principal in a mapped thread gives 204 and the Fake-Jira records no comment; messages with origin agent, app, import and system, a card, a deleted message and a message in an unmapped thread give 204 and no comment; a human text message in a mapped thread gives exactly one comment whose ADF body holds the display name with the code mark and the text in a codeBlock; the fake's echo comment_created of that comment and the following jira:issue_updated without changelog add no message; a truncated message is loaded with getMessage; a Jira 500 on the comment is logged and not repeated.
  • test_scopes.py (11 10.1 row "Scopes" through 10.2): manifest.json scopes.required equals the required scopes of that row, parsed from docs/spec/11-extensions.md, and scopes.optional its optional ones; the chat port answering 403 scope_missing to createThread ends the job after one attempt with one Warn line naming scope_missing, the Jira delivery stayed 202, and the job is gone.
  • test_manifest.py: the members of Design note 17; version equals VERSION and pyproject.toml; min_api_version is not above major and minor of info.version of docs/api/openapi.yaml; the command issue with option title and required_level trigger; both addresses https on one host; no homepage_url; no member outside the field table of 11 3.1.
  • test_config.py: required keys, unknown member, duplicate member, bad duration, wrong type, a project key in lower case, a project listed twice, a credentials line without :; the error text names key and file and no value; a secret file with mode 0644 aborts the start; a state directory with group access is refused; config.example.json loads; every jira_app.* default equals the "Default" column of docs/operations/configuration.md, and the reused keys come from the SDK's SdkSettings.
  • test_store.py (contract test for SqliteStore and MemoryStore): commits survive a reopen, modes 0700 and 0600, a second process (a subprocess holding the flock) makes the start fail, pruning after jira_app.dedupe_window with the fake clock, the thread map survives pruning, FIFO order of jobs.
  • test_chat_adapter.py: the SDK adapter against an httpx.MockTransport with schema-valid answers: the request bodies of createThread, createMessage and respondToInteraction contain no JSON null; client_msg_id and Idempotency-Key are the stored ones on a rerun; Authorization is sent; a ProblemError reaches the job with status and code.
  • test_jira_client.py: the headers of Design note 15, no redirect followed and a 302 is a failure, the time limit sdk.http_timeout, no body read on errors, the credentials file checked at each use.
  • test_no_network.py (PLAN row: "no call to a real Jira service in the test"): the guard raises for https://jira.example.com/ and for any non-loopback host and address; the configuration helper refuses a non-loopback jira_base_url.
  • test_logs.py: canary values in summaries, descriptions, comments, form values, display names, account IDs, issue and project keys, the Jira credentials, the app token and the webhook secret run through every flow above; no captured log line contains any of them.
  • test_fake_jira.py: the fake's deliveries verify with the app's check; faults and recording; auto_echo.
  • test_project.py: bothy-sdk comes from ../../sdk/python as an editable path source; every dev tool shared with sdk/python/pyproject.toml has the same exact version; fail_under equals the value of quality.coverage.python_min_percent in 23.
  • test_doc_gate.py: Design note 21.
  • License gate (PLAN row: "license gate for Python green"): make licenses reads plugins/jira-app/uv.lock and passes without a change to quality/licenses/allowed.txt; make osv passes; keep both outputs for the report.

SDK tests, sdk/python/tests/ (Design note 8):

  • test_webhooks_vectors.py: for every signature case of docs/api/webhook-test-vectors.json, verify_webhook accepts the body with the header value of the case, its public_key values and its hmac_secrets at a clock equal to its timestamp; each scheme alone (only the keys, only the secrets) accepts when the header has an entry of that scheme; the body with one byte changed, another webhook-id and another timestamp each give signature_mismatch; sign_webhook reproduces the header value byte for byte from the case's seeds and secrets; for every derivation case, parse_webhook_secret of whsec_ plus the base64 of the bytes of derived_hex returns those bytes.
  • test_webhooks.py: missing_header for each header; invalid_timestamp for abc, 1.5 and empty; timestamps exactly webhook.receiver_tolerance before and after the clock accepted, one second beyond rejected with timestamp_out_of_tolerance; no_trusted_key; a header with several spaces, an unknown scheme v2,... and bad base64 still verifies by its valid entry; a key of another type and a wrong prefix raise WebhookConfigError; a body parsed and serialized again fails (raw-body rule); a v1 entry of another length does not raise; an envelope of an unknown type returns the SDK's form for unknown events; WebhookKeyCache fetches once, refreshes after max-age (without the header after agent.jwks_max_age) and never after a failed verification; a seeded set of random headers and bodies gives only WebhookVerificationError, never another exception; no error message contains the body or a secret. M2-29's test_settings.py, test_hand_layer_types.py and tools/docs/tests/test_sdk_reference.py stay green with the new fields and module.

Live, plugins/jira-app/tests/live/test_live.py (part of the default pytest run, so the root make test and make check run it; against sdkserver as in Design note 20; the app and the Fake-Jira run in the test process; waits poll until a deadline derived from app.interaction_timeout and webhook.delivery_timeout of the test configuration):

  • validateManifest with the manifest rewritten to https://jira-app.example.com:<front port> gives valid true; installApp without components:write gives 400 validation_failed with the pointer of granted_scopes; with exactly messages:write, commands:write and components:write gives 201; createGrant write on Support/Issues; createToken writes the token file; app_backend with the app's port.
  • Threads: deliveries of jira:issue_created SUP-12, comment_created SUP-12, jira:issue_updated SUP-12 (status) and jira:issue_created SUP-13 give, in listChannelThreads of Alex, exactly two posts of the app titled SUP-12 ... and SUP-13 ..., and listThreadMessages of SUP-12 shows three messages with origin app.
  • /issue: listChannelCommands of Alex in Support/Issues lists issue; invokeCommand by Alex with title gives 202; a new post with the form card appears; createInteraction by Alex with values.description gives 201; the Fake-Jira records one issue; the post shows the confirmation with the key; the echo jira:issue_created adds no message.
  • Loop protection and mirroring: setAppScopes adds messages:read and events:subscribe with event_subscriptions [message.created]; createWebhookSubscription for the app to events_url; a new token, the app restarted with it. A Jira comment on SUP-12 is posted by the app; its message.created reaches /chat/events (listWebhookDeliveries of Robin shows it succeeded), and the Fake-Jira records no comment. Alex replies in SUP-12's thread with createMessage: the Fake-Jira records exactly one comment on SUP-12; its echoes add no message.
  • Missing scope: createToken with scopes [commands:write, components:write], the app restarted with it; a delivery of jira:issue_created SUP-14 gives 202, the server answers 403 scope_missing to createThread, the app log holds scope_missing once, and no post SUP-14 appears.
  • No request leaves loopback (guard), and no request reaches anything but the Fake-Jira and the live server.

No Go test is added (Design note 20).

Repository tools: tools/ci/tests/test_components.py (a path under sdk/python/ selects sdk-python and plugins); tools/docs/tests/test_config_values.py if changed.

Red runs: keep the red output of test_signature.py, test_threads.py, test_issue_command.py, test_loop.py, test_scopes.py, test_no_network.py and test_webhooks_vectors.py before the code exists, and of tests/live/test_live.py before the app answers.

Commands that must pass, from the repository root:

cd plugins/jira-app && uv run --locked pytest && cd ../..
cd sdk/python && uv run --locked pytest && cd ../..
cd tools && uv run --locked --quiet pytest ci/tests/test_components.py docs/tests/test_config_reference.py docs/tests/test_config_values.py docs/tests/test_sdk_reference.py && cd ..
make config-reference
make fmt-check
make lint
make typecheck
make test
make coverage
make docs
make linkcheck
make licenses
make osv
make check

Run tests/live/test_live.py once on its own (uv run --locked pytest --no-cov tests/live in plugins/jira-app) and quote the result in the report.

Acceptance criteria

  • X-Hub-Signature with method sha256 verifies with the test values; every other method answers 401 without any HMAC being computed, also when its digest is correct for that method or for SHA-256; wrong, missing and malformed signatures answer 401 and create nothing; the comparison goes only through hmac.compare_digest on the digests.
  • jira:issue_created, jira:issue_updated and comment_created from the fixtures give one forum post per issue and messages in it as in M2-49; a new issue creates exactly one post; the mapping survives a restart and a changed key; an update without field changes adds nothing.
  • /issue with the form creates exactly one issue through the Fake-Jira API and answers in the thread; failures follow Design note 12, never with a second issue.
  • The app's own messages are never mirrored back; mirrored human replies and the app's own issues and comments do not come back; Jira text never becomes a mention or link in the chat, and chat text reaches Jira only as ADF text.
  • The manifest requests exactly the scopes of 11 10.1 (through 10.2); installApp refuses an installation without one of the required scopes; a missing scope at call time gives 403 scope_missing, logged once and not retried.
  • bothy_sdk.webhooks verifies every signature case of docs/api/webhook-test-vectors.json, and the app verifies the server's requests only through it.
  • No test calls a real Jira service.
  • The app passes its live test against the real server inside make check (tests/live/test_live.py in the default pytest run).
  • The Python license gate and OSV pass without a change to the allowlist; ruff D fails on an undocumented public function of the app.
  • 11 10.2, 11.2 and 16, configuration.md, webhooks.md, the threat model, plugins/README.md, the READMEs and CHANGELOGs of the app and the SDK, the root CHANGELOG and docs/DECISIONS.md describe what was built.
  • make check is green.

Definition of done

  • Code and tests as specified; doc comment on every public symbol (purpose, parameters, return value, errors, example where useful); comments explain why.
  • README of plugins/jira-app (English) and the row in plugins/README.md updated; sdk/python/README.md and docs/sdk/python.md with the module webhooks; the sdkserver README if Design note 20 changed app_front.
  • Rulings of Design note 24 recorded in docs/DECISIONS.md (one row each with reason).
  • plugins/jira-app/CHANGELOG.md, sdk/python/CHANGELOG.md and the root CHANGELOG.md entries under 'Unreleased'.
  • An ADR only if you take an architecture decision beyond this brief (MADR 4 as in docs/adr/0000-template-madr-4.md, headings and text in English, next free number, make adr-index).
  • make check green.
  • An independent code review over the diff, then the red team gate (docs/quality/red-team-gate.md, verdict JA or NEIN), with attention to the M2 "Red team" items "exceeding the scopes of an app" and "apps and webhook targets as boundaries", forged, replayed and method-swapped Jira deliveries, forged server requests to the app, foreign text in the chat and in Jira, and loops (T-PL-01, T-PL-02); on NEIN fix with a test per finding and have the delta checked again. Record both verdicts.
  • Land the change as AGENTS.md ("How work lands on main") describes: a pull request from this branch, squash-merged only when review, red team and make check, ci-ok and the review threads are green; then git worktree remove.

Report

Write to {{REPORT_PATH}}:

  1. First line exactly Verdict: DONE or Verdict: BLOCKED <reason>.
  2. Commits (SHA and subject), changed files with line counts, test results with counts (the red run before the change and the green run after), the summary of make check.
  3. Review and red team verdicts and the findings fixed.
  4. What is not proven (at least: behaviour against a real Jira service; the hand-written fixtures, the hex encoding of the signature and the REST version 3, ADF and basic authentication facts compared with the live documentation, which needs network).
  5. Follow-ups that could not be done in this scope, each with its reason; the pinned versions of aiohttp, httpx and the dev tools; whether bothy_sdk.webhooks was created here or found on the base branch; every difference to the names or behaviour of M2-23 to M2-26, M2-29, M2-30 and M2-49 (D-W-100); spec gaps found.

Then write {{DONE_PATH}} with exit=0 sha=<sha> or exit=1 <reason>.