AI Filters & Guardrails
Inspect and block the response path — PII redaction + drift
detection. /tutorial/12-ai-filters/
When an agent asks “what’s the user’s current plan tier?”, a language model will cheerfully make one up. When it asks “what time is it?”, it’ll guess. The fix is not a better prompt — it’s a companion deterministic layer that supplies the ground truth on every turn.
FactResolver is that companion. You implement it, you declare which
keys you supply, and Atmosphere appends the resolved bundle to the
end of the system prompt before every dispatch — after the stable
persona/schema text, so provider prompt-prefix caches keep hitting.
Atmosphere ships DefaultFactResolver out of the box. It supplies
time.now (UTC ISO-8601, minute granularity — so same-minute turns
produce byte-identical blocks and stay cache-friendly) and
time.timezone only. With no extra wiring, every turn’s system prompt
ends with:
Grounded facts (deterministic, as of this turn):- time.now: 2026-04-19T18:32Z- time.timezone: UTCThat alone closes the “what year is it?” class of hallucination.
Apps typically want richer facts — user name, locale, plan tier,
feature-flag values, recent audit events. Implement FactResolver
and supply any subset of keys from FactKeys plus your own app.*
keys.
public final class UserProfileFactResolver implements FactResolver {
private final ProfileService profiles; private final FeatureFlags flags; private final AuditLog audit;
@Override public FactBundle resolve(FactRequest req) { var out = new LinkedHashMap<String, Object>();
if (req.keys().contains(FactKeys.USER_NAME)) { profiles.lookup(req.userId()) .ifPresent(p -> out.put(FactKeys.USER_NAME, p.name())); } if (req.keys().contains(FactKeys.USER_LOCALE)) { out.put(FactKeys.USER_LOCALE, profiles.lookup(req.userId()) .map(Profile::locale) .orElse("en-US")); } if (req.keys().contains(FactKeys.USER_PLAN_TIER)) { out.put(FactKeys.USER_PLAN_TIER, profiles.lookup(req.userId()) .map(Profile::planTier) .orElse("free")); } // Custom app key — the framework treats unknown keys transparently. if (req.keys().contains("app.recent_order_id")) { profiles.lastOrder(req.userId()) .ifPresent(o -> out.put("app.recent_order_id", o.id())); } return new FactBundle(out); }}Three ways to install, in priority order:
@Configurationclass FactResolverConfig { @Bean FactResolver productionResolver(ProfileService profiles, FeatureFlags flags, AuditLog audit) { return new UserProfileFactResolver(profiles, flags, audit); }}AtmosphereAiAutoConfiguration.atmosphereFactResolverBridge publishes
the bean onto framework.properties() under
FactResolver.FACT_RESOLVER_PROPERTY;
AiEndpointHandler.resolveFactResolver finds it first.
Drop a file at
META-INF/services/org.atmosphere.ai.facts.FactResolver containing
your resolver’s fully-qualified class name. The handler’s
ServiceLoader.load(FactResolver.class).findFirst() picks it up.
FactResolverHolder.install(new UserProfileFactResolver(...));The process-wide holder is the lowest priority fallback — mostly
useful for test setup via @BeforeAll + FactResolverHolder.reset()
in @AfterEach.
The resolver’s bundle is rendered as a newline-delimited block and
appended after the system prompt (volatile facts stay the suffix so
the stable prefix remains cacheable). A request from
alice@example.com on a paid tier sees, at the end of the system
prompt:
Grounded facts (deterministic, as of this turn):- time.now: 2026-04-19T18:32Z- time.timezone: UTC- user.id: alice@example.com- user.name: Alice Martin- user.locale: en-US- user.plan_tier: enterprise- app.recent_order_id: ord-7821
[then whatever the developer's @AiEndpoint systemPrompt said]AI Filters & Guardrails
Inspect and block the response path — PII redaction + drift
detection. /tutorial/12-ai-filters/
Tag agent calls with business outcomes
Sibling primitive for observability tagging via SLF4J MDC.
/tutorial/27-business-metadata-observability/
DefaultFactResolver source