Custom fields allow packages to define computed fields that are dynamically evaluated using JSONLogic expressions and exposed through the GraphQL API. This feature enables packages to add domain-specific business logic and derived fields without modifying the core Reframe codebase.
- Package-Specific: Each package can define its own custom fields
- JSONLogic-Based: Fields are computed using JSONLogic expressions
- Flexible Storage: Three storage strategies (precompute, runtime, hybrid)
- Typed GraphQL: Package-specific GraphQL types for type safety
- Filterable: Full support for filtering and sorting by custom fields
- Recomputable: Ability to recompute fields with updated logic
- Risk Scoring: Compute transaction risk scores based on amount, counterparty, and other factors
- Categorization: Classify transactions by type, region, or business rules
- Validation Flags: Derive compliance or validation status fields
- Formatting: Generate formatted summaries or display strings
- Time-Based: Calculate age, expiration, or time-sensitive values
- Cross-Field Logic: Combine multiple fields into derived insights
Each package can include an api_config.json file defining custom fields:
{
"custom_fields": [
{
"name": "transaction_risk_score",
"description": "Computed risk score based on amount and counterparty",
"type": "number",
"storage": "precompute",
"logic": {
"if": [
{">": [{"var": "context.data.amount"}, 100000]},
100,
{"if": [
{">": [{"var": "context.data.amount"}, 50000]},
70,
30
]}
]
}
},
{
"name": "is_cross_border",
"description": "Whether transaction crosses country borders",
"type": "boolean",
"storage": "precompute",
"logic": {
"!=": [
{"var": "context.data.debtor_country"},
{"var": "context.data.creditor_country"}
]
}
},
{
"name": "days_since_transform",
"description": "Days since transformation occurred",
"type": "number",
"storage": "runtime",
"logic": {
"date_diff_days": [
{"now": []},
{"var": "context.metadata.timestamp"}
]
}
},
{
"name": "formatted_summary",
"description": "Human-readable transaction summary",
"type": "string",
"storage": "hybrid",
"logic": {
"cat": [
{"var": "context.data.debtor_name"},
" → ",
{"var": "context.data.creditor_name"},
": $",
{"var": "context.data.amount"}
]
}
}
]
}| Property | Type | Required | Description |
|---|---|---|---|
name |
string | Yes | Unique field name (use snake_case) |
description |
string | Yes | Human-readable description |
type |
string | Yes | Data type: string, number, boolean, or json |
storage |
string | Yes | Storage strategy: precompute, runtime, or hybrid |
logic |
object | Yes | JSONLogic expression to compute the field value |
When to use: Fields used in filters/sorting, expensive computations, stable values
- Computation: At message storage time (before DB insert)
- Storage: Saved in
context.custom_fields.<field_name> - Query: Read directly from database (fast)
- Recompute: Can override with
recompute_custom_fields: trueflag
Example:
{
"name": "is_high_value",
"type": "boolean",
"storage": "precompute",
"logic": {">": [{"var": "context.data.amount"}, 50000]}
}When to use: Time-dependent fields, volatile computations, frequently changing logic
- Computation: Only at GraphQL query time
- Storage: Not stored in database
- Query: Evaluated fresh on every query
- Recompute: Always computed (flag has no effect)
Example:
{
"name": "days_since_transform",
"type": "number",
"storage": "runtime",
"logic": {
"date_diff_days": [
{"now": []},
{"var": "context.metadata.timestamp"}
]
}
}When to use: Fields where logic might evolve, need both speed and flexibility
- Computation: Both storage time and query time
- Storage: Saved in
context.custom_fields.<field_name>(cached) - Query:
- Default: Return stored value (fast)
- With
recompute_custom_fields: true: Re-evaluate with latest logic
- Recompute: Supports on-demand recomputation
Example:
{
"name": "compliance_status",
"type": "string",
"storage": "hybrid",
"logic": {
"if": [
{"and": [
{"var": "context.data.kyc_verified"},
{"<": [{"var": "context.data.amount"}, 10000]}
]},
"approved",
"review_required"
]
}
}Access message data using JSONPath-like syntax:
{"var": "context.data.field_name"} // Access data fields
{"var": "context.metadata.package_id"} // Access metadata
{"var": "payload"} // Access original payload
{"var": "context.custom_fields.other_field"} // Access other custom fields{"==": [{"var": "context.data.currency"}, "USD"]}
{"!=": [{"var": "context.data.status"}, "cancelled"]}
{">": [{"var": "context.data.amount"}, 1000]}
{">=": [{"var": "context.data.amount"}, 1000]}
{"<": [{"var": "context.data.amount"}, 10000]}
{"<=": [{"var": "context.data.amount"}, 10000]}{"and": [
{">": [{"var": "context.data.amount"}, 5000]},
{"==": [{"var": "context.data.currency"}, "EUR"]}
]}
{"or": [
{"==": [{"var": "context.data.priority"}, "high"]},
{">": [{"var": "context.data.amount"}, 100000]}
]}
{"!": {"var": "context.data.is_internal"}}{"+": [{"var": "context.data.amount"}, {"var": "context.data.fee"}]}
{"-": [{"var": "context.data.total"}, {"var": "context.data.tax"}]}
{"*": [{"var": "context.data.quantity"}, {"var": "context.data.price"}]}
{"/": [{"var": "context.data.total"}, {"var": "context.data.count"}]}
{"%": [{"var": "context.data.amount"}, 100]}{"cat": ["Amount: $", {"var": "context.data.amount"}]}
{"in": ["USD", {"var": "context.data.currency"}]}
{"substr": [{"var": "context.data.reference"}, 0, 10]}{"if": [
{">": [{"var": "context.data.amount"}, 50000]},
"high",
"low"
]}
{"if": [
{">": [{"var": "context.data.amount"}, 100000]},
"critical",
{">": [{"var": "context.data.amount"}, 50000]},
"high",
"normal"
]}{"in": ["EUR", ["USD", "EUR", "GBP"]]}
{"map": [
{"var": "context.data.items"},
{"var": "price"}
]}
{"filter": [
{"var": "context.data.items"},
{">": [{"var": "quantity"}, 0]}
]}
{"reduce": [
{"var": "context.data.items"},
{"+": [{"var": "accumulator"}, {"var": "current.amount"}]},
0
]}{"now": []} // Current timestamp
{"date_diff_days": [date1, date2]} // Days between dates
{"date_format": [{"var": "timestamp"}]} // Format date string{"missing": ["context.data.field"]} // Check if field is missing
{"missing_some": [1, ["field1", "field2"]]} // At least N fields missingCustom fields are exposed through a typed GraphQL interface:
type TransformationMessage {
id: String!
payload: JSON!
context: JSON!
package_id: String
# Custom fields based on package configuration
customFields: CustomFieldsUnion
}
# Union type for different packages
union CustomFieldsUnion = SwiftCbprCustomFields
# Package-specific custom fields (auto-generated from api_config.json)
type SwiftCbprCustomFields {
transaction_risk_score: Float
is_cross_border: Boolean
days_since_transform: Float
formatted_summary: String
}query {
messages(limit: 10) {
messages {
id
package_id
customFields {
... on SwiftCbprCustomFields {
transaction_risk_score
is_cross_border
formatted_summary
}
}
}
}
}query {
messages(
filter: {
package_id: "swift-cbpr-mt-mx",
custom_field_filters: {
transaction_risk_score: { gte: 70 },
is_cross_border: true
}
},
limit: 50
) {
messages {
id
customFields {
... on SwiftCbprCustomFields {
transaction_risk_score
is_cross_border
}
}
}
total_count
}
}query {
messages(
filter: { package_id: "swift-cbpr-mt-mx" },
recompute_custom_fields: true,
limit: 20
) {
messages {
id
customFields {
... on SwiftCbprCustomFields {
compliance_status # Recomputed with latest logic
formatted_summary # Recomputed with latest logic
}
}
}
}
}Custom field filters support MongoDB-style operators:
custom_field_filters: {
field_name: { eq: "value" } # Equal
field_name: { ne: "value" } # Not equal
field_name: { gt: 100 } # Greater than
field_name: { gte: 100 } # Greater than or equal
field_name: { lt: 1000 } # Less than
field_name: { lte: 1000 } # Less than or equal
field_name: { in: [1, 2, 3] } # In array
field_name: { exists: true } # Field exists
}Custom fields are stored in the context.custom_fields object:
{
"id": "msg-uuid-123",
"payload": "...",
"context": {
"metadata": {
"package_id": "swift-cbpr-mt-mx",
"timestamp": "2025-01-15T10:30:00Z"
},
"data": {
"amount": 75000,
"debtor_name": "ACME Corp",
"creditor_name": "XYZ Ltd"
},
"custom_fields": {
"transaction_risk_score": 70,
"is_cross_border": true,
"formatted_summary": "ACME Corp → XYZ Ltd: $75000"
}
}
}Note: Runtime-only fields are NOT stored in the database.
For optimal query performance, create indexes on frequently filtered custom fields:
// Generic package_id index
db.reframe_audit.createIndex({"context.metadata.package_id": 1})
// Wildcard index for all custom fields (MongoDB 4.2+)
db.reframe_audit.createIndex({"context.custom_fields.$**": 1})
// Specific custom field indexes for better performance
db.reframe_audit.createIndex({"context.custom_fields.transaction_risk_score": 1})
db.reframe_audit.createIndex({"context.custom_fields.is_cross_border": 1})
// Compound indexes for common filter combinations
db.reframe_audit.createIndex({
"context.metadata.package_id": 1,
"context.custom_fields.transaction_risk_score": 1
})- Transformation Request → Handler receives message
- Package Resolution → Determine which package to use
- Metadata Injection → Add
package_idto message metadata - Workflow Processing → Execute transformation workflows
- Custom Field Computation → Evaluate precompute/hybrid fields
- Storage → Save message with computed custom fields to database
- GraphQL Query → Resolve custom fields (use stored or recompute)
Core custom fields logic:
- JSONLogic evaluation engine
- Field computation at storage time
- Field computation at query time
- Error handling (null on failure)
Package configuration:
- Load
api_config.jsonfrom packages - Cache custom field definitions
- Validate field configurations
GraphQL integration:
- Custom field resolvers
- Package-specific type registration
- Filter and recompute support
Database operations:
- Custom field filter queries
- Index recommendations
When a JSONLogic expression fails to evaluate:
- Behavior: Field returns
null - Logging: Error logged with field name and message ID
- GraphQL: Field appears as
nullin query results - No Failure: Other fields continue to compute normally
Example:
{
"transaction_risk_score": null, // Failed to compute
"is_cross_border": true, // Successfully computed
"formatted_summary": "..." // Successfully computed
}If a JSONLogic expression references a non-existent field:
- Behavior: Variable returns
null, expression continues - Best Practice: Use
missingoperator to check for fields
{
"if": [
{"missing": ["context.data.amount"]},
null,
{">": [{"var": "context.data.amount"}, 50000]}
]
}Package with invalid api_config.json:
- Validation: Checked during package load
- Behavior: Warning logged, package loads without custom fields
- Queries: Package has no custom fields available
- Use
snake_casefor field names - Prefix domain-specific fields (e.g.,
swift_network_code) - Keep names descriptive but concise
Choose storage type based on:
| Criteria | Precompute | Runtime | Hybrid |
|---|---|---|---|
| Used in filters/sorting | ✅ | ❌ | ✅ |
| Time-dependent | ❌ | ✅ | ❌ |
| Expensive computation | ✅ | ❌ | ✅ |
| Logic frequently changes | ❌ | ✅ | ✅ |
| Stable value | ✅ | ❌ |
- Use Precompute for fields used in filters to avoid query-time overhead
- Index frequently filtered fields in MongoDB
- Limit Runtime fields as they compute on every query
- Batch recomputation when updating logic (use background jobs)
- Avoid sensitive data in custom fields (use access controls instead)
- Validate JSONLogic expressions during package load
- Limit expression complexity to prevent DoS attacks
- Sanitize field names to prevent injection attacks
- Create
api_config.jsonin package root:
{
"custom_fields": [
{
"name": "my_field",
"description": "Description",
"type": "string",
"storage": "precompute",
"logic": {"var": "context.data.field"}
}
]
}- Reload package in Reframe:
curl -X POST http://localhost:3000/admin/reload-workflows- Verify field appears in GraphQL schema:
query {
__type(name: "YourPackageCustomFields") {
fields {
name
type { name }
}
}
}For hybrid storage fields:
- Update JSONLogic in
api_config.json - Reload package
- Query with
recompute_custom_fields: trueto see new values - Optionally bulk-update database with background job
For precompute storage fields:
- Update JSONLogic in
api_config.json - Reload package
- New messages use updated logic
- Old messages retain original computed values (unless recomputed)
To update historical data with new logic:
// MongoDB aggregation pipeline to recompute (pseudo-code)
// This would be part of a background job/script
db.reframe_audit.find({
"context.metadata.package_id": "swift-cbpr-mt-mx"
}).forEach(doc => {
// Re-evaluate custom fields with updated logic
// Update document
});{
"name": "risk_level",
"type": "string",
"storage": "precompute",
"logic": {
"if": [
{">": [{"var": "context.data.amount"}, 100000]},
"critical",
{">": [{"var": "context.data.amount"}, 50000]},
"high",
{">": [{"var": "context.data.amount"}, 10000]},
"medium",
"low"
]
}
}{
"name": "region",
"type": "string",
"storage": "precompute",
"logic": {
"if": [
{"in": [{"var": "context.data.country"}, ["US", "CA", "MX"]]},
"americas",
{"in": [{"var": "context.data.country"}, ["GB", "DE", "FR", "IT"]]},
"europe",
{"in": [{"var": "context.data.country"}, ["JP", "CN", "SG"]]},
"apac",
"other"
]
}
}{
"name": "requires_approval",
"type": "boolean",
"storage": "hybrid",
"logic": {
"or": [
{">": [{"var": "context.data.amount"}, 50000]},
{"==": [{"var": "context.data.is_sanctioned_country"}, true]},
{"==": [{"var": "context.data.kyc_status"}, "pending"]}
]
}
}{
"name": "display_name",
"type": "string",
"storage": "hybrid",
"logic": {
"cat": [
"[",
{"var": "context.data.message_type"},
"] ",
{"var": "context.data.debtor_name"},
" → ",
{"var": "context.data.creditor_name"},
" (",
{"var": "context.data.currency"},
" ",
{"var": "context.data.amount"},
")"
]
}
}Symptoms: GraphQL query returns null for customFields
Possible Causes:
- Package doesn't have
api_config.json - Package not reloaded after adding config
- Message missing
package_idin metadata
Solutions:
- Verify
api_config.jsonexists in package root - Call
/admin/reload-workflowsendpoint - Check message
context.metadata.package_idfield
Symptoms: Specific custom field always null
Possible Causes:
- JSONLogic expression error
- Referenced variable doesn't exist
- Type mismatch in expression
Solutions:
- Check application logs for JSONLogic errors
- Verify variable paths exist in message context
- Test expression with sample data
Symptoms: GraphQL queries with custom field filters are slow
Possible Causes:
- Missing database indexes
- Too many runtime-computed fields
- Complex JSONLogic expressions
Solutions:
- Add indexes on filtered custom fields
- Change storage type to
precomputeorhybrid - Simplify or optimize JSONLogic expressions
Symptoms: recompute_custom_fields: true returns same values
Possible Causes:
- Field storage type is
precompute(can't recompute) - Package not reloaded with updated logic
- Querying wrong package
Solutions:
- Change storage type to
hybridfor recomputable fields - Reload package after logic changes
- Verify
package_idfilter matches updated package