Skip to content
Merged
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
4 changes: 3 additions & 1 deletion packages/policies/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,9 @@
"url": "https://github.com/Stackbilt-dev/charter/issues"
},
"homepage": "https://github.com/Stackbilt-dev/charter#readme",
"dependencies": {},
"dependencies": {
"@stackbilt/types": "workspace:*"
},
"scripts": {
"prepublishOnly": "node ../../scripts/ensure-pnpm-publish.mjs"
},
Expand Down
102 changes: 100 additions & 2 deletions packages/policies/src/__tests__/policies.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import * as os from 'node:os';
import { detectRepoConfig } from '../detect';
import { patchFloatingActionPins } from '../patch';
import { generateCallerWorkflow, generateCharterConfigPatch } from '../generate';
import { applyPolicies } from '../index';
import { applyPolicies, PolicyGovernanceGate } from '../index';

// ---------------------------------------------------------------------------
// Helpers
Expand Down Expand Up @@ -227,7 +227,7 @@ describe('applyPolicies', () => {
expect(result.alreadyCompliant).toBe(false);
});

it('already-compliant: no changes, alreadyCompliant true', async () => {
it('already-compliant: no changes, alreadyCompliant true (#200 idempotency)', async () => {
const dir = makeTempRepo({
'.github/workflows/supply-chain.yml': 'name: SC',
'.github/workflows/ci.yml': `steps:\n - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4\n`,
Expand All @@ -246,3 +246,101 @@ describe('applyPolicies', () => {
expect(result.supplyChainWorkflowAdded).toBe(false);
});
});

// ---------------------------------------------------------------------------
// PolicyGovernanceGate — authority-gated governance contract (#200)
// ---------------------------------------------------------------------------

describe('PolicyGovernanceGate', () => {
const GATE_OPTS = { fixPins: true, policyRepoRef: 'testref123' };

it('propose() returns a proposal without writing files', async () => {
const dir = makeTempRepo({
'.github/workflows/ci.yml': `steps:\n - uses: actions/checkout@v4\n`,
});
const gate = new PolicyGovernanceGate(GATE_OPTS);
const proposal = await gate.propose(dir);

expect(proposal.alreadyCompliant).toBe(false);
expect(proposal.delta.length).toBeGreaterThan(0);
expect(proposal.id).toMatch(/^[0-9a-f]{16}$/);
expect(proposal.repoPath).toBe(dir);
// Gate must not have written anything
expect(fs.existsSync(path.join(dir, '.github/workflows/supply-chain.yml'))).toBe(false);
});

it('propose() is idempotent — same repo state yields same proposal id', async () => {
const dir = makeTempRepo({
'.github/workflows/ci.yml': `steps:\n - uses: actions/checkout@v4\n`,
});
const gate = new PolicyGovernanceGate(GATE_OPTS);
const first = await gate.propose(dir);
const second = await gate.propose(dir);
expect(first.id).toBe(second.id);
expect(first.delta).toEqual(second.delta);
});

it('commit(approve) writes files and returns a receipt', async () => {
const dir = makeTempRepo({
'.github/workflows/ci.yml': `steps:\n - uses: actions/checkout@v4\n`,
});
const gate = new PolicyGovernanceGate(GATE_OPTS);
const proposal = await gate.propose(dir);
const receipt = await gate.commit(proposal, 'approve');

expect(receipt.proposalId).toBe(proposal.id);
expect(receipt.decision).toBe('approve');
expect(typeof receipt.committedAt).toBe('number');
expect(receipt.committedAt).toBeGreaterThan(0);
// Files must have been written
expect(fs.existsSync(path.join(dir, '.github/workflows/supply-chain.yml'))).toBe(true);
});

it('commit(dismiss) emits a receipt but does NOT write files', async () => {
const dir = makeTempRepo({
'.github/workflows/ci.yml': `steps:\n - uses: actions/checkout@v4\n`,
});
const gate = new PolicyGovernanceGate(GATE_OPTS);
const proposal = await gate.propose(dir);
const receipt = await gate.commit(proposal, 'dismiss');

expect(receipt.decision).toBe('dismiss');
expect(receipt.proposalId).toBe(proposal.id);
// Gate must have left state unchanged
expect(fs.existsSync(path.join(dir, '.github/workflows/supply-chain.yml'))).toBe(false);
});

it('commit(override) applies changes even when alreadyCompliant', async () => {
const dir = makeTempRepo({
'.github/workflows/supply-chain.yml': 'name: SC',
'.github/workflows/ci.yml': `steps:\n - uses: actions/checkout@${FAKE_SHA} # v4\n`,
'.charter/config.json': JSON.stringify({
drift: { enabled: true, include: ['.github/workflows/*.yml'] },
}),
'.charter/patterns/floating-action-pins.json': '{}',
});
const gate = new PolicyGovernanceGate(GATE_OPTS);
const proposal = await gate.propose(dir);
expect(proposal.alreadyCompliant).toBe(true);

// override should still emit a receipt without throwing
const receipt = await gate.commit(proposal, 'override');
expect(receipt.decision).toBe('override');
expect(receipt.proposalId).toBe(proposal.id);
});

it('alreadyCompliant proposal has an empty delta', async () => {
const dir = makeTempRepo({
'.github/workflows/supply-chain.yml': 'name: SC',
'.github/workflows/ci.yml': `steps:\n - uses: actions/checkout@${FAKE_SHA} # v4\n`,
'.charter/config.json': JSON.stringify({
drift: { enabled: true, include: ['.github/workflows/*.yml'] },
}),
'.charter/patterns/floating-action-pins.json': '{}',
});
const gate = new PolicyGovernanceGate(GATE_OPTS);
const proposal = await gate.propose(dir);
expect(proposal.alreadyCompliant).toBe(true);
expect(proposal.delta).toHaveLength(0);
});
});
68 changes: 68 additions & 0 deletions packages/policies/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
import * as crypto from 'node:crypto';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { detectRepoConfig } from './detect';
import { patchFloatingActionPins } from './patch';
import { generateCallerWorkflow, generateCharterConfigPatch, FLOATING_PIN_PATTERN } from './generate';
import type { GovernanceGate, GovernanceProposal, GovernanceReceipt, GovernanceDecision } from '@stackbilt/types';

export { detectRepoConfig } from './detect';
export { patchFloatingActionPins } from './patch';
export { generateCallerWorkflow, generateCharterConfigPatch, FLOATING_PIN_PATTERN } from './generate';
export type { RepoConfig, FloatingPin } from './detect';
export type { PatchResult, PatchReplacement } from './patch';
export type { GovernanceDecision, GovernanceProposal, GovernanceReceipt, GovernanceGate } from '@stackbilt/types';

export interface StampOptions {
dryRun: boolean;
Expand Down Expand Up @@ -89,6 +92,71 @@ export async function applyPolicies(repoPath: string, opts: StampOptions): Promi
return { config, pinsPatched, workflowsPatched, supplyChainWorkflowAdded, charterConfigUpdated, alreadyCompliant };
}

// ============================================================================
// Authority-Gated Governance implementation (#200)
// ============================================================================

/**
* Extends GovernanceProposal with the repo path needed to replay the commit.
* The gate stores this internally — callers receive the base GovernanceProposal
* shape and pass it back to commit() opaquely.
*/
export interface PolicyGovernanceProposal extends GovernanceProposal {
/** Absolute repo path — carried so commit() can re-evaluate against the same target. */
readonly repoPath: string;
}

/**
* Implements GovernanceGate<string> for supply-chain policy stamping.
*
* Enforces the propose→gate→commit invariant:
* - propose() runs detection + dry-run; never writes files.
* - commit('approve'|'override') applies the stamping. 'dismiss' is a no-op.
* - Every commit() emits a GovernanceReceipt regardless of decision.
*
* Usage:
* const gate = new PolicyGovernanceGate({ fixPins: true, policyRepoRef: sha });
* const proposal = await gate.propose('./my-repo');
* if (!proposal.alreadyCompliant) {
* const receipt = await gate.commit(proposal, 'approve');
* }
*/
export class PolicyGovernanceGate implements GovernanceGate<string, PolicyGovernanceProposal> {
constructor(private readonly opts: Omit<StampOptions, 'dryRun'>) {}

async propose(repoPath: string): Promise<PolicyGovernanceProposal> {
const result = await applyPolicies(repoPath, { ...this.opts, dryRun: true });
const delta = buildPolicyDelta(result);
const id = crypto
.createHash('sha256')
.update(path.resolve(repoPath) + '\n' + delta.join('\n'))
.digest('hex')
.slice(0, 16);
return { id, alreadyCompliant: result.alreadyCompliant, delta, repoPath };
}

async commit(proposal: PolicyGovernanceProposal, decision: GovernanceDecision): Promise<GovernanceReceipt> {
if (decision !== 'dismiss') {
await applyPolicies(proposal.repoPath, { ...this.opts, dryRun: false });
}
return { proposalId: proposal.id, decision, committedAt: Date.now() };
}
}

function buildPolicyDelta(result: PolicyStampResult): string[] {
const delta: string[] = [];
if (result.supplyChainWorkflowAdded) {
delta.push('add .github/workflows/supply-chain.yml');
}
if (result.pinsPatched > 0) {
delta.push(`patch ${result.pinsPatched} floating action pin(s) in: ${result.workflowsPatched.join(', ')}`);
}
if (result.charterConfigUpdated) {
delta.push('update .charter/ (drift pattern + config)');
}
return delta;
}

function charterConfigHasYamlDrift(configFile: string): boolean {
if (!fs.existsSync(configFile)) return false;
try {
Expand Down
3 changes: 3 additions & 0 deletions packages/policies/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,8 @@
"outDir": "./dist",
"rootDir": "./src"
},
"references": [
{ "path": "../types" }
],
"include": ["src/**/*.ts"]
}
55 changes: 55 additions & 0 deletions packages/types/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -343,3 +343,58 @@ export interface DriftReport {
scannedPatterns: number;
timestamp: string;
}

// ============================================================================
// Authority-Gated Governance Contract (#200)
//
// Formalizes the propose→gate→commit invariant that emerges across governed
// systems. The pattern has appeared independently in charter (dryRun flag on
// applyPolicies) and colonyos (override_decision / autonomy_ceiling). Naming
// and contracting it here makes the invariants enforceable at type-check time.
//
// Invariants:
// 1. propose() is always called before commit() — the gate cannot be bypassed
// 2. propose() is idempotent for the same input state
// 3. commit('dismiss') leaves state unchanged
// 4. Every commit() emits a GovernanceReceipt — auditability is non-optional
// 5. Autonomy ceiling is externally set; implementations must never self-grant
// ============================================================================

export type GovernanceDecision = 'approve' | 'override' | 'dismiss';

export interface GovernanceProposal {
/** Stable, deterministic ID for the same input state. */
readonly id: string;
/** True when the target is already in the desired state — commit would be a no-op. */
readonly alreadyCompliant: boolean;
/** Human-readable description of what would change on approve/override. */
readonly delta: readonly string[];
/** Optional Unix timestamp (ms) after which the proposal should be re-evaluated. */
readonly expires?: number;
}

export interface GovernanceReceipt {
readonly proposalId: string;
readonly decision: GovernanceDecision;
/** Unix timestamp (ms) when the commit was executed. */
readonly committedAt: number;
}

/**
* Authority-gated governance contract.
*
* @typeParam Context - Input to propose() that scopes the evaluation (e.g. a repo path).
* @typeParam P - Concrete proposal type; defaults to GovernanceProposal. Implementations
* may extend GovernanceProposal to carry context needed for the commit phase.
*/
export interface GovernanceGate<Context, P extends GovernanceProposal = GovernanceProposal> {
/** Phase 1: evaluate without committing. Must be idempotent for the same input state. */
propose(context: Context): Promise<P>;
/**
* Phase 2: authorized actor commits a proposal.
* - 'approve': apply the proposed changes
* - 'override': apply despite compliance (force re-stamp)
* - 'dismiss': leave state unchanged; receipt still emitted
*/
commit(proposal: P, decision: GovernanceDecision): Promise<GovernanceReceipt>;
}
6 changes: 5 additions & 1 deletion pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading