DEV Community

Cover image for Build a Boundary Policy Engine for Stateful AI Chat in TypeScript
Aihh
Aihh

Posted on

Build a Boundary Policy Engine for Stateful AI Chat in TypeScript

An AI chat model should not decide product permissions from prompt vibes.

If a companion can change tone, advance a relationship, introduce a sensitive topic, or generate voice and images, the application needs a deterministic decision before generation begins.

This tutorial builds a small boundary policy engine with three outcomes:

ALLOW | DENY | ASK
Enter fullscreen mode Exit fullscreen mode

It handles scope, rule authority, expiry, revocation, conflicts, and stale asynchronous jobs.

Boundary policy engine flow from requested capability through scope, active rules, conflict resolution, and generation

Define capabilities and decisions

type Capability =
  | "tone.playful"
  | "topic.work"
  | "relationship.advance"
  | "media.voice"
  | "media.image"

type PolicyEffect = "allow" | "deny" | "ask"

type RuleSource =
  | "product-default"
  | "confirmed-inference"
  | "explicit-user"

type PolicyScope =
  | { kind: "global"; userId: string }
  | { kind: "character"; userId: string; characterId: string }
  | { kind: "conversation"; conversationId: string }
  | { kind: "scene"; conversationId: string; sceneId: string }

type PolicyRule = {
  id: string
  capability: Capability
  effect: PolicyEffect
  source: RuleSource
  scope: PolicyScope
  validFrom: Date
  validUntil?: Date
  revokedAt?: Date
  version: number
}
Enter fullscreen mode Exit fullscreen mode

Rules contain decisions, not free-form conversation. A separate audit link can point to approved evidence if the product needs it.

Represent the request context

type PolicyContext = {
  now: Date
  userId: string
  characterId: string
  conversationId: string
  sceneId?: string
  policyVersion: number
}

type CapabilityRequest = {
  capability: Capability
  requestedBy: "user" | "assistant" | "automation"
}
Enter fullscreen mode Exit fullscreen mode

requestedBy matters. A user explicitly asking for an image is different from an automation deciding to insert one.

Match scope explicitly

function scopeMatches(
  scope: PolicyScope,
  context: PolicyContext,
): boolean {
  switch (scope.kind) {
    case "global":
      return scope.userId === context.userId
    case "character":
      return (
        scope.userId === context.userId &&
        scope.characterId === context.characterId
      )
    case "conversation":
      return scope.conversationId === context.conversationId
    case "scene":
      return (
        scope.conversationId === context.conversationId &&
        scope.sceneId === context.sceneId
      )
  }
}
Enter fullscreen mode Exit fullscreen mode

Semantic similarity must never override a scope mismatch. This check belongs in normal application tests, even if the database also filters by scope.

Remove inactive rules

function isActive(rule: PolicyRule, now: Date): boolean {
  if (rule.revokedAt) return false
  if (rule.validFrom > now) return false
  if (rule.validUntil && rule.validUntil <= now) return false
  return true
}
Enter fullscreen mode Exit fullscreen mode

Revocation and expiry are separate. Revocation reflects a user or product decision; expiry reflects a validity boundary.

Rank authority and specificity

const sourceRank: Record<RuleSource, number> = {
  "product-default": 0,
  "confirmed-inference": 1,
  "explicit-user": 2,
}

const scopeRank: Record<PolicyScope["kind"], number> = {
  global: 0,
  character: 1,
  conversation: 2,
  scene: 3,
}

function compareRules(a: PolicyRule, b: PolicyRule): number {
  return (
    sourceRank[b.source] - sourceRank[a.source] ||
    scopeRank[b.scope.kind] - scopeRank[a.scope.kind] ||
    b.version - a.version ||
    a.id.localeCompare(b.id)
  )
}
Enter fullscreen mode Exit fullscreen mode

The final ID comparison is only a deterministic tie-breaker. Equal stored state should produce equal decisions on every worker.

Resolve conflicts conservatively

type PolicyDecision = {
  effect: PolicyEffect
  ruleIds: string[]
  reason:
    | "explicit-rule"
    | "conflicting-rules"
    | "no-applicable-rule"
}

function decide(
  request: CapabilityRequest,
  rules: PolicyRule[],
  context: PolicyContext,
): PolicyDecision {
  const candidates = rules
    .filter(rule => rule.capability === request.capability)
    .filter(rule => scopeMatches(rule.scope, context))
    .filter(rule => isActive(rule, context.now))
    .sort(compareRules)

  if (candidates.length === 0) {
    return {
      effect: "ask",
      ruleIds: [],
      reason: "no-applicable-rule",
    }
  }

  const top = candidates[0]
  const tied = candidates.filter(rule =>
    sourceRank[rule.source] === sourceRank[top.source] &&
    scopeRank[rule.scope.kind] === scopeRank[top.scope.kind] &&
    rule.version === top.version,
  )

  const effects = new Set(tied.map(rule => rule.effect))

  if (effects.size > 1) {
    return {
      effect: "ask",
      ruleIds: tied.map(rule => rule.id),
      reason: "conflicting-rules",
    }
  }

  return {
    effect: top.effect,
    ruleIds: [top.id],
    reason: "explicit-rule",
  }
}
Enter fullscreen mode Exit fullscreen mode

When equally authoritative rules conflict, ask is safer than silently choosing the more permissive value.

Separate decision from generation

async function generateCompanionReply(
  request: CapabilityRequest,
  context: PolicyContext,
) {
  const rules = await policyStore.forUser(context.userId)
  const decision = decide(request, rules, context)

  if (decision.effect === "deny") {
    return { kind: "blocked", decision }
  }

  if (decision.effect === "ask") {
    return {
      kind: "needs-confirmation",
      decision,
      prompt: confirmationCopy(request.capability),
    }
  }

  return model.generate({
    context,
    capability: request.capability,
    policyDecision: decision,
  })
}
Enter fullscreen mode Exit fullscreen mode

Do not generate the content first and filter afterward. Post-generation filtering cannot undo an unwanted relationship transition or an already-dispatched voice job.

Re-check asynchronous jobs

type MediaJob = {
  id: string
  userId: string
  capability: "media.voice" | "media.image"
  policyVersion: number
}

async function executeMediaJob(job: MediaJob) {
  const currentVersion = await policyStore.version(job.userId)

  if (currentVersion !== job.policyVersion) {
    throw new Error("POLICY_VERSION_STALE")
  }

  return mediaWorker.generate(job)
}
Enter fullscreen mode Exit fullscreen mode

A production worker should reload the current rules and re-evaluate, not merely compare versions. The version check is a fast rejection path.

Test the uncomfortable cases

import { describe, expect, it } from "vitest"

describe("boundary policy engine", () => {
  it("lets an explicit denial beat an inferred allow", () => {
    const result = decide(imageRequest, [inferredAllow, explicitDeny], context)
    expect(result.effect).toBe("deny")
  })

  it("does not leak a character rule", () => {
    const result = decide(playfulTone, [characterRule], {
      ...context,
      characterId: "different-character",
    })
    expect(result.effect).toBe("ask")
  })

  it("ignores a revoked permission", () => {
    const result = decide(voiceRequest, [revokedVoiceRule], context)
    expect(result.effect).toBe("ask")
  })

  it("asks when equally authoritative rules conflict", () => {
    const result = decide(topicRequest, [sameRankAllow, sameRankDeny], context)
    expect(result.effect).toBe("ask")
    expect(result.reason).toBe("conflicting-rules")
  })
})
Enter fullscreen mode Exit fullscreen mode

Also test scene expiry, duplicate events, clock boundaries, stale jobs, unavailable policy storage, and retries after a user changes a rule.

Observe decisions without logging private text

Useful traces include:

capability=media.image
decision=ask
reason=no-applicable-rule
scope=character
policy_version=42
candidate_rules=0
Enter fullscreen mode Exit fullscreen mode

The trace does not need the private conversation or the rule's original message text.

Final takeaway

A stateful AI companion needs a policy decision before it needs a prompt.

Match scope. Remove revoked and expired rules. Rank authority and specificity. Turn unresolved conflicts into ask. Re-check policy before asynchronous output. Keep the decision observable and the conversation private.

That is less magical than letting the model infer every boundary. It is also far easier to test, explain, and reverse.


Affiliation and AI-assistance disclosure: I work with the LumiChat team, where character-specific tone, relationship pacing, and generated media make boundary decisions a practical engineering problem. This tutorial is vendor-neutral and does not depend on LumiChat infrastructure. AI tools assisted with editing and diagram production; the code and final claims were reviewed by the author.

Top comments (0)