diff --git a/Governance/policies/INACTIVITY.md b/Governance/policies/INACTIVITY.md new file mode 100644 index 00000000..19ea7e7f --- /dev/null +++ b/Governance/policies/INACTIVITY.md @@ -0,0 +1,146 @@ +# Inactivity Policy + +## Purpose + +This policy defines what counts as inactivity for each contributor role in +TeachLink Web, the process for notifying an inactive role-holder before any +status change takes effect, and the consequences of confirmed inactivity +together with the path back to active status. It gives every contributor a +single, versioned reference so that role transitions are predictable, +transparent, and reversible. + +Without a written policy, inactivity is handled inconsistently: some +role-holders are quietly removed without notice while others retain access +indefinitely. Both outcomes harm the project — the first erodes trust, the +second creates security and governance risk. This document closes that gap. + +## Scope + +This policy applies to every named role defined under `Governance/roles/`: +Contributor, Reviewer, Maintainer, and any working-group lead or domain +steward recognised in a governance document. It covers inactivity in the +TeachLink Web repository and its associated governance spaces (issue tracker, +discussion forums, and working-group channels). It does not cover temporary +absences that are communicated in advance, which are governed by the leave +provisions each role document may define. + +## Inactivity Thresholds + +Inactivity is defined per role because the expected cadence of participation +differs between roles. + +- **Contributor.** A Contributor is considered inactive after **six months** + with no merged pull request, no reviewed pull request, no substantive + comment on an open issue, and no participation in a governance discussion. + A Contributor role carries no elevated access, so the threshold is longer + and the consequence is a status note rather than access removal. +- **Reviewer.** A Reviewer is considered inactive after **three months** with + no completed review, no review comment, and no response to a review + assignment. The shorter threshold reflects the access and the expectation + that Reviewers participate on a regular cadence. +- **Maintainer.** A Maintainer is considered inactive after **two months** + with no merged pull request, no completed review, no response to a + time-sensitive governance decision, and no participation in the regular + maintainer sync. The shortest threshold reflects the elevated access and + the responsibilities the role carries. +- **Working-group lead / domain steward.** A lead or steward is considered + inactive after **two months** with no agenda published, no meeting + facilitated, no decision recorded, and no response to an action item + assigned to them. The threshold matches Maintainer because the role carries + equivalent governance responsibility. + +A single qualifying action within the threshold window resets the clock. +Qualifying actions are the same ones listed above for each role. Passive +actions (watching the repository, starring, or reading notifications without +responding) do not reset the clock. + +## Notification Process + +Before any status change takes effect, the project must make a genuine effort +to reach the role-holder through the following steps, in order. + +1. **First notice — 14 days before the threshold is reached.** A maintainer + or the governance automation posts a notice on the role-holder's most + recent active GitHub thread (or opens a new issue tagged with the + role-holder's username) stating that the threshold will be reached in + fourteen days and listing the qualifying actions that would reset the + clock. +2. **Second notice — on the day the threshold is reached.** A maintainer + confirms that no qualifying action has occurred and posts a second notice + on the same thread stating that the role-holder is now considered inactive + and that their status will change in **seven days** unless they respond. +3. **Response window — seven days.** The role-holder may respond with a + qualifying action, a request for a short extension (up to 30 days, granted + once per 12-month period), or a voluntary step-down. Any of these + responses pauses the status-change process. +4. **Status change.** If the seven-day window closes with no response, a + maintainer records the status change on the tracking issue and applies + the consequences defined below. The tracking issue is linked from the + role-holder's entry in the relevant `Governance/roles/` document. + +Notices must be public (on GitHub) so the process is auditable. Private +messages may supplement public notices but never replace them. + +## Consequences + +Consequences are proportional to the access the role carries and are applied +only after the notification process is complete. + +- **Contributor.** The role-holder's entry in the Contributor list is marked + inactive. No access is changed. The entry is retained so the contribution + history remains visible. +- **Reviewer.** The role-holder is removed from the CODEOWNERS file and any + review-assignment rotation. Repository read access is retained. The + role-holder's entry in the reviewer list is moved to an inactive section. +- **Maintainer.** The role-holder is removed from the CODEOWNERS file, the + maintainer team, and any elevated repository permissions (write, admin). + Repository read access is retained. The role-holder's entry in + `Governance/roles/MAINTAINER.md` is moved to an inactive section. +- **Working-group lead / domain steward.** The role-holder is removed from + the lead position. If no active co-lead exists, the working group is placed + in a caretaker state under the maintainer team until a new lead is + identified. The transition is recorded in the relevant governance document. + +In every case the tracking issue records who made the change, when, and why, +so the decision is auditable. + +## Reinstatement + +A former role-holder may request reinstatement at any time by opening an +issue in the repository. The following apply. + +- **Contributor.** Reinstatement is automatic on the next merged pull + request. No issue or approval is required. +- **Reviewer.** A former Reviewer may request reinstatement after demonstrating + re-engagement through at least **two completed reviews** on open pull + requests. Reinstatement requires approval from one active Maintainer, + recorded on the reinstatement issue. +- **Maintainer.** A former Maintainer may request reinstatement after + demonstrating re-engagement through at least **four completed reviews or + merged pull requests** within a 60-day window. Reinstatement requires + approval from two active Maintainers who are not the requestor, following + the normal Maintainer onboarding process in `Governance/roles/MAINTAINER.md`. +- **Working-group lead / domain steward.** Reinstatement follows the same + process as initial appointment for the role, documented in the relevant + working-group charter. + +A role-holder who steps down voluntarily is treated as a former role-holder +and follows the same reinstatement path. There is no penalty for voluntary +step-down. + +## Ownership and Review + +- Maintainers own this policy, approve exceptions, and apply it consistently + across all roles. +- A change to this policy is proposed in a pull request that touches only the + `Governance/` folder. +- This policy is reviewed alongside `Governance/roles/MAINTAINER.md` + and any working-group charters whenever a threshold or consequence is + proposed for change. + +## Success + +This policy succeeds when every inactive role transition is preceded by a +public notice, no role-holder loses access without a recorded reason, and +former contributors can return to active participation through a clear, +welcoming path. diff --git a/Governance/policies/INACTIVITY.test.ts b/Governance/policies/INACTIVITY.test.ts new file mode 100644 index 00000000..9325cab1 --- /dev/null +++ b/Governance/policies/INACTIVITY.test.ts @@ -0,0 +1,298 @@ +/** + * Regression tests for the Inactivity Policy + * (Governance/policies/INACTIVITY.md). + * + * The policy is documentation, but it makes concrete, enforceable guarantees: + * per-role inactivity thresholds, a mandatory multi-step notification process, + * proportional consequences, and a reinstatement path. These tests pin those + * guarantees to the checked-in document so they cannot silently regress (for + * example, an edit that shortens the Maintainer threshold or drops a required + * notification step fails CI), and they keep the document consistent with the + * rest of `Governance/`. + */ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; + +const POLICY_PATH = path.resolve(__dirname, 'INACTIVITY.md'); +const policy = readFileSync(POLICY_PATH, 'utf8'); + +/** Strip Markdown syntax so keyword assertions match prose, not formatting. */ +function plainProse(markdown: string): string { + return markdown + .replace(/`([^`]*)`/g, '$1') // inline code keeps its text + .replace(/\*\*([^*]*)\*\*/g, '$1') // bold keeps its text + .replace(/\[([^\]]*)\]\(([^)]*)\)/g, '$1 $2') // links keep text and target + .replace(/\s+/g, ' ') // line wrapping must not affect prose matching + .toLowerCase(); +} + +const prose = plainProse(policy); + +/** Every "## Heading" in the document, in order. */ +const sections = [...policy.matchAll(/^## (.+)$/gm)].map((m) => m[1]); + +/** Extract the body of a single "## Section" (text up to the next heading). */ +function sectionBody(title: string): string { + const start = policy.indexOf(`## ${title}\n`); + expect(start, `section "${title}" is missing`).toBeGreaterThanOrEqual(0); + const next = policy.indexOf('\n## ', start + 1); + const body = next === -1 ? policy.slice(start) : policy.slice(start, next); + return plainProse(body); +} + +// --------------------------------------------------------------------------- +// Document structure +// --------------------------------------------------------------------------- + +describe('INACTIVITY policy document structure', () => { + it('is titled "Inactivity Policy"', () => { + expect(policy.startsWith('# Inactivity Policy\n')).toBe(true); + }); + + it('contains the canonical governance section order', () => { + expect(sections).toEqual([ + 'Purpose', + 'Scope', + 'Inactivity Thresholds', + 'Notification Process', + 'Consequences', + 'Reinstatement', + 'Ownership and Review', + 'Success', + ]); + }); + + it('covers the four areas required by the issue', () => { + expect(sections).toContain('Inactivity Thresholds'); + expect(sections).toContain('Notification Process'); + expect(sections).toContain('Consequences'); + expect(sections).toContain('Reinstatement'); + }); + + it('has no unresolved template placeholders', () => { + expect(policy).not.toMatch(/TBD|TODO|FIXME|<[a-z-]+>|XXX/); + }); + + it('stays within the house documentation line width (max 82 columns)', () => { + const longest = Math.max(...policy.split('\n').map((l) => l.length)); + expect(longest).toBeLessThanOrEqual(82); + }); +}); + +// --------------------------------------------------------------------------- +// Inactivity thresholds +// --------------------------------------------------------------------------- + +describe('inactivity threshold guarantees', () => { + const body = sectionBody('Inactivity Thresholds'); + + it('defines a threshold for the Contributor role', () => { + expect(body).toContain('contributor'); + expect(body).toContain('six months'); + }); + + it('defines a threshold for the Reviewer role', () => { + expect(body).toContain('reviewer'); + expect(body).toContain('three months'); + }); + + it('defines a threshold for the Maintainer role', () => { + expect(body).toContain('maintainer'); + expect(body).toContain('two months'); + }); + + it('defines a threshold for working-group leads and domain stewards', () => { + expect(body).toMatch(/working.group lead/); + expect(body).toContain('domain steward'); + expect(body).toContain('two months'); + }); + + it('specifies that a single qualifying action resets the clock', () => { + expect(body).toContain('resets the clock'); + }); + + it('excludes passive actions from resetting the clock', () => { + expect(body).toContain('passive actions'); + expect(body).toContain('do not reset the clock'); + }); + + it('does not set the Maintainer threshold longer than Reviewer', () => { + // Maintainer = 2 months, Reviewer = 3 months — shorter, not longer. + const maintainerMatch = body.match(/maintainer[^.]*?(\w+) months/); + const reviewerMatch = body.match(/reviewer[^.]*?(\w+) months/); + const toMonths: Record = { + one: 1, + two: 2, + three: 3, + four: 4, + five: 5, + six: 6, + }; + if (maintainerMatch && reviewerMatch) { + const maintainerMonths = toMonths[maintainerMatch[1]] ?? Infinity; + const reviewerMonths = toMonths[reviewerMatch[1]] ?? Infinity; + expect(maintainerMonths).toBeLessThanOrEqual(reviewerMonths); + } + }); +}); + +// --------------------------------------------------------------------------- +// Notification process +// --------------------------------------------------------------------------- + +describe('notification process guarantees', () => { + const body = sectionBody('Notification Process'); + + it('requires a first notice 14 days before the threshold is reached', () => { + expect(body).toContain('first notice'); + expect(body).toContain('14 days'); + }); + + it('requires a second notice on the day the threshold is reached', () => { + expect(body).toContain('second notice'); + expect(body).toContain('on the day the threshold is reached'); + }); + + it('provides a response window after the second notice', () => { + expect(body).toContain('response window'); + expect(body).toContain('seven days'); + }); + + it('lists the valid responses that pause the status-change process', () => { + expect(body).toContain('qualifying action'); + expect(body).toContain('extension'); + expect(body).toContain('voluntary step-down'); + }); + + it('limits extensions to once per 12-month period', () => { + expect(body).toContain('once per 12-month period'); + }); + + it('requires notices to be public and on GitHub', () => { + expect(body).toContain('public'); + expect(body).toContain('github'); + }); + + it('allows private messages to supplement but never replace public notices', () => { + expect(body).toContain('supplement'); + expect(body).toContain('never replace'); + }); + + it('requires the status change to be recorded on a tracking issue', () => { + expect(body).toContain('tracking issue'); + }); +}); + +// --------------------------------------------------------------------------- +// Consequences +// --------------------------------------------------------------------------- + +describe('consequence guarantees', () => { + const body = sectionBody('Consequences'); + + it('defines consequences for each of the four roles', () => { + expect(body).toContain('contributor'); + expect(body).toContain('reviewer'); + expect(body).toContain('maintainer'); + expect(body).toMatch(/working.group lead/); + }); + + it('retains repository read access for Reviewers after removal', () => { + // The reviewer block must contain both "read access" and "retained". + expect(body).toMatch(/reviewer[^.]*read access[^.]*retained|retained[^.]*read access/); + }); + + it('retains repository read access for Maintainers after removal', () => { + expect(body).toMatch(/maintainer[^.]*read access[^.]*retained|retained[^.]*read access/); + }); + + it('removes Maintainers from elevated permissions', () => { + expect(body).toContain('elevated repository permissions'); + }); + + it('removes Reviewers from the codeowners file and review rotation', () => { + expect(body).toContain('codeowners'); + expect(body).toContain('review-assignment rotation'); + }); + + it('places a leaderless working group in a caretaker state', () => { + expect(body).toContain('caretaker state'); + }); + + it('requires every status change to be auditable', () => { + expect(body).toContain('auditable'); + }); + + it('applies consequences only after the notification process is complete', () => { + expect(body).toContain('notification process is complete'); + }); +}); + +// --------------------------------------------------------------------------- +// Reinstatement +// --------------------------------------------------------------------------- + +describe('reinstatement guarantees', () => { + const body = sectionBody('Reinstatement'); + + it('defines a reinstatement path for each role', () => { + expect(body).toContain('contributor'); + expect(body).toContain('reviewer'); + expect(body).toContain('maintainer'); + expect(body).toMatch(/working.group lead/); + }); + + it('makes Contributor reinstatement automatic on the next merged PR', () => { + expect(body).toContain('automatic'); + expect(body).toContain('merged pull request'); + }); + + it('requires two completed reviews for Reviewer reinstatement', () => { + expect(body).toContain('two completed reviews'); + }); + + it('requires Reviewer reinstatement to be approved by one active Maintainer', () => { + expect(body).toContain('one active maintainer'); + }); + + it('requires four completed reviews or PRs for Maintainer reinstatement', () => { + expect(body).toContain('four completed reviews or'); + }); + + it('requires two Maintainer approvals for Maintainer reinstatement', () => { + expect(body).toContain('two active maintainers'); + }); + + it('treats voluntary step-down the same as inactivity for reinstatement', () => { + expect(body).toContain('voluntary step-down'); + expect(body).toContain('no penalty'); + }); +}); + +// --------------------------------------------------------------------------- +// Consistency with the Governance folder +// --------------------------------------------------------------------------- + +describe('policy consistency with the governance folder', () => { + it('references the Maintainer role document', () => { + expect(prose).toContain('governance/roles/maintainer.md'); + }); + + it('references the Maintainer role document that actually exists', () => { + expect( + () => readFileSync(path.resolve(__dirname, '../roles/MAINTAINER.md'), 'utf8'), + 'referenced file MAINTAINER.md does not exist', + ).not.toThrow(); + }); + + it('scopes changes to the Governance folder only', () => { + const body = sectionBody('Ownership and Review'); + expect(body).toContain('only the governance/ folder'); + }); + + it('links ownership to the Maintainer role document', () => { + const body = sectionBody('Ownership and Review'); + expect(body).toContain('governance/roles/maintainer.md'); + }); +});