/** * Aspect approvals — operator consent for a module's base-module aspect. * * Per openspec/specs/base-module-aspects/spec.md D2: when an operator imports a module that * declares a `base_module_aspect`, they consent to that aspect's * scope (applicable_zones + triggers) ONCE at import time. The * consent is recorded in the `aspect_approvals` table and consulted * before any aspect fan-out. * * D7 adds upgrade-scope detection: if a module version upgrade * changes `applicable_zones` or `triggers`, the new version's * scope_hash will differ from the prior approval's, signaling the * framework that re-approval is required. Aspect-content-only * changes (e.g., the Ansible role file got updated) DON'T change * the scope_hash; they're allowed without re-prompting. * * The scope hash is a stable SHA-256 over the sorted JSON of the * scope fields. Sorting matters: `[a, b]` and `[b, a]` should yield * the same hash because zone order and trigger order don't carry * semantic meaning. */ import { createHash, randomUUID } from 'node:crypto'; import { and, desc, eq, sql } from 'drizzle-orm'; import type { getDb } from '../db/client'; import { aspectApprovals } from '../db/schema'; import type { BaseModuleAspect } from '../manifest/schema'; type DbClient = ReturnType; /** * Compute a stable SHA-256 hash of the aspect's scope fields. * * The hash covers `applicable_zones` and `triggers` only — these * are the fields the operator consents to. `ansible_role` is NOT * part of the scope (a module author updating the role content * within the approved zone set is normal evolution, captured by * `on_aspect_change` rather than re-approval). * * Sorted so order doesn't affect the hash. */ export function computeAspectScopeHash(aspect: BaseModuleAspect): string { const sortedZones = [...aspect.applicable_zones].sort(); const sortedTriggers = [...aspect.triggers].sort(); const canonical = JSON.stringify({ applicable_zones: sortedZones, triggers: sortedTriggers, }); return createHash('sha256').update(canonical).digest('hex'); } /** * Look up an existing approval for a (moduleId, version) pair. * Returns the row if present, undefined otherwise. */ export function findAspectApproval( moduleId: string, version: string, db: DbClient, ): typeof aspectApprovals.$inferSelect | undefined { return db .select() .from(aspectApprovals) .where(and(eq(aspectApprovals.moduleId, moduleId), eq(aspectApprovals.version, version))) .get(); } /** * Find the most recent consent row for a (moduleId, scopeHash) pair across ALL * versions. Used to carry an operator's decision forward when a module version * bump leaves the aspect scope (applicable_zones + triggers) unchanged — so a * `module update` auto-revision doesn't force re-consent (#262). The latest * decision (by approvedAt) for that scope wins. */ export function findAspectApprovalByScope( moduleId: string, scopeHash: string, db: DbClient, ): typeof aspectApprovals.$inferSelect | undefined { return db .select() .from(aspectApprovals) .where(and(eq(aspectApprovals.moduleId, moduleId), eq(aspectApprovals.scopeHash, scopeHash))) .orderBy(desc(aspectApprovals.approvedAt)) .get(); } /** * Record the operator's consent DECISION for a module version's * base-module aspect — `consented: true` (approve) or `false` * (refuse, ISS-0027). Upserts on (moduleId, version): a later * decision (e.g. a denial flipped to approval via * `import --accept-aspects`, or a re-decision after a scope change) * overwrites the prior one, keeping exactly one row per * (module, version) reflecting the latest scope + decision. * * @param approver - operator identifier (e.g., $USER). Null when the * decision was made in a context with no USER, or automated. */ export function recordAspectConsent(args: { moduleId: string; version: string; scopeHash: string; approver: string | null; consented: boolean; db: DbClient; }): typeof aspectApprovals.$inferSelect { args.db .insert(aspectApprovals) .values({ id: randomUUID(), moduleId: args.moduleId, version: args.version, scopeHash: args.scopeHash, consented: args.consented, approver: args.approver, }) .onConflictDoUpdate({ target: [aspectApprovals.moduleId, aspectApprovals.version], set: { scopeHash: args.scopeHash, consented: args.consented, approver: args.approver, approvedAt: sql`(unixepoch())`, }, }) .run(); return findAspectApproval( args.moduleId, args.version, args.db, ) as typeof aspectApprovals.$inferSelect; } /** * Record operator APPROVAL (consent = true). Thin wrapper over * `recordAspectConsent` for the import path (`--accept-aspects`). */ export function recordAspectApproval(args: { moduleId: string; version: string; scopeHash: string; approver: string | null; db: DbClient; }): typeof aspectApprovals.$inferSelect { return recordAspectConsent({ ...args, consented: true }); } /** * Check the operator's consent state for the current (moduleId, * version) against the manifest's declared aspect scope. Returns: * - 'approved': a matching-scope row with `consented: true` — run. * - 'denied': a matching-scope row with `consented: false` — the * operator explicitly refused (ISS-0027). Skip; do NOT re-prompt. * - 'scope_changed': a row exists for THIS version but the manifest's scope * differs (D7 — the prior decision was about a different scope; re-prompt). * - 'no_approval': no row for this version AND no prior decision for this * scope at any version — undecided; prompt. * * #262: when there's no row for the exact version, the operator's decision is * carried forward by SCOPE. A pure version bump (e.g. a `module update` * auto-revision) that leaves applicable_zones + triggers unchanged must NOT * re-prompt — that re-prompt, unanswerable headlessly, is what hung the * ISS-0156 cutover. A genuine scope change has a different scopeHash, so it * finds no match and correctly falls through to 'no_approval'. */ export function checkAspectApproval( moduleId: string, version: string, aspect: BaseModuleAspect, db: DbClient, ): 'approved' | 'denied' | 'scope_changed' | 'no_approval' { const currentHash = computeAspectScopeHash(aspect); const exact = findAspectApproval(moduleId, version, db); if (exact) { if (exact.scopeHash !== currentHash) return 'scope_changed'; return exact.consented ? 'approved' : 'denied'; } // No row for this exact version — carry the decision forward by scope (#262). const byScope = findAspectApprovalByScope(moduleId, currentHash, db); if (byScope) return byScope.consented ? 'approved' : 'denied'; return 'no_approval'; }