Module:
moralstack/constitution/store.py(orchestration) · Loader:moralstack/constitution/loader.py· Schema:moralstack/constitution/schema.py
The Constitution Store manages the system's ethical principles, implementing loading, indexing and contextual retrieval.
Loading engineering: YAML is read only via ruamel.yaml (typ="safe") in the load_yaml_file function in
loader.py. Structural validation is Pydantic-only (schema in schema.py). Fail-fast behavior: invalid YAML or
failed validation → ConstitutionLoadError (path, field, reason); no partial constitution. The store exposes only
typed Pydantic objects (Principle, Overlay, Constitution), never raw dicts.
For stakeholders and testers: Principles and overlays determine when a response is considered in violation and
which principles apply per domain. For testing, reduced constitutions or custom overlays can be loaded; in production
the files in moralstack/constitution/data/ define the verifiable ethical behavior.
The Constitution Store:
- Loads ethical principles from YAML files (ruamel.yaml, Pydantic validation)
- Indexes principles for efficient retrieval
- Applies domain overlays (medical, financial, legal, etc.)
- Detects domain automatically from the request
- Resolves conflicts between principles
Constitution
├── Core Principles (always active)
│ ├── Hard Constraints (priority 85-100)
│ │ └── Non-negotiable, violation = refusal
│ └── Soft Norms (priority 30-84)
│ └── Flexible, violation = caveat/revision
│
└── Domain Overlays (contextually activated)
├── Medical
├── Financial
├── Legal
├── Mental Health
└── ...
# moralstack/constitution/schema.py
class Principle(BaseModel):
model_config = ConfigDict(extra="forbid")
id: str
level: Literal["hard", "soft"]
priority: int # 1-100 (higher = more important)
title: str
rule: str
examples_allow: list[str] = []
examples_deny: list[str] = []
remediation: str = ""
domain: str | None = None- id: "CORE.NM.1"
level: hard
priority: 100
title: "Non-Maleficence (Physical Harm)"
rule: >
Never provide instructions that could facilitate
physical harm to people
examples_allow:
- "Educational explanation of mechanisms"
- "Historical information on past events"
examples_deny:
- "Instructions for building weapons"
- "Guides for causing physical harm"
remediation: "Refuse with explanation and safe alternatives"class Overlay(BaseModel):
model_config = ConfigDict(extra="forbid")
domain: str
additional_principles: list[Principle] = []
priority_overrides: dict[str, int] = {}
description: str = ""Overlays in moralstack/constitution/data/overlays/ include: medical, legal, financial, education, mental_health,
healthcare, children, research, creative, cybersecurity, emergency, enterprise, journalism, science,
political, relationships, gaming, coding, customer_service, violent_crime.
To avoid long parameter lists, use the ConstitutionStoreConfig dataclass and pass it to
ConstitutionStore(config=...):
from moralstack.constitution import ConstitutionStore, ConstitutionStoreConfig, OpenAIClientConfig
cfg = ConstitutionStoreConfig(
config_dir=Path("moralstack/constitution/data"),
policy_llm=policy,
use_llm_matching=True,
openai_config=OpenAIClientConfig.with_env_fallback(
use_openai=True,
api_key=api_key,
model="gpt-4o-mini",
),
max_parallel_agents=4,
)
store = ConstitutionStore(config=cfg)All fields have defaults; only override what you need. See ConstitutionStoreConfig in store.py for the full list (
config_dir, core_file, overlays_dir, policy_llm, use_llm_matching, openai_config, max_parallel_agents,
use_enhanced_retrieval, confidence_threshold, use_domain_prefilter, max_prefilter_domains).
When max_parallel_agents is not set explicitly, SDK/CLI runtime wiring resolves it from
MORALSTACK_CONSTITUTION_MAX_PARALLEL_AGENTS (fallback default: 4 — bumped from 2;
the env var still wins when set).
You can still pass options as keyword arguments (same behavior as before):
from moralstack.constitution import ConstitutionStore, OpenAIClientConfig
# Minimal (no OpenAI; uses policy_llm for domain agents)
constitution_store = ConstitutionStore(
policy_llm=policy,
use_llm_matching=True,
max_parallel_agents=4,
)
# With OpenAI for constitution evaluation
constitution_store = ConstitutionStore(
policy_llm=policy,
use_llm_matching=True,
openai_config=OpenAIClientConfig.with_env_fallback(
use_openai=True,
api_key=api_key,
model="gpt-4o-mini",
),
max_parallel_agents=4,
)# Retrieve principles relevant to a request
principles = constitution_store.get_relevant_principles(
prompt="I have had severe headaches for three days",
top_k=10,
)
for p in principles:
print(f"{p.id}: {p.title} (priority: {p.priority})")# Activate medical overlay
constitution = constitution_store.get_constitution(domain="medical")
# Automatic domain detection
detected_domain = constitution_store.detect_relevant_domains(prompt)
constitution = constitution_store.get_constitution(domain=detected_domain)ConstitutionStore acts as a facade: it handles load/overlay and delegates retrieval to
ConstitutionRetriever (moralstack/constitution/retriever.py). The retriever encapsulates:
- Agent creation and orchestration (DomainAgent, EnhancedDomainAgent)
- DomainPrefilter (two-stage retrieval)
- Parallel execution with configurable batch size
get_relevant_principlesinternals
DomainPrefilter, DomainAgent, and EnhancedDomainAgent reuse a single OpenAI HTTP client per component instance
when the API key is unchanged; chat completion parameters (model, messages, response_format, etc.) remain per call.
PrincipleRetrievalResult.debug_info (see below) includes aggregate retrieval_openai_client_pooling counts for
diagnostics.
Public API: get_relevant_principles, retrieve, detect_relevant_domains. get_debug_info() was removed
(retrieval-request-scoped-state, not deprecated): it read ConstitutionRetriever._last_debug_info, a mutable
instance attribute on a store built once per process and shared by concurrent request threads, so a request could
read another request's detected_domain/prefiltered_domains (see the P0 fix entry in CHANGELOG.md and
docs/CODEBASE_FACTS.md). External integrators must use retrieve(...).debug_info instead.
ConstitutionStore.retrieve(query, top_k=10, domain=None, *, retrieval_phase="risk_routing") -> PrincipleRetrievalResult (delegating to ConstitutionRetriever.retrieve, moralstack/constitution/retrieval_result.py)
returns everything one retrieval call produced, on the return value only — no instance state is written:
principles: tuple[Principle, ...]— same contentget_relevant_principlesreturns, as a tuple.prefiltered_domains: tuple[str, ...]— the decision channel (the raw prefilter output, DOES include"core"; the caller owns thecoreexclusion —estimator.py'sruntime_domainderivation,controller.py's_normalize_runtime_domain).debug_info: dict[str, Any]— best-effort telemetry, same key shape as the removedget_debug_info()dict, plus the additivedomain_channelkey ("retrieve"on the normal path; a caller withoutretrieve()getsruntime_domain=Noneanddomain_channel="fallback_no_retrieve"instead of a stale/foreign domain — loud, with a rate-limited WARNING).
get_relevant_principles(...) is now a pure, stateless projection of retrieve(...) — same exact signature and
list[Principle] return type as before.
retrieval_phase observability qualifier (unify-constitution-retrieval-
single-pass). get_relevant_principles(query, top_k=10, domain=None, *, retrieval_phase="risk_routing") threads retrieval_phase through the
DomainPrefilter call, both agent kinds' evaluate(query, *, retrieval_phase=...) → _call_openai(..., retrieval_phase=...), and
_persist_constitution_llm_call — so every llm_calls row for a given
retrieval wave (prefilter AND per-domain agents, enhanced or legacy) is labeled
consistently: "risk_routing" (default) for the single risk-owned wave (see
docs/modules/risk_estimator.md), "deliberation_retrieval" for the
deliberation runner's fallback wave when the risk-owned retrieval was
unavailable.
Domains are narrowed by an LLM classifier over a precision catalog rendered from YAML overlay metadata. When a
domain has a description (get_domain_descriptions()), DomainPrefilter splits it at the first NOT for: marker
(_split_scope_notfor, case-insensitive) and prints - {domain}: {scope}. followed by an indented
NOT: {exclusion} line; the description text is kept whole (later NOT for: sentences stay inline on the
NOT: line). Domain keywords no longer exist (removed 2026-10-11): a domain with an empty description is listed
by name only, - {domain}. ConstitutionStore.get_domain_descriptions() always returns a non-empty description
(an overlay without one gets "Principles specific to {domain} domain."), so with the store that line never
renders; it applies only to a hand-built DomainPrefilter.
Descriptions may embed NOT for: … sentences (see many overlays beneath constitution/data/overlays/*.yaml)
to steer negative scoping—for example signalling that explosives requests should not collapse into a narrowly topical label.
A (use X) redirect inside the exclusion tells the classifier to consider domain X instead.
Reply shape and per-domain gate. The classifier answers
{"payload", "selections": [{"domain", "evidence", "confidence"}], "wrapper_ignored"}. When
supports_json_schema(model) is true the call uses strict Structured Outputs
(_build_prefilter_response_format): selections[].domain is an enum of this request's candidate domains
(available_domains minus core), so an out-of-catalog name cannot be decoded; other models fall back to
{"type": "json_object"}. Each selection is gated on its own confidence (>= DOMAIN_CONFIDENCE_THRESHOLD,
_selected_from_selections, parsing via _parse_selection: non-dict entries, non-string domains, non-numeric or
non-finite confidences are dropped; a numeric string such as "0.9" is accepted), ordered by confidence descending
(ties keep model order), de-duplicated, then filtered by available_domains and cut to max_domains. A reply in the
legacy {domains, confidence} shape (no selections list) still goes through the unchanged single-global gate.
Output budget: PREFILTER_MAX_OUTPUT_TOKENS = 400 (was 200). A reply cut at the budget usually still parses:
parse_dict_with_contract repairs the truncated JSON (parse_status="fallback_ok"), so the effect depends on where
the cut falls — inside payload there is no selections list and routing is core-only with no rejection event;
inside selections the cut entry is dropped (a domain lost silently); inside wrapper_ignored routing is unaffected.
Only an unrepairable cut is parse_failed. finish_reason is not read, so a cut is not distinguishable in the audit.
The classifier prompt instructs the model to treat verbatim embedded segments that use arbitrary encoding or obfuscation as inspectable when substantive meaning can be recovered without fabricating absent material; domains should follow that recovered substance. Opaque or non-recoverable material should favour empty-domain conservatism rather than guessed intent.
Prefilter cache: DomainPrefilter.set_domain_descriptions is idempotent: the in-memory prefilter cache is
cleared only when the effective description map changes (canonical fingerprint over the map sorted by domain), so an
identical map in a different key order reuses the cache across requests. The cache key itself
(md5(query + sorted available_domains)) never contained description or keyword content. The invalidation telemetry
(prefilter_cache_status invalidated_then_hit / invalidated_then_miss, prefilter_cache_invalidation_reason
effective_descriptions_changed, and the prefilter_descriptions_changed / prefilter_descriptions_fingerprint_prefix
debug and trace fields) follows the descriptions. Until 2026-10-11 it followed the overlay keywords, so a
description-only edit cleared the cache without any signal. The DOMAIN_PREFILTER_CACHE_HIT / _MISS /
_INVALIDATED payloads carry descriptions_fingerprint* (older rows carry keywords_fingerprint*). When persistence
context (run_id / request_id) is active, cache hit / miss / invalidation are recorded as orchestration_events
(DOMAIN_PREFILTER_CACHE_HIT, DOMAIN_PREFILTER_CACHE_MISS, DOMAIN_PREFILTER_CACHE_INVALIDATED). The latest
retrieval exposes prefilter_cache_status and related fields via ConstitutionStore.retrieve(...).debug_info and
the REQUEST_ANALYSIS_CONTEXT decision trace payload.
Rejected-domain audit: on a prefilter cache miss where the LLM was called, DomainPrefilter records every
domain the classifier proposed but that was not applied, as one bounded DOMAIN_PREFILTER_DOMAINS_REJECTED
orchestration event (stage="retrieval", component="domain_prefilter"). It is a write-only audit: the applied
list, its order, the cache and the llm_calls row are byte-identical with or without it, and nothing reads the event
for routing. Each distinct proposed value gets one {domain, reason} entry, with the first gate in routing order
that dropped it: low_confidence (legacy shape: global confidence below DOMAIN_CONFIDENCE_THRESHOLD, the whole
proposal is discarded; selections shape: that domain has no scorable entry at or above the threshold — the builder
receives a per-domain best-confidence map from _selection_confidences, the same _parse_selection the routing gate
uses — and this check runs before unknown_domain/over_cap, matching routing order),
parse_failed (JSON unrecoverable, or a malformed shape; payload.parse_status tells the two apart). Only
non-iterable domains (e.g. null, a number) and a non-comparable confidence make routing fall back to
core-only without caching, recorded as parse_failed with routing_fallback=true; string domains are iterated
per character by routing, which caches core-only, and are recorded as one parse_failed entry for the whole string
with routing_fallback=false,
unknown_domain (not in available_domains; case and whitespace variants count as unknown), and over_cap
(known, but positioned after the max_domains slice; duplicates and core take cap slots, so the domain they push
out is the one recorded). A core proposed by the model is never recorded as rejected when core is in
available_domains (both production callers prepend it; direct callers may not). A routing fallback with nothing
proposed, or with only core proposed, emits decision="parse_failed" with routing_fallback=true.
Cache hits emit nothing: join a hit to the miss that produced it through cache_key_digest (same md5 key as
CACHE_MISS/HIT). Nothing is emitted for a clean accept, an empty proposal, the short-query bypass, no candidate
domains, no API key or an API exception (known gap: those paths return {} and core-only is cached). The only bound
is 16 entries x 64 characters per label (truncated flags a cut); the sinks do not truncate. The payload carries no
query text. Model labels are echoed, the full raw output stays in llm_calls.raw_response. For a selections reply,
_call_openai projects it onto the legacy fields when the model did not also send domains: domains = every
string-domain selection in confidence order (non-finite/non-numeric confidences rank as 0.0), confidence = the best
per-domain score; so proposed lists below-threshold selections too and the payload confidence is that maximum. The parse status travels
from _call_openai to the audit through a module-level ContextVar (no instance attributes). Contract: at most
one event per miss under normal operation; the audit call on the success path sits inside the routing try, so
only a BaseException escaping the audit's own swallow could produce a second call on the except branch.
A non-JSONParseError exception raised by the parser reaches the _call_openai outer handler: no status, no event.
Domain-agent cache: EnhancedDomainAgent and legacy DomainAgent cache on the exact OpenAI-relevant request
material: rendered system/user messages plus model, temperature, json_object, and completion-token parameter.
The rendered compact principle text includes id, level, and truncated rule. It intentionally does not include
principle titles, so title-only changes do not invalidate the agent cache.
Structured LLM output: Domain agents use OpenAI Chat Completions with
response_format={"type":"json_object"} where the model must return a JSON object; the domain prefilter uses a strict
json_schema response format when supports_json_schema(model) is true and json_object otherwise (see Domain
Selection above); legacy DomainAgent
uses an object wrapper {"principle_ids": [...]} (root arrays are not valid for json_object mode).
Tolerant regex / extract_json fallbacks remain for observability and compatibility. When persistence
context is active, llm_calls rows include parse_contract metadata (see moralstack/utils/llm_parse_contract.py).
Retrieval is delegated to ConstitutionRetriever and uses a two-stage LLM flow:
The LLM sees each candidate domain through its description (scope line + NOT: exclusion line; the domain name
alone when the description is empty), selecting up to the configured cap (see max_prefilter_domains), each domain gated on
its own confidence. Token usage remains constrained relative to injecting full principle payloads
at this stage.
For each selected domain, EnhancedDomainAgent (or legacy DomainAgent) sends the domain's principles (id, title,
rule, level) to the LLM. The LLM returns relevant principle IDs with confidence scores. Principles below
confidence_threshold are filtered out.
Note: principles carry no keywords (the keywords / keywords_block / keywords_style fields were removed
2026-10-11). The LLM evaluates relevance from rule and title content.
Precedence order for conflicting principles:
- Hard constraint > Soft norm
- Descending numeric priority
- Specificity (more specific principles prevail)
- Alphabetical ID order (determinism)
Specificity is _specificity_score (constitution/helpers.py:68-84): +10 when the principle has a domain,
+0.5 per examples_deny and +0.3 per examples_allow (after the cut to two), plus min(len(rule) / 100, 2).
Until 2026-10-11 it also added 0.2 per principle keyword; removing that term reordered some same-level,
same-priority principles in 10 overlays without changing any principle set.
# Example: two conflicting principles
# CORE.HELPFUL.1 (soft, priority 65): "Provide useful information"
# MED.DISCLAIMER.1 (soft, priority 80): "Include medical disclaimer"
# → MED.DISCLAIMER.1 prevails (higher priority)| ID | Title | Priority |
|---|---|---|
CORE.NM.1 |
Non-Maleficence (Physical Harm) | 100 |
CORE.CSAM.1 |
Child Safety | 100 |
CORE.NM.2 |
Non-Maleficence (Illegal) | 95 |
CORE.MALWARE.1 |
Malware Prevention | 95 |
CORE.MANIPULATION.1 |
Ban on Manipulation | 95 |
CORE.PRIV.1 |
Privacy Protection | 90 |
CORE.DISINFO.1 |
Disinformation Prevention | 90 |
CORE.DUALUSE.1 |
Dual-Use Information Leakage Prevention | 91 |
| ID | Title | Priority |
|---|---|---|
SOFT.HONEST.1 |
Honesty and Accuracy | 70 |
SOFT.VULNERABLE.1 |
Vulnerable Protection | 70 |
SOFT.HELPFUL.1 |
Helpfulness | 65 |
SOFT.BALANCED.1 |
Balanced Perspective | 60 |
SOFT.AUTONOMY.1 |
User Autonomy | 60 |
SOFT.CLARITY.1 |
Clear Communication | 40 |
# moralstack/constitution/data/overlays/medical.yaml
priority_overrides:
SOFT.HONEST.1: 85 # High accuracy in medical context
SOFT.HELPFUL.1: 75 # Informative support important
additional_principles:
- id: "MED.DISCLAIMER.1"
level: soft
priority: 80
title: "Medical Disclaimer"
rule: "Include appropriate disclaimers and recommend professional consultation"
- id: "MED.EMERGENCY.1"
level: hard
priority: 100
title: "Medical Emergency Recognition"
rule: "In case of medical emergency, immediately recommend emergency services"moralstack/constitution/data/
├── core.yaml # Core principles
└── overlays/
├── medical.yaml
├── legal.yaml
├── financial.yaml
├── education.yaml
├── mental_health.yaml
├── healthcare.yaml
├── children.yaml
├── research.yaml
├── creative.yaml
├── cybersecurity.yaml
├── emergency.yaml
├── enterprise.yaml
├── journalism.yaml
├── science.yaml
├── political.yaml
├── relationships.yaml
├── gaming.yaml
├── coding.yaml
├── customer_service.yaml
└── violent_crime.yaml
- Constitutional Critic - Uses principles for validation
- Risk Estimator - Integrates principles in estimation
- Orchestrator - Overlay coordination