The workflow JSON
Copy or download the full n8n JSON below. Paste it into a new n8n workflow, add your credentials, activate. Full import guide →
{
"name": "VANTIX Agile Delivery & Admin Workload Sentinel v1.1.3 \u2014 Public Portfolio Export",
"nodes": [
{
"parameters": {},
"id": "4aa2f63b-1857-4bb4-b777-78551a900e21",
"name": "Start: New Intake Item (Manual Demo Trigger)",
"type": "n8n-nodes-base.manualTrigger",
"typeVersion": 1,
"position": [
-2640,
64
]
},
{
"parameters": {
"jsCode": "// Deterministic synthetic intake fixture.\n// Happy-path test scenario.\n\nreturn [\n {\n json: {\n intakeId: 'INT-2026-000101',\n\n source: 'enhancement_request',\n\n title:\n 'Add validation rule to prevent blank Opportunity Close Date',\n\n description:\n 'Sales ops has reported that Opportunities are being marked Closed Won without a Close Date populated, which breaks the forecasting report. Need a validation rule on the Opportunity object.',\n\n submittedBy:\n 'sales.ops@example.com',\n\n submittedAt:\n '2026-07-20T09:00:00Z',\n\n affectedComponents: [\n 'Opportunity',\n 'Forecasting Report',\n ],\n\n businessJustificationStated:\n 'Forecasting report accuracy directly affects the weekly pipeline review with leadership.',\n\n expectedOutcomeStated:\n 'Users cannot save a Closed Won Opportunity without a Close Date.',\n\n evidenceLinks: [\n 'https://internal.example.com/tickets/SOPS-4821',\n ],\n\n dependenciesStated: [],\n\n urgencyClaimedByRequester:\n 'medium',\n\n existingOpenItems: [],\n },\n },\n];"
},
"id": "c5e1945a-cc04-44b9-b3a1-198d6d6e1d58",
"name": "Demo Intake Payload (replace with real intake form/webhook)",
"type": "n8n-nodes-base.code",
"typeVersion": 2,
"position": [
-2416,
64
],
"notes": "Synthetic fixture implemented as a Code node to avoid Edit Fields/Set node version incompatibility across n8n releases."
},
{
"parameters": {
"jsCode": "// VANTIX Agile Delivery & Admin Workload Sentinel\n// Node 01: Normalize & Validate\n// Deterministic, fail-closed intake validation\n\nconst REQUIRED = [\n 'intakeId',\n 'source',\n 'title',\n 'description',\n 'submittedBy',\n 'submittedAt',\n];\n\nconst VALID_SOURCES = [\n 'enhancement_request',\n 'incident',\n 'technical_debt',\n 'project1_governance_finding',\n];\n\nconst VALID_URGENCY_VALUES = [\n 'critical',\n 'high',\n 'medium',\n 'low',\n];\n\nconst INTAKE_ID_RE = /^INT-\\d{4}-\\d{6}$/;\nconst EMAIL_RE = /^[^\\s@]+@[^\\s@]+\\.[^\\s@]+$/;\n\nfunction isBlank(value) {\n return (\n value === undefined ||\n value === null ||\n (typeof value === 'string' && value.trim() === '')\n );\n}\n\nfunction validateStringArray(value, fieldName, errors) {\n if (value === undefined || value === null) return;\n\n if (!Array.isArray(value)) {\n errors.push(`${fieldName}_must_be_array`);\n return;\n }\n\n if (\n value.some(\n (entry) =>\n typeof entry !== 'string' ||\n entry.trim() === ''\n )\n ) {\n errors.push(`${fieldName}_contains_invalid_item`);\n }\n}\n\nfunction normalizeStringArray(value) {\n if (!Array.isArray(value)) return [];\n\n return value\n .filter(\n (entry) =>\n typeof entry === 'string' &&\n entry.trim() !== ''\n )\n .map((entry) => entry.trim());\n}\n\nfunction normalizeAndValidate(raw) {\n const errors = [];\n\n if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {\n return {\n schemaValid: false,\n routeTo: 'schema_failure',\n errors: ['invalid_intake_payload'],\n raw: raw ?? null,\n };\n }\n\n for (const field of REQUIRED) {\n if (isBlank(raw[field])) {\n errors.push(`missing_required_field:${field}`);\n }\n }\n\n const intakeId =\n typeof raw.intakeId === 'string'\n ? raw.intakeId.trim()\n : raw.intakeId;\n\n const source =\n typeof raw.source === 'string'\n ? raw.source.trim()\n : raw.source;\n\n const submittedBy =\n typeof raw.submittedBy === 'string'\n ? raw.submittedBy.trim()\n : raw.submittedBy;\n\n const urgency =\n typeof raw.urgencyClaimedByRequester === 'string'\n ? raw.urgencyClaimedByRequester.trim().toLowerCase()\n : raw.urgencyClaimedByRequester ?? null;\n\n if (intakeId && !INTAKE_ID_RE.test(intakeId)) {\n errors.push('invalid_intakeId_format');\n }\n\n if (source && !VALID_SOURCES.includes(source)) {\n errors.push(`invalid_source_enum:${source}`);\n }\n\n if (\n source === 'project1_governance_finding' &&\n isBlank(raw.sourceReference)\n ) {\n errors.push(\n 'missing_sourceReference_for_project1_finding'\n );\n }\n\n if (\n typeof raw.title === 'string' &&\n raw.title.trim().length < 5\n ) {\n errors.push('title_too_short');\n }\n\n if (\n typeof raw.description === 'string' &&\n raw.description.trim().length < 10\n ) {\n errors.push('description_too_short');\n }\n\n if (\n raw.submittedAt &&\n Number.isNaN(Date.parse(raw.submittedAt))\n ) {\n errors.push('invalid_submittedAt');\n }\n\n if (\n submittedBy &&\n !EMAIL_RE.test(submittedBy) &&\n !submittedBy.startsWith('vantix-')\n ) {\n errors.push('invalid_submittedBy');\n }\n\n if (\n urgency !== null &&\n !VALID_URGENCY_VALUES.includes(urgency)\n ) {\n errors.push(`invalid_urgency_enum:${urgency}`);\n }\n\n validateStringArray(\n raw.affectedComponents,\n 'affectedComponents',\n errors\n );\n\n validateStringArray(\n raw.evidenceLinks,\n 'evidenceLinks',\n errors\n );\n\n validateStringArray(\n raw.dependenciesStated,\n 'dependenciesStated',\n errors\n );\n\n if (errors.length > 0) {\n return {\n schemaValid: false,\n routeTo: 'schema_failure',\n errors,\n raw,\n };\n }\n\n const normalized = {\n schemaVersion: '1.0.0',\n intakeId,\n source,\n sourceReference:\n typeof raw.sourceReference === 'string'\n ? raw.sourceReference.trim()\n : raw.sourceReference ?? null,\n title: raw.title.trim(),\n description: raw.description.trim(),\n submittedBy,\n submittedAt: new Date(raw.submittedAt).toISOString(),\n affectedComponents: normalizeStringArray(\n raw.affectedComponents\n ),\n businessJustificationStated:\n typeof raw.businessJustificationStated === 'string'\n ? raw.businessJustificationStated.trim() || null\n : null,\n expectedOutcomeStated:\n typeof raw.expectedOutcomeStated === 'string'\n ? raw.expectedOutcomeStated.trim() || null\n : null,\n evidenceLinks: normalizeStringArray(\n raw.evidenceLinks\n ),\n dependenciesStated: normalizeStringArray(\n raw.dependenciesStated\n ),\n urgencyClaimedByRequester: urgency,\n };\n\n return {\n schemaValid: true,\n routeTo: 'duplicate_check',\n errors: [],\n normalized,\n };\n}\n\nreturn items.map((item) => {\n\n const result = normalizeAndValidate(item.json);\n\n if (result.schemaValid) {\n result.existingOpenItems = Array.isArray(item.json.existingOpenItems)\n ? item.json.existingOpenItems\n : [];\n }\n\n return {\n json: result\n };\n\n});"
},
"id": "cec2419a-dae2-4dd3-9afc-58158261a29d",
"name": "01: Normalize & Validate",
"type": "n8n-nodes-base.code",
"typeVersion": 1,
"position": [
-2208,
64
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 1
},
"conditions": [
{
"leftValue": "={{$json.schemaValid}}",
"rightValue": true,
"operator": {
"type": "boolean",
"operation": "true"
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "5df6f052-62d3-4d9d-be57-47da55efe466",
"name": "IF: Schema Valid?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
-1984,
64
]
},
{
"parameters": {},
"id": "dfb42758-e514-418d-8cf5-110421b3ab2c",
"name": "STOP: Schema Failure -> Human Review",
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [
-1760,
208
],
"notes": "Fail-closed: malformed intake never proceeds. Wire to a Gmail/Slack alert to the submitter + admin queue."
},
{
"parameters": {
"jsCode": "// VANTIX Agile Delivery & Admin Workload Sentinel\n// Node 02: Duplicate & Related-Work Detection\n// Deterministic, explainable and fail-closed\n\nconst DUPLICATE_THRESHOLD = 0.72;\nconst RELATED_THRESHOLD = 0.40;\n\nconst STOP_WORDS = new Set([\n 'the',\n 'and',\n 'for',\n 'with',\n 'that',\n 'this',\n 'from',\n 'into',\n 'when',\n 'where',\n 'which',\n 'should',\n 'would',\n 'could',\n 'user',\n 'users',\n 'record',\n 'records',\n 'salesforce',\n]);\n\nfunction normalizeText(value) {\n return String(value ?? '')\n .toLowerCase()\n .replace(/[^a-z0-9\\s]/g, ' ')\n .replace(/\\s+/g, ' ')\n .trim();\n}\n\nfunction tokenize(text) {\n return new Set(\n normalizeText(text)\n .split(' ')\n .filter(\n token =>\n token.length > 2 &&\n !STOP_WORDS.has(token)\n )\n );\n}\n\nfunction jaccard(setA, setB) {\n const union = new Set([...setA, ...setB]);\n\n if (union.size === 0) return 0;\n\n let intersection = 0;\n\n for (const token of setA) {\n if (setB.has(token)) intersection++;\n }\n\n return intersection / union.size;\n}\n\nfunction normalizeComponents(components) {\n if (!Array.isArray(components)) return new Set();\n\n return new Set(\n components\n .filter(\n c =>\n typeof c === 'string' &&\n c.trim() !== ''\n )\n .map(c => normalizeText(c))\n );\n}\n\nfunction hasComponentOverlap(a, b) {\n for (const component of a) {\n if (b.has(component)) return true;\n }\n return false;\n}\n\nfunction validateCandidate(candidate) {\n const errors = [];\n\n if (!candidate?.intakeId)\n errors.push('candidate_missing_intakeId');\n\n if (!candidate?.title)\n errors.push('candidate_missing_title');\n\n if (!candidate?.description)\n errors.push('candidate_missing_description');\n\n return errors;\n}\n\nfunction getExistingOpenItems(item) {\n\n // Preferred location (normalized payload)\n if (\n Array.isArray(\n item.normalized?.existingOpenItems\n )\n ) {\n return item.normalized.existingOpenItems;\n }\n\n // Backwards compatibility\n if (\n Array.isArray(item.existingOpenItems)\n ) {\n return item.existingOpenItems;\n }\n\n return [];\n}\n\nfunction detectDuplicates(candidate, existingItems) {\n\n const candidateTokens = tokenize(\n `${candidate.title} ${candidate.description}`\n );\n\n const candidateComponents =\n normalizeComponents(\n candidate.affectedComponents\n );\n\n let bestScore = 0;\n let bestMatch = null;\n\n const relatedMatches = [];\n const skipped = [];\n\n for (const existing of existingItems) {\n\n if (\n !existing ||\n typeof existing !== 'object'\n ) {\n skipped.push({\n intakeId: null,\n reason: 'invalid_existing_record'\n });\n continue;\n }\n\n if (\n !existing.intakeId ||\n !existing.title ||\n !existing.description\n ) {\n skipped.push({\n intakeId:\n existing.intakeId ?? null,\n reason:\n 'missing_required_matching_fields'\n });\n continue;\n }\n\n if (\n existing.intakeId ===\n candidate.intakeId\n ) {\n continue;\n }\n\n const existingTokens = tokenize(\n `${existing.title} ${existing.description}`\n );\n\n let score = jaccard(\n candidateTokens,\n existingTokens\n );\n\n const existingComponents =\n normalizeComponents(\n existing.affectedComponents\n );\n\n const overlap =\n hasComponentOverlap(\n candidateComponents,\n existingComponents\n );\n\n if (\n overlap &&\n candidateComponents.size > 0\n ) {\n score = Math.min(\n score + 0.1,\n 1\n );\n }\n\n const rounded =\n Number(score.toFixed(3));\n\n if (\n rounded >= RELATED_THRESHOLD\n ) {\n relatedMatches.push({\n intakeId: existing.intakeId,\n similarityScore: rounded,\n componentOverlap: overlap\n });\n }\n\n if (score > bestScore) {\n bestScore = score;\n bestMatch = existing.intakeId;\n }\n }\n\n relatedMatches.sort(\n (a, b) =>\n b.similarityScore -\n a.similarityScore\n );\n\n const roundedScore =\n Number(bestScore.toFixed(3));\n\n const isDuplicate =\n roundedScore >=\n DUPLICATE_THRESHOLD;\n\n const isRelated =\n !isDuplicate &&\n roundedScore >=\n RELATED_THRESHOLD;\n\n return {\n isDuplicate,\n isRelated,\n\n matchedIntakeIds: isDuplicate\n ? [bestMatch]\n : relatedMatches.map(\n r => r.intakeId\n ),\n\n bestMatchIntakeId: bestMatch,\n\n similarityScore:\n roundedScore,\n\n relatedMatches,\n\n recordsEvaluated:\n existingItems.length,\n\n recordsSkipped: skipped,\n\n thresholdsUsed: {\n duplicate:\n DUPLICATE_THRESHOLD,\n related:\n RELATED_THRESHOLD\n },\n\n routeTo: isDuplicate\n ? 'human_review_possible_duplicate'\n : 'dor_scoring'\n };\n}\n\nreturn items.map(item => {\n\n const candidate =\n item.json.normalized;\n\n const validationErrors =\n validateCandidate(candidate);\n\n if (\n validationErrors.length > 0\n ) {\n return {\n json: {\n ...item.json,\n\n duplicateCheck: {\n isDuplicate: false,\n isRelated: false,\n matchedIntakeIds: [],\n bestMatchIntakeId: null,\n similarityScore: 0,\n relatedMatches: [],\n recordsEvaluated: 0,\n recordsSkipped: [],\n errors:\n validationErrors,\n routeTo:\n 'human_review_duplicate_check_failure'\n },\n\n routeTo:\n 'human_review_duplicate_check_failure'\n }\n };\n }\n\n try {\n\n const existingOpenItems =\n getExistingOpenItems(\n item.json\n );\n\n const duplicateCheck =\n detectDuplicates(\n candidate,\n existingOpenItems\n );\n\n return {\n json: {\n ...item.json,\n duplicateCheck\n }\n };\n\n } catch (error) {\n\n return {\n json: {\n ...item.json,\n\n duplicateCheck: {\n isDuplicate: false,\n isRelated: false,\n matchedIntakeIds: [],\n bestMatchIntakeId: null,\n similarityScore: 0,\n relatedMatches: [],\n recordsEvaluated: 0,\n recordsSkipped: [],\n errors: [\n `duplicate_check_error:${error.message}`\n ],\n routeTo:\n 'human_review_duplicate_check_failure'\n },\n\n routeTo:\n 'human_review_duplicate_check_failure'\n }\n };\n\n }\n\n});"
},
"id": "7dc55eb0-1a76-4ae9-a3be-7c6f1f4227e9",
"name": "02: Duplicate & Related-Work Detection",
"type": "n8n-nodes-base.code",
"typeVersion": 1,
"position": [
-1760,
32
],
"notes": "DEMO: existingOpenItems is hardcoded empty. Replace with a Google Sheets/Data Table read merged in upstream."
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 1
},
"conditions": [
{
"leftValue": "={{$json.duplicateCheck.isDuplicate}}",
"rightValue": true,
"operator": {
"type": "boolean",
"operation": "true"
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "e08d0f8d-d357-4acc-bfb6-8f0d419168d0",
"name": "IF: Is Duplicate?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
-1536,
32
]
},
{
"parameters": {},
"id": "6104d7b2-1708-44b4-ac9f-8b38b9340d03",
"name": "STOP: Possible Duplicate -> Human Review Queue",
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [
-1392,
320
]
},
{
"parameters": {
"jsCode": "// VANTIX Agile Delivery & Admin Workload Sentinel\n// Node 03: DoR Completeness Scoring\n// Deterministic, auditable Definition of Ready assessment\n\nconst DOR_WEIGHTS = {\n hasClearDescription: 0.15,\n hasExpectedOutcome: 0.20,\n hasEvidence: 0.20,\n hasBusinessJustification: 0.15,\n hasAffectedComponents: 0.15,\n hasNoUnresolvedDependencyGap: 0.15,\n};\n\nconst READY_THRESHOLD = 0.75;\nconst INSUFFICIENT_THRESHOLD = 0.35;\n\nfunction scoreDoR(item) {\n const checks = {};\n\n checks.hasClearDescription =\n typeof item.description === 'string' &&\n item.description.trim().length >= 40;\n\n checks.hasExpectedOutcome =\n typeof item.expectedOutcomeStated === 'string' &&\n item.expectedOutcomeStated.trim() !== '';\n\n checks.hasEvidence =\n Array.isArray(item.evidenceLinks) &&\n item.evidenceLinks.length > 0;\n\n checks.hasBusinessJustification =\n typeof item.businessJustificationStated === 'string' &&\n item.businessJustificationStated.trim() !== '';\n\n checks.hasAffectedComponents =\n Array.isArray(item.affectedComponents) &&\n item.affectedComponents.length > 0;\n\n checks.hasNoUnresolvedDependencyGap =\n item.source === 'technical_debt'\n ? (\n Array.isArray(item.dependenciesStated) &&\n item.dependenciesStated.length > 0\n )\n : true;\n\n let score = 0;\n const missingChecks = [];\n\n for (const [checkName, weight] of Object.entries(DOR_WEIGHTS)) {\n if (checks[checkName]) {\n score += weight;\n } else {\n missingChecks.push(checkName);\n }\n }\n\n score = Number(score.toFixed(4));\n\n let dorStatus;\n\n if (score >= READY_THRESHOLD) {\n dorStatus = 'ready';\n } else if (score < INSUFFICIENT_THRESHOLD) {\n dorStatus = 'insufficient_evidence';\n } else {\n dorStatus = 'not_ready';\n }\n\n let routeTo;\n\n if (dorStatus === 'ready') {\n routeTo = 'priority_risk_workload_scoring';\n } else if (dorStatus === 'insufficient_evidence') {\n routeTo = 'request_more_evidence';\n } else {\n routeTo = 'backlog_refinement';\n }\n\n return {\n dorScore: score,\n dorStatus,\n dorChecks: checks,\n missingChecks,\n\n thresholdsUsed: {\n ready: READY_THRESHOLD,\n insufficientEvidence: INSUFFICIENT_THRESHOLD,\n },\n\n weightsUsed: DOR_WEIGHTS,\n routeTo,\n };\n}\n\nreturn items.map((item) => {\n const normalized = item.json.normalized;\n\n if (\n !normalized ||\n typeof normalized !== 'object' ||\n Array.isArray(normalized)\n ) {\n return {\n json: {\n ...item.json,\n dorScore: 0,\n dorStatus: 'insufficient_evidence',\n dorChecks: {},\n missingChecks: ['normalized_intake_missing'],\n thresholdsUsed: {\n ready: READY_THRESHOLD,\n insufficientEvidence: INSUFFICIENT_THRESHOLD,\n },\n weightsUsed: DOR_WEIGHTS,\n routeTo: 'request_more_evidence',\n dorErrors: ['normalized_intake_missing_or_invalid'],\n },\n };\n }\n\n const dor = scoreDoR(normalized);\n\n return {\n json: {\n ...item.json,\n ...dor,\n },\n };\n});"
},
"id": "03c7378e-7bd6-4dcd-a15e-27e2b47b1155",
"name": "03: DoR Completeness Scoring",
"type": "n8n-nodes-base.code",
"typeVersion": 1,
"position": [
-1328,
32
]
},
{
"parameters": {
"rules": {
"values": [
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 1
},
"conditions": [
{
"leftValue": "={{$json.dorStatus}}",
"rightValue": "ready",
"operator": {
"type": "string",
"operation": "equals"
},
"id": "b30eb51b-4737-472b-ad84-70d705a8904e"
}
],
"combinator": "and"
}
},
{
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 1
},
"conditions": [
{
"leftValue": "={{$json.dorStatus}}",
"rightValue": "insufficient_evidence",
"operator": {
"type": "string",
"operation": "equals"
},
"id": "bd366be6-32ea-4294-a7b7-ee54213a1668"
}
],
"combinator": "and"
}
}
]
},
"options": {
"fallbackOutput": "extra"
}
},
"id": "e33118db-da60-42cb-9349-b6d5a5937cd1",
"name": "SWITCH: DoR Status",
"type": "n8n-nodes-base.switch",
"typeVersion": 3,
"position": [
-1104,
32
]
},
{
"parameters": {},
"id": "f2620ea7-e7d5-4472-8176-1f4f5cb4a0f8",
"name": "STOP: Not Ready -> Backlog Refinement Queue",
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [
-624,
416
]
},
{
"parameters": {},
"id": "77d001e2-5a7f-4275-b7ac-4f1cab2b271d",
"name": "STOP: Insufficient Evidence -> Request More Info From Requester",
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [
-1056,
512
]
},
{
"parameters": {
"jsCode": "// VANTIX Agile Delivery & Admin Workload Sentinel\n// Node 04: Priority, Delivery Risk & Admin Workload Scoring\n// Deterministic, explainable and capacity-aware\n\nconst SOURCE_WEIGHT = {\n incident: 1.0,\n project1_governance_finding: 0.8,\n technical_debt: 0.5,\n enhancement_request: 0.4,\n};\n\nconst URGENCY_WEIGHT = {\n critical: 1.0,\n high: 0.7,\n medium: 0.4,\n low: 0.15,\n none: 0.2,\n};\n\nconst RISK_MATRIX = {\n low: {\n low: 1,\n medium: 2,\n high: 3,\n },\n medium: {\n low: 2,\n medium: 5,\n high: 7,\n },\n high: {\n low: 3,\n medium: 7,\n high: 9,\n },\n};\n\n/*\n * Demo fallback only.\n *\n * In production, supply item.json.admins from an upstream\n * n8n Data Table, Salesforce, Jira or Azure DevOps query.\n */\nconst DEMO_ADMINS = [\n {\n adminId: 'admin_priya',\n currentWip: 3,\n wipLimit: 5,\n },\n {\n adminId: 'admin_jordan',\n currentWip: 5,\n wipLimit: 5,\n },\n];\n\nfunction isFiniteNonNegativeNumber(value) {\n return (\n typeof value === 'number' &&\n Number.isFinite(value) &&\n value >= 0\n );\n}\n\nfunction validateAdmin(admin) {\n return (\n admin &&\n typeof admin === 'object' &&\n !Array.isArray(admin) &&\n typeof admin.adminId === 'string' &&\n admin.adminId.trim() !== '' &&\n isFiniteNonNegativeNumber(admin.currentWip) &&\n typeof admin.wipLimit === 'number' &&\n Number.isFinite(admin.wipLimit) &&\n admin.wipLimit > 0\n );\n}\n\nfunction getAdminList(input) {\n if (input.admins === undefined) {\n return {\n admins: DEMO_ADMINS,\n source: 'demo_fallback',\n invalidRecords: [],\n };\n }\n\n if (!Array.isArray(input.admins)) {\n return {\n admins: [],\n source: 'invalid_input',\n invalidRecords: [\n {\n reason: 'admins_must_be_array',\n },\n ],\n };\n }\n\n const validAdmins = [];\n const invalidRecords = [];\n\n for (const admin of input.admins) {\n if (validateAdmin(admin)) {\n validAdmins.push({\n adminId: admin.adminId.trim(),\n currentWip: admin.currentWip,\n wipLimit: admin.wipLimit,\n });\n } else {\n invalidRecords.push({\n adminId: admin?.adminId ?? null,\n reason: 'invalid_admin_capacity_record',\n });\n }\n }\n\n return {\n admins: validAdmins,\n source: 'upstream_input',\n invalidRecords,\n };\n}\n\nfunction calculateUtilization(admin) {\n return admin.currentWip / admin.wipLimit;\n}\n\nfunction sortByUtilizationThenId(admins) {\n return [...admins].sort((a, b) => {\n const utilizationDifference =\n calculateUtilization(a) -\n calculateUtilization(b);\n\n if (utilizationDifference !== 0) {\n return utilizationDifference;\n }\n\n return a.adminId.localeCompare(b.adminId);\n });\n}\n\nfunction assignCapacityAware(admins) {\n if (!Array.isArray(admins) || admins.length === 0) {\n return {\n assigned: null,\n capacityBreached: true,\n assignmentReason: 'no_valid_admin_capacity_data',\n };\n }\n\n const adminsWithCapacity = admins.filter(\n (admin) =>\n admin.currentWip + 1 <= admin.wipLimit\n );\n\n if (adminsWithCapacity.length > 0) {\n const assigned =\n sortByUtilizationThenId(\n adminsWithCapacity\n )[0];\n\n return {\n assigned,\n capacityBreached: false,\n assignmentReason:\n 'least_utilized_admin_with_available_capacity',\n };\n }\n\n const assigned =\n sortByUtilizationThenId(admins)[0];\n\n return {\n assigned,\n capacityBreached: true,\n assignmentReason:\n 'all_admins_at_or_above_wip_limit',\n };\n}\n\nfunction calculatePriority(norm, dorScore) {\n const sourceWeight =\n SOURCE_WEIGHT[norm.source];\n\n const urgencyKey =\n norm.urgencyClaimedByRequester ?? 'none';\n\n const urgencyWeight =\n URGENCY_WEIGHT[urgencyKey];\n\n const sourceContribution =\n sourceWeight * 0.5;\n\n const urgencyContribution =\n urgencyWeight * 0.3;\n\n const readinessContribution =\n dorScore * 0.2;\n\n const priorityScore = Number(\n (\n sourceContribution +\n urgencyContribution +\n readinessContribution\n ).toFixed(4)\n );\n\n let priorityBand;\n\n if (priorityScore >= 0.75) {\n priorityBand = 'critical';\n } else if (priorityScore >= 0.55) {\n priorityBand = 'high';\n } else if (priorityScore >= 0.35) {\n priorityBand = 'medium';\n } else {\n priorityBand = 'low';\n }\n\n return {\n priorityScore,\n priorityBand,\n\n calculation: {\n sourceWeight,\n urgencyWeight,\n dorScore,\n\n contributions: {\n source: Number(\n sourceContribution.toFixed(4)\n ),\n urgency: Number(\n urgencyContribution.toFixed(4)\n ),\n readiness: Number(\n readinessContribution.toFixed(4)\n ),\n },\n\n weights: {\n source: 0.5,\n urgency: 0.3,\n readiness: 0.2,\n },\n },\n };\n}\n\nfunction calculateLikelihoodBand(norm, dorScore) {\n let points = 0;\n const factors = [];\n\n if (norm.source === 'incident') {\n points += 2;\n factors.push('incident_source');\n }\n\n if (\n norm.source ===\n 'project1_governance_finding'\n ) {\n points += 1;\n factors.push('governance_finding_source');\n }\n\n if (dorScore < 0.85) {\n points += 1;\n factors.push('readiness_below_0_85');\n }\n\n if (\n Array.isArray(norm.dependenciesStated) &&\n norm.dependenciesStated.length > 0\n ) {\n points += 1;\n factors.push('dependencies_present');\n }\n\n const band =\n points >= 3\n ? 'high'\n : points >= 1\n ? 'medium'\n : 'low';\n\n return {\n band,\n points,\n factors,\n };\n}\n\nfunction calculateImpactBand(norm) {\n let points = 0;\n const factors = [];\n\n const componentCount =\n Array.isArray(norm.affectedComponents)\n ? norm.affectedComponents.length\n : 0;\n\n if (componentCount >= 3) {\n points += 2;\n factors.push(\n 'three_or_more_components_affected'\n );\n } else if (componentCount >= 1) {\n points += 1;\n factors.push(\n 'one_or_two_components_affected'\n );\n }\n\n if (\n typeof norm.businessJustificationStated ===\n 'string' &&\n norm.businessJustificationStated.trim() !== ''\n ) {\n points += 1;\n factors.push(\n 'business_impact_documented'\n );\n }\n\n if (\n norm.urgencyClaimedByRequester ===\n 'critical' ||\n norm.urgencyClaimedByRequester === 'high'\n ) {\n points += 1;\n factors.push(\n 'high_or_critical_requester_urgency'\n );\n }\n\n const band =\n points >= 3\n ? 'high'\n : points >= 1\n ? 'medium'\n : 'low';\n\n return {\n band,\n points,\n factors,\n };\n}\n\nreturn items.map((item) => {\n const norm = item.json.normalized;\n const dorScore = item.json.dorScore;\n\n if (\n !norm ||\n typeof norm !== 'object' ||\n Array.isArray(norm)\n ) {\n throw new Error(\n 'priority_scoring_missing_normalized_intake'\n );\n }\n\n if (\n typeof dorScore !== 'number' ||\n !Number.isFinite(dorScore) ||\n dorScore < 0 ||\n dorScore > 1\n ) {\n throw new Error(\n 'priority_scoring_invalid_dor_score'\n );\n }\n\n if (\n SOURCE_WEIGHT[norm.source] === undefined\n ) {\n throw new Error(\n `priority_scoring_invalid_source:${norm.source}`\n );\n }\n\n const urgencyKey =\n norm.urgencyClaimedByRequester ?? 'none';\n\n if (\n URGENCY_WEIGHT[urgencyKey] === undefined\n ) {\n throw new Error(\n `priority_scoring_invalid_urgency:${urgencyKey}`\n );\n }\n\n const priority =\n calculatePriority(norm, dorScore);\n\n const likelihood =\n calculateLikelihoodBand(\n norm,\n dorScore\n );\n\n const impact =\n calculateImpactBand(norm);\n\n const compositeScore =\n RISK_MATRIX[likelihood.band][impact.band];\n\n const adminData =\n getAdminList(item.json);\n\n const assignment =\n assignCapacityAware(adminData.admins);\n\n const assigned =\n assignment.assigned;\n\n return {\n json: {\n ...item.json,\n\n priorityScore:\n priority.priorityScore,\n\n priorityBand:\n priority.priorityBand,\n\n priorityCalculation:\n priority.calculation,\n\n riskIndex: {\n likelihoodBand:\n likelihood.band,\n\n impactBand:\n impact.band,\n\n compositeScore,\n\n likelihoodPoints:\n likelihood.points,\n\n impactPoints:\n impact.points,\n\n likelihoodFactors:\n likelihood.factors,\n\n impactFactors:\n impact.factors,\n\n note:\n 'Deterministic delivery-risk likelihood \u00d7 impact matrix. It is a governed prioritisation aid, not a statistical probability or predictive model.',\n },\n\n workloadImpact: {\n assignedAdminId:\n assigned?.adminId ?? null,\n\n currentWipForAdmin:\n assigned?.currentWip ?? null,\n\n projectedWipForAdmin:\n assigned\n ? assigned.currentWip + 1\n : null,\n\n wipLimit:\n assigned?.wipLimit ?? null,\n\n utilizationBefore:\n assigned\n ? Number(\n calculateUtilization(\n assigned\n ).toFixed(4)\n )\n : null,\n\n utilizationAfter:\n assigned\n ? Number(\n (\n (assigned.currentWip + 1) /\n assigned.wipLimit\n ).toFixed(4)\n )\n : null,\n\n capacityBreached:\n assignment.capacityBreached,\n\n assignmentReason:\n assignment.assignmentReason,\n\n capacityDataSource:\n adminData.source,\n\n invalidAdminRecords:\n adminData.invalidRecords,\n },\n\n scoringVersion: '1.1.0',\n routeTo: 'ai_story_draft',\n },\n };\n});"
},
"id": "16b51506-219d-44d4-98b5-a503b66f0600",
"name": "04: Priority / Risk / Workload Scoring",
"type": "n8n-nodes-base.code",
"typeVersion": 1,
"position": [
-848,
-80
],
"notes": "DEMO: admins list is hardcoded. Replace with a live Google Sheets/Data Table read of admin WIP."
},
{
"parameters": {
"requestMethod": "POST",
"url": "=https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent",
"jsonParameters": true,
"options": {
"timeout": 90000
},
"bodyParametersJson": "{\n \"systemInstruction\": {\n \"parts\": [\n {\n \"text\": \"You are the bounded story-drafting component of the VANTIX Salesforce Agile Delivery and Admin Workload Sentinel. Use only the supplied deterministic intake evidence. Draft one user story, evidence-supported acceptance criteria, clarification questions, clearly labelled assumptions requiring human confirmation, and a non-authoritative effort band. For this controlled positive-path test, acceptanceCriteria must contain exactly one criterion: The system prevents a user from saving an Opportunity record with a Stage of Closed Won when the Close Date field is blank. Do not add an error-message requirement. Do not add profile exemptions. Do not add other Opportunity stages. Do not add field-visibility requirements. Do not add new Salesforce fields. Do not add implementation details beyond the validation-rule requirement explicitly stated in the intake. Do not invent requirements, evidence, business value, dependencies, access rules or technical constraints. Clarification questions may identify unresolved matters, but they must not be converted into acceptance criteria or established facts. Assumptions must remain clearly labelled as assumptions. The evidenceReferenced array must contain only this exact value: https://internal.example.com/tickets/SOPS-4821. Do not include intakeId, storyId, title or any other identifier in evidenceReferenced. Return only valid JSON matching the required schema.\"\n }\n ]\n },\n \"contents\": [\n {\n \"role\": \"user\",\n \"parts\": [\n {\n \"text\": \"Create a governed backlog-ready Salesforce story for intake INT-2026-000101. Source: enhancement_request. Title: Add validation rule to prevent blank Opportunity Close Date. Description: Sales ops has reported that Opportunities are being marked Closed Won without a Close Date populated, which breaks the forecasting report. Need a validation rule on the Opportunity object. Business justification: Forecasting report accuracy directly affects the weekly pipeline review with leadership. Expected outcome: Users cannot save a Closed Won Opportunity without a Close Date. Affected components: Opportunity and Forecasting Report. Allowed evidence reference: https://internal.example.com/tickets/SOPS-4821. The acceptanceCriteria array must contain exactly one item: The system prevents a user from saving an Opportunity record with a Stage of Closed Won when the Close Date field is blank.\"\n }\n ]\n }\n ],\n \"generationConfig\": {\n \"temperature\": 0,\n \"responseMimeType\": \"application/json\",\n \"responseSchema\": {\n \"type\": \"OBJECT\",\n \"required\": [\n \"intakeId\",\n \"storyId\",\n \"userStory\",\n \"acceptanceCriteria\",\n \"clarificationQuestions\",\n \"assumptionsMade\",\n \"effortBandSuggested\",\n \"confidence\",\n \"evidenceReferenced\"\n ],\n \"properties\": {\n \"intakeId\": {\n \"type\": \"STRING\"\n },\n \"storyId\": {\n \"type\": \"STRING\"\n },\n \"userStory\": {\n \"type\": \"STRING\"\n },\n \"acceptanceCriteria\": {\n \"type\": \"ARRAY\",\n \"minItems\": 1,\n \"maxItems\": 1,\n \"items\": {\n \"type\": \"STRING\"\n }\n },\n \"clarificationQuestions\": {\n \"type\": \"ARRAY\",\n \"items\": {\n \"type\": \"STRING\"\n }\n },\n \"assumptionsMade\": {\n \"type\": \"ARRAY\",\n \"items\": {\n \"type\": \"STRING\"\n }\n },\n \"effortBandSuggested\": {\n \"type\": \"STRING\",\n \"enum\": [\n \"S\",\n \"M\",\n \"L\",\n \"XL\",\n \"unknown\"\n ]\n },\n \"confidence\": {\n \"type\": \"NUMBER\"\n },\n \"evidenceReferenced\": {\n \"type\": \"ARRAY\",\n \"minItems\": 1,\n \"maxItems\": 1,\n \"items\": {\n \"type\": \"STRING\",\n \"enum\": [\n \"https://internal.example.com/tickets/SOPS-4821\"\n ]\n }\n }\n }\n }\n }\n}",
"headerParametersJson": "\n{\n \"x-goog-api-key\": \"={{$env.GEMINI_API_KEY}}\",\n \"Content-Type\": \"application/json\"\n}"
},
"id": "ada6936d-792a-49d8-8c6e-b69747039cc4",
"name": "AI: Story Draft (Gemini/Claude \u2014 see prompts/story_draft_prompt.md)",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 1,
"position": [
-656,
32
],
"notes": "Requires endpoint configuration and provider-specific response verification. Intentionally not part of deterministic baseline test."
},
{
"parameters": {
"jsCode": "// VANTIX Agile Delivery & Admin Workload Sentinel\n// Validate AI Story Draft \u2014 governed deterministic validator\n//\n// Responsibilities:\n// 1. Restore deterministic context from Node 04.\n// 2. Safely extract and parse the Gemini response.\n// 3. Validate the response contract independently of the prompt.\n// 4. Enforce deterministic identity and evidence boundaries.\n// 5. Enforce the controlled acceptance-criteria boundary.\n// 6. Assign the authoritative story ID.\n// 7. Fail closed without throwing an unhandled parsing error.\n\nconst REQUIRED_FIELDS = [\n 'intakeId',\n 'userStory',\n 'acceptanceCriteria',\n 'clarificationQuestions',\n 'assumptionsMade',\n 'effortBandSuggested',\n 'confidence',\n 'evidenceReferenced',\n];\n\nconst VALID_EFFORT_BANDS = [\n 'S',\n 'M',\n 'L',\n 'XL',\n 'unknown',\n];\n\n/*\n * Controlled positive-path acceptance criterion.\n *\n * In the production version, this should come from an upstream\n * deterministic requirements-authorisation object rather than\n * remaining hardcoded.\n */\nconst CONTROLLED_ACCEPTANCE_CRITERION =\n 'The system prevents a user from saving an Opportunity record with a Stage of Closed Won when the Close Date field is blank.';\n\nfunction isPlainObject(value) {\n return (\n value !== null &&\n typeof value === 'object' &&\n !Array.isArray(value)\n );\n}\n\nfunction cleanJsonText(value) {\n return String(value)\n .trim()\n .replace(/^```(?:json)?\\s*/i, '')\n .replace(/\\s*```$/i, '');\n}\n\nfunction parseJsonText(value) {\n const parsed = JSON.parse(\n cleanJsonText(value)\n );\n\n if (!isPlainObject(parsed)) {\n throw new Error(\n 'parsed_ai_output_must_be_object'\n );\n }\n\n return parsed;\n}\n\nfunction extractGeminiDraft(rawResponse) {\n if (!isPlainObject(rawResponse)) {\n throw new Error('missing_or_invalid_ai_response');\n }\n\n /*\n * Gemini generateContent REST response:\n * candidates[0].content.parts[0].text\n */\n const candidate =\n rawResponse?.candidates?.[0];\n\n const candidateText =\n candidate?.content?.parts?.[0]?.text;\n\n if (\n typeof candidateText === 'string' &&\n candidateText.trim() !== ''\n ) {\n return {\n parsedDraft: parseJsonText(\n candidateText\n ),\n\n responseMetadata: {\n finishReason:\n candidate?.finishReason ?? null,\n\n candidateCount:\n Array.isArray(rawResponse.candidates)\n ? rawResponse.candidates.length\n : 0,\n\n promptFeedback:\n rawResponse.promptFeedback ?? null,\n\n usageMetadata:\n rawResponse.usageMetadata ?? null,\n },\n };\n }\n\n /*\n * Optional compatibility fallback when a future HTTP mapping\n * outputs the inner governed draft directly.\n */\n if (\n rawResponse.intakeId !== undefined &&\n rawResponse.userStory !== undefined &&\n rawResponse.acceptanceCriteria !== undefined\n ) {\n return {\n parsedDraft: rawResponse,\n\n responseMetadata: {\n finishReason: 'direct_object_mapping',\n candidateCount: null,\n promptFeedback: null,\n usageMetadata: null,\n },\n };\n }\n\n throw new Error(\n 'unsupported_ai_response_shape'\n );\n}\n\nfunction validateStringArray(\n value,\n fieldName,\n errors,\n options = {}\n) {\n const {\n minItems = 0,\n maxItems = null,\n } = options;\n\n if (!Array.isArray(value)) {\n errors.push(\n `${fieldName}_must_be_array`\n );\n return;\n }\n\n if (value.length < minItems) {\n errors.push(\n `${fieldName}_below_min_items:${minItems}`\n );\n }\n\n if (\n maxItems !== null &&\n value.length > maxItems\n ) {\n errors.push(\n `${fieldName}_above_max_items:${maxItems}`\n );\n }\n\n const invalidItem = value.some(\n (entry) =>\n typeof entry !== 'string' ||\n entry.trim() === ''\n );\n\n if (invalidItem) {\n errors.push(\n `${fieldName}_contains_invalid_item`\n );\n }\n}\n\nfunction containsDuplicates(values) {\n if (!Array.isArray(values)) {\n return false;\n }\n\n return (\n new Set(\n values.map((value) =>\n typeof value === 'string'\n ? value.trim()\n : value\n )\n ).size !== values.length\n );\n}\n\nfunction normalizeStringArray(values) {\n if (!Array.isArray(values)) {\n return [];\n }\n\n return values.map((value) =>\n value.trim()\n );\n}\n\nconst baseItems =\n $('04: Priority / Risk / Workload Scoring').all();\n\nreturn items.map((item, index) => {\n /*\n * Match the AI response to the corresponding deterministic\n * record. This remains safe if multiple intake items are\n * processed in one execution.\n */\n const base =\n baseItems[index]?.json ??\n baseItems[0]?.json ??\n null;\n\n const rawResponse = item.json;\n const errors = [];\n const warnings = [];\n\n let parsedDraft = null;\n let responseMetadata = null;\n\n if (!isPlainObject(base)) {\n errors.push(\n 'missing_deterministic_context'\n );\n }\n\n try {\n const extracted =\n extractGeminiDraft(rawResponse);\n\n parsedDraft =\n extracted.parsedDraft;\n\n responseMetadata =\n extracted.responseMetadata;\n } catch (error) {\n errors.push(\n `ai_output_parse_error:${error.message}`\n );\n }\n\n if (\n responseMetadata?.finishReason &&\n ![\n 'STOP',\n 'direct_object_mapping',\n ].includes(\n responseMetadata.finishReason\n )\n ) {\n errors.push(\n `unexpected_finish_reason:${responseMetadata.finishReason}`\n );\n }\n\n if (parsedDraft) {\n for (const field of REQUIRED_FIELDS) {\n if (\n parsedDraft[field] === undefined ||\n parsedDraft[field] === null\n ) {\n errors.push(\n `missing_field:${field}`\n );\n }\n }\n\n const expectedIntakeId =\n base?.normalized?.intakeId;\n\n if (!expectedIntakeId) {\n errors.push(\n 'deterministic_intakeId_missing'\n );\n }\n\n if (\n parsedDraft.intakeId !== undefined &&\n parsedDraft.intakeId !==\n expectedIntakeId\n ) {\n errors.push(\n 'intakeId_mismatch'\n );\n }\n\n if (\n typeof parsedDraft.userStory !==\n 'string' ||\n parsedDraft.userStory.trim().length <\n 10\n ) {\n errors.push(\n 'invalid_userStory'\n );\n }\n\n validateStringArray(\n parsedDraft.acceptanceCriteria,\n 'acceptanceCriteria',\n errors,\n {\n minItems: 1,\n maxItems: 1,\n }\n );\n\n if (\n Array.isArray(\n parsedDraft.acceptanceCriteria\n ) &&\n parsedDraft.acceptanceCriteria.length ===\n 1 &&\n parsedDraft.acceptanceCriteria[0]\n ?.trim() !==\n CONTROLLED_ACCEPTANCE_CRITERION\n ) {\n errors.push(\n 'acceptanceCriteria_not_authorised'\n );\n }\n\n validateStringArray(\n parsedDraft.clarificationQuestions,\n 'clarificationQuestions',\n errors\n );\n\n validateStringArray(\n parsedDraft.assumptionsMade,\n 'assumptionsMade',\n errors\n );\n\n validateStringArray(\n parsedDraft.evidenceReferenced,\n 'evidenceReferenced',\n errors,\n {\n minItems: 1,\n maxItems: 1,\n }\n );\n\n if (\n containsDuplicates(\n parsedDraft.evidenceReferenced\n )\n ) {\n errors.push(\n 'duplicate_evidence_reference'\n );\n }\n\n if (\n parsedDraft.effortBandSuggested !==\n undefined &&\n !VALID_EFFORT_BANDS.includes(\n parsedDraft.effortBandSuggested\n )\n ) {\n errors.push(\n 'invalid_effort_band'\n );\n }\n\n if (\n typeof parsedDraft.confidence !==\n 'number' ||\n !Number.isFinite(\n parsedDraft.confidence\n ) ||\n parsedDraft.confidence < 0 ||\n parsedDraft.confidence > 1\n ) {\n errors.push(\n 'confidence_out_of_range'\n );\n }\n\n /*\n * AI may reference only evidence already present in the\n * deterministic intake.\n */\n const allowedEvidence =\n new Set(\n base?.normalized?.evidenceLinks ??\n []\n );\n\n if (\n Array.isArray(\n parsedDraft.evidenceReferenced\n )\n ) {\n for (\n const evidence of\n parsedDraft.evidenceReferenced\n ) {\n if (\n typeof evidence === 'string' &&\n !allowedEvidence.has(\n evidence.trim()\n )\n ) {\n errors.push(\n `unsupported_evidence_reference:${evidence}`\n );\n }\n }\n }\n\n /*\n * storyId is not authoritative when returned by AI.\n * Record a warning for audit visibility, then overwrite it.\n */\n const deterministicStoryId =\n `STORY-${\n expectedIntakeId || 'UNKNOWN'\n }`;\n\n if (\n parsedDraft.storyId !== undefined &&\n parsedDraft.storyId !==\n deterministicStoryId\n ) {\n warnings.push(\n 'ai_storyId_overwritten_by_deterministic_storyId'\n );\n }\n }\n\n const valid =\n errors.length === 0;\n\n const deterministicStoryId =\n `STORY-${\n base?.normalized?.intakeId ||\n 'UNKNOWN'\n }`;\n\n const governedDraft =\n valid && parsedDraft\n ? {\n ...parsedDraft,\n\n intakeId:\n base.normalized.intakeId,\n\n storyId:\n deterministicStoryId,\n\n userStory:\n parsedDraft.userStory.trim(),\n\n acceptanceCriteria:\n normalizeStringArray(\n parsedDraft.acceptanceCriteria\n ),\n\n clarificationQuestions:\n normalizeStringArray(\n parsedDraft.clarificationQuestions\n ),\n\n assumptionsMade:\n normalizeStringArray(\n parsedDraft.assumptionsMade\n ),\n\n evidenceReferenced:\n normalizeStringArray(\n parsedDraft.evidenceReferenced\n ),\n }\n : null;\n\n return {\n json: {\n ...(base ?? {}),\n\n aiStoryDraftRaw:\n rawResponse,\n\n aiStoryResponseMetadata:\n responseMetadata,\n\n storyDraft:\n governedDraft,\n\n storyDraftValid:\n valid,\n\n storyDraftErrors:\n errors,\n\n storyDraftWarnings:\n warnings,\n\n storyValidationVersion:\n '1.1.0',\n\n routeTo: valid\n ? 'ai_critique'\n : 'human_review_invalid_ai_output',\n },\n };\n});"
},
"id": "f722de85-f930-4f15-92a5-0e53f93357d8",
"name": "Validate AI Story Draft (schema check)",
"type": "n8n-nodes-base.code",
"typeVersion": 1,
"position": [
-448,
32
]
},
{
"parameters": {
"conditions": {
"options": {
"caseSensitive": true,
"leftValue": "",
"typeValidation": "strict",
"version": 1
},
"conditions": [
{
"leftValue": "={{$json.storyDraftValid}}",
"rightValue": true,
"operator": {
"type": "boolean",
"operation": "true"
}
}
],
"combinator": "and"
},
"options": {}
},
"id": "99f76f4a-5dd3-451c-8cc1-767faa03520d",
"name": "IF: Draft Schema Valid?",
"type": "n8n-nodes-base.if",
"typeVersion": 2,
"position": [
-224,
32
]
},
{
"parameters": {},
"id": "5b287ab8-6a7d-4b1d-baa2-f44c05e14cd9",
"name": "STOP: Invalid AI Output -> Human Review",
"type": "n8n-nodes-base.noOp",
"typeVersion": 1,
"position": [
0,
208
]
},
{
"parameters": {
"requestMethod": "POST",
"url": "=https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:generateContent",
"jsonParameters": true,
"options": {
"timeout": 120000
},
"bodyParametersJson": "={{ $json.critiqueRequestBody }}",
"headerParametersJson": "{\n \"x-goog-api-key\": \"={{$env.GEMINI_API_KEY}}\",\n \"Content-Type\": \"application/json\"\n}"
},
"id": "7b06085e-a796-4338-a37e-dc8d06da8100",
"name": "AI: Independent Critique (see prompts/critique_prompt.md)",
"type": "n8n-nodes-base.httpRequest",
"typeVersion": 1,
"position": [
304,
-224
],
"notes": "Separate critique call. Requires endpoint configuration and provider-specific response verification. Must remain fail-closed."
},
{
"parameters": {
"jsCode": "// VANTIX Agile Delivery & Admin Workload Sentinel\n// Validate AI Critique \u2014 deterministic, fail-closed validator\n//\n// Responsibilities:\n// 1. Restore the governed story and deterministic context.\n// 2. Extract and safely parse the Gemini critique response.\n// 3. Validate identity, types, arrays, routing and confidence.\n// 4. Prevent adverse or malformed critique output from advancing.\n// 5. Preserve raw AI output for audit evidence.\n// 6. Route only a clean critique to human approval.\n\nconst REQUIRED_FIELDS = [\n 'intakeId',\n 'storyId',\n 'inventedContentFlag',\n 'missingEvidenceFlag',\n 'unsupportedAcceptanceCriteria',\n 'routingRecommendation',\n 'critiqueNotes',\n 'overallConfidence',\n];\n\nconst VALID_ROUTES = [\n 'proceed_to_human_review',\n 'request_more_evidence',\n 'likely_duplicate',\n 'reject_out_of_scope',\n];\n\nconst CRITIQUE_VALIDATION_VERSION = '1.1.0';\n\nfunction isPlainObject(value) {\n return (\n value !== null &&\n typeof value === 'object' &&\n !Array.isArray(value)\n );\n}\n\nfunction cleanJsonText(value) {\n return String(value)\n .trim()\n .replace(/^```(?:json)?\\s*/i, '')\n .replace(/\\s*```$/i, '');\n}\n\nfunction parseJsonText(value) {\n const parsed = JSON.parse(\n cleanJsonText(value)\n );\n\n if (!isPlainObject(parsed)) {\n throw new Error(\n 'parsed_critique_must_be_object'\n );\n }\n\n return parsed;\n}\n\nfunction extractCritique(rawResponse) {\n if (!isPlainObject(rawResponse)) {\n throw new Error(\n 'missing_or_invalid_critique_response'\n );\n }\n\n /*\n * Standard Gemini generateContent REST response:\n * candidates[0].content.parts[0].text\n */\n const candidate =\n rawResponse?.candidates?.[0];\n\n const candidateText =\n candidate?.content?.parts?.[0]?.text;\n\n if (\n typeof candidateText === 'string' &&\n candidateText.trim() !== ''\n ) {\n return {\n critique: parseJsonText(\n candidateText\n ),\n\n responseMetadata: {\n finishReason:\n candidate?.finishReason ?? null,\n\n candidateCount:\n Array.isArray(\n rawResponse.candidates\n )\n ? rawResponse.candidates.length\n : 0,\n\n promptFeedback:\n rawResponse.promptFeedback ?? null,\n\n usageMetadata:\n rawResponse.usageMetadata ?? null,\n },\n };\n }\n\n /*\n * Compatibility paths for alternative HTTP mappings.\n */\n const possibleValues = [\n rawResponse.body,\n rawResponse.data,\n rawResponse.output,\n rawResponse.text,\n rawResponse.content?.[0]?.text,\n rawResponse.aiCritiqueRaw,\n ];\n\n for (const value of possibleValues) {\n if (\n typeof value === 'string' &&\n value.trim() !== ''\n ) {\n try {\n return {\n critique: parseJsonText(value),\n\n responseMetadata: {\n finishReason:\n 'alternative_text_mapping',\n candidateCount: null,\n promptFeedback: null,\n usageMetadata: null,\n },\n };\n } catch (_) {\n // Continue checking other supported shapes.\n }\n }\n\n if (isPlainObject(value)) {\n return {\n critique: value,\n\n responseMetadata: {\n finishReason:\n 'alternative_object_mapping',\n candidateCount: null,\n promptFeedback: null,\n usageMetadata: null,\n },\n };\n }\n }\n\n /*\n * Direct-object fallback.\n */\n if (\n rawResponse.intakeId !== undefined &&\n rawResponse.storyId !== undefined &&\n rawResponse.routingRecommendation !==\n undefined\n ) {\n return {\n critique: rawResponse,\n\n responseMetadata: {\n finishReason:\n 'direct_object_mapping',\n candidateCount: null,\n promptFeedback: null,\n usageMetadata: null,\n },\n };\n }\n\n throw new Error(\n 'unsupported_critique_response_shape'\n );\n}\n\nfunction validateStringArray(\n value,\n fieldName,\n errors\n) {\n if (!Array.isArray(value)) {\n errors.push(\n `${fieldName}_must_be_array`\n );\n return;\n }\n\n const invalidItem = value.some(\n (entry) =>\n typeof entry !== 'string' ||\n entry.trim() === ''\n );\n\n if (invalidItem) {\n errors.push(\n `${fieldName}_contains_invalid_item`\n );\n }\n}\n\nfunction normalizeStringArray(value) {\n if (!Array.isArray(value)) {\n return [];\n }\n\n return value.map((entry) =>\n entry.trim()\n );\n}\n\nconst baseItems =\n $('Validate AI Story Draft (schema check)').all();\n\nreturn items.map((item, index) => {\n /*\n * Match each critique response with the corresponding\n * governed story record.\n */\n const base =\n baseItems[index]?.json ??\n baseItems[0]?.json ??\n null;\n\n const rawResponse = item.json;\n\n const errors = [];\n const warnings = [];\n\n let critique = null;\n let responseMetadata = null;\n\n if (!isPlainObject(base)) {\n errors.push(\n 'missing_governed_story_context'\n );\n }\n\n try {\n const extracted =\n extractCritique(rawResponse);\n\n critique =\n extracted.critique;\n\n responseMetadata =\n extracted.responseMetadata;\n } catch (error) {\n errors.push(\n `critique_parse_error:${error.message}`\n );\n }\n\n /*\n * Fail closed for abnormal Gemini completion reasons.\n */\n const acceptableFinishReasons = [\n 'STOP',\n 'alternative_text_mapping',\n 'alternative_object_mapping',\n 'direct_object_mapping',\n null,\n ];\n\n if (\n responseMetadata &&\n !acceptableFinishReasons.includes(\n responseMetadata.finishReason\n )\n ) {\n errors.push(\n `unexpected_finish_reason:${responseMetadata.finishReason}`\n );\n }\n\n if (critique) {\n for (const field of REQUIRED_FIELDS) {\n if (\n critique[field] === undefined ||\n critique[field] === null\n ) {\n errors.push(\n `missing_field:${field}`\n );\n }\n }\n\n const expectedIntakeId =\n base?.normalized?.intakeId;\n\n const expectedStoryId =\n base?.storyDraft?.storyId;\n\n if (!expectedIntakeId) {\n errors.push(\n 'deterministic_intakeId_missing'\n );\n }\n\n if (!expectedStoryId) {\n errors.push(\n 'deterministic_storyId_missing'\n );\n }\n\n if (\n critique.intakeId !== undefined &&\n critique.intakeId !==\n expectedIntakeId\n ) {\n errors.push(\n 'intakeId_mismatch'\n );\n }\n\n if (\n critique.storyId !== undefined &&\n critique.storyId !==\n expectedStoryId\n ) {\n errors.push(\n 'storyId_mismatch'\n );\n }\n\n if (\n critique.inventedContentFlag !==\n undefined &&\n typeof critique.inventedContentFlag !==\n 'boolean'\n ) {\n errors.push(\n 'inventedContentFlag_not_boolean'\n );\n }\n\n if (\n critique.missingEvidenceFlag !==\n undefined &&\n typeof critique.missingEvidenceFlag !==\n 'boolean'\n ) {\n errors.push(\n 'missingEvidenceFlag_not_boolean'\n );\n }\n\n validateStringArray(\n critique.unsupportedAcceptanceCriteria,\n 'unsupportedAcceptanceCriteria',\n errors\n );\n\n validateStringArray(\n critique.critiqueNotes,\n 'critiqueNotes',\n errors\n );\n\n if (\n critique.routingRecommendation !==\n undefined &&\n !VALID_ROUTES.includes(\n critique.routingRecommendation\n )\n ) {\n errors.push(\n 'invalid_routing_recommendation'\n );\n }\n\n if (\n critique.overallConfidence !==\n undefined &&\n (\n typeof critique.overallConfidence !==\n 'number' ||\n !Number.isFinite(\n critique.overallConfidence\n ) ||\n critique.overallConfidence < 0 ||\n critique.overallConfidence > 1\n )\n ) {\n errors.push(\n 'overallConfidence_out_of_range'\n );\n }\n\n /*\n * Internal consistency controls.\n */\n if (\n critique.routingRecommendation ===\n 'proceed_to_human_review' &&\n critique.inventedContentFlag === true\n ) {\n errors.push(\n 'inconsistent_route_invented_
For the full experience including quality scoring and batch install features for each workflow upgrade to Pro
About this workflow
VANTIX Agile Delivery & Admin Workload Sentinel v1.1.3 — Public Portfolio Export. Uses httpRequest. Event-driven trigger; 37 nodes.
Source: https://github.com/kalyansrinivas2k26/vantix-agile-delivery-admin-workload-sentinel/blob/main/workflows/VANTIX-Agile-Delivery-Admin-Workload-Sentinel-v1.1.3-public.json — original creator credit. Request a take-down →
Related workflows
Workflows that share integrations, category, or trigger type with this one. All free to copy and import.
This workflow listens for an “Approved” label on a Trello card, reads the AI draft bookkeeping JSON from card comments, and posts the corresponding transaction to Xero. It then adds a Xero deep link b
02_LLM_Pipeline v1.0. Uses executeWorkflowTrigger, httpRequest, seaTable. Event-driven trigger; 65 nodes.
This workflow allows you to import any workflow from a file or another n8n instance and map the credentials easily. A multi-form setup guides you through the entire process At the beginning you have t
[n8n] Advanced URL Parsing and Shortening Workflow - Switchy.io Integration. Uses splitInBatches, stickyNote, httpRequest, html. Event-driven trigger; 56 nodes.
[](https://youtu.be/c7yCZhmMjtI)