Plugin framework
The existing plugin framework allows adopters to publish npm or PyPI packages that declare custom fields for CommonGrants schema objects. We want to expand this framework to also support bidirectional data transformations — toCommon/fromCommon functions that convert between a source system’s native data shape and the CommonGrants schema. We also want to organize this framework to support other potential future features with minimal rework.
Plugin authors are free to implement toCommon/fromCommon as plain hand-written functions. The SDK also provides a buildTransforms() / build_transforms() utility (see ADR-0017) that can generate these functions from separate declarative mapping objects, but using it is not required.
How should the Plugin object be structured to support both custom field declarations and bidirectional transforms, while enabling clean dependency injection and remaining stable as the protocol’s object list grows?
Questions
Section titled “Questions”- Should the top-level Plugin structure group by feature (
meta,client,schemas) or by object (Opportunity,Application, …)? - Should client configuration (auth, transport, rate-limiting) sit alongside per-object schemas, or be lifted to the top level as a system-level concern?
- Should custom fields and transforms be coupled in the same package, or allowed independently?
Decision drivers
Section titled “Decision drivers”- The framework must be implementable in both the Python and TypeScript SDKs with as consistent an interface as possible.
- The inputs to
definePlugin()should be declarative wherever possible, with optional overrides for custom code-driven transformation logic. - Existing plugin packages that declare only custom fields should remain valid with minimal changes.
- Auth, transport, and rate-limiting are system-level concerns that belong to a single client, not distributed across per-object branches.
- The top-level Plugin surface should be short and stable — adding new protocol objects should not expand the top-level key set.
Decision
Section titled “Decision”We decided to:
-
Keep “plugin” as the unified term for both the published npm/PyPI packages in the website catalog and the runtime SDK object. No change to
PluginSourceEntryorsrc/content/plugins/index.json. The existingdefinePlugin()function is expanded to accept the full set of top-level fields described below. -
Use functional grouping at the top level with two keys —
metaandschemas— rather than grouping by object name at the root. Client configuration, auth, and custom filters are deferred to future capabilities and are not part of the current Plugin shape. -
Use per-object grouping inside
schemaswhere it reflects real coupling: each object’s source schema, CommonGrants schema, and bidirectional transforms are tightly coupled and change together. -
Expand
definePlugin()to accept all top-level fields —metaandschemas— rather than onlycustomFields. All per-object declarations (custom fields, source schema, transforms, and declarative mappings) live underschemas.<Object>. -
Make all top-level Plugin fields optional so adopters can publish a plugin that provides only the features they need — for example, custom fields only — and expand to include transforms, client config, or additional schemas incrementally over time.
-
Plugin authors provide
toCommon/fromCommonas functions; mappings are one way to generate them. The SDK exposesbuildTransforms()/build_transforms()as a public utility wrapping the existing mapping runtimes.schemas.<Object>gains an optionalmappingskey carryingtoCommon/fromCommonmapping objects; when those are declared and no explicit transform is supplied inschemas.<Object>, the SDK invokesbuildTransforms()automatically. In TypeScript this happens insidedefinePlugin(); in Python it happens inside the code generator (generate.py) at generation time, emitting abuild_transforms()call into the generated__init__.py. Both mapping directions must be provided explicitly —buildTransforms()/build_transforms()does not invert one direction into the other, because many-to-one handlers likeswitchare not reversible. -
toCommon/fromCommonreturn aTransformResult<T>of{ result, errors }unconditionally; mapping definitions are validated atbuildTransforms()call time. Partial failure is routine for cross-schema transforms — field handlers can emit warnings that do not invalidate a record — so the transform surface is safe by default rather than throwing. Runtime schema validation (Zod.parse()/ Pydanticmodel_validate()) surfaces as entries inerrorsrather than thrown exceptions.buildTransforms()acceptscommonModel/common_modelandsourceModel/source_modelparameters — when supplied, validation runs insidetoCommonandfromCommonrespectively against the relevant schema.definePlugin()additionally injects validation when auto-generating transforms fromschemas.<Object>.mappings. Plugin authors using hand-written transforms are responsible for their own validation. Consumers apply their own rule for what counts as success — strict adopters treat any non-emptyerrorsas failure, lenient adopters tolerate warnings. Mappings passed tobuildTransforms()are checked at the call site, failing fast on structural errors, unknown handlers, or unresolvable field paths. -
Custom handlers are registered per utility call, not globally.
buildTransforms()accepts an optionalhandlersargument (Map<string, Handler>) for registering additional handler names. Per-call scoping keeps behavior explicit and testable; name collisions with the default set raise atbuildTransforms()call time rather than silently shadowing them. The registry is aMaprather than a plain object so that handler-name lookup usesMap.has()— which does not walk the prototype chain — rather thaninor own-property checks on a plain object. -
Transformation errors carry structured context. SDK-emitted transformation errors extend a single
PluginErrorbase carrying field path, handler name, source value, and underlying cause, enabling programmatic reasoning without parsing error text. The source value may contain PII when transforming applicant data; adopters are responsible for redacting it before logging or re-raising, and the SDK does not redact by default.
The resulting Plugin shape:
plugin.meta // name, version, sourceSystem, capabilitiesplugin.schemas.<Name> // SchemaConfig instance — unified access to model class and transformsplugin.schemas.<Name>.common // Zod schema / Pydantic model class (includes any declared custom fields)plugin.schemas.<Name>.source // source system type (defaults to dict / Record<string, unknown>)plugin.schemas.<Name>.to_common // callable: source → TransformResult[common] (None/undefined if not configured)plugin.schemas.<Name>.from_common // callable: common → TransformResult[source] (None/undefined if not configured)Python note: In the Python SDK, define_plugin() returns a PluginConfig (build-time input) rather than a fully compiled Plugin. The code generator (generate.py) compiles PluginConfig → Plugin by injecting the generated model classes as the common schema and auto-generating build_transforms() calls for any objects that have schemas[obj].mappings but no explicit to_common/from_common in schemas[obj]. This split is necessary because cg_config.py cannot import from generated/ — it is the input to code generation. All per-object declarations (custom_fields, source, mappings, and transforms) live on ObjectSchemasInput inside schemas.
Example interface
Section titled “Example interface”type PluginCapability = | "customFields" // declares custom fields on CommonGrants schema objects | "customFilters" // declares custom filter parameters for resource methods | "transforms"; // provides toCommon/fromCommon transformation functions
interface PluginMeta { name: string; version?: string; // optional; if omitted, definePlugin() infers it from the package's package.json sourceSystem: string; capabilities?: PluginCapability[];}
// Defined in lib/ts-sdk/src/extensions/types.ts — reproduced here for referenceinterface CustomFieldSpec { name?: string; // optional; dict key is used as the display name fallback fieldType: CustomFieldType; // enum defined in the SDK value?: z.ZodTypeAny; // optional Zod schema to validate the value; defaults based on fieldType description?: string;}
// Unconditional return shape for toCommon / fromCommon (see Decision #7).// `result` is the transformed value; `errors` aggregates PluginErrors emitted during// transformation and runtime schema validation. `errors` may be empty on full success,// or non-empty alongside a result when handlers emit non-fatal warnings.interface TransformResult<T> { result: T; errors: PluginError[];}
// Runtime type — produced by definePlugin(), not provided directly by plugin authorsinterface SchemaConfig<TSource, TCommon> { source: ZodType<TSource>; common: ZodType<TCommon>; toCommon: (source: TSource) => TransformResult<TCommon>; fromCommon: (common: TCommon) => TransformResult<TSource>;}
// Input type — provided by plugin authors inside DefinePluginOptions.schemas.// All per-object declarations live here: custom fields, source schema, declarative// mappings, and explicit transform callables.// common is intentionally absent: the plugin config file cannot import from generated/// since it is the input to generation. definePlugin() injects common during compilation// from SchemaInput → SchemaConfig, resolved from the generated model classes.// customFields declares extra fields beyond the base CG schema; definePlugin() extends// the base schema with them to produce the typed common schema.interface SchemaInput<TSource = unknown, TCommon = unknown> { source?: ZodType<TSource>; // defaults to Record<string, unknown> if omitted customFields?: Record<string, CustomFieldSpec>; mappings?: SchemaMappings; // declarative ADR-0017 mappings; auto-wired by definePlugin() toCommon?: (source: TSource) => TransformResult<TCommon>; fromCommon?: (common: TCommon) => TransformResult<TSource>;}
// Declarative mapping objects in ADR-0017 format — held under SchemaInput.mappings.// When present and no explicit toCommon/fromCommon is in SchemaInput, definePlugin()// auto-invokes buildTransforms() on these. Each direction is author-provided; see Decision #6.interface SchemaMappings { toCommon?: Record<string, unknown>; // ADR-0017 mapping: source → CommonGrants fromCommon?: Record<string, unknown>; // ADR-0017 mapping: CommonGrants → source}
// Scalar types only — filters are query parameters, not schema fieldstype CustomFilterType = "string" | "number" | "integer" | "boolean";
interface CustomFilterSpec { filterType: CustomFilterType; description?: string;}
interface Plugin { meta?: PluginMeta; schemas: Partial<Record<ExtensibleSchemaName, SchemaConfig<unknown, unknown>>>;}
// Input object for definePlugin(). Using a named-options object makes it easy to add// new inputs over time without breaking existing callers.interface DefinePluginOptions { meta?: PluginMeta; // Plugin authors provide input schemas and transforms; definePlugin() compiles them // into the full SchemaConfig runtime type, extending the base schema with any customFields. schemas?: Partial<Record<ExtensibleSchemaName, SchemaInput>>;}
// Factory: all options are optional so adopters can start with only what they need// and expand incrementally.//// definePlugin compiles DefinePluginOptions into a Plugin by:// - extending the base CommonGrants schema with any declared customFields → common// - source defaults to Record<string, unknown> if omitted//// toCommon / fromCommon may be plain hand-written functions, generated via// buildTransforms() and passed in schemas, or auto-generated by definePlugin()// itself — when schemas.<Object>.mappings is declared and schemas.<Object>// provides no explicit transform, definePlugin() invokes buildTransforms() internally.// All transforms return TransformResult<T>; definePlugin() validates the result field// at runtime with schema.parse / model_validate and appends any validation failures// to the errors array rather than throwing (see Decision #7).function definePlugin(options: DefinePluginOptions): Plugin;
// Handler signature matches ADR-0017 runtime conventions.type Handler = (value: unknown, context: unknown) => unknown;
// Utility: generates toCommon and fromCommon functions from separate declarative// mapping objects (ADR-0017 format). Using this utility is optional — plugin authors// may provide plain hand-written functions instead. Mappings are validated at call// time (see Decision #7); the optional `handlers` argument registers custom handler// names for this call only (see Decision #8).// When commonModel is provided, toCommon calls commonModel.parse (Zod) on its output// and appends any validation errors to TransformResult.errors rather than throwing.// commonModel must be the fully extended generated schema (e.g. the generated// Opportunity with typed customFields), not the base schema — passing a base schema// silently weakens validation of typed custom fields.// When sourceModel is provided, fromCommon similarly validates its output against that schema.// The underlying mapping runtime normalizes model/schema instances to plain objects// at the entry point, so fromCommon can receive the validated output of toCommon// and field paths still resolve correctly.function buildTransforms<TSource, TCommon>( toCommonMapping: Record<string, unknown>, // ADR-0017 mapping from source → CommonGrants fromCommonMapping: Record<string, unknown>, // ADR-0017 mapping from CommonGrants → source handlers?: Map<string, Handler>, // Map (not plain object) — Map.has() is prototype-safe commonModel?: ZodType<TCommon>, // must be the generated extended schema, not the base sourceModel?: ZodType<TSource>, // optional; validates fromCommon output): { toCommon: (source: TSource) => TransformResult<TCommon>; fromCommon: (common: TCommon) => TransformResult<TSource>;};
// Base class for SDK-emitted transformation errors (see Decision #9).interface PluginError extends Error { path?: string; handler?: string; sourceValue?: unknown; cause?: unknown;}from dataclasses import dataclassfrom typing import Any, Callable, Generic, Literal, TypeVarfrom pydantic import BaseModel, ConfigDict, Field
TSource = TypeVar('TSource')TCommon = TypeVar('TCommon')T = TypeVar('T')
# Unconditional return shape for to_common / from_common (see Decision #7).# `result` is the transformed value; `errors` aggregates PluginErrors emitted during# transformation and runtime schema validation. `errors` may be empty on full success,# or non-empty alongside a result when handlers emit non-fatal warnings.@dataclassclass TransformResult(Generic[T]): result: T errors: list['PluginError']
# Defined in lib/python-sdk/common_grants_sdk/extensions/specs.py — reproduced here for reference@dataclassclass CustomFieldSpec: """Custom Field spec class to support adding custom fields""" field_type: CustomFieldType # enum defined in the SDK value: Any | None = None # optional; used to validate the field value name: str = "" # optional; dict key is used as the display name fallback description: str = ""
# Runtime schema container — assembled by the code generator, not provided directly by authors.# Accessed via plugin.schemas.<Name> (attribute access, not dict lookup).# common includes any custom fields declared by the plugin (it is a generated subclass of the# base CG model, e.g. OpportunityBase, with typed custom_fields baked in).@dataclassclass ObjectSchemas(Generic[TSource, TCommon]): source: type[TSource] # source system type; defaults to dict common: type[TCommon] # generated Pydantic model class (includes declared custom fields) to_common: Callable[[TSource], TransformResult[TCommon]] | None = None from_common: Callable[[TCommon], TransformResult[TSource]] | None = None
class ObjectMappings(BaseModel): model_config = ConfigDict(populate_by_name=True)
# ADR-0017 mappings. Each direction is author-provided; see Decision #6. to_common: dict[str, Any] | None = Field(default=None, alias='toCommon') # source → CommonGrants from_common: dict[str, Any] | None = Field(default=None, alias='fromCommon') # CommonGrants → source
# Input type — provided by plugin authors inside define_plugin(schemas=...).# All per-object declarations live here: custom fields, source type, declarative# mappings, and explicit transform callables.# common is intentionally absent: cg_config.py cannot import from generated/ since# it is the input to generation. define_plugin() injects common during compilation# from ObjectSchemasInput → ObjectSchemas, resolved from the generated model classes.# custom_fields declares extra fields beyond the base CG schema; the code generator# reads these and emits typed subclasses.# mappings holds optional ADR-0017 declarative mappings. When present and no explicit# to_common / from_common is supplied, the code generator auto-invokes build_transforms()# on these. Explicit callables take priority and disable auto-wiring for that object.@dataclassclass ObjectSchemasInput(Generic[TSource, TCommon]): source: type[TSource] | None = None # defaults to dict[str, Any] if omitted custom_fields: dict[str, CustomFieldSpec] | None = None mappings: ObjectMappings | None = None to_common: Callable[[TSource], TransformResult[TCommon]] | None = None from_common: Callable[[TCommon], TransformResult[TSource]] | None = None
# Scalar types only — filters are query parameters, not schema fieldsCustomFilterType = Literal['string', 'number', 'integer', 'boolean']
@dataclassclass CustomFilterSpec: filter_type: CustomFilterType description: str = ""
PluginCapability = Literal['customFields', 'customFilters', 'transforms']
# Plugin identity and capability declaration. All fields are optional.class PluginExtensionsMeta(BaseModel): model_config = ConfigDict(populate_by_name=True)
name: str | None = None version: str | None = None source_system: str | None = Field(default=None, alias='sourceSystem') capabilities: list[PluginCapability] | None = None
# Runtime plugin container — assembled by the code generator (generate.py) from# the generated model classes and a compiled PluginConfig. Plugin authors do not# construct this directly; it is emitted into the plugin's __init__.py.## schemas: the _Schemas object from generated/schemas.py. Each attribute is an# ObjectSchemas instance providing unified access to the model class and transforms:# plugin.schemas.Opportunity.common → Pydantic model class (with custom fields)# plugin.schemas.Opportunity.to_common → transform callable (or None)# plugin.schemas.Opportunity.from_common → transform callable (or None)# plugin.schemas.Opportunity.source → source system type (or dict)@dataclassclass Plugin(Generic[T]): schemas: T meta: PluginExtensionsMeta | None = None
# Build-time config — produced by define_plugin(), consumed by generate.py.## Compilation from PluginConfig → Plugin (injecting the common model classes from# generated/) happens inside generate.py at code-generation time, not at# define_plugin() call time. This split is necessary in Python because cg_config.py# cannot import from generated/ — it is the input to code generation.@dataclass(frozen=True)class PluginConfig: meta: PluginExtensionsMeta | None = None schemas: dict[str, ObjectSchemasInput[Any, Any]] | None = None
# All params are optional — adopters can start with only what they need and expand# incrementally. Unlike TypeScript, Python supports named optional params at the# function root, so no wrapper object is needed.## define_plugin stores inputs as-is in a PluginConfig. The code generator# (generate.py) then compiles PluginConfig → Plugin by:# - extending the base CommonGrants model with any declared custom_fields → common# - source defaults to dict[str, Any] if omitted## to_common / from_common may be plain hand-written callables, generated via# build_transforms() and passed in schemas, or auto-generated by the code generator# itself — when schemas[obj].mappings is declared and schemas[obj] has no# to_common/from_common, generate.py invokes build_transforms() in the emitted code.def define_plugin( meta: PluginExtensionsMeta | None = None, schemas: dict[str, ObjectSchemasInput[Any, Any]] | None = None,) -> PluginConfig: ...
# Handler signature matches ADR-0017 runtime conventions.Handler = Callable[[Any, Any], Any]
# Utility: generates to_common and from_common callables from separate declarative# mapping dicts (ADR-0017 format). Using this utility is optional — plugin authors# may provide plain hand-written callables instead. Mappings are validated at call# time (see Decision #7); the optional `handlers` argument registers custom handler# names for this call only (see Decision #8).# When common_model is provided, to_common calls model_validate on its output and# appends any ValidationErrors to TransformResult.errors rather than raising.# common_model must be the fully extended generated model class (e.g.# generated/schemas.py's Opportunity), not the base class — passing a base class# silently weakens validation of typed custom fields.# When source_model is provided, from_common similarly validates its output.# transform_from_mapping normalizes Pydantic model instances to plain dicts via# model_dump(mode="json") at the entry point, so from_common can receive the# validated model output of to_common and field paths still resolve correctly.def build_transforms( to_common_mapping: dict[str, Any], # ADR-0017 mapping from source → CommonGrants from_common_mapping: dict[str, Any], # ADR-0017 mapping from CommonGrants → source handlers: dict[str, Handler] | None = None, common_model: type[BaseModel] | None = None, # must be the generated extended model, not the base source_model: type[BaseModel] | None = None, # optional; validates from_common output) -> tuple[ Callable[[Any], TransformResult[Any]], Callable[[Any], TransformResult[Any]],]: ...
# Base class for SDK-emitted transformation errors (see Decision #9).class PluginError(Exception): path: str | None handler: str | None source_value: Any cause: BaseException | NoneExample author usage
Section titled “Example author usage”// buildTransforms() is a utility that generates toCommon/fromCommon from declarative// mappings (ADR-0017). Using it is optional — plain functions work just as well.const { toCommon, fromCommon } = buildTransforms( // toCommon: grants.gov source shape → CommonGrants Opportunity { title: "data.opportunity_title", status: { value: { match: { field: "data.opportunity_status", case: { posted: "open", archived: "closed" }, default: "custom", }, }, }, }, // fromCommon: CommonGrants Opportunity → grants.gov source shape { "data.opportunity_title": "title", "data.opportunity_status": { value: { match: { field: "status", case: { open: "posted", closed: "archived" }, default: "custom", }, }, }, },);
const plugin = definePlugin({ meta: { name: "grants-gov-plugin", version: "1.0.0", sourceSystem: "grants.gov", }, // All per-object declarations — customFields, source schema, and transforms — live // under schemas.<Object> so authors have one entry per object. schemas: { Opportunity: { source: GrantsGovOpportunitySchema, customFields: { programArea: { fieldType: CustomFieldType.String, description: "HHS program area code", }, legacyGrantId: { fieldType: CustomFieldType.Integer, description: "Numeric ID from the legacy grants system", }, }, toCommon, fromCommon, }, },});# build_transforms() is a utility that generates to_common/from_common from declarative# mappings (ADR-0017). Using it is optional — plain callables work just as well.to_common, from_common = build_transforms( # to_common: grants.gov source shape → CommonGrants Opportunity to_common_mapping={ 'title': 'data.opportunity_title', 'status': { 'value': { 'match': { 'field': 'data.opportunity_status', 'case': {'posted': 'open', 'archived': 'closed'}, 'default': 'custom', }, }, }, }, # from_common: CommonGrants Opportunity → grants.gov source shape from_common_mapping={ 'data.opportunity_title': 'title', 'data.opportunity_status': { 'value': { 'match': { 'field': 'status', 'case': {'open': 'posted', 'closed': 'archived'}, 'default': 'custom', }, }, }, },)
plugin = define_plugin( meta=PluginExtensionsMeta(name='grants-gov-plugin', version='1.0.0', source_system='grants.gov'), # source_system serializes as 'sourceSystem' in JSON # All per-object declarations — custom_fields, source type, and transforms — live # on ObjectSchemasInput so authors have one entry per object. schemas={ 'Opportunity': ObjectSchemasInput( source=GrantsGovOpportunity, custom_fields={ 'programArea': CustomFieldSpec(field_type=CustomFieldType.STRING, description='HHS program area code'), 'legacyGrantId': CustomFieldSpec(field_type=CustomFieldType.INTEGER, description='Numeric ID from legacy system'), }, to_common=to_common, from_common=from_common, ), },)Example consumer usage
Section titled “Example consumer usage”import { grantsGovPlugin } from "grants-gov-plugin";
// Use the compiled schemas to transform source data into CommonGrants shape.// toCommon / fromCommon return TransformResult<T> = { result, errors } — consumers// apply their own strict-vs-lenient rule for what counts as success.const { toCommon } = grantsGovPlugin.schemas.Opportunity;const { result, errors } = toCommon(rawGrantsGovData);if (errors.length === 0) { use(result); // strict: treat any error (including handler warnings) as failure} else { // lenient: use result despite warnings; inspect errors for context for (const err of errors) console.warn(err.path, err.message);}
// Batch transformation is a plain .map — each element carries its own result and errorsconst items = rawBatch.map(toCommon);const successful = items .filter((r) => r.errors.length === 0) .map((r) => r.result);
// Inspect what the plugin declares about itselfconsole.log(grantsGovPlugin.meta.sourceSystem); // "grants.gov"console.log(grantsGovPlugin.meta.capabilities); // ["customFields", "transforms"]from grants_gov_plugin import grants_gov_plugin
# Use the compiled schemas to transform source data into CommonGrants shape.# to_common / from_common return TransformResult[T] = {result, errors} — consumers# apply their own strict-vs-lenient rule for what counts as success.to_common = grants_gov_plugin.schemas.Opportunity.to_commonoutcome = to_common(raw_grants_gov_data)if not outcome.errors: use(outcome.result) # strict: treat any error (including handler warnings) as failureelse: # lenient: use outcome.result despite warnings; inspect errors for context for err in outcome.errors: print(err.path, err)
# Batch transformation is a plain list comprehension — each element carries its own result and errorsitems = [to_common(x) for x in raw_batch]successful = [r.result for r in items if not r.errors]
# Inspect what the plugin declares about itselfprint(grants_gov_plugin.meta.source_system) # "grants.gov"print(grants_gov_plugin.meta.capabilities) # ["customFields", "transforms"]Consequences
Section titled “Consequences”- Positive consequences
- Top-level surface (
meta,schemas) is short, closed, and stable — adding protocol objects adds a key underschemasonly - Per-object grouping inside
schemaspreserves the real coupling between source schema, CommonGrants schema, and bidirectional transforms — they share type signatures and change together - All per-object declarations (custom fields, source type, declarative mappings, and explicit callables) are co-located under
schemas.<Object>— no split across multiple top-level keys toCommon/fromCommoncan be plain hand-written functions, generated viabuildTransforms()and passed inschemas, or auto-generated bydefinePlugin()from mappings declared inschemas.<Object>.mappings— plugin authors are not required to use a declarative mapping formatbuildTransforms()accepts separatetoCommonMappingandfromCommonMappingobjects, reflecting that the two directions of a bidirectional transform are distincttoCommon/fromCommonreturnTransformResult<T>so partial failure surfaces as data, batch processing is a plain.map, and consumers apply their own strict-vs-lenient rule for what counts as success; structuredPluginErrorlets adopters reason about those failures programmatically without parsing error text- Custom handlers are registered per-call on
buildTransforms(), not globally — behavior stays explicit and testable, and collisions with the default set raise atbuildTransforms()call time customFieldsis optional — thecustomFields-only config structure remains valid; existing plugin packages require only minimal code changes to adoptdefinePlugin()- All top-level Plugin fields are optional — adopters can start with only what they need and expand incrementally
- Top-level surface (
- Negative consequences
- Client configuration, auth, and custom filters are deferred to future capabilities — current Plugin shape does not support them
Criteria
Section titled “Criteria”- Backward compatible: Existing custom-fields-only plugins remain valid without changes
- SDK-friendly: Config shape maps naturally to Pydantic/Zod one-model-at-a-time usage inside
schemas - Language-agnostic config: Both SDKs use camelCase keys (
customFields,fieldType,sourceSystem) for serialized forms — Python source uses snake_case attributes with camelCasealiasfields, matching the existing SDK convention - Clear naming: A single term — “plugin” — is used consistently across the registry catalog and SDK
- Supports both capabilities: Custom field declarations and bidirectional transforms can coexist or be used independently; transforms may be hand-written or generated from declarative mappings
- Incremental adoption: All top-level fields are optional, so adopters can start with only what they need
- Stable surface: New protocol objects do not expand the top-level key set
Options considered
Section titled “Options considered”- Object-first structure with adapted model/schema (no separate Plugin class)
- Pure object-first structure with “Plugin” for both registry and SDK
- Functional top-level with per-object schema grouping (chosen)
Evaluation
Section titled “Evaluation”Side-by-side
Section titled “Side-by-side”| Criteria | Object-first / Adapted Schema | Pure object-first / Plugin | Functional top-level / per-object schemas |
|---|---|---|---|
| Backward compatible | ✅ | ✅ | ✅ |
| SDK-friendly | ✅ | ✅ | ✅ |
| Language-agnostic config | ✅ | ✅ | ✅ |
| Clear naming | 🟡 | ✅ | ✅ |
| Supports both capabilities | ✅ | ✅ | ✅ |
| DI-friendly | 🟡 | 🔴 | ✅ |
| Stable surface | 🟡 | 🔴 | ✅ |
Option 1: Object-first structure, adapted model/schema (no separate Plugin class)
Section titled “Option 1: Object-first structure, adapted model/schema (no separate Plugin class)”Instead of constructing a separate Plugin object, the SDK returns an extended version of the model/schema itself with the transform baked in. Adopters call native parse/validate methods directly on the returned object.
// TypeScript: createPlugin returns an extended Zod schema (ZodEffects), not a Plugin objectconst opportunityPlugin = createPlugin(opportunitySchema, pluginConfig);const opportunity = opportunityPlugin.parse(grantsGovData); // native Zodconst result = opportunityPlugin.safeParse(grantsGovData); // native Zod non-throwing# Python: create_plugin returns a new Pydantic model class with a custom validator appliedOpportunityPlugin = create_plugin(Opportunity, plugin_config)opportunity = OpportunityPlugin.model_validate(grants_gov_data) # native Pydantic- Pros
- Very idiomatic —
.parse()/safeParse()in Zod and.model_validate()in Pydantic are the expected call sites - No new runtime class name to explain; the adapted schema is still recognizably a schema
- Backward compatible — both keys optional,
custom_fields-only is valid
- Very idiomatic —
- Cons
- No named
Plugintype to import, document, or type-hint against - Client, auth, and transport have no natural home in this model
- DI requires passing each model’s plugin separately rather than a unified
Schemasobject — callers must accept one plugin per object rather than a singleSchemasunit (DI-friendly: 🟡) - Top-level surface tracks the object list indirectly via function calls (
createPlugin(opportunitySchema, ...),createPlugin(applicationSchema, ...)), but there is no stable type that enumerates supported objects (Stable surface: 🟡) - In Python,
create_pluginmust dynamically generate a new model class, which is less transparent
- No named
Option 2: Pure object-first structure, “Plugin” for both registry and SDK
Section titled “Option 2: Pure object-first structure, “Plugin” for both registry and SDK”Config and runtime object both keyed by CommonGrants model name at the root. meta and client sit alongside object keys but are not themselves objects.
interface Plugin { meta?: PluginMeta; client?: Client; Opportunity?: ObjectPluginConfig; Application?: ObjectPluginConfig; // ... one key per protocol object}# Python equivalent — same object-keyed shape@dataclassclass Plugin: meta: PluginMeta | None = None client: Client | None = None Opportunity: ObjectPluginConfig | None = None Application: ObjectPluginConfig | None = None # ... one field per protocol object- Pros
- Co-location: all of an object’s config is in one branch during authoring
- Single unified term —
Pluginis used for both the registry catalog and the SDK runtime object
- Cons
- Top-level keys track the protocol’s object list, which is long and open-ended — surface grows as protocol grows and raises questions about which of 100+ schemas belongs at the top level
- Client, auth, and transport are system-level but must either be duplicated per object or kept implicit alongside object keys, creating an awkward mix of concerns
- Filters attach to resource methods rather than schemas, and resource methods aren’t consistent across objects (e.g.
opportunities.list/get/searchvsapplications.start/submit), creating a poor fit - DI requires reassembling a flat view across all object branches (e.g. an
allSchemashelper) — working against the grain of the structure mergeExtensions()must deeply merge nested per-object branches rather than operating on a flat declarative root
Option 3: Functional top-level with per-object schema grouping (chosen)
Section titled “Option 3: Functional top-level with per-object schema grouping (chosen)”Top-level keys are functional (meta, schemas). Per-object grouping is used only inside schemas, where it reflects real coupling between source schemas, CommonGrants schemas, and bidirectional transforms. All per-object declarations (custom fields, source type, declarative mappings, and explicit callables) live under schemas.<Object>.
interface Plugin { meta?: PluginMeta; schemas: Partial<Record<ExtensibleSchemaName, SchemaConfig>>; // per-object grouping only here}- Pros
- Short, stable top-level surface —
meta,schemastracks a closed list regardless of how many protocol objects exist - Per-object grouping inside
schemaspreserves real coupling — source schema, CommonGrants schema,toCommon, andfromCommonshare type signatures and change together - All per-object declarations are co-located: no split across
schemasand a separateextensionskey
- Short, stable top-level surface —
- Cons
- Client configuration and auth are deferred; the Plugin shape does not yet support them