Skip to content

Authorization Contract v1 ​

Normative rules governing which actors may trigger agent dispatch.

This document is the single living contract for authorization policy. The historical decision and rationale are recorded in ADR 0054. The NormalizedEvent v1 specification defines the actor.role, actor.kind, actor.role_verified, and actor.bot_role fields consumed by this contract. Bot identity and role resolution are specified by ADR 0107.

ADR 0107 target contract — not yet implemented: The bot identity fields, resolver outcomes, and bot-specific fail-closed rows below describe the behavior to be implemented by the adapters and dispatch path. Until that migration lands, existing compatibility behavior remains authoritative.

Role ordering ​

Fullsend uses a forge-neutral role hierarchy. Roles are listed from most to least privileged:

admin > maintain > write > triage > read > none > external
RoleMeaning
adminFull repository control
maintainRepository settings without destructive admin
writePush, label, comment, merge
triageLabel and moderate without push access
readRead-only collaborator
noneAuthenticated user without explicit repository permission
externalActor outside the repository or project (currently Jira-only; GitHub maps non-collaborators to read on public repositories or none; GitLab maps them to none)

Forge-native permission mapping ​

ForgeNative levelFullsend role
GitHubadminadmin
maintainmaintain
writewrite
triagetriage
readread
(no collaborator entry)none
(fork/non-collaborator)read (public repository) or none
GitLabOwneradmin
Maintainermaintain
Developerwrite
Reportertriage
Guestread
JiraAdministratorsadmin
Developerswrite
(other named role)read
(no project membership)external

On GitHub the mapping source is the collaborator permission API (GET /repos/{owner}/{repo}/collaborators/{username}/permission), which returns the user's effective role including inherited org grants regardless of membership visibility. A built-in role_name is used as is. Custom repository roles are an organization-level GitHub Enterprise Cloud feature, so most repositories only ever return built-in names; Fullsend does not interpret a custom role's name or its fine-grained permissions. A custom repository role resolves to the strongest base role set to true in user.permissions (admin, maintain, push → write, triage, pull → read), or none if no flag is set. Only when user.permissions is absent or null does the legacy permission field (admin, write, read) apply; anything else is none. Legacy read stays read, because it cannot distinguish Read from Triage. Fork authors and non-collaborators are mapped to read (public repositories) or none, never external. The external role is currently produced only by the Jira adapter for actors without project membership. The default thresholds deny read, none and external alike. The author_association field is not used because it does not correctly reflect private org membership (see Excluded fields).

A GitHub repository that lists owners_file under authorization in .fullsend/config.yaml consults its repo-root Prow OWNERS file before the collaborator API:

OWNERS listRole granted
approversat least write
reviewersat least triage
(not listed)none; the collaborator API decides

OWNERS can only raise a role, never lower one. An entry that names an OWNERS_ALIASES key stands for that alias's members. A login equal to any alias key never matches, and nested aliases are not expanded. The files are read from the base branch for PR-scoped events and from the default branch otherwise, so a PR cannot add its author. See the authorization field.

On Jira the mapping source is the project's role membership roster, resolved once per poll cycle for the configured --jira-project. An actor is matched by Jira account ID. Role names are matched case-insensitively against the names above; unrecognized role names fall through to read. Actors with no membership in the configured project — or whose issue belongs to a different project — are mapped to external (fail-closed). See the Jira integration guide for details on the name-based matching limitation.

Bot identity fields ​

The adapter/provider MUST classify the verified actor using authoritative source-system metadata before authorization. This may include a provider actor name when that forge gives it bot-specific semantics, such as GitHub's [bot] logins; labels, review types, and arbitrary event-content strings are not bot identity signals. For post-migration producers with actor.kind: bot, actor.role MUST be none, and actor.role_verified is true exactly when the provider completes the bot-role lookup, including a successful no-match result; it is false when resolution fails. For actor.kind: human, actor.bot_role MUST be absent or null, and actor.role contains the forge permission role when actor.role_verified is true. For non-label dispatch, only a non-null, provider-resolved actor.bot_role can pass the bot gate. Once that gate succeeds, the actor.role-keyed observation and mutation thresholds below do not apply to the bot; platform authorization instead requires a valid normalized event, an applicable source and target, and a transition supported by the selected harness. CEL may further restrict routing but cannot create or broaden bot identity authorization.

For humans on non-label paths, role_verified: false denies the event regardless of the role string. A missing role_verified on a trusted pre-migration event is treated as legacy input and retains the current human authorization behavior; new adapters MUST emit the field, and their false value MUST fail closed. Legacy status is determined by the adapter implementation contract, not by guessing from this field's absence. On the permanent label exception, role_verified is not an authorization input: an accepted forge label mutation authorizes a human, and a bot additionally requires positive provider bot classification. For bots, omission means that bot-role lookup is not applicable, not that it failed.

Default thresholds ​

CategoryMinimum roleRationale
Observation (triage, review)triageRead-only analysis; lower barrier to reduce maintainer toil
Mutation (code, fix, retro slash command, prioritize)writeAgents that push commits or alter state require push access

A role satisfies a threshold when it is at or above the minimum in the role ordering. For example, admin satisfies both triage and write thresholds.

These thresholds apply to human actors and to legacy bot events that still use forge permissions during migration. They do not apply to a bot after successful actor.bot_role recognition, because actor.role remains the compatibility value none for that target representation.

The bash dispatch implementation uses a parameterized has_repo_permission(username, min) helper that encodes this comparison (see Enforcement point).

Implementation note: the Go dispatch path (IsAuthorized() in harnessdispatch) currently uses a coarse write+ filter for all non-exception transitions — it does not yet distinguish observation from mutation thresholds. Poll-based dispatch (GitLab/Jira) uses the Go path, so a triage-role user triggering observation through that path is denied today. The bash path (GitHub webhook dispatch) implements the full parameterized threshold.

Fail-closed behavior ​

The authorization gate is fail-closed: when a role cannot be determined, the actor is denied.

The entity-discovery rows below specify the future ADR 0098 path and do not describe behavior currently implemented by fullsend dispatch or fullsend poll.

The bot-specific rows in the following table are ADR 0107 target behavior; legacy events remain subject to the compatibility rules until implementation migration is complete.

ConditionOutcome
Collaborator API returns a custom role_name with no effective permission signalMapped to none; denied
Collaborator API returns an error or times outDenied (function returns failure)
Bot-role lookup returns no registered identity on a non-label pathactor.role remains none; actor.role_verified is true; actor.bot_role is absent; denied
Bot-role lookup fails or is unverifiable on a non-label pathactor.role remains none; actor.role_verified is false; actor.bot_role is absent; denied, with the failure retained in resolver/audit diagnostics
Collaborator API response is not valid JSON or does not match the expected shapeDenied
actor.role is empty or missingEvent fails NormalizedEvent validation; never reaches dispatch
actor.role_verified is false for a humanDenied regardless of actor.role
Legacy bot event has an actor.role other than noneValid in the v1 compatibility schema; authorization follows current compatibility behavior during migration and is not an ADR 0107 bot-role authorization result
actor.role_verified is false but actor.bot_role is non-null for a botEvent fails NormalizedEvent validation; never reaches dispatch
actor.bot_role is non-null for a humanEvent fails NormalizedEvent validation; never reaches dispatch
Username is emptyDenied
OWNERS is missing or malformed, or OWNERS_ALIASES is present but malformed (owners_file enabled)OWNERS check skipped; the collaborator API decides
Username contains characters outside [A-Za-z0-9-] (owners_file enabled)OWNERS check skipped; the collaborator API decides
Fullsend poll invocation provenance is missing or unverifiableEntity discovery denied
Harness entity sources are missing or malformedThat harness is skipped for scheduled evaluation
Effective platform eligibility policy is missing, malformed, or unverifiableScheduled evaluations governed by that policy are denied
Effective platform eligibility policy uses a wildcard without explicit platform-level justificationScheduled evaluations governed by that policy are denied
Action-indicating enumeration fails, is unavailable, or does not cover the entity kindEvaluation of that entity is denied
Current permission for an action-indicating element's actor is missing or unverifiableThat element cannot trigger execution
Current permission for an action-indicating element's actor is below the applicable stage thresholdThat element cannot trigger execution
Versioned normalized-entity contract is missing or incompleteEntity-first execution denied

Exceptions ​

Certain transitions are authorized without requiring a write or triage role from the acting user. Each exception is documented with its rationale.

Label application ​

Current compatibility behavior: GitHub authorizes an added-label event without applying the ordinary actor.role threshold. This existing behavior preserves bot-to-bot handoffs such as adding ready-to-code after triage completes, including handoffs from provider bots that are not Fullsend- registered.

ADR 0107 target behavior: When transition.kind is label_changed and label.action is added, GitHub authorizes the event regardless of actor.role once the adapter has established an accepted forge label mutation and authoritative actor-to- transition provenance. This is a separate platform authorization grant, not an application of the ordinary stage threshold. At the time of this ADR, GitHub is the only implementation; another adapter MUST provide equivalent forge-authoritative evidence before enabling the same grant, and absent or unverifiable evidence denies the event. For a bot actor, the adapter MUST positively classify it using provider-controlled metadata, but actor.bot_role lookup is not required. The platform gate MUST NOT inspect the label name or maintain an agent-role label allowlist; harness/CEL routing owns that mapping and may further narrow the dispatch.

Bot-submitted reviews (GitHub) ​

Current compatibility behavior: Until the resolver is implemented, fullsend dispatch continues to authorize a GitHub review_submitted event from a bot without requiring a collaborator permission or actor.bot_role. This existing exception is not evidence that the bot identity was resolved.

ADR 0107 target behavior: After the resolver is implemented, a GitHub review event is authorized only when the provider classifies the actor as a bot and resolves its exact registered actor.bot_role; actor.role remains none. The downstream harness CEL trigger may further constrain which bot role and review state are accepted.

Lifecycle close (pull_request_target.closed) ​

The pull_request_target.closed event that triggers the retro stage is intentionally ungated: any closer may trigger read-only lifecycle accounting. The retro agent performs only observation work and does not mutate repository state. This exception is currently implemented in the bash dispatch path; the Go IsAuthorized() path has no special handling for closed transitions and applies the standard write+ gate.

Schedule and manual dispatch ​

When source.system is schedule or manual, the actor is the configured operator or service identity (for example, a GitHub App bot or workflow GITHUB_ACTOR). Adapters MUST classify that identity from authoritative provider metadata. Human identities use verified forge permissions and the human thresholds; bot identities use provider-backed actor.bot_role resolution and retain actor.role as none. An unrecognized or unresolved bot identity is denied.

Current compatibility behavior: Until provider-backed bot-role resolution is wired into the adapters and dispatch path, adapters set actor.role to the service identity's effective repository permission (typically write), so the standard permission gate applies.

Target contract, not yet implemented: Existing schedule/manual compatibility handling remains authoritative until provider-backed bot-role resolution is wired into the adapters and dispatch path.

Fullsend-originated entity discovery ​

This is the future path adopted by ADR 0098; it is not yet implemented by fullsend dispatch or fullsend poll, and entity-first execution MUST NOT be enabled until the versioned normalized-entity contract defines CEL-eligible and prompt-eligible fields. That contract is a platform allowlist; a harness may request fewer fields but cannot expand it.

fullsend poll may perform scheduled entity discovery without synthesizing a NormalizedEvent or event actor. This path is authorized only when its caller has trusted Fullsend-controlled invocation provenance: a verified, non-user-assertable platform execution identity bound to the invocation and its target, such as an attested workflow/job identity or installation credential. A CLI flag, request header, or other caller-supplied claim is insufficient. A caller that cannot establish that provenance MUST be denied. Wildcard eligibility (* or all) requires the same explicit platform-level justification as any other wildcard allowlist in this contract.

Authorization of the poll origin does not authorize entity content. For each candidate harness, before evaluating its CEL predicate, Fullsend MUST enumerate a platform-defined closed superset of action-indicating elements for the entity kind. These are actor-originated entity-history elements whose content or state could be treated as a request for a stage to run; the superset includes issue or change-proposal bodies, comments, reviews, and label applications wherever the entity kind supports them. A harness cannot exclude a supported category from classification. If enumeration fails, is unavailable, or does not cover the entity kind, evaluation of that entity MUST be denied; only successful enumeration may return an empty set for a state-only predicate.

Fullsend MUST resolve the current forge permission level for every enumerated element's actor and remove the element unless that permission meets the candidate harness's observation or mutation threshold. Historical or cached actor relationships are insufficient. A harness CEL predicate MAY further restrict selection using retained elements and their current actor-permission fields, but cannot weaken the platform gate or be relied upon to identify an element for later authorization.

Fullsend MUST omit or minimize other unneeded untrusted fields where the normalized entity contract permits, while retaining the state, content, and actor provenance required by the harness. Further trust or injection filtering MAY run after CEL routing and before the harness pre-script. Retained content remains untrusted throughout. Any resulting agent run uses the harness's configured identity and permissions.

Excluded fields ​

The following fields are not authorization evidence and must not be used for dispatch gating:

FieldWhy excluded
author_associationDoes not reflect private org membership; an org admin with private membership gets CONTRIBUTOR instead of MEMBER (github/gh-aw-mcpg#2862). Also reflects contribution history, not current authority.
Contribution historyPast contributions do not confer current repository permissions. A former maintainer whose access was revoked should not pass authorization.
actor.is_entity_authorBeing the author of an issue or PR does not grant repository permissions. This field supports routing decisions in CEL triggers, not authorization.

Principle: relationship and contribution-history fields are not evidence of current authority. Event-backed authorization must be derived from the forge's permission model at event time, not from cached or inferred relationships. Fullsend-originated entity discovery must instead be authorized from trusted Fullsend-controlled invocation provenance for enumeration and evaluation. Action-indicating elements are separately gated by current actor permission; historical actors and relationship fields must not serve as authorization evidence.

Enforcement point ​

Authorization is enforced as a platform-level gate before CEL trigger evaluation. Event-backed dispatch uses the normalized event actor; Fullsend-originated entity discovery uses trusted invocation provenance to authorize enumeration and evaluation, then independently filters action-indicating elements by current actor permission before CEL.

Forge event
  --> NormalizedEvent (adapter)
  --> Event-actor authorization gate        <-- enforced here
  --> CEL trigger evaluation (harness routing)
  --> Execution

Fullsend poll invocation
  --> Trusted-origin authorization gate     <-- enforced here
  --> Entity enumeration and resolution
  --> For each candidate harness:
      --> Enumerate action-indicating elements
      --> Harness-stage actor gate           <-- enforced here
      --> CEL trigger evaluation (event is null)
      --> Further trust/injection filtering
      --> Execution

CEL triggers: routing only ​

Harness trigger expressions express routing and may tighten input selection, not platform permission policy. A CEL expression may tighten dispatch conditions (e.g., require a specific label, restrict to non-fork PRs, filter by bot identity) but may never weaken the platform authorization gate. On the event-backed path, an event that fails authorization never reaches CEL evaluation.

This separation is enforced architecturally: on the event-backed path, IsAuthorized() runs before MatchHarnesses() in the dispatch core. On the entity-discovery path, the trusted-origin gate runs before enumeration and CEL evaluation. For each candidate harness, the platform removes unauthorized action-indicating elements using that harness's stage threshold before its CEL predicate runs, and event remains null. Neither path lets a CEL expression override or relax an authorization denial.

Per-repo configurability ​

The authorization gate is a platform-level security boundary. Individual repositories cannot disable it. Per-repo configuration (which stages are enabled, which labels trigger automation) operates within the authorization boundary. A repository can disable a stage entirely but cannot make it available to unauthorized users.

A repository can add permission sources through the authorization field (today only owners_file). These sources extend the has_repo_permission helper and the Go IsAuthorized check; they do not bypass the gate or change the thresholds.

Versioning ​

This is a living normative document under ADR 0015. Breaking changes require docs/normative/authorization/v2/.

ADR 0107 migration exception: The migration from legacy bot authorization exceptions to provider-backed bot identity is permitted within v1. It is a policy tightening, not a new supported actor population: existing compatibility behavior remains authoritative until an adapter implementation is updated; once updated, registered bots use actor.bot_role and unregistered non-label bots are denied. The adapter implementation itself is the migration boundary; no per-event mode marker or separate deprecation window is required. An updated adapter MUST enforce the target behavior and MUST NOT silently fall back to legacy bot authorization when its resolver fails. The normalized-event fields used to carry this distinction are additive, and the permanent label-added exception is preserved. This exception does not alter the general v2 rule for unrelated authorization changes.

Changev1 impact
Breaking (requires v2): remove a role from the hierarchy, raise a default threshold, remove a documented exception, change fail-closed to fail-openDispatch implementations must migrate
Adapter bot-identity migration (allowed in v1): update an adapter to emit and enforce the ADR 0107 target representationThe adapter's existing compatibility behavior applies until that implementation update; afterward, registered bots use actor.bot_role and unregistered non-label bots are denied
Non-breaking (allowed in v1): add a role, lower a default threshold, add a new exception, add forge mappings, clarify documentationExisting dispatch behavior is preserved or relaxed