Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
176 changes: 176 additions & 0 deletions Governance/policies/REVOCATION.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
# Privilege Revocation Policy

## Purpose

This policy defines how TeachLink Web revokes repository and project
privileges that have already been granted: repository access levels, team
and working-group membership, credentials used by automation and release
tooling, and other named authorities. It states the grounds on which a
privilege may be revoked, who may initiate a revocation, the steps every
revocation follows, and the appeal path available to the affected holder.
It exists so that revocation is prompt when it must be, and documented,
reviewable, and non-arbitrary at all other times.

## Scope

This policy applies to every privilege granted through the project,
including but not limited to:

- Repository access levels: read, triage, write, maintain, and administer.
- Team, working-group, and security-response membership.
- Credentials and authorities used by bots, automation, and release tooling.
- Any other named authority recorded in this `Governance/` folder.

It does not cover enforcement of conduct standards, which follows the Code
of Conduct and its appeals process, nor the ordinary removal of access that
simply follows inactivity, which follows the Inactivity Policy. Where this
policy and another governance document both apply, the stricter requirement
governs.

## Grounds for Revocation

A privilege may be revoked only on an enumerated ground:

- **Security risk.** The holder's credentials, devices, or accounts are
compromised, or the holder poses an active risk to the repository, its
users, or its releases.
- **Confirmed misconduct.** A conduct enforcement decision under the Code
of Conduct requires or recommends removal of the privilege.
- **Breach of trust.** Deliberate abuse of the privilege, such as bypassing
required checks, leaking private data, or acting against the project while
using the granted authority.
- **Loss of competence or availability.** The holder cannot exercise the
privilege safely under the applicable role requirements.
- **Legal or platform requirement.** A law, court order, or hosting platform
requires the privilege to be removed.

A revocation is never used as a general sanction. It is proportionate to the
ground, and the record states which ground applies and why.

## Who May Initiate

- **Maintainers** may open a revocation for any holder, under any ground, as
defined in `Governance/roles/MAINTAINER.md`.
- **The Security Response Team** may open a revocation on security grounds,
as described in `Governance/SECURITY_RESPONSE_TEAM.md`, and may act first
and document afterwards when there is an active risk to users.
- **The relevant working group or role owner** may open a revocation for the
privileges it grants, for its own members.
- **The holder** may voluntarily surrender a privilege at any time by
telling a maintainer. A voluntary surrender follows the record steps but
does not require the grounds test.

An initiator may not decide a revocation in which they are the affected
holder or the subject, and must disclose any personal stake under
`Governance/policies/CONFLICT_OF_INTEREST.md` and step aside when one exists.

## Revocation Steps

Every revocation moves through the following steps in order, except that a
security revocation may compress steps 1 and 2 when there is an active risk
to users, with the compression and its reason recorded.

### 1. Open a revocation record

The initiator opens a private revocation record that names the holder, the
privilege, the ground relied on, the evidence, and who raised it. Reports
that do not name a ground are sent back for that detail rather than actioned.

- **Owner:** the initiator.
- **Exit criterion:** a private record exists with holder, privilege,
ground, and evidence.

### 2. Independent decision

An independent decision-maker reviews the record against the enumerated
grounds and decides to revoke, to restrict instead, or to decline. A
revocation that removes a privilege on a ground not listed above is invalid.

- **Owner:** a maintainer or delegate who is independent of the holder.
- **Exit criterion:** a written decision that names the ground and the
reasoning, within 5 business days of the record being complete.

### 3. Apply the removal

The privilege is removed through the platform or tooling that grants it, and
every credential, token, or key issued for it is rotated or revoked. Removal
is complete: nothing that grants the privilege is left active.

- **Owner:** maintainers with the access to apply the change.
- **Exit criterion:** the access, membership, or credential is gone, and the
platform audit log confirms it.

### 4. Notify and record

The holder is notified privately of the decision, the privilege removed, the
ground, and the appeal path in this document. The decision and its reasoning
are recorded where maintainers can audit them; public spaces carry only the
fact that a change was made, never private details.

- **Owner:** maintainers.
- **Exit criterion:** holder notified and the decision recorded.

### 5. Follow up

When the revocation exposed a gap in access control, review, or process, a
follow-up issue is opened without disclosing private details. Retaliation for
participating in a revocation is treated as a fresh conduct matter.

- **Owner:** maintainers.
- **Exit criterion:** any follow-up issue opened and linked to the record.

## Appeal Path

Every holder whose privilege is revoked may appeal that decision:

- **Who may appeal.** The affected holder, or a maintainer acting on behalf
of a holder who cannot safely appeal alone. An appeal may use a pseudonym
where safety requires it.
- **How to appeal.** File within 14 calendar days of the decision notice in
the private channel named in that notice, stating which decision is
appealed, why it should be reconsidered, and any new evidence.
- **Independent review.** An independent reviewer, uninvolved in the
original decision, examines the record and the appeal grounds and decides
to uphold, amend, or overturn, within 10 business days of assignment.
- **Written decision with reasoning.** The outcome is communicated to the
appellant in writing, stating what was decided, why, and what could reopen
it.
- **Re-review.** A party may request re-review once with new evidence; after
that the decision stands unless new facts emerge.
- **Exceptions.** A security or legal revocation may remain in force during
an appeal when the risk is ongoing. The exception, its reason, and a
retrospective independent review are recorded, as described in
`Governance/processes/ESCALATION_PATH.md`.

A review that merely restates the original decision without engaging the
appeal grounds is not an independent review.

## Ownership and Review

- Maintainers own this policy and are accountable for applying its grounds
and steps consistently and for recording every revocation and exception.
- Changes to this policy are proposed in a pull request that touches only
the `Governance/` folder, and are reviewed like any other governance
change.
- This document is versioned with the repository and describes the
revocation practice the project actually follows.

## Success

This policy succeeds when every revocation names an enumerated ground, when
removals are complete and their credentials rotated, when every affected
holder is told the decision and their appeal path, and when the record lets
the project review its own access decisions without exposing private
details.

## Regression Tests

Coverage is provided by `Governance/policies/REVOCATION.test.ts`, which pins
the grounds, the initiation rights, and the appeal guarantees in this
document.

## Revision History

| Version | Date | Change | Author |
| --- | --- | --- | --- |
| 1.0 | 2026-09-28 | Initial version. | TeachLink maintainers |
220 changes: 220 additions & 0 deletions Governance/policies/REVOCATION.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,220 @@
/**
* Regression tests for the Privilege Revocation Policy
* (Governance/policies/REVOCATION.md).
*
* The policy is documentation, but it makes concrete, enforceable
* guarantees: a closed list of grounds, named parties who may initiate a
* revocation, a fixed sequence of steps, and an appeal path with real
* windows. These tests pin those guarantees to the checked-in document so
* they cannot silently regress (for example, an edit that adds an
* unenumerated ground or drops the appeal path 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, 'REVOCATION.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((match) => match[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);
}

describe('REVOCATION policy document structure', () => {
it('is titled "Privilege Revocation Policy"', () => {
expect(policy.startsWith('# Privilege Revocation Policy\n')).toBe(true);
});

it('keeps the canonical governance document sections', () => {
expect(sections).toEqual([
'Purpose',
'Scope',
'Grounds for Revocation',
'Who May Initiate',
'Revocation Steps',
'Appeal Path',
'Ownership and Review',
'Success',
'Regression Tests',
'Revision History',
]);
});

it('covers the three areas the issue requires', () => {
expect(sections).toContain('Grounds for Revocation');
expect(sections).toContain('Who May Initiate');
expect(sections).toContain('Appeal Path');
});

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((line) => line.length));
expect(longest).toBeLessThanOrEqual(82);
});
});

describe('grounds for revocation guarantees', () => {
const body = sectionBody('Grounds for Revocation');

it('is a closed list rather than an open-ended sanction', () => {
expect(body).toContain('only on an enumerated ground');
expect(body).toContain('never used as a general sanction');
});

it('includes the five enumerated grounds', () => {
const required = [
'security risk',
'confirmed misconduct',
'breach of trust',
'loss of competence or availability',
'legal or platform requirement',
];
for (const ground of required) {
expect(body).toContain(ground);
}
});

it('requires the record to name the ground relied on', () => {
expect(body).toContain('states which ground applies');
});
});

describe('who may initiate guarantees', () => {
const body = sectionBody('Who May Initiate');

it('names maintainers, the security team, role owners, and the holder', () => {
expect(body).toContain('maintainers');
expect(body).toContain('security response team');
expect(body).toContain('working group or role owner');
expect(body).toContain('voluntarily surrender');
});

it('lets the security team act first on an active risk and document after', () => {
expect(body).toContain('act first');
expect(body).toContain('active risk');
});

it('forbids an initiator deciding a revocation they are subject to', () => {
expect(body).toContain('may not decide');
});

it('requires a conflict-of-interest disclosure', () => {
expect(body).toContain('conflict_of_interest.md');
});
});

describe('revocation step guarantees', () => {
const body = sectionBody('Revocation Steps');

it('opens a record with holder, privilege, ground, and evidence', () => {
expect(body).toContain('holder');
expect(body).toContain('privilege');
expect(body).toContain('ground');
expect(body).toContain('evidence');
});

it('requires an independent decision-maker', () => {
expect(body).toContain('independent');
});

it('rotates every credential and revokes issued keys on removal', () => {
expect(body).toContain('rotated or revoked');
});

it('notifies the holder and records the decision', () => {
expect(body).toContain('notified');
expect(body).toContain('recorded');
});
});

describe('appeal path guarantees', () => {
const body = sectionBody('Appeal Path');

it('offers an appeal to every affected holder', () => {
expect(body).toContain('may appeal');
});

it('sets a 14 calendar day filing window', () => {
expect(body).toContain('14 calendar days');
});

it('requires an independent review decided within 10 business days', () => {
expect(body).toContain('independent reviewer');
expect(body).toContain('10 business days');
});

it('requires a written decision with reasoning', () => {
expect(body).toContain('written decision with reasoning');
});

it('allows one re-review with new evidence', () => {
expect(body).toContain('re-review once');
});

it('keeps a security revocation in force during appeal only with a record', () => {
expect(body).toContain('security or legal');
expect(body).toContain('retrospective independent review');
});

it('rejects a review that merely restates the original decision', () => {
expect(body).toContain('not an independent review');
});
});

describe('policy consistency with the governance folder', () => {
it('routes escalation to the escalation-path process document', () => {
expect(prose).toContain('governance/processes/escalation_path.md');
expect(() =>
readFileSync(path.resolve(__dirname, '../processes/ESCALATION_PATH.md'), 'utf8'),
).not.toThrow();
});

it('references the maintainer role document that exists', () => {
expect(prose).toContain('governance/roles/maintainer.md');
expect(() =>
readFileSync(path.resolve(__dirname, '../roles/MAINTAINER.md'), 'utf8'),
).not.toThrow();
});

it('references the security response team charter that exists', () => {
expect(prose).toContain('governance/security_response_team.md');
expect(() =>
readFileSync(path.resolve(__dirname, '../SECURITY_RESPONSE_TEAM.md'), 'utf8'),
).not.toThrow();
});

it('mentions the governance-folder-only rule for changes to this policy', () => {
const body = sectionBody('Ownership and Review');
expect(body).toContain('only the governance/ folder');
});

it('keeps the change self-contained in the governance folder', () => {
const body = sectionBody('Scope');
expect(body).toContain('repository access levels');
expect(body).toContain('credentials');
});
});
Loading