Common Integration Patterns
This guide documents the core architectural patterns used throughout the Stratum Platform. These patterns enforce security, compliance, and data integrity across all integrations. Each pattern includes implementation examples, critical gotchas, and verification checklists.1. Multi-Tenant Isolation Pattern
The Pattern: Every query filters bytenant_id. Data from one tenant is never accessible to another, regardless of how a user structures their request.
Implementation
SQL (Backend):-- Correct: filtered by tenant
SELECT FROM appeals WHERE tenant_id = $1 AND id = $2
-- Wrong: no tenant filter (SECURITY BREACH)
SELECT FROM appeals WHERE id = $1
TypeScript (Node.js):
// Correct: extract tenant from header, use in WHERE
app.get('/api/appeals/:id', (req, res) => {
const tenantId = req.headers['x-user-id'] // Never trust client
const appealId = req.params.id
const appeal = db('appeals')
.where({ tenant_id: tenantId, id: appealId })
.first()
return res.json(appeal)
})
// Wrong: use client-supplied tenant
app.get('/api/appeals/:id', (req, res) => {
const tenantId = req.body.tenant_id // User could send wrong tenant!
const appeal = db('appeals')
.where({ tenant_id: tenantId, id: req.params.id })
.first()
})
React Query (Frontend):
const useUserAppeals = (tenantId: string) => {
return useQuery(['appeals', tenantId], async () => {
const response = await fetch('/api/appeals', {
headers: { 'X-User-Id': tenantId }
})
if (!response.ok) throw new Error('Failed to fetch appeals')
return response.json()
})
}
// User gets their own appeals only
Critical Gotchas
- [ ] Never forget
tenant_idin WHERE clause — If you do, one user could see every tenant's data. Always add it as first condition. - [ ] Never JOIN across tenants — If you SELECT from appeals and precedents without filtering both tables by tenant, data leaks. Test with two mock tenants.
- [ ] Never trust client to send tenant_id — Always extract from
X-User-Idheader (server-authenticated, not client-controlled). - [ ] Always test with two users — Create test user A and user B, verify A cannot see B's data. This is non-negotiable for every endpoint.
- [ ] Check middleware layer — If you have auth middleware, ensure it attaches tenant_id to request context before route handlers see it.
2. Credit Deduction Pattern
The Pattern: Deduct credits atomically with query execution. Users are only charged if their request succeeds. Failed requests don't consume credits.Implementation
// Wrapper function for all credit-consuming operations
const withCreditDeduction = async (
db: Database,
userId: string,
operation: 'marketplace_query' 'export' 'api_call',
cost: number,
callback: (tx: Transaction) => Promise<any>
) => {
const tx = await db.transaction()
try {
// Step 1: Check balance BEFORE deduction
const account = await tx('credit_accounts')
.where({ user_id: userId })
.first()
if (account.balance < cost) {
await tx.rollback()
throw new Error('Insufficient credits')
}
// Step 2: Execute the operation (could fail)
const result = await callback(tx)
// Step 3: Deduct credits AFTER successful operation
await tx('credit_accounts')
.where({ user_id: userId })
.decrement('balance', cost)
// Step 4: Record ledger entry (append-only audit trail)
await tx('credit_ledger').insert({
user_id: userId,
amount: cost,
operation: operation,
balance_after: account.balance - cost,
created_at: new Date(),
request_id: generateId() // Idempotency key
})
await tx.commit()
return result
} catch (error) {
await tx.rollback()
throw error
}
}
// Usage: querying marketplace
app.post('/api/marketplace/query', async (req, res) => {
const { query, tenantId } = req.body
try {
const results = await withCreditDeduction(
db,
tenantId,
'marketplace_query',
10, // 10 credits per query
async (tx) => {
// This only runs if credits are available
return await tx('marketplace_clusters')
.where({ visibility: 'public' })
.search(query)
}
)
res.json({ results, creditsDeducted: 10 })
} catch (error) {
if (error.message === 'Insufficient credits') {
return res.status(402).json({
status: 402,
error: 'Insufficient credits',
required: 10,
available: error.balance
})
}
res.status(500).json({ error: error.message })
}
})
Critical Gotchas
[ ] Deduct credits AFTER operation succeeds, not before — If you deduct first and the query fails, user loses credits for nothing.
[ ] Rollback transaction on error — If anything fails mid-transaction, rollback undoes both the operation and the deduction. User pays nothing.
[ ] Record ledger entry for EVERY deduction — Ledger is append-only. Never update or delete entries. This is your audit trail for disputes and refunds.
[ ] Never delete ledger entries — Support may need to verify why a charge occurred. Immutability is critical.
[ ] Balance check BEFORE deduction — Prevent negative balances. Check balance first, then deduct atomically.
[ ] Use request_id for idempotency — If the deduction succeeds but the response fails, the client might retry. Use the same request_id to detect duplicate ledger entries.
3. PHI Validation Pattern
The Pattern: Validate Protected Health Information (PHI) and Personally Identifiable Information (PII) before processing. Reject requests containing 18 HIPAA-protected identifiers with a 422 error (unprocessable entity).
Implementation
// Comprehensive list of HIPAA-protected identifiers
const PROTECTED_IDENTIFIERS = [
'SSN', 'Social Security',
'Phone', 'Telephone',
'Email', 'Email address',
'DOB', 'Date of Birth',
'MRN', 'Medical Record Number',
'IIN', 'Insurance ID',
'Name',
'Address',
'Zip', 'Zipcode', 'Postal code',
'Credit Card', 'Card Number',
'Account Number',
'Driver License',
'Passport',
'Vehicle ID'
]
const containsProtectedId = (text: string): string[] => {
const found: string[] = []
for (const identifier of PROTECTED_IDENTIFIERS) {
const regex = new RegExp(\\b${identifier}\\b, 'i')
if (regex.test(text)) {
found.push(identifier)
}
}
return found
}
// Middleware: runs FIRST, before route handlers
const phiValidationMiddleware = (req, res, next) => {
// Check all text fields: narrative, context, labels, descriptions
const textFields = [
req.body.narrative_content,
req.body.denial_context,
req.body.clinical_notes,
req.body.appeal_rationale,
JSON.stringify(req.body.metadata), // Check nested objects
JSON.stringify(req.body.attachments)
]
const allText = textFields.filter(Boolean).join('\n')
const detected = containsProtectedId(allText)
if (detected.length > 0) {
return res.status(422).json({
status: 422,
type: 'PHI_VALIDATION_FAILED',
message: 'Request contains PHI/PII. Please remove before submitting.',
detected: detected, // Help user fix
hint: 'Remove: ' + detected.join(', ')
})
}
next()
}
// Apply middleware BEFORE route handlers
app.post('/api/appeals', phiValidationMiddleware, async (req, res) => {
// If we reach here, request is PHI-safe
const appeal = await db('appeals').insert({
tenant_id: req.headers['x-user-id'],
narrative_content: req.body.narrative_content, // Safe to store
created_at: new Date()
})
res.json({ id: appeal.id })
})
Critical Gotchas
[ ] Run validation BEFORE business logic — If you validate after querying the database, you've already stored PHI. Run as middleware, before route handlers.
[ ] Check ALL text fields — Narrative, context, labels, metadata, attachments. Attackers hide PII in nested objects or file descriptions.
[ ] Return 422, not 400 — 422 Unprocessable Entity signals validation error. 400 Bad Request is for malformed syntax. 422 tells client "your data is valid JSON but contains disallowed content."
[ ] List detected identifiers in error — Help the user fix the problem. Show what you found so they can remove it and retry.
[ ] Don't try to strip or redact — If you find PII, reject the entire request. Don't try to automatically remove it (you might destroy legitimate clinical data).
4. SUD Compliance Pattern (42 CFR Part 2)
The Pattern: Substance Use Disorder (SUD) data is subject to 42 CFR Part 2, which has stricter sharing and deidentification rules than HIPAA. Mark SUD data at intake, track it separately, and exclude it from deidentified exports.
Implementation
// Mark SUD data at intake
const appeal = {
id: 'appeal_123',
tenant_id: 'facility_456',
denial_type: 'Prior Auth Denied',
narrative_content: 'Patient presented with opioid use disorder...',
// Critical: flag SUD data
has_sud_data: true,
// Track sharing restrictions
sharing_restrictions: ['NO_DEIDENTIFY', 'REQUIRE_CONSENT'],
// Keep SUD fields separate (logical grouping)
sud_details: {
substance_type: 'opioid',
diagnosis_code: 'F11.20', // ICD-10 for opioid dependence
treatment_modality: 'medication_assisted'
},
// Standard appeal fields (not SUD-specific)
service_type: 'Residential',
primary_payer: 'Cigna'
}
// Export removes SUD data
const deidentifyAppeal = (appeal: Appeal): any => {
const deidentified: any = {
id: appeal.id,
denial_type: appeal.denial_type,
service_type: appeal.service_type,
primary_payer: appeal.primary_payer,
outcome_won: appeal.outcome_won
}
// If SUD data present, exclude from deidentified export
if (appeal.has_sud_data) {
console.warn(Deidentifying appeal ${appeal.id}: SUD data present, excluding from export)
// Delete all SUD-related fields
delete appeal.sud_details
delete appeal.substance_type
delete appeal.medication_type
// Don't include narrative (could contain SUD references)
} else {
// Only include narrative if NO SUD data
deidentified.narrative_summary = appeal.narrative_content.substring(0, 500)
}
// Apply Safe Harbor deidentification (remove 18 HIPAA identifiers)
return safeHarborDeidentify(deidentified)
}
// API endpoint: export with consent check
app.post('/api/appeals/:id/export', async (req, res) => {
const { format = 'json' } = req.body
const appeal = await db('appeals')
.where({ id: req.params.id, tenant_id: req.headers['x-user-id'] })
.first()
if (!appeal) {
return res.status(404).json({ error: 'Appeal not found' })
}
// If SUD data and format is deidentified, require consent
if (appeal.has_sud_data && format === 'deidentified') {
const hasConsent = await db('sud_consents')
.where({ appeal_id: appeal.id, user_id: req.user.id })
.first()
if (!hasConsent) {
return res.status(403).json({
error: 'SUD consent required',
message: 'This appeal contains SUD data. Explicit consent is required to export.',
sudDataFields: appeal.sharing_restrictions
})
}
}
// Export (deidentify if needed)
const exported = format === 'deidentified'
? deidentifyAppeal(appeal)
: appeal // Raw export with consent
res.json({
appeal: exported,
format: format,
sudDataIncluded: format !== 'deidentified' && appeal.has_sud_data
})
})
Critical Gotchas
[ ] Mark SUD data at intake — You cannot detect SUD data retroactively. If you don't mark it when the appeal enters the system, you won't know later. Make has_sud_data a required field on intake forms.
[ ] Never export SUD data deidentified — 42 CFR Part 2 prohibits sharing SUD data in any form without explicit consent. Deidentification does not satisfy this requirement. If SUD data is present, either don't export or export with full identifiers and consent.
[ ] Require explicit consent to share SUD data — Document consent in a separate table (sud_consents). Never assume consent from HIPAA authorization.
[ ] Keep SUD data segregated — Either store in a separate table or use a flag + separate column group. Don't mix SUD and non-SUD fields.
[ ] Audit SUD exports — Log every export of SUD data with user, timestamp, and consent reference. This is required for compliance audits.
5. Version History Pattern (Immutable Versioning)
The Pattern: The current row is mutable (shows latest values). Updates snapshot the old values to a history table. History table is append-only and immutable.
Implementation
// Update precedent (current row changes)
const updatePrecedent = async (
db: Database,
tenantId: string,
precedentId: string,
updates: Partial<Precedent>,
userId: string,
reason: string
) => {
const tx = await db.transaction()
try {
// Step 1: Fetch current values BEFORE update
const current = await tx('precedents')
.where({ id: precedentId, tenant_id: tenantId })
.first()
if (!current) {
throw new Error('Precedent not found')
}
// Step 2: Get current version number
const lastVersion = await tx('precedent_versions')
.where({ precedent_id: precedentId })
.orderBy('version_number', 'desc')
.first()
const nextVersion = (lastVersion?.version_number ?? 0) + 1
// Step 3: Update the current row (mutable)
await tx('precedents')
.where({ id: precedentId, tenant_id: tenantId })
.update({
...updates,
updated_at: new Date(),
updated_by: userId,
current_version: nextVersion
})
// Step 4: Snapshot OLD values to history (immutable)
await tx('precedent_versions').insert({
precedent_id: precedentId,
tenant_id: tenantId,
version_number: nextVersion,
snapshot: {
...current,
// Exclude system fields
id: undefined,
created_at: undefined,
updated_at: undefined
},
changed_fields: Object.keys(updates),
changed_by: userId,
changed_at: new Date(),
change_reason: reason
})
await tx.commit()
return { precedent: { ...current, ...updates }, version: nextVersion }
} catch (error) {
await tx.rollback()
throw error
}
}
// Usage
await updatePrecedent(db, tenantId, 'prec_123', {
evidence_quality_score: 2.0,
outcome_status: 'Won'
}, userId, 'outcome_updated')
// Result:
// - precedents row updated (new values now current)
// - precedent_versions row added (old values preserved)
// Query version history
app.get('/api/precedents/:id/history', async (req, res) => {
const versions = await db('precedent_versions')
.where({ precedent_id: req.params.id })
.orderBy('version_number', 'desc')
res.json({
versions: versions.map(v => ({
version: v.version_number,
snapshot: v.snapshot,
changedBy: v.changed_by,
changedAt: v.changed_at,
reason: v.change_reason
}))
})
})
Critical Gotchas
[ ] Never UPDATE history table — History is append-only. You never modify precedent_versions. If a change was logged incorrectly, insert a new entry explaining the correction, don't delete the old one.
[ ] Always snapshot BEFORE updating — Fetch current values, insert to history, THEN update the current row. Order matters.
[ ] Always include version_number — This ensures you can replay history in chronological order, even if clocks are slightly off.
[ ] Always include change_reason — Help audit why a change was made. Was it user edit, admin correction, compliance gate, or system auto-fix?
[ ] Include changed_fields list — Track which specific fields changed. Helps auditors understand scope of change.
6. Soft Delete Pattern
The Pattern: Mark records as deleted with timestamp and user, don't actually remove them. Queries always filter out soft-deleted records. Data retention applies even to deleted data (7-year compliance requirement).
Implementation
// Soft delete (don't actually DELETE)
const softDeleteAppeal = async (
db: Database,
tenantId: string,
appealId: string,
userId: string,
reason: string
) => {
const result = await db('appeals')
.where({ id: appealId, tenant_id: tenantId })
.update({
deleted_at: new Date(),
deleted_by: userId,
deletion_reason: reason,
status: 'archived' // Optional: mark as archived
})
if (result === 0) {
throw new Error('Appeal not found or already deleted')
}
// Log deletion for audit
await db('audit_log').insert({
action: 'soft_delete',
resource_type: 'appeal',
resource_id: appealId,
user_id: userId,
reason: reason,
created_at: new Date()
})
return { deleted: true, appealId }
}
// Query: always exclude soft-deleted records
const getAppeals = async (db: Database, tenantId: string) => {
return await db('appeals')
.where({ tenant_id: tenantId })
.where('deleted_at', 'is', null) // Critical: exclude deleted
.orderBy('created_at', 'desc')
}
// Admin restore (undo soft delete)
const restoreAppeal = async (
db: Database,
tenantId: string,
appealId: string,
userId: string
) => {
const result = await db('appeals')
.where({ id: appealId, tenant_id: tenantId })
.update({
deleted_at: null,
deleted_by: null,
deletion_reason: null,
restored_at: new Date(),
restored_by: userId
})
return { restored: true, appealId }
}
// Route: list active appeals (soft deletes filtered out automatically)
app.get('/api/appeals', async (req, res) => {
const tenantId = req.headers['x-user-id']
const appeals = await getAppeals(db, tenantId)
res.json({ appeals })
})
// Route: admin view deleted appeals
app.get('/api/appeals/trash', adminOnly, async (req, res) => {
const tenantId = req.headers['x-user-id']
const deleted = await db('appeals')
.where({ tenant_id: tenantId })
.where('deleted_at', 'is not', null)
.orderBy('deleted_at', 'desc')
res.json({ deleted })
})
Critical Gotchas
[ ] Always filter deleted_at IS NULL in queries — Every SELECT that should exclude deleted records must have this clause. If you forget it on one query, deleted data leaks. Create a query builder helper to enforce this automatically.
[ ] Never actually DELETE rows — Once deleted via SQL DELETE, you can't restore. Always use UPDATE to mark deleted_at.
[ ] Include deleted_by and deleted_at — Required for audit trail. Show user who deleted it and when.
[ ] 7-year retention applies to deleted data too — Compliance requires you keep deleted records for 7 years. Don't run a cleanup job to purge deleted appeals before 7 years is up.
[ ] Test the deletion filter — Create a test that soft-deletes an appeal, then queries appeals. Verify deleted appeal doesn't appear. Do this in every test suite.
7. Audit Logging Pattern
The Pattern: Log every mutation (INSERT, UPDATE, DELETE) for compliance and dispute resolution. Logs are append-only, immutable, and retained for 7 years.
Implementation
// Audit logging function
const logAuditEvent = async (
db: Database,
event: {
user_id: string
action: 'create' 'update' 'delete' 'export' 'share' 'access'
resource_type: string // 'appeal', 'precedent', 'credit', etc.
resource_id: string
changes?: {
before?: Record<string, any>
after?: Record<string, any>
}
ip_address?: string
user_agent?: string
tenant_id: string
}
) => {
await db('audit_log').insert({
user_id: event.user_id,
action: event.action,
resource_type: event.resource_type,
resource_id: event.resource_id,
changes: event.changes ? JSON.stringify(event.changes) : null,
ip_address: event.ip_address,
user_agent: event.user_agent,
tenant_id: event.tenant_id,
created_at: new Date(),
event_id: generateId() // For tracing
})
}
// Middleware: capture IP and user-agent
const auditMiddleware = (req, res, next) => {
req.audit = {
ip_address: req.ip,
user_agent: req.get('user-agent')
}
next()
}
// Usage: after appeal created
app.post('/api/appeals', auditMiddleware, async (req, res) => {
const appeal = await db('appeals').insert({
tenant_id: req.headers['x-user-id'],
denial_type: req.body.denial_type,
created_at: new Date()
})
// Log creation
await logAuditEvent(db, {
user_id: req.user.id,
action: 'create',
resource_type: 'appeal',
resource_id: appeal.id,
changes: {
after: {
id: appeal.id,
denial_type: req.body.denial_type,
created_at: appeal.created_at
}
},
ip_address: req.audit.ip_address,
user_agent: req.audit.user_agent,
tenant_id: req.headers['x-user-id']
})
res.json({ id: appeal.id })
})
// Usage: after appeal updated
app.patch('/api/appeals/:id', auditMiddleware, async (req, res) => {
const current = await db('appeals')
.where({ id: req.params.id })
.first()
const updated = await db('appeals')
.where({ id: req.params.id })
.update(req.body)
await logAuditEvent(db, {
user_id: req.user.id,
action: 'update',
resource_type: 'appeal',
resource_id: req.params.id,
changes: {
before: current,
after: { ...current, ...req.body }
},
ip_address: req.audit.ip_address,
user_address: req.audit.user_agent,
tenant_id: req.headers['x-user-id']
})
res.json({ updated: true })
})
// Query: audit trail for a resource
app.get('/api/appeals/:id/audit', async (req, res) => {
const tenantId = req.headers['x-user-id']
const events = await db('audit_log')
.where({
resource_id: req.params.id,
resource_type: 'appeal',
tenant_id: tenantId
})
.orderBy('created_at', 'desc')
res.json({
resourceId: req.params.id,
events: events.map(e => ({
action: e.action,
user: e.user_id,
timestamp: e.created_at,
changes: JSON.parse(e.changes || '{}'),
ipAddress: e.ip_address
}))
})
})
Critical Gotchas
[ ] Log AFTER successful mutation — Don't log before. If the operation fails, don't log it (failed operations don't need audit trails).
[ ] Include what changed (before and after values) — For updates, include both old and new values. Helps auditors understand the scope of change.
[ ] Include who, when, where — Always log user_id, timestamp, and IP address. This reconstructs context if needed.
[ ] 7-year retention — Never delete audit logs. Not even after 7 years (if a lawsuit happens, you'll need them). Archive to cold storage if needed, but don't purge.
[ ] Use event_id for tracing — Generate a unique ID for each event. If something went wrong and the user reports it, you can grep audit logs by event_id to see full context.
8. Marketplace Clustering Pattern
The Pattern: Group precedents by deterministic cluster key (Payer + Denial Type + LOC). Clusters become visible when 2+ tenants contribute precedents. Maturity progression signals data quality.
Implementation
// Generate deterministic cluster key
const getClusterId = (
payer: string,
denialType: string,
levelOfCare: string
): string => {
// Sort to ensure same inputs always produce same key
return [payer, denialType, levelOfCare]
.map(s => s.toLowerCase().trim())
.sort()
.join('|')
}
// Example:
// Payer: "Cigna", Denial: "Prior Auth", LOC: "Residential"
// → "cignaprior auth residential"
// Check cluster visibility (requires 2+ contributors)
const getClusterVisibility = async (
db: Database,
clusterId: string
): Promise<'private' | 'marketplace'> => {
const contributors = await db('precedent_versions')
.distinct('tenant_id')
.where({ cluster_id: clusterId })
return contributors.length >= 2 ? 'marketplace' : 'private'
}
// Calculate maturity tier based on precedent count and win rate
const calculateMaturityTier = (
precedentCount: number,
aggregateWinRate: number
): 'Embryonic' 'Emerging' 'Mature' 'Canonical' => {
if (precedentCount < 5) {
return 'Embryonic' // New cluster, insufficient data
}
if (precedentCount < 20 || aggregateWinRate < 60) {
return 'Emerging' // Growing but unproven
}
if (precedentCount < 50) {
return 'Mature' // Solid evidence base
}
return 'Canonical' // 50+ precedents, 60%+ win rate, proven track record
}
// Get cluster metadata
app.get('/api/marketplace/clusters/:clusterId', async (req, res) => {
const { clusterId } = req.params
const precedents = await db('precedents')
.where({ cluster_id: clusterId })
.where('deleted_at', 'is', null)
const visibility = await getClusterVisibility(db, clusterId)
const wins = precedents.filter(p => p.outcome_won).length
const winRate = precedents.length > 0
? Math.round((wins / precedents.length) * 100)
: 0
const maturity = calculateMaturityTier(precedents.length, winRate)
res.json({
clusterId,
payer: extractPayerFromKey(clusterId),
denialType: extractDenialTypeFromKey(clusterId),
levelOfCare: extractLocFromKey(clusterId),
visibility,
precedentCount: precedents.length,
winRate: ${winRate}%,
maturity,
visibility_requirement: '2+ tenants',
current_contributors: visibility === 'marketplace' ? 2 : 1,
can_export: visibility === 'marketplace'
})
})
// Only marketplace-visible clusters show in search
app.post('/api/marketplace/search', async (req, res) => {
const { query, tenantId } = req.body
const results = await db('marketplace_clusters')
.whereRaw(
visibility = 'marketplace'
AND (payer ILIKE ? OR denial_type ILIKE ? OR loc ILIKE ?)
, [query, query, query])
.orderBy('maturity', 'desc') // Canonical clusters first
.orderBy('precedent_count', 'desc')
res.json({
results: results.map(c => ({
clusterId: c.cluster_id,
maturity: c.maturity,
precedentCount: c.precedent_count,
winRate: c.win_rate,
description: ${c.payer} ${c.denial_type} ${c.loc}
}))
})
})
Critical Gotchas
[ ] Cluster key must be deterministic — Same inputs must always produce the same key. Sort fields alphabetically, lowercase, trim whitespace. If key generation varies, you'll create duplicate clusters.
[ ] Visibility requires 2+ distinct tenants — Prevents one tenant from gaming the marketplace by submitting multiple precedents. Enforce at creation time, not lookup time.
[ ] Maturity based on count AND win rate — A cluster with 100 losing precedents shouldn't be "Canonical." Check both precedent count and win rate.
[ ] Don't hardcode maturity thresholds — If business logic changes thresholds, it should be in CLAUDE.md or a configuration table, not buried in code.
9. Prevention Rules Engine Pattern (PR-U and PR-D)
The Pattern: PR-U gates are blocking (user cannot proceed without fix). PR-D gates are non-blocking (warn user, but allow action). This prevents invalid data from entering the system.
Implementation
// Prevention Rule types
type PreventionRule = {
name: string
description: string
check: (appeal: Appeal) => boolean
severity: 'error' | 'warning' // error = PR-U, warning = PR-D
}
// PR-U gates: blocking (user must fix to proceed)
const PR_U_GATES: PreventionRule[] = [
{
name: 'has_narrative',
description: 'Appeal must have clinical narrative',
check: (appeal) => !!appeal.narrative_content?.trim(),
severity: 'error'
},
{
name: 'has_precedents',
description: 'Appeal must reference at least one precedent',
check: (appeal) => appeal.precedents?.length > 0,
severity: 'error'
},
{
name: 'valid_precedents',
description: 'All referenced precedents must exist',
check: async (appeal) => {
const ids = appeal.precedents.map(p => p.id)
const found = await db('precedents').whereIn('id', ids)
return found.length === ids.length
},
severity: 'error'
},
{
name: 'narrative_length',
description: 'Narrative must be at least 100 characters',
check: (appeal) => appeal.narrative_content?.length >= 100,
severity: 'error'
},
{
name: 'precedent_relevance',
description: 'Precedents must be from same denial type',
check: (appeal) => {
const denialTypes = appeal.precedents.map(p => p.denial_type)
return new Set(denialTypes).size === 1 // All same type
},
severity: 'error'
}
]
// PR-D gates: non-blocking (warn but allow)
const PR_D_GATES: PreventionRule[] = [
{
name: 'outcome_resolution',
description: 'Outcome not yet marked Won/Lost',
check: (appeal) => !!appeal.outcome_status,
severity: 'warning'
},
{
name: 'sud_compliance',
description: 'SUD data present but not flagged',
check: (appeal) => {
const hasSudKeywords = /opioidsubstance addiction
disorder/i.test(
appeal.narrative_content
)
return !hasSudKeywords || appeal.has_sud_data
},
severity: 'warning'
},
{
name: 'traceability_complete',
description: 'Payer and facility linked',
check: (appeal) => !!appeal.payer_id && !!appeal.facility_id,
severity: 'warning'
},
{
name: 'submission_readiness',
description: 'Ready to submit to payer',
check: (appeal) => appeal.precedents.length >= 2,
severity: 'warning'
}
]
// Validation function
const validateAppealSubmission = async (
appeal: Appeal,
rules: PreventionRule[]
): Promise<ValidationResult> => {
const errors: string[] = []
const warnings: string[] = []
for (const rule of rules) {
const passed = await rule.check(appeal)
if (!passed) {
if (rule.severity === 'error') {
errors.push(❌ ${rule.description})
} else {
warnings.push(⚠️ ${rule.description})
}
}
}
return {
status: errors.length > 0 ? 'errors' : 'valid',
errors,
warnings
}
}
// API: submit appeal (PR-U blocking)
app.post('/api/appeals/submit', async (req, res) => {
const appeal = req.body
const validation = await validateAppealSubmission(appeal, PR_U_GATES)
if (validation.status === 'errors') {
// Block submission
return res.status(400).json({
status: 400,
message: 'Cannot submit: fix errors below',
errors: validation.errors,
blocking: true
})
}
// If validation passes, submit
const submitted = await db('appeals')
.where({ id: appeal.id })
.update({ status: 'submitted_to_payer' })
res.json({ submitted: true })
})
// API: export appeal (PR-D non-blocking)
app.post('/api/appeals/:id/export', async (req, res) => {
const appeal = await db('appeals')
.where({ id: req.params.id })
.first()
const validation = await validateAppealSubmission(appeal, PR_D_GATES)
// Don't block export, just warn
const exported = {
...appeal,
warnings: validation.warnings // Include in response
}
res.json({
appeal: exported,
message: validation.warnings.length > 0
? 'Export successful, but see warnings'
: 'Export successful'
})
})
Critical Gotchas
See Also
Last updated: 2026-04-14 Status: Active Reviewers: Patrick Lord, Security Team