Most AI behavioral rules are embedded in system prompts. They work. They're also invisible, unversioned, unattributed, and tightly coupled to the code that deploys them.
When a rule needs to change, someone opens a file, edits a string, creates a PR, waits for review, waits for deploy. The rule is now live — and there's no record of when the old version governed which sessions, no attribution to who wrote the original, and no way to ask "what rules were in effect for the session that produced this output" without reading the commit history and cross-referencing deployment times.
Engine rules are a different bet. Rules live in the database, not in the source tree. They're loaded at runtime, not compiled in. They're attributed, versioned, and cited in a session-level log that persists independently of the session. The system that executes the rules and the system that governs them are separate concerns.
This post is about why we designed it that way, what the schema looks like, and where the design has teeth.
The two jobs that shouldn't share a file
There are two distinct jobs happening when an AI agent operates under behavioral rules.
The first is execution: the agent runs, calls tools, produces output. This is code. It changes at engineering velocity. It's reviewed, tested, deployed.
The second is governance: the rules that constrain what the agent should and shouldn't do. This changes at business velocity — when a compliance requirement shifts, when the team learns something about what "correct" behavior means in practice, when a new category of case needs a handling rule.
Embedding governance in execution — rules in the system prompt, in the same file as the tool definitions and response templates — conflates these two jobs. Every governance change becomes a code change. Every rule update goes through the same review and deploy cycle as a bug fix.
That's fine at low rule volume with a small team. It breaks at scale: when the people who understand the business rules aren't the people who deploy code, when the audit requirement demands a trail the prompt history doesn't provide, when the number of rules and the number of agents both start growing.
The schema
Rules live in house_rules. The table is the permanent record — not a cache, not a config file, not a prompt snippet. Each row is a rule artifact.
CREATE TABLE house_rules (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
title TEXT NOT NULL,
distilled TEXT, -- the rule in natural language
polarity VARCHAR(20), -- 'always' | 'never' | 'prefer'
scope VARCHAR(20), -- 'personal' | 'team' | 'org'
status VARCHAR(30) DEFAULT 'candidate',
types TEXT[], -- tags: ['rule:clara', 'compliance:customer-comms']
conflict_policy VARCHAR(20) DEFAULT 'soft',
proposer_id TEXT, -- who submitted the rule
approver_id TEXT, -- who promoted it to active
approved_at TIMESTAMPTZ,
snoozed_until TIMESTAMPTZ,
retired_at TIMESTAMPTZ,
retire_reason TEXT,
org_id TEXT,
project TEXT,
last_cited_at TIMESTAMPTZ -- updated on each session load
);
A few things worth noting in the schema:
status has a lifecycle: candidate → active → snoozed | retired. Rules don't jump from "someone suggested this" to "the agent is governed by this." There's a promotion step, and the step carries attribution: proposer_id is who wrote it, approver_id is who promoted it to active.
types is the tagging surface. A rule tagged rule:clara loads for Clara's sessions. A rule tagged compliance:customer-comms loads for any session tagged for customer-facing communication. Rules can carry multiple tags; a session's rule load is the union of rules matching the session's context tags.
conflict_policy determines what happens when this rule conflicts with another rule in the loaded set. soft means "raise for resolution"; hard means "this rule wins." Most rules are soft; rules derived from compliance requirements are typically hard.
last_cited_at is a staleness signal — updated every time the rule is loaded in a session. A rule that hasn't been cited in 90 days surfaces in the Stale Shelf review queue. Rules that govern a busy agent should have recent last_cited_at; a rule with a last_cited_at 6 months ago either isn't loading correctly or has been superseded in practice without a formal retirement.
The citation log
The second table that matters is rule_citation_events.
CREATE TABLE rule_citation_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
rule_id UUID NOT NULL REFERENCES house_rules(id) ON DELETE CASCADE,
memory_id UUID, -- the memory that surfaced this rule, if any
user_id TEXT NOT NULL,
source TEXT NOT NULL, -- 'recall' | 'search' | 'session_init'
created_at TIMESTAMPTZ DEFAULT NOW()
);
Every time a rule is loaded — in a recall, in a session init, in a search — a row is written here. The row carries the rule ID, the memory ID that returned it (for traceability back to the specific memory artifact), the user, and the source.
The log is append-only and never cleaned up as part of normal operation. It's the audit surface.
For any session, the citation log answers: which rules were active, who wrote them, when were they last approved, and via which source did they enter the session context. For any rule, the citation log answers: how frequently is this rule actually loading, for which users, and when did it last appear.
The logging function runs fire-and-forget in a background thread — it never blocks recall:
def _log_rule_citations(cited_rules: list, user_id: str, source: str):
"""
Fire-and-forget: insert rule_citation_events rows and update last_cited_at.
cited_rules: list of dicts with keys 'rule_id' and 'memory_id'.
Runs in a background thread; errors are swallowed so recall is never broken.
"""
def _do_log():
conn = get_conn(); cur = conn.cursor()
for item in cited_rules:
cur.execute(
"""INSERT INTO rule_citation_events (rule_id, memory_id, user_id, source)
VALUES (%s, %s, %s, %s)""",
(item["rule_id"], item.get("memory_id"), str(user_id), source)
)
conn.commit()
rule_ids = list({item["rule_id"] for item in cited_rules})
cur.execute(
"UPDATE house_rules SET last_cited_at=NOW() WHERE id=ANY(%s)",
(rule_ids,)
)
conn.commit()
threading.Thread(target=_do_log, daemon=True).start()
The fire-and-forget pattern is deliberate. Rule citation logging is an observability concern, not a correctness concern. A failure in the citation log should not surface as a recall failure to the user. Errors are swallowed; recall completes; the log has a gap. The gap is visible in monitoring; it doesn't cascade into the user experience.
The rule lifecycle in practice: Clara
Clara is an AI engine inside Operator Stack, one of the production deployments running on Memory Stack's substrate. Clara handles lead qualification — evaluating inbound leads against a set of rules that change as the team's qualification criteria evolve.
The Clara rule set lives in house_rules tagged rule:clara. When a new qualification criterion needs to be added, the operator logs it directly: natural language, tagged, submitted as candidate. An approver reviews and promotes it to active. Clara's next session loads the updated rule set at init. No code change. No deploy.
A concrete example: the Realscout dismissal rule.
title: "Dismiss Realscout leads — extended inactivity"
distilled: "Dismiss any Realscout lead where last activity > 90 days and no booked showing."
polarity: "never" -- as in: never keep these leads in the active pipeline
scope: "team"
types: ["rule:clara"]
status: "active"
proposer_id: <operator user ID>
approver_id: <team lead user ID>
When Clara recalls context at session start, the rule loads alongside any relevant memories. The citation event is written: rule_id = <dismissal rule UUID>, source = "recall", user_id = <Clara's service account>. The audit trail records that this session ran under this rule.
If the threshold changes from 90 days to 60 days, the operator updates distilled and bumps updated_at. The prior version isn't deleted — rule_events logs the change. Clara's next session loads the new threshold. Sessions before the update loaded the old threshold, and the citation log for those sessions cites the rule version that was active at that time.
Where the design has teeth
Separation of concerns. The person who understands the qualification threshold is the one who sets it. Engineering doesn't review it, doesn't test it, doesn't ship it. The rule is live on the next session after the approver promotes it.
Attribution at every step. Every rule carries its proposer and approver. Every update to the rule is a logged event. There is no anonymous behavioral constraint embedded in a prompt somewhere — every governing rule is owned by a person.
Auditable by default. The audit question — "what rules governed this session?" — is always answerable: query rule_citation_events by session timestamp range and user. The answer is a list of rule IDs; look up each in house_rules to get the exact text and version. This is the trail regulators ask for, and it's produced automatically by normal operation.
Conflict surfacing. When two active rules for the same agent contradict each other, conflict_warnings on the memory record surfaces the contradiction. The team resolves it explicitly; the resolution is logged. Rules don't silently conflict in production — the contradiction is visible before it shapes agent behavior.
Staleness detection. last_cited_at is the Stale Shelf signal. A rule that hasn't been loaded in a configurable period surfaces for review. Rules that are active but never loading are either misconfigured or obsolete; the detection makes that visible.
The honest version
The engine rules model works well when rules are policy-level — natural language constraints that the agent should follow. It works less well for rules that are complex conditional logic, require code to evaluate, or depend on external state the agent doesn't have access to at session time. Those still require code. Engine rules are for the class of behavioral constraints that a business person can write in one sentence without referencing a code path.
The schema is also Postgres-specific in its current form. UUID primary keys, TIMESTAMPTZ, gen_random_uuid() — the model is portable conceptually, but this implementation is tied to the database engine. Teams running on different storage would need to adapt the schema; the citation log pattern and the session-init recall pattern transfer cleanly.
And adoption requires a new habit: when a behavioral constraint exists, log it as a rule instead of embedding it in a prompt. Teams that make that shift get the attribution, versioning, and audit trail automatically. Teams that continue treating behavioral rules as prompt text get none of it.
The CTA this post is recovering
The landing section for Memory Stack's enterprise tab includes a "Tune your AI without a deploy" section. Its call to action originally pointed here. The link was removed at launch because this post didn't exist yet.
Now it does. The landing section CTA will be restored.
The architecture is the product. The schema above is the one running in production. The Clara example is the one running in production. This is what it looks like to treat AI behavioral rules as a first-class data artifact rather than a string in a source file.
Give your AI tools persistent memory
Memory Stack gives every AI tool you use — Claude, Cursor, ChatGPT — access to the same shared context. No download, no key paste, no config file.
Start for free →