Skip to content

Latest commit

 

History

History
436 lines (376 loc) · 32.3 KB

File metadata and controls

436 lines (376 loc) · 32.3 KB

Domain Contract (spire-contract)

Work-item channels and policy (M3, ADR-043/045)

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.

Repository metadata and ingress channels (M3 slices 1–2, ADR-042)

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 ReviewLifecycle decider, 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.

1. Conventions

  • 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 reviewId for per-PR ordering (partition key on every topic).
  • Idempotent by eventId at 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.

2. Identifiers

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

3. Event envelope

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.

4. Event catalog

Integration events (ingress / worker results / egress)

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 reviewRequestReview{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 in ReviewGenerated (small) and are projected to the review_finding read model. See DATA-MODEL.md.

Domain events (appended by the ReviewLifecycle aggregate)

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)

5. Command catalog

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 ∉ raisedFindingCommentsConversationFindingRaised (redelivered comment id → no-op)

Sagas translate integration result events → the next Action command and the matching Record command (e.g. on CommentsPostedRecordCommentsPosted; on PullRequestClosedCancelReview; 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.

6. ReviewLifecycle decider

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.
  • RequestReview idempotent by (reviewId, commit) (unless force).
  • 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).

7. SPI ports (hand-rolled interfaces)

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.

8. Context-aggregation policy

  1. On GatherContext, the context-worker computes expectedSources = every enabled ContextProvider where supports(req) is true, and emits ContextRequested{expectedSources}.
  2. Each provider emits ContextContributed{status} (OK/EMPTY/ERROR).
  3. An aggregator tracks arrivals and emits ContextAssembled when received ⊇ expected OR a timeout T (default 20s) elapses, recording contributingSources and missingSources. Shipped form (single worker): the aggregator is worker-local — an in-process allOf(providers).get(T) fan-out rather than an event-sourced View + timer; a distributed arrivals model is only needed once context providers run in separate processes.
  4. 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 by contextRef on ContextAssembled/GenerateReview. Jira is the first live provider (spire-context-jira).

9. Kafka topics

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.

10. Bitbucket Cloud mapping

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}

11. Versioning

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.

Resolved people and repository fix overrides (M3 slice 3)

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.

Effective repository permission (M3 slice 4)

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.