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
webhooksmodule that note 8 mirrors, and its live files that drivesdkserverinsidenpm test), M2-29 (sdk/python:Client,SyncClient,Secret,Credentials,FileDpopKey,ProblemErrorand its classes,uuid7,SdkSettings, thecomponentsbuilders withform,bothy_sdk.testing, the live driverservices/server/test/sdkserverand its protocol,sdk/python/tests/realserver/conftest.py,docs/sdk/python.md), M2-26 (getWebhookKeys,createWebhookSubscription,listWebhookDeliveries,docs/api/webhook-test-vectors.json, thehost[:port]entries ofwebhook.allowed_hosts), M2-25 (requests todelivery.interactions_urlfrom the jobruns.app_dispatch, the answer forms200and202,no_threadin a forum, the section on the interactions address in webhooks.md), M2-24 (AppManifestafter 11 3.1,validateManifest,installApp,setAppScopes,createTokenfor apps with a scope subset), M2-23 (layoutsdk/python/src/bothy_sdk/, distributionbothy-sdk,bothy_sdk.generated,bothy_sdk.tolerance, the coverage keyquality.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.mdcompletely: 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, ownVERSIONandCHANGELOG.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" (rowsauth,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)" (formwithfields), 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.createundermessages:write,message.readundermessages:read,interaction.respondundercomponents:write, "Event subscriptions" underevents:subscribe,self.readandinstance.readwithout scope); 4.10 rowscommand.invoke,interaction.create(leveltrigger),interaction.respond; 5 "Agent invocationagent.invoke" (last paragraph: app commands withoutagent.invokeand I2).docs/spec/03-data-model.md(core contract, read only):messages(origin,kind) and the content formatcontent_format = 1(CommonMark 0.31.2; rule 2: no mentions in inline code and code blocks; rule 3: only principals of kinduseroragentare resolved).docs/spec/04-sync-protocol.md(core contract, read only): 2.2 "Envelope" (delivery fieldsync_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 is403with reasonscope_missing), SR4 "Only humans trigger".docs/spec/06-api.md: 2 "Basic rules" ("Nonull", "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: operationsgetMe,getInstance,getWebhookKeys,createThread(its description:first_message.client_msg_idmakes it idempotent),createMessage,getMessage,respondToInteraction, and for the live testcreateChannel,validateManifest,installApp,setAppScopes,createGrant,createToken,createWebhookSubscription,listWebhookDeliveries,listChannelCommands,invokeCommand,createInteraction,listChannelThreads,listThreadMessages; schemasAppManifest,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 (rowmessage.created:messages:readin addition toevents: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 rowsD-M2-23-<n>,D-M2-24-<n>,D-M2-25-<n>,D-M2-26-<n>,D-M2-29-<n>,D-M2-30-<n>andD-M2-49-<n>on the base branch; section 10 "Rulings of the build milestones" (row formatD-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 rowswebhook.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 thegithub_app.*rows of M2-49.docs/quality/gates.md,docs/quality/licenses.md(point 3:PackageOverridesin anosv-scanner.tomlof 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.reportPathsalready listsplugins/*/coverage.xml);.gitignore; the rootMakefile(PY_PROJECTSincludesplugins/*pyproject.toml; the Python lines offmt-check,lintwith ruff and bandit,typecheck,test,licenses);tools/ci/components.pyand 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 linttest_hand_layer_types.py; the reference testtest_reference_page_lists_every_public_module; Out of scope: the missingwebhooksmodule), 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 ofsdkserver), M2-26 (Design notes 9, 11 and 12:ValidateTarget, signing set and header order,whpk_andwhsec_, 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 driverservices/server/test/sdkserverwith the protocol parts of M2-49 (memberapp_frontofstart, the TLS front, the opsapp_backend,create_forumandcall),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):
- No MVP. New from scratch and directly in the final state; stages are only the order.
- Strictly modular: clear module and service boundaries, dependencies only through defined interfaces, no cross access.
- 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).
- 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.
- Quality gates (SonarQube, linters, type checking, security scanners, test coverage) block the merge.
Rules for every brief:
- Test first: write the failing test, run it and keep the red output for the report, then make it green, then clean up.
- No en dash and no em dash in any file you write; the
text-rulespre-commit hook rejects them. Use a hyphen or rephrase. - White label: examples and test data use
example.comand the people Alex, Sam and Robin; no company, host or personal names, no paths under a home directory. - 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. - Specification first: a change to behaviour, API, event, right or configuration updates
docs/spec/ordocs/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:
- 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). - 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 exactlysha256, 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). - 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_updatedandcomment_created(11 10.2 "Like 10.1", 10.1 row "Output"). /issuewith 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).- Scopes exactly as 11 10.1 after M2-49: required
messages:write,commands:write,components:write; optionalmessages:readandevents:subscribewith eventmessage.created; grantwriteon the forum. An action without its scope is403 scope_missing(07 2.10, 05 2.6). - The app never mirrors its own messages (author = app) back (11 10.1 row "Loops"); apps never trigger agents and never hold
triggerormanage(11 6 row "Never allowed", R-EX-03, SR4). - Commands and interactions reach the app as signed
POSTrequests todelivery.interactions_url, answered withinapp.interaction_timeoutwith202and acted on through the API (11 9); requests of the server are verified with the SDK'swebhooksmodule: raw body,webhook-timestampwithinwebhook.receiver_tolerance,v1akeys fromgetWebhookKeysonly (webhooks.md 3, 11 11.2 rowwebhooks). - The Python SDK has the
webhooksmodule of 11 11.2 with the behaviour of M2-30's TypeScript module, and every signature case ofdocs/api/webhook-test-vectors.jsonverifies with it (R-EX-11, ADR 0026, M2-30 Design note 2). - Duplicates are recognised by
webhook-id, at least forwebhook.retry_max_age(webhooks.md 3 point 4), and Jira deliveries by the digest of their body. - 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.
- 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).
- 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
0400or0600(SEC-SEC-1 and SEC-SEC-3 applied to the app, SEC-LOG-2, SEC-LOG-4,plugins/README.md). - Dependencies locked with hashes in
uv.lock; the license gate for Python and OSV pass without a change toquality/licenses/allowed.txt(SEC-SC-1, licenses.md, PLAN row). - The app's own names do not carry the product name; it appears only as the SDK distribution
bothy-sdkand the import packagebothy_sdk(02 6, ADR 0044). - Own
VERSIONandCHANGELOG.mdin the folder; the app is not a release component (11 10, D-W-38 for the English CHANGELOG). - No JSON
nullin requests to the server (ADR 0053); unknown members of server events are tolerated through the SDK (06 2). - 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.tomlanduv.lock(Design note 2),VERSION(0.0.0),CHANGELOG.md(Keep a Changelog, English, likesdk/python/CHANGELOG.md),README.md(English, Design note 22),manifest.json(Design note 17),config.example.json(every key of Design note 3 withhttps://chat.example.com,https://jira.example.comand 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 andasync 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 SDKClient),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/*.jsonandtests/fixtures/README.md(Design note 18),tests/live/conftest.pyandtests/live/test_live.py(Design note 20).osv-scanner.tomlonly 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_webhookinbothy_sdk.testing;tests/test_webhooks.pyandtests/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(modulewebhooksbuilt),sdk/python/CHANGELOG.md,sdk/README.md(if it lists the modules per SDK).sdk/python/src/bothy_sdk/client.pyorretry.pyonly where the merged SDK lacks the access to response headers of Design note 8 or lets a caller'sIdempotency-Keybe replaced (Design note 9), each with a test insdk/python/tests/.tools/ci/components.pyandtools/ci/tests/test_components.py(Design note 21);tools/docs/tests/test_config_values.pyonly if its sources list Python projects one by one (Design note 21).docs/spec/11-extensions.md10.2, 11.2 and 16 (Design note 22), thenmake config-referencefordocs/operations/configuration.md;docs/api/webhooks.md(the sentence on SDK verifiers).docs/security/threat-model.md5.11 and the register rows T-PL-01 and T-PL-02 (Design note 23).services/server/test/sdkserverand itsREADME.mdonly if M2-49'sapp_frontdoes not take the host name (Design note 20);services/server/test/harnessonly if it cannot pass the egress test seams.plugins/README.md(rowjira-app/: status built, M2-50), rootCHANGELOG.mdunder "Unreleased",docs/DECISIONS.mdsection 10 rowsD-M2-50-1and 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 withVerdict: 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'sIdempotency-Keyof Design note 9; if another needed SDK function is missing, stop withVerdict: 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.closeorthread.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 ofAGENTS.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.mdentries named in this brief. - Follow the host rules supplied by the launcher (ports, machine rules).
- Tests bind only
127.0.0.1and 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 oruvthe live test fails; it never skips. - Stop only processes you started, by PID. Temporary directories only via
mktemp -dor pytest'stmp_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 underexample.comreach loopback only through the test resolver of the egress client. - Dependencies pinned exactly in
uv.lock; dev tools at the exact versions ofsdk/python/pyproject.toml. Do not extendquality/licenses/allowed.txt; if a needed package or its tree fails the gate, stop withVerdict: 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¶
-
Start check. Run
make checkon the fresh worktree; if it is red, stop withVerdict: BLOCKED base branch redand the failing gate. Confirm on the base branch: M2-49 (plugins/github-appwith its live filetest/live/github-app.live.test.tsinsidenpm test, andsdkserverwith the memberapp_frontofstartand the opsapp_backend,create_forumandcall), M2-29 (services/server/test/sdkserverwith its stdin protocol,sdk/python/tests/realserver/conftest.py,sdk/pythonwithClient,Secret,FileDpopKey,ProblemError,uuid7,SdkSettings,bothy_sdk.componentswith a form builder,bothy_sdk.testing,docs/sdk/python.md), M2-26 (docs/api/webhook-test-vectors.jsonwith signature cases,getWebhookKeys,createWebhookSubscription), M2-25 (requests todelivery.interactions_urlfrom the jobruns.app_dispatch), M2-24 (AppManifestwithscopes.required,scopes.optional,delivery,requested_grants,data_egress), M1-57 (getMessage,listThreadMessages). A missing one ends withVerdict: BLOCKED dependency <ticket> missing. Ifbothy_sdk.webhooksalready exists (the gap M2-29 reported was closed elsewhere), use it, skip the creation of Design note 8 and run its tests unchanged. -
Package, runtime and project. Ruling for docs/DECISIONS.md.
plugins/jira-appis a Python project with the flat packagejira_appand[tool.uv] package = falseliketools/pyproject.toml, for therequires-pythonofsdk/python/pyproject.toml, on Linux and macOS (the file modes andfcntlof 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-sdkfrom[tool.uv.sources]withpath = "../../sdk/python"andeditable = 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 insdk/python/uv.lock, exact versions with hashes inuv.lock. Everything else from the standard library:hmacandhashlib,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-covat exactly the versionssdk/python/pyproject.tomlpins,bothy-sdk[testing](forsign_webhook), and async tests with the anyio pytest plugin as in the SDK (nopytest-asyncio, M2-29 Design note 17). - Tool configuration in
pyproject.toml: ruff with the rule set ofsdk/pythonincludingD(conventiongoogle, tests excluded); mypy strict overjira_appandtests; pytest withtestpaths = ["tests"],filterwarnings = ["error"]and no marker that deselects tests, so the default run ofmake testincludestests/live/(Design note 20); coverage with branches,source = ["jira_app"](sotests/live/is never measured) andfail_underat the value ofquality.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-sdkand the importbothy_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
.pyfile carriesSPDX-License-Identifier: Apache-2.0in its first lines (tools/ci/license_check.py).
-
Configuration (new rows in 11 16 with Place "Jira app"; Ruling for docs/DECISIONS.md). A JSON file named by
--configholds one objectjira_appwhose 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 requiredgithub_app.*keys.jira_app.listen: required;host:portof the HTTP listener.jira_app.server_url: required; base URL of the instance,https,httponly for a loopback host.jira_app.token_file: required; file with the app's API token (M2-24createToken), read with the SDK'sSecret.from_file.jira_app.dpop_key_file: empty; key file for a DPoP-bound app token (auth.dpop_required_for), loaded with the SDK'sFileDpopKey.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(httponly 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_typeis the name of the Jira issue type that/issuecreates 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-idvalues, the Jira objects the app created and open drafts are kept. The default equals the default ofwebhook.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 answer503.
Reused keys, never new numbers:
webhook.receiver_tolerance(the default of the SDK'sverify_webhook),sdk.http_timeout(also the time limit of each Jira request),sdk.retry_max_attempts,sdk.retry_backoff_baseandsdk.retry_backoff_cap(the SDK client;sdk.retry_backoff_capis also the worker's pause after a transient failure), all through the SDK'sSdkSettingsdefaults. 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 mode0400or0600and 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 answers503or 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 to0o077at start;state_diris created with mode0700, an existing one with group or other access is refused.config.example.jsonparses with the loader (test). -
HTTP surface (Ruling for docs/DECISIONS.md). One aiohttp application with routes
POST /jira/webhook,POST /chat/interactions,POST /chat/events; any other path gives404, another method405. Per request, in this order:Content-Typemust beapplication/json(parameters allowed), else415; the body is read as bytes up tojira_app.max_body_bytes(client_max_size), a larger body answers413; signature check (Design notes 5 and 7), failure401; JSON parse, where invalid UTF-8, invalid JSON and duplicate member names answer400; duplicate check; classification and, in the same store transaction, the seen identifier and the new job; then202when a job was queued and204when there is nothing to do;503when the queue holdsjira_app.max_queued_jobsjobs, 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 onjira_app.listenbehind the TLS-terminating proxy of the instance;delivery.interactions_urlanddelivery.events_urlarehttpsaddresses 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. -
Jira signature (11 10.2; RK-83). Ruling for docs/DECISIONS.md.
- The request must carry exactly one
X-Hub-Signatureheader: none ismissing, more than one ismalformed. - The value is split at its first
=into method and signature; no=or an empty method ismalformed. - 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) isunsupported_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 ismalformed. - 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 thehmacmodule so tests can replace it. No string comparison of a signature anywhere. - Every failure answers
401without 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, payloadHello, World!, header valuesha256=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=01dc10d0c83e72ed246219cdd91669667fe2ca59andsha512=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.
- The request must carry exactly one
-
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 answers204with log reasonunsupported. 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 ofjira_app.projects; an unknown project answers204with reasonproject_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.displayNamefor issue events,comment.author.displayNamefor comments; absent: "someone"), the description (issue.fields.description, forjira:issue_created),changelog.items[]withfield,fromStringandtoString(forjira:issue_updated), andcomment.idandcomment.body(forcomment_created). A missing or malformedissue.id,issue.key, project key or, for comments,comment.idanswers400with reasoninvalid_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
typedoc(Atlassian Document Format, ADF) contributes thetextof itstextnodes in document order, theattrs.textofmentionnodes, and a line break after eachparagraph,heading,codeBlock,blockquoteandlistItemnode and for eachhardBreak; 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_updatedwithoutchangelog.itemsor with an empty list answers204with reasonno_change, because only field changes are news (a comment arrives ascomment_created).
- After a valid signature the body must be a JSON object with a string member
-
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_webhookover the raw body with the keys of oneWebhookKeyCacheof the app's client and the default tolerancewebhook.receiver_tolerance; never an own verifier and never keys from the request.start_apploads the keys (together withgetMeandgetInstance) before it opens the listener; a failure aborts the start. AWebhookVerificationErroranswers401and logs itscode. Nov1secret: an interactions address has no HMAC (M2-25), and the app's subscription is created withoutenable_hmac. - Duplicates:
webhook-id(the eventid) is remembered forjira_app.dedupe_window; a repeat answers as the first time (202on/chat/interactions,202or204on/chat/events) without a second job. /chat/interactions:command.invokedwithcommand_nameissuequeues the jobissue_command;interaction.createdqueuesissue_form; both answer202.command.autocomplete_requestedanswers200with{"choices": []}(AutocompleteAnswer; the app declares no option with autocomplete). Another type or command name answers202with Warn reasonunknown_request. Reason: in a forum the server has no reply thread for a200(M2-25no_thread), and Jira calls do not fit intoapp.interaction_timeout; the person sees the result through the API answers of Design note 12./chat/events: onlymessage.createdis handled (Design note 13); any other type answers204.sync_epochis ignored, because the app keeps noseqstate.
- Both
-
The SDK module
bothy_sdk.webhooks(11 11.2 rowwebhooks; 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 withcryptography; another prefix, bad base64 or another key type raisesWebhookConfigError.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, elsemissing_header;webhook-timestampan integer of Unix seconds, elseinvalid_timestamp; its distance to the clock at mosttolerance(defaultwebhook.receiver_tolerancefromSdkSettings) in both directions, elsetimestamp_out_of_tolerance; no key and no secret givesno_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; av1aentry is tried against every key over{webhook-id}.{webhook-timestamp}.{body}, av1entry against every secret with HMAC-SHA256 andhmac.compare_digest; one valid entry returnsVerifiedWebhook(id, timestamp, scheme, envelope, sync_epoch)with the envelope decoded through M2-23'sdecode_eventinto the SDK'sEnvelope; none givessignature_mismatch. Errors areWebhookVerificationErrorwithcodefrom exactly this list; messages never contain the body, a signature or a secret.WebhookKeyCache(client):await keys()fetchesgetWebhookKeysonce, trusts every listed key (next,active,retiring), refreshes after the response'sCache-Controlmax-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 mergedClientoffers 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: thewebhook-signaturevalue withv1aentries first in seed order, thenv1entries in the given order (D-M2-26-10).SdkSettingsgains the fieldswebhook_receiver_tolerance(webhook.receiver_tolerance) andwebhook_keys_max_age(agent.jwks_max_age), held by M2-29'stest_defaults_match_configuration_reference.- No class of the module carries the name of an
openapi.yamlschema (M2-29'stest_hand_layer_types.py;WebhookKeyandWebhookKeysare schema names). Not in the SDK: storing seenwebhook-idvalues (the app's duty, Design note 7).
-
State, queue and worker (Ruling for docs/DECISIONS.md).
- Port
Store(store/base.py, atyping.Protocol) withSqliteStoreinjira_app.state_dir: the filestate.sqlite3withPRAGMA journal_mode = WALandPRAGMA synchronous = FULL, one connection used throughasyncio.to_threadunder oneasyncio.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 (issueorcommentwith 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). Files0600through the umask of Design note 3.MemoryStoreserves unit tests; one contract test runs against both. - The lock file
lockin the state directory is held withfcntl.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
/issuejob records its issue before Jira'sjira:issue_createdevent 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(SDKuuid7) forcreateMessageand forcreateThreadwithfirst_message, anIdempotency-Key(SDKuuid7) forrespondToInteraction, 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 withinsdk.retry_max_attempts; when they are exhausted, the job stays queued and the worker pausessdk.retry_backoff_cap. Permanent answers end the job (Design note 14). - A write to Jira is never repeated: the job marks the step
attemptedin the store before it sends; a rerun that findsattemptedwithout 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_windoware pruned at each write; the thread map stays. - SIGTERM and SIGINT (
loop.add_signal_handler): stop accepting (requests after the signal get503), let the running step finish, close the store, release the lock, exit 0.
- Port
-
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) withme(),limits(),create_forum_thread(...),post_message(...),get_message(...),respond_interaction(...), typed with the generated models ofbothy_sdk.generated(no own type repeats a schema, ADR 0026 "Confirmation"; drafts areIssueDraft, becauseDraftis a schema name);chat/sdk_adapter.pyimplements it on the SDKClient(getMe,getInstance,createThread,createMessage,getMessage,respondToInteraction). - Known issue:
createMessageinto its thread. Unknown issue:createThreadin the forum of its project withtitleandfirst_message(the event's message) in one call, idempotent throughfirst_message.client_msg_id; the map entry is stored with the answer. - Title
<KEY> <summary>(for exampleSUP-12 Login fails), cleaned and cut as Design note 11 says; a thread is never retitled when the key or summary changes. 404 not_foundwhen 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 reasonthread_unreachable; a second404 not_foundends 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 bySam" plus the description;jira:issue_updated"SUP-12 updated bySam:" followed by one line per changelog item, "<field>:<from>to<to>" with(none)for an empty value, except for the fielddescription, whose line is "descriptionchanged" (the full text stays in Jira);comment_created"Samcommented on SUP-12:" plus the comment.
- The key is Jira's
-
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 (selfmembers are REST addresses, and a payload URL is foreign text). - Messages are cut to
limits.message_max_content_bytesofgetInstance(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 tolimits.thread_title_max_charscode 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 answers400 validation_failedon the title, the job retries once with the key alone. - Limits come from
getInstanceat start and once more after a400 validation_failedwitherrors[].codetoo_long, followed by one retry. - Text the app writes itself (the confirmation of
/issue, the form card) contains no mention.
-
/issuewith a form (11 10.1 row "Commands"; Ruling for docs/DECISIONS.md).- The manifest command
issue: description "Create a Jira issue" withincommand.description_max_chars, optiontitle(string, required, description "Issue title"),required_leveltrigger. Reason: the form is submitted withinteraction.create, which needstrigger(05 4.10); withwritea person could run the command but never submit its form. - Job
issue_command: the payload'schannel_idmust be a forum ofjira_app.projects, else Warn reasonchannel_not_mappedand end (the README tells operators to grantwriteonly on configured forums, so the command is listed only there, 09 10.3). ThencreateThreadin that forum with the optiontitlecleaned and cut as the title (empty: "New issue") and the first message "New Jira issue. Fill in the form below.", thencreateMessagein the new thread withkindcardand one component built with the SDK's form builder:custom_idissue:<draft id>(a UUIDv7, withincomponent.custom_id_max_chars),single_usetrue,restricted_toany, fieldsdescription(textarea, label "Description", not required) and, only when more than one project maps to the forum,project(choiceof those keys, required), withincomponent.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/issuealways opens its own forum post, also when it was run inside a thread. - Job
issue_form: the draft is found byinteraction.custom_id, else byinteraction.message_id; unknown or pruned:respondToInteractionwith staterejectedand text "This form expired. Run /issue again.". The project is the only one or the chosen one, checked against the draft. Markattempted, then create the issue withsummary= the title,issuetype.name= the configuredissue_typeanddescription= 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: storeidandkey, record the issue as created by the app (Design note 13), map the issue ID to the draft's thread,respondToInteractionwith stateacknowledgedand text "Created issue", createMessageinto the thread "Created Jira issue SUP-13." with the key linked as Design note 11 allows, delete the draft. - Jira answers
3xxor4xx(not followed, so the issue was not created):respondToInteractionwith statefailedand 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 aftersdk.http_timeout, a transport error, orattemptedwithout result after a restart (outcome unknown):respondToInteractionwith statefailedand text "Jira did not confirm the issue. Check the project before you try again."; no new card, the draft is deleted.- A
409answer to a repeatedrespondToInteraction(rerun after a crash) counts as done.
- The manifest command
-
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:readandevents:subscribe, addedmessage.createdto the installation'sevent_subscriptionsand created a subscription todelivery.events_urlformessage.created(webhooks.md 1); otherwise/chat/eventsreceives nothing. - Decision at receipt, in this order, each answering
204without a job and logging its reason: the author is the app itself (message.author.idequals the principal ID fromgetMe; reasonown_message, the loop protection of 10.1);originis nothuman(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);kindis nottext(not_text);deleted_atis set (deleted); the thread is not mapped (thread_not_mapped). Otherwise the jobmirror, answer202. - Job
mirror: ifcontent_truncatedis set, load the message withgetMessage; the comment body is an ADF document with one paragraph holding the display name as a text node with thecodemark followed by the text " wrote in chat:", and onecodeBlockwith the content, because chat mentions name chat people and the content must stay literal; markattempted, send the comment to the issue ID of the thread;201: record the comment ID as created by the app; any other outcome: logstatusor reasonjira_outcome_unknownand end without retry. - Echo protection in the other direction: Jira's
jira:issue_createdevent of an issue the app created through/issueand thecomment_createdevent of a comment the app created are not posted (reasonown_jira_object). The check runs at receipt (answer204when 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.
- Mirroring exists only when the owner granted
-
Answers of the server (06 5, 07 2.10), as M2-49 Design note 13:
403 scope_missing: end the job, one Warn line withoperation,statusand reasonscope_missing, no retry; the Jira delivery was already answered202.404 not_found: Design note 10 for threads; forrespondToInteractionandgetMessageend the job.403 forbiddenand403 channel_read_only: end the job and log the code andreason.400 validation_failed: the retries of Design note 11, otherwise end the job and log theerrors[].codevalues.401 unauthenticated(token revoked, app suspended or uninstalled, M2-24): end the job with an Error line; the endpoints answer503untilgetMesucceeds again, tried once persdk.retry_backoff_cap.422 idempotency_key_reuse: Error line (the same identifier with another body is a programming fault), end the job.429 rate_limitedand503 unavailable: retried by the SDK, then Design note 9. Unknown codes are classified by status through the SDK'sProblemErrorclasses.
-
Jira REST client (
jira/client.py,jira/adf.py; Ruling for docs/DECISIONS.md). Two calls on onehttpx.AsyncClientwithfollow_redirects=False(a3xxis a failure) and the time limitsdk.http_timeout:POST <jira_base_url>/rest/api/3/issuewith{"fields": {"project": {"key": ...}, "issuetype": {"name": ...}, "summary": ..., "description": <ADF>}}, andPOST <jira_base_url>/rest/api/3/issue/<issue id>/commentwith{"body": <ADF>}. HeadersAccept: application/json,Content-Type: application/json,Authorization: Basic <standard base64 of the credentials line>andUser-Agent: jira-app/<VERSION>. The response body is read only on201, foridandkeyof an issue (the key checked against the pattern of Design note 6) oridof a comment; errors carry the status only, never a body. ADF: one function builds{"type": "doc", "version": 1, "content": [...]}fromparagraphnodes (one per line of a text, an empty line as a paragraph without content),textnodes (never empty) with thecodemark only where Design note 13 says, andcodeBlocknodes; 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. -
Logs (SEC-LOG-2, SEC-LOG-4 and SEC-SEC-3 as the rule for this app). JSON lines on stdout through
loggingwith one formatter:time,level,msg(a fixed code such asjira.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 loggerbothy_sdkgoes through the same handler. Start errors name key and file, never a value. -
Manifest (
plugins/jira-app/manifest.json, 11 3.1 as M2-24 builtAppManifest). Members:schema_version1;slugjira;nameJira;description(English, one sentence);publisherReference integration;versionequal toVERSION;min_api_versionequal to major and minor ofinfo.versionofdocs/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};commandswithissueof Design note 12;components[form];requested_grantsone{target_kind: channel, level: write, capabilities: [], purpose: ...};data_egress(English): issue and comment events of the configured Jira projects are posted into the forum;/issuesends title and description to the configured Jira site; withmessages:readandevents:subscribe, messages of people in linked threads with their display name go to Jira as comments. Nohomepage_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 towebhook.allowed_hosts; an owner uploads the manifest withinstallApp(required scopes; the optional ones only for mirroring), grantswriteon each configured forum withcreateGrant, creates the token withcreateToken, and for mirroring creates the subscription withcreateWebhookSubscriptiontoevents_url; a later scope needs a new token (M2-24). The Jira side: a webhook with the URL of/jira/webhookbehind the proxy, a secret, the three events and, as the operator chooses, a filter on the configured projects. -
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 on127.0.0.1port 0 with the two endpoints of Design note 15. It checks the Basic credentials (401without the test credentials),Accept,Content-TypeandUser-Agent; answers400for 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 oftypedocandversion1;404for a comment on an unknown issue; otherwise201with increasing IDs from10001and keys<PROJECT>-<n>. It records method, path, JSON body and header names in memory. Fault injection per call: status500, status400, a302to 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 withX-Hub-SignatureandContent-Type: application/json; options omit, alter or truncate the signature, use another method, add a second header or repeat the identical body. Withauto_echo, every issue created through the fake is followed by itsjira:issue_createddelivery, and every comment by itscomment_createddelivery and ajira:issue_updateddelivery withoutchangelog.- Fixtures
tests/fixtures/jira/jira_issue_created.json,jira_issue_updated.json(changes ofstatus,summaryanddescription),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 (sitehttps://jira.example.com, projectsSUPandWEB, Jira display names Sam and Robin, account IDs such asexample-account-sam). No package publishes Jira's examples, and the build has no network;tests/fixtures/README.mdnames 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".
-
No real Jira service (PLAN row).
tests/conftest.pyhas an autouse fixture that replacessocket.getaddrinfo,socket.socket.connectandsocket.socket.connect_exthroughmonkeypatch: a host other than127.0.0.1,::1orlocalhostand an address outside127.0.0.0/8and::1raiseNetworkBlocked(Unix sockets pass);tests/live/conftest.pykeeps the same guard. The test helper that builds configurations refuses ajira_base_urlorserver_urloutside loopback. The app opens outgoing connections only throughhttpx(the SDK client and the Jira client). -
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.pyfinds the repository root (the directory withdocs/api), buildssdkserveronce per session likesdk/python/tests/realserver/conftest.py(go test -c -tags sdkserver -o <tmp>/sdkserver.test ./test/sdkserverinservices/server), runs it with-test.run '^TestSDKServer$'and drives it over its JSON-lines protocol on stdin:startwith the members of M2-29's fixture plusapp_front {host: "jira-app.example.com"}, then M2-49'screate_forum,app_backendandcall, at the endstopand then the PID. The live tests run in the default pytest run, so the rootmake testandmake checkrun them; without Docker, Go oruvthey fail and never skip. - TLS front: M2-49's
app_frontwith the hostjira-app.example.com; the driver setswebhook.allowed_hosts=jira-app.example.com:<front port>andegress.allowed_hosts=jira-app.example.com:<front port>;allow_privateand wiresDeps.RootCAsand the test resolver as M2-49 does; the server runs its jobs, soruns.app_dispatchandapps.webhook_deliverdeliver. If the mergedapp_frontserves only a fixed host name, make the host a parameter there; M2-49's live file stays green. - The ops
app_backend {port}andcall {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 throughcall, so the live test runs the documented operator steps, and no token is ever printed. - World: the driver's world (owner Robin, Alex with
triggeron space Support through G-1) plus the forum Support/Issues fromcreate_forum {name: "issues"}. The app and the Fake-Jira run in the test process.
- Mechanism: M2-29's real-server fixture with the protocol parts of M2-49, and no Go test.
-
Tool chain and gates.
- Root
Makefile: no change expected; its Python lines (ruff format, ruff check, bandit withouttests, mypy, pytest with coverage, licenses) run on every trackedplugins/*/pyproject.toml. If a recipe breaks on a second Python project underplugins/(for example a scanner reaching the project's.venv), fix it for all projects with a test intools/ci/tests/; no new target. tools/ci/components.py: a change undersdk/python/also selects the gate componentplugins, in the way M2-49 madesdk/typescript/andservices/server/select it; tests intools/ci/tests/test_components.py.- Coverage:
tools/docs/tests/test_config_values.pymust hold thefail_underofplugins/jira-app/pyproject.tomlequal toquality.coverage.python_min_percent; if its sources name projects one by one, add this one. - Licenses:
make licensesreadsplugins/jira-app/uv.lock. If OSV-Scanner reports the local packagebothy-sdkasUNKNOWN, addplugins/jira-app/osv-scanner.tomlwith onePackageOverridesentry and its reason ("local package of the repository under Apache-2.0,sdk/python"), as licenses.md point 3 says; never a change toquality/licenses/allowed.txt. - Documentation in code:
tests/test_doc_gate.pycopiespyproject.tomlandjira_app/intotmp_path, adds a public function without docstring to a module and runsruff checkwith the copy's configuration: a non-zero exit namingD103and the file; the unchanged copy passes (23 "Documentation in code": every gate has a test). - Sonar and
.gitignorealready coverplugins/*/coverage.xml; no change.
- Root
-
Specification and documents (spec first; English in chapters, READMEs, CHANGELOGs and the manifest).
docs/spec/11-extensions.md10.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 exactlysha256, every other method401without computing, 64 hex digits, HMAC over the raw body in constant time before parsing, duplicates by the SHA-256 of the body withinjira_app.dedupe_window, the event list of Design note 6), "Output" (keyissue.id, title<KEY> <summary>, a thread out of reach is replaced once,jira:issue_updatedonly with field changes), "Commands" (/issuewith optiontitleandrequired_leveltrigger, own forum post with a form, answer202, 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 toevents_urlonly 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.md11.2 rowwebhooks: both SDKs, Pythonbothy_sdk.webhooks(M2-50) and TypeScript (M2-30); 16: the twelve keys of Design note 3 with Place "Jira app"; thenmake config-reference.docs/api/webhooks.md: the sentence on the SDK verifiers names the Python module as well.docs/sdk/python.md: a blockbothy_sdk.webhooksand one for every other new public module that M2-29's reference test requires;sdk/python/README.md(modulewebhooksbuilt, example of a verification);sdk/README.mdwhere 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: rowjira-app/with status built in M2-50.
-
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-Signaturewith 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 withmessages:read,events:subscribeand 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, onlyorigin = 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 afterjira_app.dedupe_window, because the signature carries no timestamp"); addplugins/jira-app/tests/live/test_live.pyto 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. -
Rulings for
docs/DECISIONS.md(section 10, one rowD-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_changeand the two rich-text forms (6);202for every command and interaction, SDK verifier, keys loaded at start, nov1(7); the Python modulewebhooksand its parity with M2-30 (8); state in SQLite withflock, 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_leveltrigger, 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 oversdkserverwith M2-49'sapp_front,app_backend,create_forumandcall(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 withsha256valid, an unknown method rejected instead of assuming SHA-256 (11, 10.2), a wrong and a missing signature rejected"):test_sha256_valid(secretIt's a Secret to Everybody, payloadHello, World!,sha256=757107ea0eb2509fc211221cce984b8a37570b6d7586c22c46f4379c8b043e17verifies; the same digits in upper case verify);test_unknown_method_refused_instead_of_sha256(sha1=01dc10d0c83e72ed246219cdd91669667fe2ca59,sha512=11ed355a617e98134e842012a7944ccf59c10256cb182357bd7e3a42013ff07c376f8c14cf5cc1923da20b51d64256b2fb8ebbf100aa67a61326f61fea8111bc,md5=andSHA256=each with the valid SHA-256 digits, andsha384=with 96 hex digits areunsupported_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, twoX-Hub-Signatureheaders:malformed; none raises);test_constant_time_comparison(withhmac.compare_digestreplaced throughmonkeypatchby a recording wrapper it is called exactly once with twobytesof length 32 for the valid and for a wrong signature of the same length; a replacement that returnsTruelets a wrong signature pass and one that returnsFalsemakes the valid one fail, which proves the decision goes only throughcompare_digest).test_jira_endpoint.py: with the Fake-Jiradeliver: a validjira:issue_createdgives202and one job; wrong, missing, truncated signatures and an unknown method give401, no job and no call of the chat port;jira:issue_deletedgives204; the identical body again gives204without a second job; an unknown project gives204; a payload withoutissue.idand with a valid signature gives400;text/plaingives415; a body abovejira_app.max_body_bytesgives413;GETgives405; an unknown path404; a full queue (jira_app.max_queued_jobsset small)503; a webhook secret file with mode0644gives503.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 ofstatus,summaryanddescription; a payload withoutissue.fields.project.keyisinvalid_payload; unknown members are ignored.test_adf.py: built documents havetypedocandversion1, no emptytextnode, one paragraph per line, thecodemark only on the display name, the content of acodeBlockunchanged (backticks,[~accountid:x],{code}and@alexstay text).test_threads.py(PLAN row: "jira:issue_created,jira:issue_updatedandcomment_createdfrom fixtures yield a thread and messages as in M2-49"):jira:issue_createdof SUP-12 creates one forum post in the forum ofSUPtitledSUP-12 <summary>with the event as first message and stores the map;comment_createdon SUP-12 posts into the same thread and creates none;jira:issue_updatedof SUP-12 with a status change posts the change line into the same thread;jira:issue_updatedof SUP-12 without changelog posts nothing;jira:issue_createdof SUP-13 creates a second post; afterstop()and a newstart_appon the same state directory an event of SUP-12 still lands in its thread; an update of the same issue ID whose key becameWEB-3lands in the same thread;404 not_foundon posting into SUP-12's thread drops the entry, creates one new post and logsthread_unreachable; a rerun of a job after a simulated crash betweencreateThreadand the map write sends the samefirst_message.client_msg_id.test_render.py:@alexand[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 respectslimits.message_max_content_bytesat a code point boundary with the fence closed and the cut line; titles are cut tolimits.thread_title_max_charscode points; a link appears only tohttps://jira.example.com/browse/SUP-12, never for a key likeSUP-12)and never from a payload URL; a400 validation_failedon the title leads to one retry with the key alone; a400 validation_failedwitherrors[].codetoo_longreloads the limits and retries once.test_issue_command.py(PLAN row: "form against the fake API"): aCommandInvokedEventsigned with the SDK'ssign_webhook(a test key the app's key source trusts) gives202; the job creates a forum post titled fromtitleand a card whose form hasdescriptionand noprojectfield; withSUPandWEBon the forum the form has aprojectchoice; a signedInteractionCreatedEventwithvalues.descriptiongives202; the Fake-Jira records exactly onePOST /rest/api/3/issuewithfields.project.keySUP, the configured issue type, the title assummaryand an ADF description whose last paragraph is "Created from chat.";respondToInteractionwith stateacknowledgedand the key; one message "Created Jira issue ..." in the draft's thread; the issue ID maps to that thread; the fake's echojira:issue_createdadds no message, also when it is delivered before the create call returned; a latercomment_createdby Sam posts into the thread. Variants: Fake-Jira400(and a302) gives statefailedand a new form card;500and a time-out give statefailedwith the check text, no new card and no second POST; a restart afterattemptedsends no second POST and answersfailed; the samewebhook-idtwice gives one job; an unknowncustom_idgives staterejected;command.autocomplete_requestedgives200with an emptychoices; a request signed by an unknown key, with a timestamp beyondwebhook.receiver_toleranceor with a changed body gives401and no job.test_loop.py(PLAN row: "loop protection"): a signedMessageCreatedEventwhose author is the app's own principal in a mapped thread gives204and the Fake-Jira records no comment; messages withoriginagent,app,importandsystem, a card, a deleted message and a message in an unmapped thread give204and no comment; a human text message in a mapped thread gives exactly one comment whose ADF body holds the display name with thecodemark and the text in acodeBlock; the fake's echocomment_createdof that comment and the followingjira:issue_updatedwithout changelog add no message; a truncated message is loaded withgetMessage; a Jira500on the comment is logged and not repeated.test_scopes.py(11 10.1 row "Scopes" through 10.2):manifest.jsonscopes.requiredequals the required scopes of that row, parsed fromdocs/spec/11-extensions.md, andscopes.optionalits optional ones; the chat port answering403 scope_missingtocreateThreadends the job after one attempt with one Warn line namingscope_missing, the Jira delivery stayed202, and the job is gone.test_manifest.py: the members of Design note 17;versionequalsVERSIONandpyproject.toml;min_api_versionis not above major and minor ofinfo.versionofdocs/api/openapi.yaml; the commandissuewith optiontitleandrequired_leveltrigger; both addresseshttpson one host; nohomepage_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 mode0644aborts the start; a state directory with group access is refused;config.example.jsonloads; everyjira_app.*default equals the "Default" column ofdocs/operations/configuration.md, and the reused keys come from the SDK'sSdkSettings.test_store.py(contract test forSqliteStoreandMemoryStore): commits survive a reopen, modes0700and0600, a second process (a subprocess holding theflock) makes the start fail, pruning afterjira_app.dedupe_windowwith the fake clock, the thread map survives pruning, FIFO order of jobs.test_chat_adapter.py: the SDK adapter against anhttpx.MockTransportwith schema-valid answers: the request bodies ofcreateThread,createMessageandrespondToInteractioncontain no JSONnull;client_msg_idandIdempotency-Keyare the stored ones on a rerun;Authorizationis sent; aProblemErrorreaches the job with status and code.test_jira_client.py: the headers of Design note 15, no redirect followed and a302is a failure, the time limitsdk.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 forhttps://jira.example.com/and for any non-loopback host and address; the configuration helper refuses a non-loopbackjira_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-sdkcomes from../../sdk/pythonas an editable path source; every dev tool shared withsdk/python/pyproject.tomlhas the same exact version;fail_underequals the value ofquality.coverage.python_min_percentin 23.test_doc_gate.py: Design note 21.- License gate (PLAN row: "license gate for Python green"):
make licensesreadsplugins/jira-app/uv.lockand passes without a change toquality/licenses/allowed.txt;make osvpasses; keep both outputs for the report.
SDK tests, sdk/python/tests/ (Design note 8):
test_webhooks_vectors.py: for every signature case ofdocs/api/webhook-test-vectors.json,verify_webhookaccepts the body with the header value of the case, itspublic_keyvalues and itshmac_secretsat 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, anotherwebhook-idand another timestamp each givesignature_mismatch;sign_webhookreproduces the header value byte for byte from the case's seeds and secrets; for every derivation case,parse_webhook_secretofwhsec_plus the base64 of the bytes ofderived_hexreturns those bytes.test_webhooks.py:missing_headerfor each header;invalid_timestampforabc,1.5and empty; timestamps exactlywebhook.receiver_tolerancebefore and after the clock accepted, one second beyond rejected withtimestamp_out_of_tolerance;no_trusted_key; a header with several spaces, an unknown schemev2,...and bad base64 still verifies by its valid entry; a key of another type and a wrong prefix raiseWebhookConfigError; a body parsed and serialized again fails (raw-body rule); av1entry of another length does not raise; an envelope of an unknown type returns the SDK's form for unknown events;WebhookKeyCachefetches once, refreshes aftermax-age(without the header afteragent.jwks_max_age) and never after a failed verification; a seeded set of random headers and bodies gives onlyWebhookVerificationError, never another exception; no error message contains the body or a secret. M2-29'stest_settings.py,test_hand_layer_types.pyandtools/docs/tests/test_sdk_reference.pystay 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):
validateManifestwith the manifest rewritten tohttps://jira-app.example.com:<front port>givesvalidtrue;installAppwithoutcomponents:writegives400 validation_failedwith the pointer ofgranted_scopes; with exactlymessages:write,commands:writeandcomponents:writegives201;createGrantwriteon Support/Issues;createTokenwrites the token file;app_backendwith the app's port.- Threads: deliveries of
jira:issue_createdSUP-12,comment_createdSUP-12,jira:issue_updatedSUP-12 (status) andjira:issue_createdSUP-13 give, inlistChannelThreadsof Alex, exactly two posts of the app titledSUP-12 ...andSUP-13 ..., andlistThreadMessagesof SUP-12 shows three messages withoriginapp. /issue:listChannelCommandsof Alex in Support/Issues listsissue;invokeCommandby Alex withtitlegives202; a new post with the form card appears;createInteractionby Alex withvalues.descriptiongives201; the Fake-Jira records one issue; the post shows the confirmation with the key; the echojira:issue_createdadds no message.- Loop protection and mirroring:
setAppScopesaddsmessages:readandevents:subscribewithevent_subscriptions[message.created];createWebhookSubscriptionfor the app toevents_url; a new token, the app restarted with it. A Jira comment on SUP-12 is posted by the app; itsmessage.createdreaches/chat/events(listWebhookDeliveriesof Robin shows itsucceeded), and the Fake-Jira records no comment. Alex replies in SUP-12's thread withcreateMessage: the Fake-Jira records exactly one comment on SUP-12; its echoes add no message. - Missing scope:
createTokenwithscopes[commands:write, components:write], the app restarted with it; a delivery ofjira:issue_createdSUP-14 gives202, the server answers403 scope_missingtocreateThread, the app log holdsscope_missingonce, 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-Signaturewith methodsha256verifies with the test values; every other method answers401without any HMAC being computed, also when its digest is correct for that method or for SHA-256; wrong, missing and malformed signatures answer401and create nothing; the comparison goes only throughhmac.compare_digeston the digests. -
jira:issue_created,jira:issue_updatedandcomment_createdfrom 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. -
/issuewith 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);
installApprefuses an installation without one of the required scopes; a missing scope at call time gives403 scope_missing, logged once and not retried. -
bothy_sdk.webhooksverifies every signature case ofdocs/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.pyin the default pytest run). - The Python license gate and OSV pass without a change to the allowlist; ruff
Dfails 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 anddocs/DECISIONS.mddescribe what was built. -
make checkis 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 inplugins/README.mdupdated;sdk/python/README.mdanddocs/sdk/python.mdwith the modulewebhooks; thesdkserverREADME if Design note 20 changedapp_front. - Rulings of Design note 24 recorded in
docs/DECISIONS.md(one row each with reason). plugins/jira-app/CHANGELOG.md,sdk/python/CHANGELOG.mdand the rootCHANGELOG.mdentries 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 checkgreen.- An independent code review over the diff, then the red team gate (
docs/quality/red-team-gate.md, verdictJAorNEIN), 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); onNEINfix with a test per finding and have the delta checked again. Record both verdicts. - Land the change as
AGENTS.md("How work lands onmain") describes: a pull request from this branch, squash-merged only when review, red team andmake check,ci-okand the review threads are green; thengit worktree remove.
Report¶
Write to {{REPORT_PATH}}:
- First line exactly
Verdict: DONEorVerdict: BLOCKED <reason>. - 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. - Review and red team verdicts and the findings fixed.
- 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).
- Follow-ups that could not be done in this scope, each with its reason; the pinned versions of
aiohttp,httpxand the dev tools; whetherbothy_sdk.webhookswas 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>.