From 07d30484a23644008eeb68794b31620b9a546c0b Mon Sep 17 00:00:00 2001 From: tuanadr Date: Wed, 20 May 2026 18:16:59 +0700 Subject: [PATCH] Add project role delegation guard --- project-role-delegation-guard/README.md | 14 ++ .../acceptance-notes.md | 6 + .../demo-output/approval-checklist.md | 11 + .../demo-output/delegation-review-packet.json | 98 ++++++++ .../demo-output/demo.mp4 | Bin 0 -> 41673 bytes .../demo-output/demo.svg | 12 + project-role-delegation-guard/demo.js | 74 ++++++ project-role-delegation-guard/index.js | 223 ++++++++++++++++++ .../requirements-map.md | 11 + project-role-delegation-guard/sample-data.js | 126 ++++++++++ project-role-delegation-guard/test.js | 57 +++++ 11 files changed, 632 insertions(+) create mode 100644 project-role-delegation-guard/README.md create mode 100644 project-role-delegation-guard/acceptance-notes.md create mode 100644 project-role-delegation-guard/demo-output/approval-checklist.md create mode 100644 project-role-delegation-guard/demo-output/delegation-review-packet.json create mode 100644 project-role-delegation-guard/demo-output/demo.mp4 create mode 100644 project-role-delegation-guard/demo-output/demo.svg create mode 100644 project-role-delegation-guard/demo.js create mode 100644 project-role-delegation-guard/index.js create mode 100644 project-role-delegation-guard/requirements-map.md create mode 100644 project-role-delegation-guard/sample-data.js create mode 100644 project-role-delegation-guard/test.js diff --git a/project-role-delegation-guard/README.md b/project-role-delegation-guard/README.md new file mode 100644 index 00000000..973de0d4 --- /dev/null +++ b/project-role-delegation-guard/README.md @@ -0,0 +1,14 @@ +# Project Role Delegation Guard + +This self-contained slice reviews temporary delegated authority before sensitive project actions such as publication, restricted-data export, archive freeze, and role transfer. + +The guard checks sponsor approval, separation of duties, identity posture, object-level scope, and expiry. It produces deterministic reviewer packets so project owners can approve or block delegated actions without relying on ad hoc comments. + +## Run locally + +```bash +node project-role-delegation-guard/test.js +node project-role-delegation-guard/demo.js +``` + +Demo outputs are written to `project-role-delegation-guard/demo-output/`. diff --git a/project-role-delegation-guard/acceptance-notes.md b/project-role-delegation-guard/acceptance-notes.md new file mode 100644 index 00000000..d604c8c6 --- /dev/null +++ b/project-role-delegation-guard/acceptance-notes.md @@ -0,0 +1,6 @@ +# Acceptance Notes + +- The module is dependency-free and uses synthetic data only. +- Tests cover blocked sensitive delegations, deterministic packet ordering, and a ready case with a near-expiry warning. +- Demo artifacts include JSON, Markdown, SVG, and MP4 output. +- The slice is intentionally separate from previous #11 submissions around broad RBAC ledgers, offboarding, access recertification, anonymous review, data-room consent, project archive handoff, profile sync, and audit anomaly monitoring. diff --git a/project-role-delegation-guard/demo-output/approval-checklist.md b/project-role-delegation-guard/demo-output/approval-checklist.md new file mode 100644 index 00000000..48cd7190 --- /dev/null +++ b/project-role-delegation-guard/demo-output/approval-checklist.md @@ -0,0 +1,11 @@ +# Delegation approval checklist + +Workspace: project-neuro-catalyst +Status: hold + +- del-archive-5: Narrow the object-level delegation scope before approving the action. +- del-export-7: Block the delegation until MFA, SAML, and ORCID posture requirements are satisfied. +- del-publish-2: Record sponsor approval or route the action back to the project owner. +- del-transfer-3: Route approval to an independent owner or institutional sponsor. + +Audit digest: eb75b6a2db4222fa1c946655d7ea70920e1685e0ed6e5303d829ea16dc51b0ed diff --git a/project-role-delegation-guard/demo-output/delegation-review-packet.json b/project-role-delegation-guard/demo-output/delegation-review-packet.json new file mode 100644 index 00000000..02f4912c --- /dev/null +++ b/project-role-delegation-guard/demo-output/delegation-review-packet.json @@ -0,0 +1,98 @@ +{ + "packetType": "project-role-delegation-guard", + "workspaceId": "project-neuro-catalyst", + "generatedAt": "2026-05-20T12:00:00Z", + "overallStatus": "hold", + "decisions": [ + { + "requestId": "del-publish-2", + "action": "publish_release", + "delegateId": "delegate-2", + "decision": "needs_sponsor", + "expiresAt": "2026-06-20T00:00:00Z" + }, + { + "requestId": "del-export-7", + "action": "restricted_data_export", + "delegateId": "delegate-7", + "decision": "block", + "expiresAt": "2026-06-10T00:00:00Z" + }, + { + "requestId": "del-transfer-3", + "action": "role_transfer", + "delegateId": "delegate-2", + "decision": "block", + "expiresAt": "2026-06-15T00:00:00Z" + }, + { + "requestId": "del-archive-5", + "action": "archive_freeze", + "delegateId": "delegate-9", + "decision": "block", + "expiresAt": "2026-06-15T00:00:00Z" + } + ], + "holds": [ + { + "requestId": "del-archive-5", + "code": "delegated_scope_exceeds_action", + "owner": "delegate-9", + "severity": "blocker", + "evidence": "del-archive-5 includes excess scope(s): repository:admin.", + "requiredAction": "Narrow the object-level delegation scope before approving the action." + }, + { + "requestId": "del-export-7", + "code": "identity_posture_gap", + "owner": "delegate-7", + "severity": "blocker", + "evidence": "Export Contractor is missing mfa, orcid for restricted_data_export.", + "requiredAction": "Block the delegation until MFA, SAML, and ORCID posture requirements are satisfied." + }, + { + "requestId": "del-publish-2", + "code": "missing_sponsor_approval", + "owner": "owner-1", + "severity": "blocker", + "evidence": "publish_release requires sponsor approval before delegate-2 can act.", + "requiredAction": "Record sponsor approval or route the action back to the project owner." + }, + { + "requestId": "del-transfer-3", + "code": "separation_of_duties_violation", + "owner": "delegate-2", + "severity": "blocker", + "evidence": "delegate-2 cannot both request/delegate and approve role_transfer.", + "requiredAction": "Route approval to an independent owner or institutional sponsor." + } + ], + "warnings": [], + "approvalChecklist": [ + { + "requestId": "del-archive-5", + "owner": "delegate-9", + "requiredAction": "Narrow the object-level delegation scope before approving the action.", + "evidence": "del-archive-5 includes excess scope(s): repository:admin." + }, + { + "requestId": "del-export-7", + "owner": "delegate-7", + "requiredAction": "Block the delegation until MFA, SAML, and ORCID posture requirements are satisfied.", + "evidence": "Export Contractor is missing mfa, orcid for restricted_data_export." + }, + { + "requestId": "del-publish-2", + "owner": "owner-1", + "requiredAction": "Record sponsor approval or route the action back to the project owner.", + "evidence": "publish_release requires sponsor approval before delegate-2 can act." + }, + { + "requestId": "del-transfer-3", + "owner": "delegate-2", + "requiredAction": "Route approval to an independent owner or institutional sponsor.", + "evidence": "delegate-2 cannot both request/delegate and approve role_transfer." + } + ], + "auditDigest": "eb75b6a2db4222fa1c946655d7ea70920e1685e0ed6e5303d829ea16dc51b0ed" +} diff --git a/project-role-delegation-guard/demo-output/demo.mp4 b/project-role-delegation-guard/demo-output/demo.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..41bb123ee80d8dbc9fcf6c98fa82201421dc368a GIT binary patch literal 41673 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 literal 0 HcmV?d00001 diff --git a/project-role-delegation-guard/demo-output/demo.svg b/project-role-delegation-guard/demo-output/demo.svg new file mode 100644 index 00000000..de0e1e4c --- /dev/null +++ b/project-role-delegation-guard/demo-output/demo.svg @@ -0,0 +1,12 @@ + + + + Project Role Delegation Guard + project-neuro-catalyst + 4 sensitive delegation holds + 1. del-archive-5: delegated_scope_exceeds_action + 2. del-export-7: identity_posture_gap + 3. del-publish-2: missing_sponsor_approval + 4. del-transfer-3: separation_of_duties_violation + audit digest: eb75b6a2db4222fa1c946655... + diff --git a/project-role-delegation-guard/demo.js b/project-role-delegation-guard/demo.js new file mode 100644 index 00000000..ea8eba81 --- /dev/null +++ b/project-role-delegation-guard/demo.js @@ -0,0 +1,74 @@ +const fs = require('fs'); +const path = require('path'); +const { execFileSync } = require('child_process'); +const { buildDelegationReviewPacket } = require('./index'); +const { sampleWorkspace } = require('./sample-data'); + +const outputDir = path.join(__dirname, 'demo-output'); +fs.mkdirSync(outputDir, { recursive: true }); + +const packet = buildDelegationReviewPacket(sampleWorkspace, { + asOf: '2026-05-20T12:00:00Z', +}); + +fs.writeFileSync(path.join(outputDir, 'delegation-review-packet.json'), `${JSON.stringify(packet, null, 2)}\n`); + +const rows = packet.holds + .map((hold, index) => `${index + 1}. ${hold.requestId}: ${hold.code}`) + .join('\n '); + +fs.writeFileSync(path.join(outputDir, 'demo.svg'), ` + + + Project Role Delegation Guard + ${packet.workspaceId} + ${packet.holds.length} sensitive delegation holds + ${rows} + audit digest: ${packet.auditDigest.slice(0, 24)}... + +`); + +fs.writeFileSync(path.join(outputDir, 'approval-checklist.md'), [ + '# Delegation approval checklist', + '', + `Workspace: ${packet.workspaceId}`, + `Status: ${packet.overallStatus}`, + '', + ...packet.approvalChecklist.map((item) => `- ${item.requestId}: ${item.requiredAction}`), + '', + `Audit digest: ${packet.auditDigest}`, + '', +].join('\n')); + +function renderMp4() { + const videoPath = path.join(outputDir, 'demo.mp4'); + const font = 'C\\:/Windows/Fonts/arial.ttf'; + const escapeText = (value) => String(value).replace(/\\/g, '\\\\').replace(/:/g, '\\:').replace(/'/g, "\\'"); + const filters = [ + `drawtext=fontfile='${font}':text='${escapeText('Project Role Delegation Guard')}':x=70:y=80:fontsize=44:fontcolor=white`, + `drawtext=fontfile='${font}':text='${escapeText(`${packet.holds.length} approval blockers found`)}':x=70:y=155:fontsize=34:fontcolor=0xffd166`, + ...packet.holds.map((hold, index) => + `drawtext=fontfile='${font}':text='${escapeText(`${hold.requestId}: ${hold.code}`)}':x=90:y=${235 + index * 58}:fontsize=28:fontcolor=white`, + ), + `drawtext=fontfile='${font}':text='${escapeText(`audit ${packet.auditDigest.slice(0, 20)}...`)}':x=70:y=630:fontsize=24:fontcolor=0x93c5fd`, + ].join(','); + + execFileSync('ffmpeg', [ + '-y', + '-f', + 'lavfi', + '-i', + 'color=c=0x0b1320:s=1280x720:d=7', + '-vf', + filters, + '-c:v', + 'libx264', + '-pix_fmt', + 'yuv420p', + videoPath, + ], { stdio: 'inherit' }); +} + +renderMp4(); + +console.log(`Wrote demo artifacts to ${outputDir}`); diff --git a/project-role-delegation-guard/index.js b/project-role-delegation-guard/index.js new file mode 100644 index 00000000..a5f34fba --- /dev/null +++ b/project-role-delegation-guard/index.js @@ -0,0 +1,223 @@ +const crypto = require('crypto'); + +const SENSITIVE_ACTIONS = new Set([ + 'publish_release', + 'restricted_data_export', + 'archive_freeze', + 'role_transfer', +]); + +const ALLOWED_SCOPES_BY_ACTION = { + publish_release: new Set(['manuscript:approve', 'metadata:update', 'release:submit']), + restricted_data_export: new Set(['dataset:read', 'export:restricted']), + archive_freeze: new Set(['archive:freeze', 'citation:snapshot']), + role_transfer: new Set(['role:transfer']), +}; + +function stableStringify(value) { + if (Array.isArray(value)) { + return `[${value.map(stableStringify).join(',')}]`; + } + if (value && typeof value === 'object') { + return `{${Object.keys(value) + .sort() + .map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`) + .join(',')}}`; + } + return JSON.stringify(value); +} + +function digest(value) { + return crypto.createHash('sha256').update(stableStringify(value)).digest('hex'); +} + +function toTime(value) { + return new Date(value).getTime(); +} + +function daysUntil(value, asOf) { + return Math.ceil((toTime(value) - toTime(asOf)) / 86_400_000); +} + +function addHold(holds, request, code, owner, evidence, requiredAction) { + holds.push({ + requestId: request.requestId, + code, + owner, + severity: 'blocker', + evidence, + requiredAction, + }); +} + +function addWarning(warnings, request, code, owner, evidence, requiredAction) { + warnings.push({ + requestId: request.requestId, + code, + owner, + severity: 'warning', + evidence, + requiredAction, + }); +} + +function evaluateRequest(request, workspace, options) { + const usersById = new Map(workspace.users.map((user) => [user.userId, user])); + const delegate = usersById.get(request.delegateId); + const holds = []; + const warnings = []; + const sensitive = SENSITIVE_ACTIONS.has(request.action); + + if (toTime(request.expiresAt) < toTime(options.asOf)) { + addHold( + holds, + request, + 'delegation_expired', + request.delegateId, + `${request.requestId} expired on ${request.expiresAt}.`, + 'Create a new delegation request before approving the sensitive action.', + ); + } else if (daysUntil(request.expiresAt, options.asOf) <= workspace.policy.nearExpiryWarningDays) { + addWarning( + warnings, + request, + 'delegation_near_expiry', + request.delegateId, + `${request.requestId} expires in ${daysUntil(request.expiresAt, options.asOf)} days.`, + 'Confirm the delegated authority will still be valid when the action executes.', + ); + } + + if (sensitive && !request.sponsorApproval) { + addHold( + holds, + request, + 'missing_sponsor_approval', + request.sponsorId, + `${request.action} requires sponsor approval before ${request.delegateId} can act.`, + 'Record sponsor approval or route the action back to the project owner.', + ); + } + + if (request.requiresIdentityPosture) { + const missing = request.requiresIdentityPosture.filter((flag) => !delegate.identity[flag]); + if (missing.length > 0) { + addHold( + holds, + request, + 'identity_posture_gap', + request.delegateId, + `${delegate.name} is missing ${missing.join(', ')} for ${request.action}.`, + 'Block the delegation until MFA, SAML, and ORCID posture requirements are satisfied.', + ); + } + } + + if (sensitive && (request.approverId === request.requestorId || request.approverId === request.delegateId)) { + addHold( + holds, + request, + 'separation_of_duties_violation', + request.approverId, + `${request.approverId} cannot both request/delegate and approve ${request.action}.`, + 'Route approval to an independent owner or institutional sponsor.', + ); + } + + const allowedScopes = ALLOWED_SCOPES_BY_ACTION[request.action] || new Set(); + const extraScopes = request.scopes.filter((scope) => !allowedScopes.has(scope)); + if (extraScopes.length > 0) { + addHold( + holds, + request, + 'delegated_scope_exceeds_action', + request.delegateId, + `${request.requestId} includes excess scope(s): ${extraScopes.join(', ')}.`, + 'Narrow the object-level delegation scope before approving the action.', + ); + } + + const holdCodes = new Set(holds.map((hold) => hold.code)); + const decision = holdCodes.has('missing_sponsor_approval') && holds.length === 1 + ? 'needs_sponsor' + : holds.length > 0 + ? 'block' + : 'approve'; + + return { + decision: { + requestId: request.requestId, + action: request.action, + delegateId: request.delegateId, + decision, + expiresAt: request.expiresAt, + }, + holds, + warnings, + }; +} + +function evaluateDelegationRequests(workspace, options = {}) { + const evaluationOptions = { + asOf: options.asOf || new Date().toISOString(), + }; + const decisions = []; + const holds = []; + const warnings = []; + + for (const request of workspace.delegationRequests) { + const result = evaluateRequest(request, workspace, evaluationOptions); + decisions.push(result.decision); + holds.push(...result.holds); + warnings.push(...result.warnings); + } + + const sortedHolds = holds.sort((left, right) => left.code.localeCompare(right.code)); + const sortedWarnings = warnings.sort((left, right) => left.code.localeCompare(right.code)); + + return { + workspaceId: workspace.workspaceId, + overallStatus: sortedHolds.length > 0 ? 'hold' : 'ready', + summary: { + requestsReviewed: workspace.delegationRequests.length, + blockingHolds: sortedHolds.length, + warnings: sortedWarnings.length, + approved: decisions.filter((decision) => decision.decision === 'approve').length, + blocked: decisions.filter((decision) => decision.decision === 'block').length, + }, + decisions, + holds: sortedHolds, + warnings: sortedWarnings, + }; +} + +function buildDelegationReviewPacket(workspace, options = {}) { + const review = evaluateDelegationRequests(workspace, options); + const packet = { + packetType: 'project-role-delegation-guard', + workspaceId: review.workspaceId, + generatedAt: options.asOf || new Date().toISOString(), + overallStatus: review.overallStatus, + decisions: review.decisions, + holds: review.holds, + warnings: review.warnings, + approvalChecklist: review.holds.map((hold) => ({ + requestId: hold.requestId, + owner: hold.owner, + requiredAction: hold.requiredAction, + evidence: hold.evidence, + })), + }; + + return { + ...packet, + auditDigest: digest(packet), + }; +} + +module.exports = { + evaluateDelegationRequests, + buildDelegationReviewPacket, + stableStringify, + digest, +}; diff --git a/project-role-delegation-guard/requirements-map.md b/project-role-delegation-guard/requirements-map.md new file mode 100644 index 00000000..f0611fc7 --- /dev/null +++ b/project-role-delegation-guard/requirements-map.md @@ -0,0 +1,11 @@ +# Requirements Map + +Issue #11 asks for user and project management: profiles, roles, access controls, project visibility, invitation workflows, audit trails, and administrative safety. + +| Requirement area | Coverage in this slice | +| --- | --- | +| User identity | Checks MFA, SAML, and ORCID posture before delegated actions. | +| Project roles | Evaluates delegated authority for publication, export, archive, and role transfer actions. | +| Access control | Enforces action-specific object scopes and blocks over-broad delegated scopes. | +| Auditability | Produces deterministic review packets, approval checklists, and SHA-256 audit digests. | +| Project governance | Requires sponsor approval and separation of duties for sensitive project workflows. | diff --git a/project-role-delegation-guard/sample-data.js b/project-role-delegation-guard/sample-data.js new file mode 100644 index 00000000..29599439 --- /dev/null +++ b/project-role-delegation-guard/sample-data.js @@ -0,0 +1,126 @@ +const sampleWorkspace = { + workspaceId: 'project-neuro-catalyst', + policy: { + nearExpiryWarningDays: 7, + }, + users: [ + { + userId: 'owner-1', + name: 'Dr. Lina Owner', + identity: { mfa: true, saml: true, orcid: true }, + }, + { + userId: 'delegate-2', + name: 'Postdoc Delegate', + identity: { mfa: true, saml: true, orcid: true }, + }, + { + userId: 'delegate-7', + name: 'Export Contractor', + identity: { mfa: false, saml: true, orcid: false }, + }, + { + userId: 'delegate-9', + name: 'Archive Assistant', + identity: { mfa: true, saml: true, orcid: true }, + }, + { + userId: 'approver-3', + name: 'Independent Project Approver', + identity: { mfa: true, saml: true, orcid: true }, + }, + ], + delegationRequests: [ + { + requestId: 'del-publish-2', + action: 'publish_release', + requestorId: 'owner-1', + delegateId: 'delegate-2', + approverId: 'approver-3', + sponsorId: 'owner-1', + sponsorApproval: false, + scopes: ['manuscript:approve', 'metadata:update', 'release:submit'], + expiresAt: '2026-06-20T00:00:00Z', + requiresIdentityPosture: ['mfa', 'saml', 'orcid'], + }, + { + requestId: 'del-export-7', + action: 'restricted_data_export', + requestorId: 'owner-1', + delegateId: 'delegate-7', + approverId: 'approver-3', + sponsorId: 'owner-1', + sponsorApproval: true, + scopes: ['dataset:read', 'export:restricted'], + expiresAt: '2026-06-10T00:00:00Z', + requiresIdentityPosture: ['mfa', 'saml', 'orcid'], + }, + { + requestId: 'del-transfer-3', + action: 'role_transfer', + requestorId: 'delegate-2', + delegateId: 'delegate-2', + approverId: 'delegate-2', + sponsorId: 'owner-1', + sponsorApproval: true, + scopes: ['role:transfer'], + expiresAt: '2026-06-15T00:00:00Z', + requiresIdentityPosture: ['mfa', 'saml'], + }, + { + requestId: 'del-archive-5', + action: 'archive_freeze', + requestorId: 'owner-1', + delegateId: 'delegate-9', + approverId: 'approver-3', + sponsorId: 'owner-1', + sponsorApproval: true, + scopes: ['archive:freeze', 'citation:snapshot', 'repository:admin'], + expiresAt: '2026-06-15T00:00:00Z', + requiresIdentityPosture: ['mfa', 'saml'], + }, + ], +}; + +const compliantWorkspace = { + workspaceId: 'project-clean-delegation', + policy: { + nearExpiryWarningDays: 7, + }, + users: [ + { + userId: 'owner-5', + name: 'Dr. Maya Sponsor', + identity: { mfa: true, saml: true, orcid: true }, + }, + { + userId: 'delegate-5', + name: 'Release Delegate', + identity: { mfa: true, saml: true, orcid: true }, + }, + { + userId: 'approver-8', + name: 'Independent Approver', + identity: { mfa: true, saml: true, orcid: true }, + }, + ], + delegationRequests: [ + { + requestId: 'del-clean-1', + action: 'publish_release', + requestorId: 'owner-5', + delegateId: 'delegate-5', + approverId: 'approver-8', + sponsorId: 'owner-5', + sponsorApproval: true, + scopes: ['manuscript:approve', 'metadata:update', 'release:submit'], + expiresAt: '2026-05-26T00:00:00Z', + requiresIdentityPosture: ['mfa', 'saml', 'orcid'], + }, + ], +}; + +module.exports = { + sampleWorkspace, + compliantWorkspace, +}; diff --git a/project-role-delegation-guard/test.js b/project-role-delegation-guard/test.js new file mode 100644 index 00000000..00d7ab40 --- /dev/null +++ b/project-role-delegation-guard/test.js @@ -0,0 +1,57 @@ +const assert = require('assert'); +const { + evaluateDelegationRequests, + buildDelegationReviewPacket, +} = require('./index'); +const { sampleWorkspace, compliantWorkspace } = require('./sample-data'); + +function testSensitiveDelegationsAreHeld() { + const review = evaluateDelegationRequests(sampleWorkspace, { + asOf: '2026-05-20T12:00:00Z', + }); + + assert.equal(review.overallStatus, 'hold'); + assert.equal(review.summary.blockingHolds, 4); + assert.ok(review.holds.some((hold) => hold.code === 'missing_sponsor_approval')); + assert.ok(review.holds.some((hold) => hold.code === 'identity_posture_gap')); + assert.ok(review.holds.some((hold) => hold.code === 'separation_of_duties_violation')); + assert.ok(review.holds.some((hold) => hold.code === 'delegated_scope_exceeds_action')); + assert.equal(review.decisions.find((decision) => decision.requestId === 'del-export-7').decision, 'block'); + assert.equal(review.decisions.find((decision) => decision.requestId === 'del-publish-2').decision, 'needs_sponsor'); +} + +function testReviewPacketIsDeterministic() { + const packet = buildDelegationReviewPacket(sampleWorkspace, { + asOf: '2026-05-20T12:00:00Z', + }); + + assert.match(packet.auditDigest, /^[a-f0-9]{64}$/); + assert.deepEqual( + packet.holds.map((hold) => hold.code), + [ + 'delegated_scope_exceeds_action', + 'identity_posture_gap', + 'missing_sponsor_approval', + 'separation_of_duties_violation', + ], + ); + assert.equal(packet.approvalChecklist.length, 4); + assert.ok(packet.approvalChecklist.every((item) => item.requestId && item.requiredAction)); +} + +function testCompliantDelegationsPassWithExpiryWarning() { + const review = evaluateDelegationRequests(compliantWorkspace, { + asOf: '2026-05-20T12:00:00Z', + }); + + assert.equal(review.overallStatus, 'ready'); + assert.equal(review.summary.blockingHolds, 0); + assert.equal(review.warnings.length, 1); + assert.equal(review.warnings[0].code, 'delegation_near_expiry'); +} + +testSensitiveDelegationsAreHeld(); +testReviewPacketIsDeterministic(); +testCompliantDelegationsPassWithExpiryWarning(); + +console.log('project-role-delegation-guard tests passed');