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,extensions) 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
extensionsconfig must be serializable and able to pass validation (JSON-safe), and must be combinable across multiple extension packages viamergeExtensions()(TypeScript) /merge_extensions()(Python). - Existing plugin packages that declare only custom fields should remain valid with minimal changes.
- The SDK interface should support clean dependency injection — it must be possible to pass
clientorschemasas a coherent unit without reassembling them from per-object branches. - 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 four keys —
meta,client,schemas, andextensions— rather than grouping by object name at the root. -
Use per-object grouping inside
schemaswhere it reflects real coupling: each object’s native schema, CommonGrants schema, and bidirectional transforms are tightly coupled and change together. -
Expand
definePlugin()to accept all top-level fields —meta,client,schemas, andextensions— rather than onlyextensions.extensionswill contain any parts of the plugin that can be merged with other plugins by usingmergeExtensions(), which has a flag for handling key conflicts. -
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.
The resulting Plugin shape:
plugin.meta // name, version, sourceSystem, capabilitiesplugin.getClient // (config: ClientConfig) => Client; memoized by definePlugin()plugin.extensions // serializable; used by mergeExtensions()plugin.schemas.<ObjectName> = { native, common, toCommon, fromCommon }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 | "client"; // provides a runtime client (auth, transport, resource methods)
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;}
// Runtime type — produced by definePlugin(), not provided directly by plugin authorsinterface ObjectSchemas<TNative, TCommon> { native: ZodType<TNative>; common: ZodType<TCommon>; toCommon: (native: TNative) => TCommon; fromCommon: (common: TCommon) => TNative;}
// Input type — provided by plugin authors inside DefinePluginOptions.schemasinterface ObjectSchemasInput<TNative = unknown, TCommon = unknown> { native?: ZodType<TNative>; // defaults to Record<string, unknown> if omitted toCommon?: (native: TNative) => TCommon; fromCommon?: (common: TCommon) => TNative;}
// Scalar types only — filters are query parameters, not schema fieldstype CustomFilterType = "string" | "number" | "integer" | "boolean";
interface CustomFilterSpec { filterType: CustomFilterType; description?: string;}
// Per-object config shape inside extensions.schemas — mirrors Python's PluginExtensionsSchemainterface PluginExtensionsObjectConfig { customFields?: Record<string, CustomFieldSpec>;}
// Serializable portion of the plugin config — safe to store as JSON.// schemas keys are restricted to ExtensibleSchemaName (the known set of CommonGrants// objects that support custom fields), following the existing SchemaExtensions pattern.interface PluginExtensions { meta?: Partial<PluginMeta>; schemas?: Partial<Record<ExtensibleSchemaName, PluginExtensionsObjectConfig>>;}
// ClientConfig is defined by the plugin author to declare the system-specific inputs// they require (e.g. auth token, base URL, max page size, timeouts).interface ClientConfig { [key: string]: unknown;}
// Client is a placeholder for the SDK's runtime client type (not shown here)interface Plugin { meta?: PluginMeta; getClient?: (config: ClientConfig) => Client; extensions?: PluginExtensions; // serializable schemas?: Partial< Record<ExtensibleSchemaName, ObjectSchemas<unknown, unknown>> >; filters?: Partial< Record<ExtensibleSchemaName, Record<string, CustomFilterSpec>> >;}
// 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 a factory function; definePlugin() wraps it with memoization // so the same Client instance is returned for equivalent configs automatically. getClient?: (config: ClientConfig & { auth?: AuthMethod }) => Client; extensions?: PluginExtensions; // serializable // Plugin authors provide input schemas and transforms; definePlugin() compiles them // into the full ObjectSchemas runtime type, merging any customFields from extensions. schemas?: Partial<Record<ExtensibleSchemaName, ObjectSchemasInput>>; filters?: Partial< Record<ExtensibleSchemaName, Record<string, CustomFilterSpec>> >;}
// 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// - native defaults to Record<string, unknown> if omitted (extensions is JSON-safe;// runtime Zod schemas cannot be included)// - wrapping getClient with memoization so the same Client instance is returned// for equivalent configs automatically//// toCommon / fromCommon may be plain hand-written functions or generated via// buildTransforms(). Either way they are provided in schemas, not derived from extensions.function definePlugin(options: DefinePluginOptions): Plugin;
// Combine multiple extension objects (e.g. from separate packages)function mergeExtensions( ...extensions: PluginExtensions[], onConflict: "firstWins" = "firstWins",): PluginExtensions;
// 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.function buildTransforms<TNative, TCommon>( toCommonMapping: Record<string, unknown>, // ADR-0017 mapping from native → CommonGrants fromCommonMapping: Record<string, unknown>, // ADR-0017 mapping from CommonGrants → native): { toCommon: (native: TNative) => TCommon; fromCommon: (common: TCommon) => TNative;};from dataclasses import dataclassfrom typing import Any, Callable, Generic, Literal, TypeVarfrom pydantic import BaseModel, ConfigDict, Field# Client is a placeholder for the SDK's runtime client type (not shown here)
TNative = TypeVar('TNative')TCommon = TypeVar('TCommon')
# 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 type — produced by define_plugin(), not provided directly by plugin authors@dataclassclass ObjectSchemas(Generic[TNative, TCommon]): native: type[TNative] # expects a Pydantic BaseModel subclass common: type[TCommon] # expects a Pydantic BaseModel subclass to_common: Callable[[TNative], TCommon] from_common: Callable[[TCommon], TNative]
# Input type — provided by plugin authors inside define_plugin(schemas=...)@dataclassclass ObjectSchemasInput(Generic[TNative, TCommon]): native: type[TNative] | None = None # defaults to dict[str, Any] if omitted to_common: Callable[[TNative], TCommon] | None = None from_common: Callable[[TCommon], TNative] | 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', 'client']
class PluginMeta(BaseModel): model_config = ConfigDict(populate_by_name=True)
name: str version: str | None = None # optional; if omitted, define_plugin() infers it from the package's pyproject.toml / importlib.metadata source_system: str = Field(alias='sourceSystem') capabilities: list[PluginCapability] | None = None
# Equivalent to TypeScript's Partial<PluginMeta>. Defined as a separate model# rather than reusing PluginMeta because Pydantic does not have a built-in Partial.# Note: if PluginMeta gains new required fields, this class must be updated manually.# Drift can be caught with: assert PluginMeta.model_fields.keys() == PluginExtensionsMeta.model_fields.keys()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
class PluginExtensionsSchema(BaseModel): model_config = ConfigDict(populate_by_name=True)
custom_fields: dict[str, CustomFieldSpec] | None = Field(default=None, alias='customFields')
class PluginExtensions(BaseModel): meta: PluginExtensionsMeta | None = None schemas: dict[str, PluginExtensionsSchema] | None = None
ClientConfig = dict[str, Any] # plugin authors define their own keys (auth, base_url, timeout, etc.)
@dataclassclass Plugin: meta: PluginMeta | None = None get_client: Callable[[ClientConfig], Client] | None = None extensions: PluginExtensions | None = None schemas: dict[ExtensibleSchemaName, ObjectSchemas[Any, Any]] | None = None filters: dict[ExtensibleSchemaName, dict[str, CustomFilterSpec]] | 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 DefinePluginOptions wrapper object is needed.## define_plugin compiles inputs into a Plugin by:# - extending the base CommonGrants model with any declared custom_fields → common# - native defaults to dict[str, Any] if omitted (extensions is JSON-safe; runtime# Pydantic models cannot be included)# - wrapping get_client with memoization so the same Client instance is returned# for equivalent configs automatically## to_common / from_common may be plain hand-written callables or generated via# build_transforms(). Either way they are provided in schemas, not derived from extensions.def define_plugin( meta: PluginMeta | None = None, get_client: Callable[[ClientConfig], Client] | None = None, extensions: PluginExtensions | None = None, schemas: dict[ExtensibleSchemaName, ObjectSchemasInput[Any, Any]] | None = None, filters: dict[ExtensibleSchemaName, dict[str, CustomFilterSpec]] | None = None,) -> Plugin: ...def merge_extensions(*extensions: PluginExtensions, on_conflict: Literal["first_wins"] = "first_wins") -> PluginExtensions: ...
# 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.def build_transforms( to_common_mapping: dict[str, Any], # ADR-0017 mapping from native → CommonGrants from_common_mapping: dict[str, Any], # ADR-0017 mapping from CommonGrants → native) -> tuple[Callable[[TNative], TCommon], Callable[[TCommon], TNative]]: ...Example 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: native grants.gov shape → CommonGrants Opportunity { title: "data.opportunity_title", status: { value: { match: { field: "data.opportunity_status", case: { posted: "open", archived: "closed" }, default: "custom", }, }, }, }, // fromCommon: CommonGrants Opportunity → native grants.gov 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", }, // definePlugin memoizes getClient — the same Client is returned for equivalent configs. getClient: (config: ClientConfig & { auth?: AuthMethod }) => new Client({ baseUrl: config.baseUrl ?? "https://api.grants.gov", timeout: config.timeout, pageSize: config.pageSize, maxItems: config.maxItems, auth: config.auth, }), extensions: { schemas: { Opportunity: { customFields: { programArea: { fieldType: CustomFieldType.String, description: "HHS program area code", }, legacyGrantId: { fieldType: CustomFieldType.Integer, description: "Numeric ID from the legacy grants system", }, }, }, }, }, schemas: { Opportunity: { native: GrantsGovOpportunitySchema, toCommon, fromCommon, }, },});
// Combine extensions from multiple packages before constructing the pluginconst merged = mergeExtensions(baseExtensions, grantsGovExtensions);const mergedPlugin = definePlugin({ extensions: merged });
// Calling getClient() with a config object — memoized, so repeated calls return the same instanceconst client = plugin.getClient({ auth: Auth.bearer("token"), pageSize: 50 });# 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: native grants.gov 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 → native grants.gov 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=PluginMeta(name='grants-gov-plugin', version='1.0.0', source_system='grants.gov'), # source_system serializes as 'sourceSystem' in JSON # define_plugin memoizes get_client — the same Client is returned for equivalent configs. get_client=lambda config: Client(config=Config( base_url=config.get('base_url', 'https://api.grants.gov'), api_key=config['api_key'], timeout=config.get('timeout', 10.0), page_size=config.get('page_size', 100), list_items_limit=config.get('list_items_limit', 1000), )), extensions=PluginExtensions( schemas={ 'Opportunity': PluginExtensionsSchema( 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'), }, ), }, ), schemas={ 'Opportunity': ObjectSchemasInput( native=GrantsGovOpportunity, to_common=to_common, from_common=from_common, ), },)
# Combine extensions from multiple packages before constructing the pluginmerged = merge_extensions(base_extensions, grants_gov_extensions)merged_plugin = define_plugin(extensions=merged)
# Calling get_client() with a config dict — memoized, so repeated calls return the same instanceclient = plugin.get_client({'api_key': 'abc123', 'page_size': 50})Example consumer usage
Section titled “Example consumer usage”import { grantsGovPlugin } from "grants-gov-plugin";
// Get a configured client for this source systemconst client = grantsGovPlugin.getClient({ auth: Auth.bearer(process.env.GRANTS_GOV_API_KEY), pageSize: 25,});
// Use the compiled schemas to transform native data into CommonGrants shapeconst { toCommon } = grantsGovPlugin.schemas.Opportunity;const opportunity = toCommon(rawGrantsGovData);
// Inspect what the plugin declares about itselfconsole.log(grantsGovPlugin.meta.sourceSystem); // "grants.gov"console.log(grantsGovPlugin.meta.capabilities); // ["customFields", "transforms", "client"]from grants_gov_plugin import grants_gov_plugin
# Get a configured client for this source systemclient = grants_gov_plugin.get_client({ 'api_key': os.environ['GRANTS_GOV_API_KEY'], 'page_size': 25,})
# Use the compiled schemas to transform native data into CommonGrants shapeto_common = grants_gov_plugin.schemas['Opportunity'].to_commonopportunity = to_common(raw_grants_gov_data)
# Inspect what the plugin declares about itselfprint(grants_gov_plugin.meta.source_system) # "grants.gov"print(grants_gov_plugin.meta.capabilities) # ["customFields", "transforms", "client"]Consequences
Section titled “Consequences”- Positive consequences
- Client stays singular —
getClient()is memoized bydefinePlugin(), so one source system always produces oneClientinstance regardless of how many timesgetClient()is called - Top-level surface (
meta,client,schemas,extensions) is short, closed, and stable — adding protocol objects adds a key underschemasonly - Dependency injection works along functional lines: pass
getClient, passSchemas, passExtensionsas coherent units without needing to reassemble from per-object branches mergeExtensions()/merge_extensions()operates on flat, serializable data at the root, not on deeply nested per-object branches- Per-object grouping inside
schemaspreserves the real coupling between native schema, CommonGrants schema, and bidirectional transforms — they share type signatures and change together - Mirrors the SDK module structure (
client,schemas,extensions), so Plugin reads as a system-specific version of the existing SDK rather than a different mental model toCommon/fromCommoncan be plain hand-written functions or generated viabuildTransforms()— plugin authors are not required to use a declarative mapping formatbuildTransforms()accepts separatetoCommonMappingandfromCommonMappingobjects, reflecting that the two directions of a bidirectional transform are distinctcustomFieldsis 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
- Client stays singular —
- Negative consequences
extensions(serializable config) andplugin(runtime object including client) are distinct concepts that adopters must understand separately
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: The
extensionsJSON document uses camelCase keys (customFields,fieldType,sourceSystem) in both SDKs — 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
- DI-friendly: Functional top-level keys can be passed as coherent units without reassembly
- 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 serializable 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, client, schemas, extensions). Per-object grouping is used only inside schemas, where it reflects real coupling between native schemas, CommonGrants schemas, and bidirectional transforms.
interface Plugin { meta?: PluginMeta; client?: Client; extensions?: PluginExtensions; schemas?: Partial<Record<ExtensibleSchemaName, ObjectSchemas>>; // per-object grouping only here}- Pros
- Short, stable top-level surface —
meta,client,schemas,extensionstracks a closed list regardless of how many protocol objects exist - Client is singular —
getClient()is memoized bydefinePlugin(), so one source system always produces oneClientinstance - DI works along functional lines: pass
getClient, passSchemas, passExtensionsas units mergeExtensions()operates on flat, serializable data at the root, not deeply nested per-object branches- Per-object grouping inside
schemaspreserves real coupling — native schema, CommonGrants schema,toCommon, andfromCommonshare type signatures and change together - Mirrors the SDK module structure (
client,schemas,extensions) — Plugin is a system-specific version of the existing SDK, not a different mental model
- Short, stable top-level surface —
- Cons
extensions(serializable config) andplugin(runtime object including client) are distinct concepts that adopters must learn separately