From 989483e6943318695b019ee091b6289f883b12fd Mon Sep 17 00:00:00 2001 From: Kurt Overmier Date: Thu, 11 Jun 2026 18:05:24 -0500 Subject: [PATCH] =?UTF-8?q?feat(contracts):=20authority-gated=20governance?= =?UTF-8?q?=20=E2=80=94=20GovernanceGate=20contract=20(#200)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Names and contracts the propose→gate→commit invariant that was emerging as an unnamed dryRun pattern across charter and colonyos. Makes the invariants enforceable at type-check time rather than by convention. Types in @stackbilt/types (generic, reusable): - GovernanceDecision = 'approve' | 'override' | 'dismiss' - GovernanceProposal — id (deterministic), alreadyCompliant, delta, expires? - GovernanceReceipt — proposalId, decision, committedAt - GovernanceGate — propose + commit Implementation in @stackbilt/policies: - PolicyGovernanceProposal extends GovernanceProposal (adds repoPath for commit replay without re-passing context through the interface) - PolicyGovernanceGate implements GovernanceGate wrapping applyPolicies; propose() never writes, commit('dismiss') is a no-op, proposal id is a sha256 of resolved repoPath + delta Invariants verified by 6 new tests: - propose() does not write files - propose() is idempotent (same state → same id) - commit('approve') writes and emits receipt - commit('dismiss') does not write, still emits receipt - commit('override') applies even when alreadyCompliant - alreadyCompliant proposals have empty delta Closes #200 Co-Authored-By: Claude Sonnet 4.6 --- packages/policies/package.json | 4 +- .../policies/src/__tests__/policies.test.ts | 102 +++++++++++++++++- packages/policies/src/index.ts | 68 ++++++++++++ packages/policies/tsconfig.json | 3 + packages/types/src/index.ts | 55 ++++++++++ pnpm-lock.yaml | 6 +- 6 files changed, 234 insertions(+), 4 deletions(-) diff --git a/packages/policies/package.json b/packages/policies/package.json index b0d5fd9..039fadc 100644 --- a/packages/policies/package.json +++ b/packages/policies/package.json @@ -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" }, diff --git a/packages/policies/src/__tests__/policies.test.ts b/packages/policies/src/__tests__/policies.test.ts index 3bad8fb..bea93ac 100644 --- a/packages/policies/src/__tests__/policies.test.ts +++ b/packages/policies/src/__tests__/policies.test.ts @@ -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 @@ -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`, @@ -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); + }); +}); diff --git a/packages/policies/src/index.ts b/packages/policies/src/index.ts index 8bcdc0b..7cd8055 100644 --- a/packages/policies/src/index.ts +++ b/packages/policies/src/index.ts @@ -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; @@ -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 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 { + constructor(private readonly opts: Omit) {} + + async propose(repoPath: string): Promise { + 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 { + 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 { diff --git a/packages/policies/tsconfig.json b/packages/policies/tsconfig.json index 98f36d9..c54e979 100644 --- a/packages/policies/tsconfig.json +++ b/packages/policies/tsconfig.json @@ -4,5 +4,8 @@ "outDir": "./dist", "rootDir": "./src" }, + "references": [ + { "path": "../types" } + ], "include": ["src/**/*.ts"] } diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 89f7535..cc2a7e7 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -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 { + /** Phase 1: evaluate without committing. Must be idempotent for the same input state. */ + propose(context: Context): Promise

; + /** + * 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; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0868f46..6fe6186 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -104,7 +104,11 @@ importers: specifier: workspace:* version: link:../types - packages/policies: {} + packages/policies: + dependencies: + '@stackbilt/types': + specifier: workspace:* + version: link:../types packages/surface: dependencies: