Signed issue delivery produces WorkSourceDelivery on cs.work-integration, keyed by the
stable WorkItemIds digest. Its coordinates bind the registration, repository, source, SCM
origin and external scope; a webhook hint is not proof of complete current-label history.
WorkItemIntake and the scanner fetch the current issue and bounded audit evidence through
the explicitly selected source account. Unknown attribution grants nothing, even with an
otherwise allowed actor hint. Actor membership and missing identity have separate reasons.
WorkItemLifecycle is the sole work-event decider. WorkItemEvent is stored and decoded as
its own type, outside the review DomainEvent and IntegrationEvent hierarchies. One JTA
transaction appends its encrypted envelope, updates bookkeeping, deduplicates delivery and
enqueues the encrypted notification. The outbox publishes the stable envelope ID on
cs.work-events and marks it published only after broker acknowledgement; delivery is at least
once. Work ingress failures use cs.work-dlq. Review history does not consume work events.
Profiles have immutable versions and an operator-defined unique precedence. Every eligible
current label contributes a component-wise restriction; the repository ceiling and the full
mode vector retained at admission also bound later decisions. The selected profile name is a
display choice, not the whole effective policy. Events retain applied/ignored evidence and the
source, account, repository and policy revisions. Humans register fetched specification and plan
references; their versions bind later decisions. Dashboard answers, explicit tracker commands
and supported current native PR approvals enter ResolveGate and persist GATE_RESOLVED.
Production VERIFY and LAND remain unavailable; manual artifact acceptance does not invent an
executor completion. Tracker status never supplies workflow status.
ExecuteWorkRun uses cs.run-commands and yields a durable RunWorkReady checkpoint without
publishing. PublishWorkRun on cs.run-control carries the exact current delivery permit;
only the trusted publisher resumes. HoldWorkRun uses that same control topic and durably revokes
the exact run binding before stopping compute. The revocation survives restart and orphan salvage
independently of M1 cancellation. Ordinary ExecuteRun and standalone /fix retain their automatic
publication path. Readiness and terminal results use cs.run-results; transcript facts remain
on cs.run-events and do not become work-item domain events.
Takeover records stable actor IDs in WorkControl, supersedes open gates and invalidates pending
effects. Deliberate authorized commands are classified before generic comment takeover. Resume
requires the server-derived operator subject, expected revision and note, fresh issue/repository/
head/policy evidence, and a new generation. Retired items cannot resume. See the
acceptance record for measured journeys and remaining live limits.
cs.registry-integration carries RepositoryRegistration, keyed by registration UUID (not a
review id). Its type discriminator is RepositoryRegistration; fields are registrationId,
positive monotonic revision, providerType, nullable forgeOrigin, scope (repo/org),
target, enabled, deleted, nullable repositoryId, eventKind (legacy default REVIEWER),
and nullable sourceId. The gateway outbox publishes only after its SQL transaction
commits and marks sent only after broker acknowledgement. The orchestrator accepts newer
revisions transactionally. New records reject blank origins while allowing null. At legacy
ingress the bridge converts blank origins to null before strict record decoding, then records
registration_origin_unknown and acknowledges the snapshot. Missing origins are never inferred
from account workspace equality; they remain operator-visible pending mappings for repair.
This is an integration snapshot, not a domain event or a new aggregate. Webhook keys and secrets
never cross the channel. Failed processing uses cs.dlq; the discriminator routes manual replay
back to cs.registry-integration.
Slice 2 sends signed SCM ingress on cs.repository-integration as RepositoryDelivery, with the
RepositoryDelivery discriminator, repositoryId (nullable for legacy/org hooks), registrationId,
registrationRevision, providerType, forgeOrigin, eventKind, deliveryId and the existing typed
IntegrationEvent. Gateway deliveryId is the SHA-256 of the signed request bytes. A manual review
uses its explicit repository UUID. The consumer resolves full forge identity, verifies any
explicit UUID and repository state, then applies REVIEWER lifecycle handling or forwards FACTORY
activity to cs.repository-activity for the existing activity publication. M3's WorkActivityConsumer
independently consumes FACTORY RepositoryActivity envelopes directly from cs.repository-integration
under the spire-orchestrator-work-activity group, rechecking registration and repository identity.
It does not consume cs.repository-activity. ISSUE routes through the bound work-source ingress to
cs.work-integration rather than the SCM review path.
New deliveries for unknown repositories produce Attention with their incoming registration and prefilled coordinates. Unknown origins remain repairable, never inferred from namespace equality. Old raw SCM messages on cs.integration fail with a provenance reason and enter cs.dlq; operators must redeliver through a verified webhook. DLQ replay of RepositoryDelivery returns to its new topic with provenance intact. Worker integration-result channels and old review IDs stay intact.
Upgrade order: provision cs.registry-integration, cs.repository-integration and cs.repository-activity and their producer/consumer ACLs when auto-creation is disabled. Upgrade gateway first: new ingress can wait durably on its new topic while the old orchestrator runs. Then upgrade orchestrator (the new input begins at earliest), then the UI. Upgrading orchestrator first would dead-letter still-legacy gateway deliveries. Retain database/keyset backups; an application-only downgrade is not an ingress rollback because the wire topic changed.
API cutover: account ProviderInput/ProviderView have no workspace. Account create/update accept an optional validationRepositoryId query parameter for account-less token validation; it must name the same forge kind and origin and creates no binding. Stored account checks may use one of that account's explicit repository bindings as validation scope. Scope introspection remains advisory; an unobserved report never establishes permission.
Manual registration without a URL and POST /api/runs require repositoryId. Supplied coordinates must agree with it; URL preview resolves complete forge identity. Serving views take repositoryId and read the selected REVIEWER/FACTORY accounts. Missing, disabled and unmapped configurations cannot dispatch. The repository screen owns coordinates, role bindings and optional per-kind hooks; legacy organization hooks and origin repair remain at /settings/webhooks.
The shared kernel every service depends on: identifiers, the event envelope, the event & command catalog, the
ReviewLifecycledecider, the SPI ports, the context-aggregation policy, topics, and the Bitbucket Cloud mapping. Companion to EVENT-MODEL.md (the narrative slices) and ARCHITECTURE.md. Status: design — pre-implementation.
- Two event kinds. Integration events cross a system boundary (ingress from SCM, results from workers, egress). Domain events are appended by an aggregate and are the source of truth for its state. Only the aggregate writes its own domain-event stream (single-writer → clean optimistic concurrency); workers never write aggregate streams directly.
- Naming. Events = past tense (
ReviewRequested). Commands = imperative (RequestReview). - Everything is keyed by
reviewIdfor per-PR ordering (partition key on every topic). - Idempotent by
eventIdat every consumer; re-delivery is safe. - Additive evolution only — new optional fields; breaking changes get a new
eventVersion+ an upcaster. Published events are never mutated.
| Id | Shape | Notes |
|---|---|---|
RepoRef |
{workspace}/{repoSlug} |
Bitbucket Cloud workspace + repo |
prId |
long |
Bitbucket PR number, unique within a repo |
reviewId |
review::{workspace}/{repoSlug}#{prId} |
aggregate stream id — one ReviewLifecycle per PR |
commit |
provider head identifier as delivered (12-char short hash on Bitbucket Cloud) | the PR head commit a run targets; the idempotency/supersede key. Expanded to the 40-char SHA only in the worker where an outbound API requires it (ADR-013) |
threadRef |
opaque thread handle (ThreadRef) |
conversation anchor — comment id on BB/GH/DC, discussion_id on GitLab (SCM-MAPPING §6) |
eventId |
UUID | globally unique; dedup key |
correlationId |
= reviewId |
threads the whole flow across services |
causationId |
UUID | the command/event that caused this one |
Every event (integration or domain) is wrapped:
{
eventId: UUID, // dedup key
eventType: string, // "ReviewRequested"
eventVersion: int, // schema version, starts at 1
streamId: string, // aggregate id (domain) or source id (integration)
sequence: long, // per-stream monotonic (domain events only; optimistic concurrency)
occurredAt: Instant, // producer clock
correlationId: string, // = reviewId
causationId: UUID, // parent event/command id
actor: string, // "system" | "bot:<name>" | "webhook" | "operator:<sub>"
payload: { … } // type-specific, below
}
globalPosition (a monotonic long) is assigned by the event store on append and used by the dispatcher; it is store metadata, not part of the published payload.
| Event | Producer | Payload (beyond envelope) |
|---|---|---|
PullRequestEventReceived |
spire-gateway |
repo, prId, action(OPENED/UPDATED), title, description, sourceBranch, targetBranch, diffRefs{baseSha,startSha,headSha} (populate what the provider gives), author{providerUserId,username,displayName}, htmlUrl |
AuthorReplied |
spire-gateway |
repo, prId, reviewId, threadRef, commentId, text, author{providerUserId,username} |
PullRequestClosed |
spire-gateway |
repo, prId, reason(MERGED/DECLINED) — triggers the cancel saga (ADR-013) |
ManualCommandReceived |
spire-gateway |
repo, prId, command(review/…), args, author{providerUserId,username} — parsed from a /command PR comment; the saga maps review → RequestReview{force=true} |
PushReceived (P3) |
spire-gateway |
repo, ref, commits[] |
DiffFetched |
spire-review-worker (via DiffSource) |
reviewId, prId, commit, changedFiles, languages[], sizeBytes, truncated — metadata only; no diff content (deliberate two-fetch: content is re-fetched by commit at generate time; a 404 on re-fetch means the commit was force-pushed away → treat as superseded) |
ContextRequested |
spire-context-worker |
reviewId, repo, prId, commit, references[], expectedSources[], scmType — fan-out signal each ContextProvider subscribes to (§8) |
ContextContributed |
each ContextProvider |
reviewId, source(JIRA/CONFLUENCE/GITHUB_ISSUES/GITLAB_ISSUES/RULES/CODE/MEMORY), status(OK/EMPTY/ERROR), items[], latencyMs |
ContextAssembled |
aggregator | reviewId, prId, commit, contextRef, contributingSources[], missingSources[] |
ReviewGenerated |
spire-review-worker (via LlmProvider) |
reviewId, prId, commit, findings[] (inline ReviewResult, small), summary, model, tokensIn, tokensOut, costMillicents, verdicts[]? (reconciliation verdicts — ADR-019), reconcileUsage? (reconcile LLM call usage — ADR-019) |
ReviewFailed |
any worker | reviewId, commit, phase, error, retryable, attempt |
CommentsPosted |
spire-review-worker (via CommentSink) |
reviewId, prId, commit, summaryCommentId, inline[]{commentId,path,line}, threadOutcomes[]? (thread resolution outcomes — ADR-019) |
FollowUpGenerated |
spire-review-worker |
reviewId, threadRef, answerText |
FollowUpPosted |
spire-review-worker (via CommentSink) |
reviewId, threadRef, commentId |
TurnCapNotified |
spire-review-worker (via CommentSink) |
reviewId, threadRef, commentId — deliberately not FollowUpPosted: the hand-off notice must not consume a conversation turn |
ArchivedNotified |
spire-review-worker (via CommentSink) |
reviewId, threadRef? (null → top-level), commentId |
FindingConfirmed |
spire-review-worker (via CommentSink) |
reviewId, threadRef, commentId — deliberately not FollowUpPosted; links the confirmation back to the conversation root so a reply to it (on an SCM that threads by immediate parent) is still recognized |
FindingRefused |
spire-review-worker (via CommentSink) |
reviewId, threadRef, commentId — same non-turn-consuming, link-back-to-root shape as FindingConfirmed |
Only the assembled context is offloaded to the object store (encrypted, referenced by
contextRef) so events stay small. Diffs are never stored (re-fetched by commit); findings ride inline inReviewGenerated(small) and are projected to thereview_findingread model. See DATA-MODEL.md.
| Event | Meaning |
|---|---|
ReviewRequested |
a run started for commit (trigger OPENED/UPDATED) |
ReviewSuperseded |
a newer commit arrived mid-run; the old run is abandoned |
ReviewOutcomeRecorded |
the aggregate acknowledges a produced review for commit |
ReviewCompleted |
comments posted; run done; commit added to reviewed set (carries summaryCommentId so evolve can fold it into state) |
ReviewFailedTerminally |
non-retryable failure; run ended |
ReviewCancelled |
the PR was closed/merged/declined mid-run (or an operator cancelled); run abandoned |
ThreadOpened |
a conversational thread was started |
FollowUpRecorded |
a follow-up answer was posted in a thread |
ConversationFindingRaised |
a human ran /finding in a thread; anchor + severity only — no message (may quote source, stays out of the replayable log, DATA-MODEL §5) |
Action commands (to workers/adapters — cause side effects, produce integration result events):
| Command | Handler | Payload |
|---|---|---|
FetchDiff |
spire-review-worker |
reviewId, repo, prId, commit |
GatherContext |
spire-context-worker (fan-out) |
reviewId, repo, prId, commit, references (Set<String>), contextCredential, scmType — the platform the review runs on, so a repo-relative reference (an issue number) is not resolved against the wrong host's same-named repo |
GenerateReview |
spire-review-worker |
reviewId, prId, commit, contextRef, attempt, providerOverride? (set by the fallback saga on retry; worker re-fetches the diff by commit), priorRun? (prior posted run's findings — ADR-019) |
PostComments |
spire-review-worker |
reviewId, repo, prId, commit, findings[] (inline — same ReviewResult as ReviewGenerated; findings are not stored as blobs, ADR-011), verdicts[]? (follow-up reconciliation verdicts — ADR-019), priorSummaryRef? (summary comment to update in place on follow-up review) |
AnswerFollowUp |
spire-review-worker |
reviewId, repo, prId, threadRef, question — the worker fetches the thread history from the SCM on demand (no blob; same re-fetch philosophy as diffs) |
NotifyTurnCap |
spire-review-worker |
reviewId, repo, prId, threadRef, turnCap — fixed-text hand-off notice, no LLM credential; the worker claims once per thread (the slot is the thread ref) so later replies to a capped thread post nothing |
NotifyArchived |
spire-review-worker |
reviewId, repo, prId, threadRef? (null → top-level PR comment) — fixed-text retirement notice, no LLM credential; claimed once per review (ADR-024) |
ConfirmFinding |
spire-review-worker |
reviewId, repo, prId, threadRef, triggeringCommentId, severity, path, line — fixed-text confirmation that a /finding was filed, no LLM credential; claimed per triggering comment, so a second finding in one thread gets its own confirmation |
RefuseFinding |
spire-review-worker |
reviewId, repo, prId, threadRef — fixed-text refusal that a /finding had nowhere to anchor, no LLM credential; only emitted when there is a thread to reply into (a bare timeline note otherwise) |
Record commands (to the ReviewLifecycle decider — append domain events):
| Command | Guard → Emits |
|---|---|
RequestReview{commit,trigger,force} |
new commit → ReviewRequested; if newer run active → also ReviewSuperseded; force=true bypasses the reviewed-commit idempotency (FR-12) |
CancelReview{reason} |
REVIEWING → ReviewCancelled (from PullRequestClosed or an operator action) |
RecordReviewOutcome{commit,findingsCount,summaryDigest} |
commit==current → ReviewOutcomeRecorded (aggregate keeps only a digest, not the findings) |
RecordCommentsPosted{commit,summaryCommentId,count} |
commit==current → ReviewCompleted |
RecordFailure{commit,phase,retryable} |
commit==current AND !retryable → ReviewFailedTerminally (stale failure from a superseded run → no-op) |
OpenThread{threadRef,parentCommentId} |
→ ThreadOpened |
RecordFollowUp{threadRef,commentId} |
→ FollowUpRecorded |
RaiseConversationFinding{threadRef,path,line,severity,message,triggeringCommentId} |
triggeringCommentId ∉ raisedFindingComments → ConversationFindingRaised (redelivered comment id → no-op) |
Sagas translate integration result events → the next Action command and the matching Record command
(e.g. on CommentsPosted → RecordCommentsPosted; on PullRequestClosed → CancelReview; on
ManualCommandReceived{review} → RequestReview{force=true}). Fine-grained progress (diff fetched,
context assembled) lives in the read model, not the aggregate — the aggregate holds only
decision-relevant state.
State
reviewId, repo, prId
status: IDLE | REVIEWING | COMPLETED | FAILED | CANCELLED
currentCommit: sha | null
reviewedCommits: Set<sha> // idempotency across redeliveries
summaryCommentId: string | null
threads: Map<threadRef, {status, lastCommentId}>
raisedFindingComments: Set<commentId> // idempotency for /finding, same shape as reviewedCommits
decide(command, state) → events
| In state | Command | Guard | Emits | Next state |
|---|---|---|---|---|
| any | RequestReview{c, force=false} |
c ∈ reviewedCommits or c == currentCommit |
[] (idempotent no-op) |
— |
| any | RequestReview{c, force=true} |
— (bypasses reviewed-commit idempotency, FR-12) | (ReviewSuperseded{currentCommit} if a run is active) + ReviewRequested{c} |
REVIEWING, currentCommit=c |
note: forcing while c == currentCommit is mid-run deliberately yields ReviewSuperseded{c} + ReviewRequested{c} — a commit superseding itself, modeled as restart the run |
||||
REVIEWING |
RequestReview{c} |
c != currentCommit |
ReviewSuperseded{currentCommit} + ReviewRequested{c} |
REVIEWING, currentCommit=c |
IDLE/COMPLETED/FAILED/CANCELLED |
RequestReview{c} |
new commit | ReviewRequested{c} |
REVIEWING, currentCommit=c |
REVIEWING |
RecordReviewOutcome{c} |
c == currentCommit |
ReviewOutcomeRecorded |
REVIEWING |
REVIEWING |
RecordCommentsPosted{c} |
c == currentCommit |
ReviewCompleted{c} |
COMPLETED, +reviewedCommits, summaryCommentId set |
REVIEWING |
RecordFailure{c,retryable=false} |
c == currentCommit (stale failure from a superseded run → no-op) |
ReviewFailedTerminally |
FAILED |
REVIEWING |
CancelReview{reason} |
— | ReviewCancelled{reason} |
CANCELLED |
IDLE/COMPLETED/FAILED/CANCELLED |
CancelReview |
— | [] (nothing in flight — no-op) |
— |
| any | OpenThread / RecordFollowUp |
— | ThreadOpened / FollowUpRecorded |
threads updated |
| any | RaiseConversationFinding{threadRef,path,line,severity,message,triggeringCommentId} |
triggeringCommentId ∉ raisedFindingComments |
ConversationFindingRaised (redelivered comment id → [], idempotent no-op) |
+raisedFindingComments |
Invariants
- One active run per PR; a newer commit supersedes an in-flight run (latest-commit-wins).
- A commit is reviewed at most once (
reviewedCommits) unless re-review is forced (force=true). - Stale worker results and stale failures (
commit != currentCommit) are ignored — they belong to a superseded run and must never flip the state of the current run. - A closed/merged/declined PR cancels the in-flight run (CANCELLED); a reopened PR starts fresh via
RequestReview. - Conversational threads never block or change the main
status.
Idempotency keys
- Consumers dedup on
eventId. RequestReviewidempotent by(reviewId, commit)(unlessforce).- Workers idempotent by
causationId(never re-fetch/re-generate for the same command). - Comment posting idempotent by
(reviewId, commit, anchorKey)persisted before the external call, with reconcile-on-retry (ADR-013; table in DATA-MODEL §5).
Core abstractions (in spire-contract):
interface Decider<C, S, E> { S initialState(); List<E> decide(C c, S s); S evolve(S s, E e); }
interface View<S, E> { S initialState(); S evolve(S s, E e); }
interface Saga<E, C> { List<C> react(E e); }Plugin ports:
interface ScmIngress { // gateway
boolean verifySignature(RawWebhook raw);
List<IntegrationEvent> translate(RawWebhook raw); // → PullRequestEventReceived / PullRequestClosed / ManualCommandReceived / AuthorReplied / PushReceived
}
interface DiffSource { // scm adapter
ScmType type();
PullRequest fetchPullRequest(RepoRef repo, long prId);
Diff fetchDiff(RepoRef repo, long prId, String commit); // canonical FilePatch list
}
interface CommentSink { // scm adapter
ScmType type();
CommentRef postSummary(RepoRef repo, long prId, String bodyMd);
CommentRef postInline(RepoRef repo, long prId, DiffRefs refs, InlineAnchor anchor, String bodyMd);
CommentRef replyInThread(RepoRef repo, long prId, ThreadRef thread, String bodyMd); // ThreadRef, not bare id
Author getPullRequestAuthor(RepoRef repo, long prId);
} // DiffRefs feeds GitLab/GitHub anchoring; ThreadRef = comment id (BB/GH/DC) or discussion_id (GitLab). See SCM-MAPPING.md
interface ThreadSource { // scm adapter
ScmType type();
ThreadTranscript fetchThread(RepoRef repo, long prId, ThreadRef thread); // the whole conversation, in order
}
interface IdentitySource { // scm adapter
ScmType type();
Author whoami(); // who the configured token IS — the self-loop guard
}
interface PullRequestSink { // scm adapter — the factory opens PRs (M2)
ScmType type();
PullRequestRef open(RepoRef repo, NewPullRequest request); // throws NothingToPropose
Optional<PullRequestRef> findByHead(RepoRef repo, String head, String base); // find-first, so a redelivery opens nothing
} // NothingToPropose normalises "the agent changed nothing", which all four forges report as a 4xx that reads like failure
interface ContextProvider { // jira, confluence, issues, rules (shipped) / code (P3) / memory
String source();
boolean supports(ContextRequest req);
CompletionStage<ContextContribution> contribute(ContextRequest req); // async; may return EMPTY/ERROR
}
interface LlmProvider { // langchain4j default impl
String id();
Uni<Completion> complete(Prompt prompt, ModelParams params);
}
interface Capability { // review (v1), describe, changelog…
String name();
Set<String> commands(); // e.g. {"/review"}
CapabilityConfig defaultConfig();
Uni<Void> run(CapabilityContext ctx); // ctx exposes diff, assembled context, ports, resolved config
}Discovery: each is a CDI bean; the core injects @All List<ContextProvider> etc. Config selects the
active LlmProvider/DiffSource. Adding a plugin = new bean, no core edit.
- On
GatherContext, the context-worker computesexpectedSources= every enabledContextProviderwheresupports(req)is true, and emitsContextRequested{expectedSources}. - Each provider emits
ContextContributed{status}(OK/EMPTY/ERROR). - An aggregator tracks arrivals and emits
ContextAssembledwhen received ⊇ expected OR a timeoutT(default 20s) elapses, recordingcontributingSourcesandmissingSources. Shipped form (single worker): the aggregator is worker-local — an in-processallOf(providers).get(T)fan-out rather than an event-sourcedView+ timer; a distributed arrivals model is only needed once context providers run in separate processes. - Guarantees the pipeline never blocks on a slow/broken provider; missing sources degrade gracefully.
The assembled context is persisted encrypted to the
BlobStore(Postgres today, DATA-MODEL §4) and referenced bycontextRefonContextAssembled/GenerateReview. Jira is the first live provider (spire-context-jira).
| Topic | Carries |
|---|---|
cs.registry-integration |
Revisioned registration metadata; keyed by registration UUID |
cs.repository-integration |
Verified RepositoryDelivery envelopes around SCM ingress; keyed by the existing event key |
cs.repository-activity |
Existing FACTORY activity publication, keyed by repository UUID; M3 takeover instead consumes the verified original envelope on cs.repository-integration |
cs.work-integration |
Bound tracker deliveries, keyed by stable work-item identity |
cs.work-events |
Durable work-item event notifications from the encrypted outbox |
cs.work-dlq |
Failed tracker and factory-activity processing |
cs.run-commands |
Standalone ExecuteRun and held ExecuteWorkRun, keyed by run ID |
cs.run-control |
CancelRun, SteerRun, PublishWorkRun and HoldWorkRun; each worker consumes control independently |
cs.run-results |
RunStarted, RunWorkReady and terminal RunFinished/RunFailed facts |
cs.run-events |
Bounded run transcript facts, independent of workflow decisions |
cs.integration |
Retained legacy SCM ingress; new consumers dead-letter it with a provenance repair reason |
cs.commands |
action + record commands |
cs.events |
aggregate domain events |
cs.results |
worker-produced integration events (DiffFetched, ContextRequested, ContextContributed, ContextAssembled, ReviewGenerated, ReviewFailed, CommentsPosted, follow-ups). Short retention — carries source-quoting payloads without app-layer encryption (ADR-014) |
cs.dlq |
dead-letters (after retry budget); surfaced on the dashboard with a replay action (FR-8) |
Review lifecycle messages retain their existing reviewId ordering. Repository metadata and
factory activity use the explicit UUID keys shown above.
Webhook subscriptions on the bot's repo/workspace hook: pullrequest:created, pullrequest:updated,
pullrequest:comment_created, pullrequest:fulfilled (merged), pullrequest:rejected
(declined) — the close events feed PullRequestClosed → the cancel saga (+ repo:push for P3).
Verify X-Hub-Signature (HMAC) with the hook secret.
| Contract field | Bitbucket Cloud webhook path |
|---|---|
| repo | repository.workspace.slug + / + repository.name |
| prId | pullrequest.id |
| action | event key: created→OPENED, updated→UPDATED, fulfilled→CLOSED(MERGED), rejected→CLOSED(DECLINED) |
| title / description | pullrequest.title / pullrequest.rendered.description.raw |
| headCommit | pullrequest.source.commit.hash |
| sourceBranch / targetBranch | pullrequest.source.branch.name / pullrequest.destination.branch.name |
| author | pullrequest.author.{account_id, nickname, display_name} |
| htmlUrl | pullrequest.links.html.href |
| (comment) threadRef/parent | comment.id / comment.parent.id |
| (comment command) | ScmIngress parses the comment body: if it starts with a registered /command (e.g. /review, from Capability.commands()), emit ManualCommandReceived{command,args} instead of AuthorReplied. Bot-authored comments are dropped before either (ADR-013) |
REST (api.bitbucket.org/2.0), auth = bot App Password (Basic) or OAuth, scopes pullrequest:write,
repository:read:
- diff:
GET /repositories/{ws}/{repo}/pullrequests/{id}/diff - summary comment:
POST …/pullrequests/{id}/comments{content:{raw}} - inline comment: same +
{inline:{path, to}} - thread reply: same +
{parent:{id}} - author: from the PR resource
GET …/pullrequests/{id}
eventVersion starts at 1 per type. Additive fields don't bump it; breaking changes bump it and ship an
upcaster (vN → vN+1) in spire-contract. Consumers tolerate unknown fields. Published events are immutable.
All person endpoints require spire-admin and use the explicitly selected account. A resolution
response contains status, candidates (providerUserId, observed handle, displayName) and a
safe capability explanation. It never contains credentials or email fields.
| Endpoint | Behavior |
|---|---|
POST /api/providers/{id}/actors/resolve |
Resolve {handle, repositoryId?}; repository scope must be bound to this account. |
GET /api/providers/{id}/actors?refresh=true |
Read account policy; optional refresh resolves each stored ID, never its old handle. |
POST /api/providers/{id}/actors |
Save {handle, providerUserId, revision, repositoryId?} after repeating lookup and by-ID verification. |
DELETE /api/providers/{id}/actors/{actorId}?revision= |
Remove an account entry with optimistic policy revision. |
/api/repositories/{id}/fix-actors |
Corresponding list/save/delete operations using its reviewer; save additionally requires `effect: ALLOW |
POST /api/repositories/{id}/fix-actors/resolve |
Resolve through that repository's selected reviewer. |
Not-found, ambiguous or unsupported identity input returns 422 without writing; upstream
unavailability returns 503. A stale policy, disabled account or missing reviewer returns 409.
GitHub/GitLab support exact handles. Bitbucket/Jira return SELECTION_REQUIRED; the browser
must name a returned candidate, and the server repeats that selection check on save. Candidate
IDs are disambiguators in selection controls, not an operator input requirement.
Account create no longer accepts a raw author list: save credentials first, then resolve people.
An ordinary account update can omit authors to preserve policy; a supplied list must match the
current list under the account lock. A policy edit during token validation makes the old form
return 409 and rolls back its credential/configuration changes. It cannot replace the list with
unresolved text. Account views include actorDisplays for cached labels and stale states. Legacy
unresolved entries are labelled for repair. Repository fix overrides are separate from account
review/conversation policy; /fix uses explicit overrides and measured push access.
RepositoryPermissionSource.permission(RepoRef, providerUserId) returns CAN_PUSH, CANNOT_PUSH or UNKNOWN with a safe explanation. It reads through the repository's selected reviewer account. FixAuthorization evaluates unknown identity, DENY, ALLOW, then measured permission. UNKNOWN records PERMISSION_UNAVAILABLE and refuses dispatch; a prior successful read gives no authority. The saga records the exact reason as FixAuthorization in the timeline, with the reason and safe capability detail in the durable refusal. Allowed commands still pass the existing dispatch guards. The full lookup, including identity, redirects and pagination, is bounded to 20 seconds.