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-backendrepository as theblack/dev-automationGitHub 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
- Introduction
- Motivation
- 2.1 Current State
- 2.2 Use Cases
- 2.3 Design Goals
- Specification
- Security Considerations
- Backward Compatibility
- Resolved Design Questions (v0.1 → v0.2)
- References
- 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
- No automation observes or corrects GitHub issue lifecycle state anywhere in the organization; hygiene depends entirely on individual habit.
- Issues can be created, left open, and closed with no assignee at any point, no resolution reasoning, and no board-status linkage.
- Parent issues (tracked via GitHub’s native Sub-issues relationships) can sit at an earlier board status than their own sub-issues indefinitely — nothing keeps a parent’s status consistent with how far its descendants have actually progressed.
- There is no existing mechanism for org-wide GitHub App infrastructure — no CloudFormation stack, Lambda, or webhook-receiving endpoint exists today for this purpose.
2.2 Use Cases
- A new issue is filed without an assignee; it should default to a named responsible party (a repository’s project manager) rather than sit unowned indefinitely.
- An issue is closed; any assignees still on it should be cleared, since an assignee on a closed issue no longer signals active ownership.
- An issue is closed; its project-board status should reflect that closure automatically, so the board does not require a separate manual step to stay accurate.
- A parent issue’s sub-issues progress through the board (or close); the parent should never be found sitting behind its own least-progressed descendant, without requiring someone to manually walk every parent epic forward as pieces of it move.
2.3 Design Goals
- Enforce invariants reactively (after GitHub has already accepted the user’s change), never by blocking or rejecting the user’s original GitHub action.
- 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.)
- 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.
- 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).
- 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:
- Creation with no assignee (
opened): count is 0 → assign the PM. This is the same case the original A1 covered. - Illegal full unassignment (
unassigned): if the removal left the issue with zero assignees, the PM is added back. If instead thisunassignedevent is one half of a same-moment reassignment (an assignee removed and a different one added around the same time), the re-read at processing time finds a non-empty list — because the new assignee is already present, or arrives moments later on the low-volumeissuesevent stream — so nothing fires and the swap is not fought. There remains a narrow race if the removal’s webhook is processed before the addition’s, in which case the PM is added alongside the intended new assignee rather than blocking the swap outright; this is the same bounded eventual-consistency window already accepted in §3.6. - Reopen with no assignee (
reopened): a previously closed issue has its assignees cleared by C2 (§3.4); reopening it now re-runs the same zero-assignee check that creation does, instead of leaving the reopened issue silently unowned.
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
- Eventual, not preventive. GitHub applies the user’s change before the webhook fires; there is a real window during which an invariant is violated before the Lambda restores it. Consumers of issue data must tolerate this window.
- Delivery is not instantaneous and can be delayed or throttled, extending that window.
- Re-read, don’t trust the payload. Multiple rapid edits can race; the Lambda re-reads current issue state before acting rather than trusting the webhook payload’s snapshot (reflected in the pseudocode in §3.3).
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):
- Hard floor, always enforced. Like every other invariant in this proposal, this is continuously reactive: if a parent is ever found below its children’s current floor — including immediately after a human deliberately drags it down — it is corrected back up. This is a conscious choice: an epic representing a body of work should never be considered less-refined than its own least-refined piece, even by deliberate manual action.
- S13 (Closed) floor closes the parent issue, rather than only
setting its Status field to
Closed. Setting the field alone would reintroduce the “Closed board status, open issue” inconsistency C1 was built to prevent (§3.4); closing the parent through the Issues API keeps the two invariants consistent with each other, at the cost of this being a more consequential automated action (auto-closing an issue, not just moving a board field) than anything else in this proposal. - S0 (no status) children participate in the floor. A sub-issue that hasn’t even been added to the project board pins the parent’s floor at S0 — an untriaged descendant means the parent cannot be considered to have progressed past it either.
- Hierarchy source is GitHub’s native Sub-issues feature (the same
parent/child relationship already used elsewhere in this
organization’s tooling, e.g.
use-case-backend-analyze’s formal sub-issue linking) — not a checklist, label, or other convention.
4. Security Considerations
- Transport-layer authenticity: the Lambda Function URL is
AuthType: NONEby necessity (GitHub cannot produce a SigV4 signature), so the endpoint is reachable by anything on the internet. TheX-Hub-Signature-256HMAC check is the sole authenticity boundary; unverified or malformed deliveries are rejected before any payload handling. SmallReservedConcurrentExecutions, a shortTimeout, and minimalMemorySizebound worst-case cost from unauthenticated traffic reaching the public endpoint before rejection. - Secret handling: the webhook HMAC secret is minted and owned by the automation stack (Secrets Manager, stack-scoped name), never checked into templates or source. It is provided to the GitHub App’s webhook-secret field once, out-of-band, at App registration time.
- Least-privilege GitHub App permissions:
Issues(read/write),Contents(read-only, for §3.5), andOrganization projects(read/write). The Projects v2 grant is needed by two invariants, not one: C1 (§3.4) writes the board Status field toClosedon every issue closure, and the roll-up invariant (§3.7) additionally reads Status changes via theprojects_v2_itemsubscription. An earlier revision of this section attributed the entire Projects v2 grant to the roll-up invariant alone; that was incorrect — C1 ships without the roll-up invariant (see §5) and still requires the grant on its own. No broader repository-administration scope is required or requested. - Auto-closing is the highest-consequence action this automation takes. Every other corrective action is a board-status field update, an assignee change, or a comment; §3.7’s S13 case is the one path where the automation closes a GitHub issue outright. It is gated by the same signature verification as everything else, and only ever triggered by the state of an issue’s own already-closed sub-issues (not directly by external webhook payload content), but is called out here as the single highest-blast-radius action this App can take.
- Config-file trust boundary: reading the per-repository config only from the default branch (never a PR head) prevents an untrusted contributor branch from redirecting the identity the automation acts as a proxy for (who gets auto-assigned).
- No privileged actions: the automation’s write surface is limited to issue assignees, a project-board status field, and issue comments — it cannot alter code, CI, deployments, or infrastructure state.
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:
- C1/C2 (closure invariants) take effect for every repository the moment the App is installed at the organization level, since they need no per-repository configuration. Repository owners should be informed before org-wide installation, since closing an issue will immediately begin clearing its assignees and updating its board status where it did not before.
- A1 (the assignment invariant) is opt-in per repository: it takes
effect only once a repository adds
.ayode/issue-automation.config. No repository is affected by A1 before it adds that file. - The roll-up invariant (§3.7) takes effect org-wide immediately on installation, for any existing parent issue that already has Sub-issues, with no opt-in file. This includes a one-time backfill risk: any pre-existing parent whose children are already all closed will be auto-closed the first time any of those children (or the parent) produces a Status-change event after installation, potentially surfacing a batch of unexpected auto-closures on day one rather than only affecting issues going forward. Worth an explicit dry-run or an initial deploy scoped to a single pilot repository before organization-wide rollout.
6. Resolved Design Questions (v0.1 → v0.2)
These were open in v0.1 and are resolved as of v0.2:
-
v0.3 → v0.4 correction: §3.1 and §4 previously attributed the
Organization projectspermission grant solely to the roll-up invariant (§3.7). This was wrong — C1 (§3.4) writes the board Status field on every closure and needs the same grant independently of whether the roll-up invariant ships. Found during #2785 (Phase 1 implementation planning for C1/C2), which requested the grant regardless of the ARFC’s wording; corrected here at the source. - Reassignment semantics. Resolved by unifying A1/A2 into a single count-based, re-read-driven check (§3.3): an open issue only gets the PM assigned when it currently has zero assignees, not whenever a specific person is removed. A same-moment swap is no longer fought.
- Reopen assignee restoration. Resolved by the same unification: the
A1 check now also fires on
issues.reopened, so a reopened issue that lost its assignees on closure (C2) is re-evaluated exactly like a newly opened one. - Resolution-label enforcement. Resolved by not introducing a
separate label requirement. GitHub’s native
state_reason(completed/not_planned/duplicate) already serves as the resolution reason and is already the basis for C1 (§3.4); maintaining a parallel custom label taxonomy in sync withstate_reasonwas judged to add upkeep without adding signal. No invariant enforcesstate_reasonpresence in this revision — closing without one (possible via API calls that omit it) is accepted as-is rather than corrected.
7. References
- GitHub issue:
ayode-institute/codermerlin.academy-backend#2434— “GitHub App webhook stack: org-level Issues events into Lambda (no API Gateway)” - Session note:
docs/silicon-based-how/session/2434.mdon branch2434-github-issues-lifecycle-webhook(codermerlin.academy-backendrepository) — codebase findings, stack/build-model decisions, and the slice-1 (signature-verification-only) implementation plan this ARFC’s design builds toward.
8. Author
ĀYŌDÈ Development Team Codermerlin Academy Architecture