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
It handles scope, rule authority, expiry, revocation, conflicts, and stale asynchronous jobs.
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
}
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"
}
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
)
}
}
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
}
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)
)
}
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",
}
}
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,
})
}
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)
}
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")
})
})
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
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)