ARFC-1008: GitHub Issue Lifecycle Automation (Org-Level Webhook Invariants)

Status

Implemented Date: 2026-07-23 Last Call Date: 2026-07-24 Publication Date: 2026-07-24 Version: 0.5

Implementation note (2026-07-24): All invariants specified here are implemented and deployed to the development silo in the codermerlin.academy-backend repository as the black/dev-automation GitHub App webhook receiver: the App/receiver foundation (#2434), the §3.3 assignment (A1), §3.4 closure (C1/C2), and §3.7 parent/sub-issue status roll-up invariants (#2785 phases 1–3), and the requirement that every enforcement action posts an on-issue explanatory comment (#2807). The related issue-type-taxonomy automation invariants that also run on this App are specified in ARFC-1009 §3.8.

Scope note: This ARFC deliberately documents a 100% internal engineering-process design — it has no effect on the ĀYŌDÈ platform, the Codermerlin Academy API, or any developer/end-user-visible behavior. It is filed here, rather than as a plain docs/silicon-based-how/ note or a GitHub issue body (the normally-prescribed vehicles for internal tooling per this repository’s ARFC scope gate), per explicit operator direction on 2026-07-22, so that this design carries the same Draft → Last Call → Published review lifecycle as a platform-facing proposal.


Abstract

This RFC proposes a reactive, organization-level GitHub App that enforces a small set of GitHub issue lifecycle invariants — assignment-on-open, closure/board-status consistency, and parent/sub-issue status roll-up — across every repository in ayode-institute. The App subscribes to issues webhook events, verifies each delivery’s X-Hub-Signature-256 HMAC, and calls back into the GitHub API to correct any state that violates a defined invariant. Enforcement is eventual, not preventive: GitHub always accepts the user’s change first, and the automation observes and corrects afterward. Per-repository policy (currently limited to the identity of that repository’s default assignee) is supplied by a config file committed in each governed repository, not centralized in the automation’s own source.


Table of Contents

  1. Introduction
  2. Motivation
  3. Specification
  4. Security Considerations
  5. Backward Compatibility
  6. Resolved Design Questions (v0.1 → v0.2)
  7. References
  8. Author

1. Introduction

Issues across ayode-institute repositories are tracked without any automated guarantee that basic lifecycle hygiene is maintained: an issue can sit open with no assignee, a resolved issue can be closed while still carrying assignees, and an issue’s GitHub state can drift out of sync with its project-board status. This proposal introduces a single, org-installed GitHub App — backed by one AWS Lambda function reachable via a Function URL (no API Gateway) — that observes issues events for every repository in the organization and reactively corrects violations of a small, explicit invariant set.

The Lambda is intentionally narrow in this iteration: it enforces assignment and closure hygiene only. It is not a general workflow engine, does not gate merges or deployments, and has no authority over anything outside GitHub issue metadata (assignees, labels, and project-board status fields).


2. Motivation

2.1 Current State

2.2 Use Cases

2.3 Design Goals

  1. Enforce invariants reactively (after GitHub has already accepted the user’s change), never by blocking or rejecting the user’s original GitHub action.
  2. Keep every invariant single-direction: each invariant reacts to one event type and writes to one piece of state, never reads a piece of state it also writes elsewhere. (An earlier bidirectional board-status ⇄ issue-state design was rejected — see §3.4 — because it could re-close an issue immediately after a legitimate reopen.)
  3. Keep per-repository policy (initially: who the default assignee is) out of the automation’s own deployable source, so a repository owner can change it without requiring a redeploy of the automation stack.
  4. Bound the blast radius of a public, unauthenticated-at-the-transport -layer endpoint (a Lambda Function URL is reachable by anything on the internet before any application-layer check runs).
  5. Check current state (count, not identity) before correcting, so a same-moment reassignment (remove one assignee, add another) is not fought as if it were an illegal removal — see §3.3.

3. Specification

3.1 Event Model and Scope

The App is installed at the organization level and subscribes to:

Webhook event Purpose
issues Assignment (§3.3) and closure (§3.4) invariants (opened, closed, reopened, assigned, unassigned, labeled)
projects_v2_item (edited, field = Status) Parent/sub-issue status roll-up only (§3.7)

For A1–C2 (§3.3–§3.4), board-status changes are a write target only, never a read/trigger source — this is what keeps those invariants from re-fighting a legitimate reopen. The roll-up invariant in §3.7 is the sole exception that reads Status changes, since a sub-issue’s board status (not any native issues field) is the thing being rolled up — it is why the App subscribes to projects_v2_item at all. That is not, however, the only invariant that writes a Projects v2 field: C1 (§3.4) sets the board Status field to Closed purely from the issues event stream, with no Projects v2 subscription needed, but the write itself is still a Projects v2 mutation. The App therefore holds the “Organization projects” permission (read/write) for both C1 and the roll-up invariant, in addition to Issues and Contents — see the corrected accounting in §4.

sequenceDiagram
    participant User
    participant GitHub
    participant Lambda as Webhook Lambda
    participant API as GitHub REST/GraphQL API

    User->>GitHub: Close issue / remove assignee / etc.
    GitHub-->>User: Change accepted immediately
    GitHub->>Lambda: issues webhook event (HMAC-signed)
    Lambda->>Lambda: Verify X-Hub-Signature-256
    alt signature invalid
        Lambda-->>GitHub: 4xx (rejected, no processing)
    else signature valid
        Lambda->>API: Re-read current issue state (not the payload snapshot)
        Lambda->>API: Corrective call(s) if an invariant is violated
        Lambda-->>GitHub: 200
    end

3.2 Signature Verification

Every delivery is authenticated via GitHub’s X-Hub-Signature-256 header: an HMAC-SHA256 over the raw request body, keyed by a shared webhook secret held in Secrets Manager. Only the SHA-256 form is accepted; the legacy SHA-1 X-Hub-Signature header is not honored. Deliveries with a missing or mismatched signature are rejected before any payload parsing.

3.3 Assignment Invariants

A single invariant, checked from three trigger events, replaces the original per-event rules:

# Invariant Triggers Action
A1 An open issue must have ≥1 assignee issues.opened, issues.unassigned, issues.reopened Re-read the issue’s current assignee list; if empty, assign the repository’s configured project manager (§3.5) and comment noting the automatic assignment
ON issues.opened OR issues.unassigned OR issues.reopened:
    issue := reread(issue.number)
    IF issue.state == OPEN AND issue.assignees.count == 0:
        addAssignee(issue, config.projectManager)
        comment(issue, "Assigned <projectManager> — this issue had no assignee.")

This check is count-based, not identity-based, and always re-reads live state rather than trusting the webhook payload. That is what makes it safe to trigger from three different events without conflicting behavior:

3.4 Closure Invariants

The original design considered a bidirectional pair — “board status ‘Closed’ implies issue closed” alongside “issue closed implies board status ‘Closed’”. That pairing was rejected: the App has no subscription to Projects v2 field-change events, so the “board status → issue state” direction has no observable trigger under this event model; and if implemented anyway by re-checking consistency on unrelated events, it would re-close an issue immediately after a legitimate reopened action, since nothing in that design cleared board status off Closed on reopen.

The revised invariants are single-direction, both keyed off the same issues.closed event, and never read board status back:

# Invariant Trigger Action
C1 A closed issue is moved to the Closed board status issues.closed (any state_reason) Set the linked Projects v2 item’s status field to Closed
C2 A closed issue has no assignees issues.closed (any state_reason) Remove all current assignees

C1 intentionally reacts to “the issue is closed,” not to which of GitHub’s closed state_reason values (completed, not_planned, duplicate) applies — gating on the enumerated set risks silently skipping a legacy issue with a null state_reason or a future reason GitHub adds, and the action taken does not vary by reason today.

C2 fires from the same issues.closed event as C1, not from observing the board-status field — a manual board-only move to Closed (issue left open) must not strip real assignees from an active issue.

stateDiagram-v2
    [*] --> Open: issues.opened (A1: assign PM if empty)
    Open --> Open: issues.unassigned (A1: assign PM if empty)
    Open --> Closed: issues.closed
    Closed --> Open: issues.reopened (A1: assign PM if empty)
    Closed: Closed\n(C1: board status = Closed)\n(C2: assignees cleared)

3.5 Per-Repository Configuration

Per-repository policy is supplied by a config file committed in each governed repository — the same pattern used by other org-installed GitHub Apps (e.g. Dependabot’s .github/dependabot.yml) — rather than centralized in this automation’s own deployable source, so a repository owner can change their default assignee without requiring a redeploy of the automation stack.

Path: .ayode/issue-automation.config (YAML content; the .ayode/ directory is a deliberate namespace distinct from .github/, to avoid implying this is a GitHub-native mechanism).

Minimum shape:

Key Type Description
projectManager string GitHub login assigned by A1 when no other assignee is present
projectManager: some-github-login

The file is read from each repository’s default branch, never the triggering event’s own ref or PR head — otherwise a contributor’s own branch could redirect who gets auto-assigned before merge.

A repository with no such file is not fully unmanaged: C1 and C2 need no repository-specific data and apply unconditionally everywhere the App is installed. Only A1 (which needs a configured project manager) is unable to act for a repository until that repository adds the file — onboarding a repository to the assignment invariants is “add the file,” not a flag flipped in the automation stack.

3.6 Enforcement Semantics and Caveats

3.7 Parent/Sub-Issue Status Roll-Up

Invariant: given a parent issue and its GitHub-native Sub-issues, the parent’s board status must be no less advanced than the least-advanced status among its immediate children — applied recursively up the tree (a grandparent is bound by its children’s own already-enforced status, not by reaching directly to the leaves).

This is a floor, not a ceiling: it stops a parent from lagging behind its slowest descendant, but nothing here stops a parent from being more advanced than a child (e.g. closed while a child is still open) — that failure mode is out of scope for this invariant.

ORDER: S0 (no status) < S1 (Backlog Unrefined) < S2 (Backlog Ready)
     < S3 (Sprint Selected) < ... < S13 (Closed)

ON projects_v2_item.edited (field == "Status") for issue Y:
    enforceFloor(Y)

FUNCTION enforceFloor(Y):
    children := Y.subIssues              // GitHub-native Sub-issues
    IF children is NOT empty:
        floor := MIN(child.status FOR child IN children)   // S0 participates
        IF Y.status < floor:
            IF floor == S13:
                closeIssue(Y)            // reuses C1 + C2 as a side effect
            ELSE:
                setProjectStatus(Y, floor)
    IF Y.parent EXISTS:
        enforceFloor(Y.parent)           // walk the full ancestor chain

enforceFloor walks the entire ancestor chain in a single invocation (bounded by tree depth), rather than relying on the automation’s own write to a lower level re-triggering a fresh webhook delivery for the next level up. This sidesteps an open question of whether GitHub even re-fires projects_v2_item events for changes made by the App’s own credentials — the walk-up is correct regardless of that answer, and is naturally idempotent (a level already at or above its floor is a no-op).

Design decisions (resolved during review):


4. Security Considerations


5. Backward Compatibility

This proposal has no effect on the ĀYŌDÈ platform or Codermerlin Academy API surface — no schema, endpoint, or privilege changes accompany it, since it is an internal engineering-process tool (see the Scope note in Status).

Within its own scope:


6. Resolved Design Questions (v0.1 → v0.2)

These were open in v0.1 and are resolved as of v0.2:


7. References


8. Author

ĀYŌDÈ Development Team Codermerlin Academy Architecture