Citadel lets operators choose which Bedrock foundation model each part of the arbiter uses — the Supervisor, the Fabricator, and the intake/extraction agents — without editing code or redeploying. Model choices are curated in DynamoDB against a catalog that is discovered from the live Bedrock inventory, edited through an admin-only UI, and resolved at runtime by a pure, dependency-free resolver that always falls back to a safe default.
The system is data-driven: outside of a single deployment-time seed, no model id is hardcoded anywhere. The set of valid models lives entirely in the catalog table, and every runtime decision is validated against it.
- Per-slot model choice. Each arbiter role ("slot") —
supervisor,fabricator,intake_agent,extraction— can be pointed at a different model, or left on a shared global default. - Operator-curated catalog. A daily sync keeps an inventory of invokable
foundation models current with what Bedrock exposes in the account/region.
Operators enable, disable, or deprecate entries; only
enabledmodels can be selected. - Safe by construction. Resolution is a pure function with a bulletproof fallback. A missing config, a malformed catalog row, a disabled model, or any read failure returns the caller's previous default rather than raising — model configuration can never break agent dispatch.
- Region-aware. A chosen model is mapped to a cross-region inference profile appropriate to the deployment region, with an optional data-locality policy.
┌───────────────────────────────────────────┐
Bedrock APIs │ Catalog sync (model-catalog-sync.ts) │
ListFoundation │ · daily EventBridge schedule (24h) │
Models / Infer. │ · on-demand via syncModelCatalog │
Profiles ───────▶│ discover → refresh (preserve status) → │
│ deprecate models Bedrock no longer lists │
└───────────────────┬──────────────────────-┘
│ upsert
▼
DynamoDB: citadel-model-catalog-{env}
▲
read (validate) │ read (list)
│
Operator UI ─────────────────────────┼─────────────────────────┐
Model Configuration page │ │
updateModelConfig / setStatus ▼ │
DynamoDB: citadel-model-config-{env} │
│ │
│ read (config + catalog) │
▼ ▼
Arbiter / Services runtime loaders ─────────
supervisor · fabricator · intake/extraction
│
▼
Pure resolver (model_resolver.py)
precedence walk → validate for slot →
map to inference-profile id (else bootstrap fallback)
│
▼
Effective inference-profile id used for Bedrock calls
There are three moving parts:
- Discovery (write path). A scheduled Lambda reconciles the catalog table against the live Bedrock inventory.
- Configuration (write path). An admin edits resolved defaults and per-slot choices through the GraphQL API / UI, which writes the config table after validating every referenced model against the catalog.
- Resolution (read path). At runtime each arbiter entry point reads the config + catalog, runs the pure resolver, and uses the resulting inference-profile id — falling back to its previous default on any miss.
Both tables are defined in backend/lib/backend-stack.ts with on-demand
billing (PAY_PER_REQUEST) and point-in-time recovery enabled.
Partition key: modelKey (a slug derived from the Bedrock model id).
| Field | Type | Notes |
|---|---|---|
modelKey |
String (PK) | Stable slug, e.g. derived from the base model id |
provider |
String | Lowercased provider name (e.g. anthropic) |
baseModelId |
String | Raw Bedrock model id |
status |
String | enabled | disabled | deprecated | discovered |
modality |
String | text | embedding | image | other |
invocationMode |
String | converse (text) or invoke_model |
supportsTools |
Boolean | Tool-use capability (from provider overlay) |
supportsSystemPrompt |
Boolean | System-prompt capability |
supportsStreaming |
Boolean | From the Bedrock API |
regionProfiles |
AWSJSON | Map of cross-region prefix → inference-profile id (e.g. {"us": "...", "global": "..."}) |
discoveredAt |
String | Timestamp first seen by the sync |
Additive fields on the same catalog row, read by the cost-ledger writer (backend/src/lambda/cost-ledger-writer.ts, see EVENTBRIDGE_CATALOG.md) to compute per-invocation token cost. The Bedrock ListFoundationModels API does not expose pricing, so these fields are populated by the sync's PROVIDER_PRICING overlay (provider-keyed, mirroring PROVIDER_CAPABILITIES) or the deployment-time seed — never by the resolver.
| Field | Type | Notes |
|---|---|---|
inputPer1kTokens |
Number | List price per 1000 input tokens |
outputPer1kTokens |
Number | List price per 1000 output tokens |
currency |
String | ISO 4217 code, e.g. USD |
pricingSource |
String | seed | synced_default | operator |
pricingUpdatedAt |
String | ISO 8601 timestamp of the last pricing write |
Additive-only merge. model-catalog-sync.ts applies the provider default pricing only when the row has no pricing yet — exactly like the existing operator-status preservation ({...existing, ...apiDerived, status: existing.status}): a row with any usable pricing (all three of inputPer1kTokens/outputPer1kTokens/currency present) keeps its values untouched on every subsequent sync, and pricingSource is left as 'operator' rather than being overwritten to 'synced_default'. Newly-discovered rows and the deployment seed get pricingSource: 'synced_default' / 'seed' respectively.
No mutation added this story. listModelCatalog passes pricing fields through verbatim (interface passthrough only — ModelCatalogEntry in model-config-resolver.ts was extended with the five optional fields above). An operator-facing updateModelPricing mutation to edit pricing directly through the GraphQL API is a deferred follow-up; until it lands, pricing is set by the seed, the sync overlay, or a direct edit of the catalog row.
Unpriced policy. The cost-ledger writer never fabricates a price: a model absent from the catalog, or present without a complete pricing set, produces priced:false with null cost fields rather than a guessed value — see EVENTBRIDGE_CATALOG.md for the full policy.
Partition key: scope (the platform-wide row uses scope = "platform").
| Field | Type | Notes |
|---|---|---|
scope |
String (PK) | Defaults to platform |
globalDefaultKey |
String | null | Catalog modelKey used when no more specific default applies |
slotDefaults |
AWSJSON | Map of slot → modelKey |
orgDefaults |
AWSJSON | Map of slot → modelKey (part of the resolution precedence and data model; not editable through the current UI) |
agentOverrides |
AWSJSON | Map of agent id → modelKey (see Per-agent scope) |
localityMode |
String | off | regional_preferred | strict |
updatedAt / updatedBy |
String | Stamped by the resolver on each update |
The resolution logic is a set of pure, I/O-free modules shared across arbiter
Lambdas via the bundled layer (arbiter/common/, mirrored in
service/agent_intake_single/): model_types.py (value types),
model_resolver.py (the selection walk), model_mapping.py (item → type
mapping), and region.py (prefix helper).
Precedence. For a given slot the resolver walks configured layers in order and takes the first candidate that exists in the catalog, is valid for the slot, and resolves to a profile:
- Agent override (when an agent id is supplied) —
agentOverrides[agentId] - Org default for the slot —
orgDefaults[slot] - Slot default —
slotDefaults[slot] - Global default —
globalDefaultKey - Bootstrap fallback — the caller-supplied default id (never fails)
Validity for a slot. A candidate must be enabled, match the slot's
required modality, support tools if the slot requires them, and use the
converse invocation mode if required. The supervisor, fabricator, and
intake_agent slots require tools; extraction does not; all four require
text + converse.
Profile mapping & data locality. The chosen entry is mapped to a concrete
inference-profile id from its regionProfiles, keyed by the deployment
region's cross-region prefix:
localityMode |
Behavior |
|---|---|
off |
Prefer the regional profile, else the global profile, else construct {prefix}.{baseModelId} by Bedrock convention |
regional_preferred |
Same as off, but attaches a warning when it has to fall back to global/constructed |
strict |
Only a known regional profile is acceptable; if none exists the candidate is rejected and resolution moves on |
Cross-region prefixes (arbiter/common/region.py):
| Region prefix | Inference-profile prefix |
|---|---|
us-* |
us |
eu-* |
eu |
ap-southeast-2 |
au |
other ap-* |
apac |
me-* |
me |
ca-* |
ca |
sa-* |
sa |
af-* |
af |
| anything else | us (default) |
The API is served by backend/src/lambda/model-config-resolver.ts, which
dispatches on event.info.fieldName. Query fields are open to authenticated
callers; every mutation is admin-gated in the resolver.
type Query {
# Operator-facing model catalog + resolved model-selection config.
listModelCatalog: [ModelCatalogEntry!]!
getModelConfig(scope: String): ModelConfig
}
type Mutation {
# Admin-gated + validated in the resolver.
updateModelConfig(input: UpdateModelConfigInput!): ModelConfig
setModelCatalogEntryStatus(modelKey: String!, status: String!): ModelCatalogEntry
# On-demand catalog sync trigger (admin-gated).
syncModelCatalog: ModelCatalogSyncResult!
}listModelCatalogreturns every catalog row.getModelConfig(scope)returns the resolved config for a scope (defaults toplatform); when no row exists it returns a well-formed empty skeleton (localityMode: "off", empty maps) rather than null.updateModelConfigacceptsUpdateModelConfigInput—scope,globalDefaultKey,slotDefaults(AWSJSON), andlocalityMode. It merges the partial input onto the existing row, and validates that every referencedmodelKeyexists in the catalog and isenabled(rejecting unknown/disabled models) and thatlocalityModeis one of the three allowed values. It stampsupdatedAt/updatedByand emits a model-config-changed event.setModelCatalogEntryStatuschanges one catalog entry's lifecycle status (validated againstenabled/disabled/deprecated/discovered).syncModelCatalogpublishes amodel.catalog.sync_requestedevent onto the custom agent bus (sourcecitadel.backend) and returns{ triggered, message }. It never invokes the sync Lambda directly — an event-pattern rule routes the event (see Catalog sync).
Admin gating uses isAdminFromEvent; non-admin callers receive an error.
Mutations emit EventBridge events for auditability (a model-config-changed
event on config/status changes, and a catalog-synced summary from the sync
Lambda).
The Model Configuration page (frontend/src/pages/ModelConfiguration.tsx)
is admin-only — non-admins see an "Administrator access required" notice. Data
is loaded by the useModelConfig hook (catalog + platform config on mount,
with an explicit refresh), and mutations go through
frontend/src/services/modelConfigService.ts, which parses the AWSJSON map
fields on read and stringifies slotDefaults on write.
The page has two areas:
- Defaults — a Global default dropdown (populated only with
enabledentries), Per-slot defaults forintake_agent,extraction,supervisor,fabricator, andfabricated_agent_default(each with a "Use global default" option that clears the slot), and a Data locality selector (off/regional_preferred/strict). - Model Catalog — a table of every entry (model, provider, capabilities,
region count, status) with a per-row Status dropdown
(
enabled/disabled/deprecated).
Two header actions: Model Sync triggers syncModelCatalog (new models
appear after the sync completes — use Refresh a few seconds later), and
Refresh re-fetches the catalog and config. Each mutation shows a success or
error toast.
A separate catalog-backed control, ModelOverrideSelect.tsx, edits a
per-agent-binding model override (see below). It stores a catalog modelKey or
an empty value meaning "use the platform default", and preserves any legacy
free-text value as a "Current: …" option so editing a binding never silently
drops it.
Each arbiter entry point resolves its slot at cold start via a small,
non-pure I/O loader (model_config_loader.py) that reads the
MODEL_CONFIG_TABLE and MODEL_CATALOG_TABLE env vars, fetches the platform
config row plus the catalog, and delegates to the pure resolver:
| Slot | Where | Requires tools |
|---|---|---|
supervisor |
arbiter/supervisor/index.py (MODEL_ID) |
yes |
fabricator |
arbiter/fabricator/index.py (FABRICATOR_MODEL_ID) |
yes |
intake_agent |
service/agent_intake_single (load_intake_model_id) |
yes |
extraction |
service/agent_intake_single (load_extraction_model_id) |
no |
Each loader is handed a bootstrap fallback model id by its caller (a region-prefixed default constant defined at the call site) and returns that fallback on any failure, logging a warning — it never raises.
Per-agent binding override. In addition to slot defaults, the Supervisor
reads a per-binding modelOverride (a String on the agent binding — see
UpdateRegistryAgentBindingInput in the schema, edited via
ModelOverrideSelect). At dispatch it resolves that catalog key to a concrete
inference-profile id (resolve_agent_override) and forwards it in the worker
dispatch payload. This path is a no-op unless a binding actually sets
modelOverride and the key resolves against an enabled catalog entry, and any
resolution failure is swallowed so a bad override can never break dispatch.
Note: the config table's
agentOverridesmap andorgDefaultsmap are part of the resolver's precedence chain and data model, but the current per-slot loaders resolve without passing an agent id and the operator UI's update path does not edit those two maps — so the active override mechanism today is the per-bindingmodelOverridefield described above.
The sync Lambda (backend/src/lambda/model-catalog-sync.ts) keeps the catalog
current with the live Bedrock inventory. It is entirely data-driven — it
contains no model-id or model-family literals — and layers tool/system-prompt/
Converse capability onto the API-derived fields via a small overlay keyed by
provider name with a conservative default.
Each run:
- Lists foundation models (
ListFoundationModels) and inference profiles (ListInferenceProfiles, paginated) and builds abaseModelId → {prefix → inferenceProfileId}map. - Upserts each live model: brand-new entries are written with status
discovered; known entries have their API-derived metadata refreshed while preserving the operator-ownedstatus. - Deprecates catalog entries Bedrock no longer returns (status →
deprecated). - Emits a summary event (
{ discovered, updated, deprecated, total, syncedAt }).
Triggers (wired in backend/lib/backend-stack.ts):
- Scheduled —
ModelCatalogSyncRule, a daily EventBridge schedule (rate(24 hours)). - On-demand — the
syncModelCatalogmutation publishesmodel.catalog.sync_requestedonto thecitadel-agents-{env}bus;ModelCatalogSyncRequestRule(an event-pattern rule) routes it to the same Lambda. EventBridge invokes the target via the rule's managed permission — the resolver holds nolambda:InvokeFunctiongrant.
Permissions. The sync function is granted read-only Bedrock discovery
(ListFoundationModels, ListInferenceProfiles, GetFoundationModel,
GetInferenceProfile), read/write on the catalog table, and put-events on the
bus. The Bedrock actions are on * (account/region-level enumeration APIs, with
a documented cdk-nag suppression) and are strictly read-only — the sync never
mutates Bedrock.
Seed baseline. A CloudFormation custom resource
(backend/src/lambda/seed-model-catalog/index.ts) writes one baseline catalog
entry (the seed modelKey anthropic-claude-sonnet-5, enabled) and one
platform config row on deploy. Both writes are conditional
(attribute_not_exists), so a redeploy never clobbers operator edits. This
seed file is the single sanctioned place a concrete model id appears.
- Fallback is total. If the tables are empty, unreachable, or misconfigured, every arbiter slot keeps running on its caller-supplied bootstrap default. Nothing about model configuration is on the critical path for dispatch.
- Cold-start reads. Each loader performs one config
GetItem+ one catalogScanper Lambda container; TTL caching can be layered on later without changing the contract. - Least privilege. The Supervisor (
arbiter-stack.ts) and the intake runtime (services-stack.ts) receive read-only grants on both tables; only the resolver and the sync Lambda write. - Only
enabledmodels are selectable. The UI populates dropdowns fromenabledentries and the resolver rejects non-enabledcandidates; thediscoveredstatus is applied by the sync and is not offered in the UI status dropdown. - Regions. Choose
localityMode: strictto guarantee an in-region inference profile (candidates without one are rejected);off/regional_preferredallow global or constructed profiles.
| Concern | File(s) |
|---|---|
| GraphQL resolver | backend/src/lambda/model-config-resolver.ts |
| Catalog sync | backend/src/lambda/model-catalog-sync.ts |
| Seed baseline | backend/src/lambda/seed-model-catalog/index.ts |
| Tables, sync schedule/rules | backend/lib/backend-stack.ts |
| Supervisor grants | backend/lib/arbiter-stack.ts |
| Intake grants | backend/lib/services-stack.ts |
| Resolution core (pure) | arbiter/common/model_resolver.py, model_types.py, model_mapping.py, region.py |
| Runtime loaders | arbiter/supervisor/model_config_loader.py, arbiter/fabricator/model_config_loader.py, service/agent_intake_single/model_config_loader.py |
| Operator UI | frontend/src/pages/ModelConfiguration.tsx, frontend/src/components/ModelOverrideSelect.tsx, frontend/src/hooks/useModelConfig.ts, frontend/src/services/modelConfigService.ts |