feat(filter_runtime): typed FilterConfigBase + emit_schema (FILTER-441)#86
Conversation
lucasmundim
left a comment
There was a problem hiding this comment.
Solid PR — the design cleanly separates operator-facing from platform-managed config, the pydantic-settings integration is well done, and the 37 tests cover the core contracts well. The _NamedFormat pattern for injecting format: into JSON Schema while keeping the runtime validator is elegant, and the authoring guide is thorough.
A few items worth addressing before merge:
e06cfce to
b0175e7
Compare
lucasmundim
left a comment
There was a problem hiding this comment.
Second-pass review. All first-round items confirmed fixed in b0175e7. The PR is in good shape — none of these are blockers, mostly suggestions and edge-case hardening.
# 📦 Pull Request ## 📋 What does this PR do? Introduces `FilterConfigBase(BaseSettings)` — the typed, introspectable successor to the legacy `adict`-based `FilterConfig` — and the surrounding machinery that lets a filter declare its config contract in Python and emit it as a JSON Schema (draft 2020-12) the platform can ingest without pulling the filter image. Specifically: - New `FilterConfigBase` in `openfilter/filter_runtime/config.py`. Subclasses get pydantic-settings env-var sourcing (`FILTER_*` prefix, `__` nested delimiter), declarative range/enum/pattern/format constraints, and a `model_json_schema()` export. - New `openfilter/filter_runtime/formats.py` — `OpenfilterSource` and `VideoSource` Annotated aliases plus their Python-side validators (FC-1.5 py). - FC-2 markers: `x-openfilter-managed`, `x-openfilter-resolve`, and `x-openfilter-preflight` JSON Schema extension keywords, plus `Managed(...)` and `Resolve(...)` field helpers. Managed fields are stripped from the operator-facing schema by default (filter exposes operator surface only; orchestrator handles managed fields). - New authoring guide at `docs/declarative-config.md` covering the typed-config contract end to end (Tier 1/2/3 migration, env-var sourcing, tagged unions, named formats, the `Managed` / `Resolve` boundary). - Tier-3 fallback intact: filters that don't subclass `FilterConfigBase` keep working unchanged. - Adds `pydantic~=2.9` and `pydantic-settings~=2.6` as hard deps; lock file refreshed. The build-time CLI half (`openfilter emit-schema`) is stacked on top of this PR as [FILTER-442](https://plainsight-ai.atlassian.net/browse/FILTER-442). --- ## 🔍 Why is this needed? Filter authors need a way to declare their config contract once, in Python, and have the platform pick it up at deploy time without side-loading filter images. Today the platform infers config from hand-maintained env-var docs that drift; the platform-side ingest work ([Declarative Configuration Validation](https://plainsight-ai.atlassian.net/browse/FILTER-440)) is blocked on having a real schema artifact to ingest. This PR is the SDK-side scaffolding for that contract. It ships the typed base class, the named-format aliases, and the FC-2 boundary markers that distinguish operator-visible config from platform-managed / resolved / preflight fields. Once it lands, individual filters can opt in at their own cadence (Tier 1/2 per the design doc); unmigrated filters keep working through the openfilter 1.0 cut. Design doc: [Declarative Filter Configuration Contract — Goldenrod.2](https://plainsight-ai.atlassian.net/wiki/spaces/ENG/pages/2755919874). --- ## 🧪 How was it tested? ### Unit tests (37, all passing) `tests/test_filter_config_base.py` covers: - env-var sourcing with the `FILTER_*` prefix - tagged unions via `env_nested_delimiter="__"` (sam3 prompt-mode fitness test) - managed-field exclusion in `emit_schema()` and `--include-managed` parity - `Managed` / `Resolve` field helpers and parent/child override semantics - `OpenfilterSource` / `VideoSource` validators (accept/reject + JSON Schema `format` keyword) ``` $ uv run --with pytest pytest tests/test_filter_config_base.py -q ..................................... [100%] 37 passed in 0.30s ``` ### Integration spike: sam3-shaped config Validated FILTER-441's load-bearing acceptance criterion — sam3's three mutually-exclusive prompt modes as a tagged union — against a sam3-shaped `FilterConfigBase` subclass (text / multi-text / exemplars prompt modes, plus `Managed` `sources` and `device` fields, plus representative hyperparams). Results: - ✅ `prompting` emits a discriminated union with `discriminator: {propertyName: "prompt_mode", mapping: {...}}` and `oneOf` referring to `$defs/{TextPrompt,MultiTextPrompt,Exemplars}` - ✅ Managed fields (`sources`, `device`) stripped from operator-facing schema; `--include-managed` surfaces them with `x-openfilter-managed` and `x-openfilter-resolve` markers - ✅ `sources.items.format == "video-source"` (FC-1.5 named format round-trips) - ✅ Pydantic-settings env-var sourcing into the tagged union: `FILTER_PROMPTING__PROMPT_MODE=text FILTER_PROMPTING__TEXT_PROMPT='person walking'` parses into the `TextPrompt` variant; `FILTER_CONFIDENCE_THRESHOLD=0.7` overrides the top-level default - ✅ Top-level `required: ["prompting"]` correctly surfaces fields with no default This stacks on [FILTER-442](https://plainsight-ai.atlassian.net/browse/FILTER-442) for the CLI invocation; the spike was run via `python -m openfilter.cli emit-schema` from the FILTER-442 worktree. ### Tier-3 fallback No production filter touched in this PR. Filters that don't subclass `FilterConfigBase` (i.e. every existing one) are unaffected. --- ## 🔗 Related Issues - [FILTER-441](https://plainsight-ai.atlassian.net/browse/FILTER-441) — this ticket - [FILTER-440](https://plainsight-ai.atlassian.net/browse/FILTER-440) — Declarative Configuration Contract (parent epic) - [FILTER-442](https://plainsight-ai.atlassian.net/browse/FILTER-442) — `openfilter emit-schema` CLI (stacked, follows this PR) --- ## 🖼️ Screenshots or Logs (if applicable) Sample emitted schema (managed field stripped by default): ```python class MyFilter(FilterConfigBase): sources: list[VideoSource] = Managed([], resolve="orchestrator-generated") confidence_threshold: float = Field(default=0.5, ge=0, le=1) >>> MyFilter.emit_schema() { "title": "MyFilter", "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "confidence_threshold": { "type": "number", "minimum": 0, "maximum": 1, "default": 0.5 } }, ... } ``` --- ## ✅ Checklist - [x] I have read and agreed to the terms of the [LICENSE](../LICENSE) - [x] I have read the [CONTRIBUTING](../CONTRIBUTING.md) guide - [x] I have followed the [coding style](../CONTRIBUTING.md#coding-style) - [x] I have signed all commits in compliance with the DCO (`git commit -s`) - [x] I have added or updated **tests** as needed - [x] I have added or updated **documentation** as needed (`docs/declarative-config.md` — authoring guide for the typed-config contract; the parent epic FC-12 row is closed by this addition) Signed-off-by: stwilt <swilt@plainsight.ai>
b0175e7 to
d724eb5
Compare
lucasmundim
left a comment
There was a problem hiding this comment.
Round-3 review — all round-2 items verified in d724eb5:
- typing-extensions floor relaxed to
>=4.6to match pydantic required: []omitted when every required field is managed (top-level and nested); newrequired_handledflag handles dict-iteration order both ways$schema: "https://json-schema.org/draft/2020-12/schema"injected inemit_schema()viasetdefaultResolveHintadded to__all__and re-exported fromopenfilter.filter_runtimejson_schema_extramerge behavior locked in by test
The new @deprecated decorator on legacy FilterConfig is well-scoped — verified third-party subclassers still see the warning under python -W error::DeprecationWarning, and openfilter-internal ones are correctly silenced via the module-name regex. The append=False choice is deliberate and well-commented.
46/46 tests in test_filter_config_base.py pass.
# 📦 Pull Request ## 📋 What does this PR do? Adds the `openfilter emit-schema <module>[:Class]` CLI subcommand — the build-time producer half of the FC-3 schema-transport contract. The command imports a filter module, locates its `FilterConfigBase` subclass, and writes the emitted JSON Schema (draft 2020-12) to stdout (default) or `-o <path>`. Filter Dockerfiles invoke this at build time; the resulting JSON is pushed as a sibling OCI artifact (`application/vnd.openfilter.schema+json`) and the image manifest is stamped with a `com.plainsight.openfilter.schema` label carrying the artifact's sha256 digest. Specifically: - New `openfilter/cli/cmd_emit_schema.py` — argparse-driven subcommand with `module` / `module:Class` resolution, automatic candidate detection (errors out cleanly when a module declares 0 or 2+ `FilterConfigBase` subclasses), `--include-managed` opt-in to surface platform-managed fields, and `-o <path>` output redirection. - Tweak the `openfilter` CLI dispatcher (`openfilter/cli/__main__.py`) to translate `cmd-with-hyphens` to `cmd_with_hyphens` so subcommands can use their canonical hyphenated spellings without breaking the underscore-named Python handler convention. - 5 unit tests in `tests/test_emit_schema_cli.py` covering stdout output, `-o <path>`, `--include-managed`, single-class auto-pick, and unknown-class failure. The wiring of the sibling-artifact upload + label-stamping into `cloudbuild-cascade.yaml` is tracked as a sub-step of [FILTER-442](https://plainsight-ai.atlassian.net/browse/FILTER-442); it depends on PLAT-side FC-5 ingest and lands separately. --- ## 🔍 Why is this needed? [FILTER-441](https://plainsight-ai.atlassian.net/browse/FILTER-441) landed `FilterConfigBase.emit_schema()` — the in-process serializer that produces each filter's JSON Schema. This PR is the CLI wrapper Dockerfiles invoke at build time so the artifact can ride alongside the image without a Python-runtime hop in the platform's ingest path. Once both are in place, the transport contract closes: filter CI emits the schema, pushes it as a sibling OCI artifact, and stamps the image manifest's `com.plainsight.openfilter.schema` label with the sha256 digest. The platform fetches the artifact by digest from the same registry without pulling the filter image's layers. Integrity is transitively protected by whatever trust we already place in the registry serving the image bytes — no separate key infrastructure needed. (Cosign-signed attestations were considered for the transport but deferred to a future-driver-only ticket — see [FILTER-448](https://plainsight-ai.atlassian.net/browse/FILTER-448), closed Won't Do — the marginal threat surface didn't justify standing up KMS for the first internal use.) --- ## 🧪 How was it tested? 5 unit tests in `tests/test_emit_schema_cli.py`: - `test_emit_schema_cli_stdout` — `module:Class` spec, stdout output, `$schema` keyword + managed-field stripping - `test_emit_schema_cli_writes_file` — `-o <path>` redirection - `test_emit_schema_cli_include_managed_surfaces_overrides` — `--include-managed` round-trips FC-2 markers - `test_emit_schema_cli_auto_picks_single_class` — module-only spec finds the lone `FilterConfigBase` subclass - `test_emit_schema_cli_fails_on_unknown_class` — typo'd class name errors out cleanly ``` $ uv run --with pytest pytest tests/test_emit_schema_cli.py -q ..... [100%] 5 passed in 1.79s ``` Combined with [FILTER-441](https://plainsight-ai.atlassian.net/browse/FILTER-441)'s config-side suite (already merged in #86, 46 tests), the full FC-1 / FC-1.5 / FC-2 / FC-3-emission contract has 51 tests exercising it end-to-end. --- ## 🔗 Related Issues - [FILTER-442](https://plainsight-ai.atlassian.net/browse/FILTER-442) — this ticket - [FILTER-441](https://plainsight-ai.atlassian.net/browse/FILTER-441) — `FilterConfigBase.emit_schema()` (merged in #86) - [FILTER-440](https://plainsight-ai.atlassian.net/browse/FILTER-440) — Declarative Configuration Contract (parent epic) - [FILTER-448](https://plainsight-ai.atlassian.net/browse/FILTER-448) — cosign / KMS signing (closed Won't Do; revisit on concrete compliance/multi-tenancy driver) --- ## 🖼️ Screenshots or Logs (if applicable) ``` $ openfilter emit-schema my_filter.filter --include-managed { "title": "MyFilterConfig", "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "sources": { "items": {"format": "video-source", "type": "string"}, "type": "array", "x-openfilter-managed": true, "x-openfilter-resolve": "orchestrator-generated" }, "confidence_threshold": { "type": "number", "minimum": 0, "maximum": 1, "default": 0.5 }, ... } } ``` --- ## ✅ Checklist - [x] I have read and agreed to the terms of the [LICENSE](../LICENSE) - [x] I have read the [CONTRIBUTING](../CONTRIBUTING.md) guide - [x] I have followed the [coding style](../CONTRIBUTING.md#coding-style) - [x] I have signed all commits in compliance with the DCO (`git commit -s`) - [x] I have added or updated **tests** as needed - [x] I have added or updated **documentation** as needed (`docs/declarative-config.md`'s "Schema emission" section, landed with FILTER-441 in #86, already describes this CLI's surface) Signed-off-by: stwilt <swilt@plainsight.ai>
# 📦 Pull Request ## 📋 What does this PR do? Adds the `openfilter emit-schema <module>[:Class]` CLI subcommand — the build-time producer half of the FC-3 schema-transport contract. The command imports a filter module, locates its `FilterConfigBase` subclass, and writes the emitted JSON Schema (draft 2020-12) to stdout (default) or `-o <path>`. Filter Dockerfiles invoke this at build time; the resulting JSON is pushed as a sibling OCI artifact (`application/vnd.openfilter.schema+json`) and the image manifest is stamped with a `com.plainsight.openfilter.schema` label carrying the artifact's sha256 digest. Specifically: - New `openfilter/cli/cmd_emit_schema.py` — argparse-driven subcommand with `module` / `module:Class` resolution, automatic candidate detection (errors out cleanly when a module declares 0 or 2+ `FilterConfigBase` subclasses), `--include-managed` opt-in to surface platform-managed fields, and `-o <path>` output redirection. - Tweak the `openfilter` CLI dispatcher (`openfilter/cli/__main__.py`) to translate `cmd-with-hyphens` to `cmd_with_hyphens` so subcommands can use their canonical hyphenated spellings without breaking the underscore-named Python handler convention. - 5 unit tests in `tests/test_emit_schema_cli.py` covering stdout output, `-o <path>`, `--include-managed`, single-class auto-pick, and unknown-class failure. The wiring of the sibling-artifact upload + label-stamping into `cloudbuild-cascade.yaml` is tracked as a sub-step of [FILTER-442](https://plainsight-ai.atlassian.net/browse/FILTER-442); it depends on PLAT-side FC-5 ingest and lands separately. --- ## 🔍 Why is this needed? [FILTER-441](https://plainsight-ai.atlassian.net/browse/FILTER-441) landed `FilterConfigBase.emit_schema()` — the in-process serializer that produces each filter's JSON Schema. This PR is the CLI wrapper Dockerfiles invoke at build time so the artifact can ride alongside the image without a Python-runtime hop in the platform's ingest path. Once both are in place, the transport contract closes: filter CI emits the schema, pushes it as a sibling OCI artifact, and stamps the image manifest's `com.plainsight.openfilter.schema` label with the sha256 digest. The platform fetches the artifact by digest from the same registry without pulling the filter image's layers. Integrity is transitively protected by whatever trust we already place in the registry serving the image bytes — no separate key infrastructure needed. (Cosign-signed attestations were considered for the transport but deferred to a future-driver-only ticket — see [FILTER-448](https://plainsight-ai.atlassian.net/browse/FILTER-448), closed Won't Do — the marginal threat surface didn't justify standing up KMS for the first internal use.) --- ## 🧪 How was it tested? 5 unit tests in `tests/test_emit_schema_cli.py`: - `test_emit_schema_cli_stdout` — `module:Class` spec, stdout output, `$schema` keyword + managed-field stripping - `test_emit_schema_cli_writes_file` — `-o <path>` redirection - `test_emit_schema_cli_include_managed_surfaces_overrides` — `--include-managed` round-trips FC-2 markers - `test_emit_schema_cli_auto_picks_single_class` — module-only spec finds the lone `FilterConfigBase` subclass - `test_emit_schema_cli_fails_on_unknown_class` — typo'd class name errors out cleanly ``` $ uv run --with pytest pytest tests/test_emit_schema_cli.py -q ..... [100%] 5 passed in 1.79s ``` Combined with [FILTER-441](https://plainsight-ai.atlassian.net/browse/FILTER-441)'s config-side suite (already merged in #86, 46 tests), the full FC-1 / FC-1.5 / FC-2 / FC-3-emission contract has 51 tests exercising it end-to-end. --- ## 🔗 Related Issues - [FILTER-442](https://plainsight-ai.atlassian.net/browse/FILTER-442) — this ticket - [FILTER-441](https://plainsight-ai.atlassian.net/browse/FILTER-441) — `FilterConfigBase.emit_schema()` (merged in #86) - [FILTER-440](https://plainsight-ai.atlassian.net/browse/FILTER-440) — Declarative Configuration Contract (parent epic) - [FILTER-448](https://plainsight-ai.atlassian.net/browse/FILTER-448) — cosign / KMS signing (closed Won't Do; revisit on concrete compliance/multi-tenancy driver) --- ## 🖼️ Screenshots or Logs (if applicable) ``` $ openfilter emit-schema my_filter.filter --include-managed { "title": "MyFilterConfig", "$schema": "https://json-schema.org/draft/2020-12/schema", "properties": { "sources": { "items": {"format": "video-source", "type": "string"}, "type": "array", "x-openfilter-managed": true, "x-openfilter-resolve": "orchestrator-generated" }, "confidence_threshold": { "type": "number", "minimum": 0, "maximum": 1, "default": 0.5 }, ... } } ``` --- ## ✅ Checklist - [x] I have read and agreed to the terms of the [LICENSE](../LICENSE) - [x] I have read the [CONTRIBUTING](../CONTRIBUTING.md) guide - [x] I have followed the [coding style](../CONTRIBUTING.md#coding-style) - [x] I have signed all commits in compliance with the DCO (`git commit -s`) - [x] I have added or updated **tests** as needed - [x] I have added or updated **documentation** as needed (`docs/declarative-config.md`'s "Schema emission" section, landed with FILTER-441 in #86, already describes this CLI's surface) [FILTER-442]: https://plainsight-ai.atlassian.net/browse/FILTER-442?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ Signed-off-by: stwilt <swilt@plainsight.ai>
📦 Pull Request
📋 What does this PR do?
Introduces
FilterConfigBase(BaseSettings)— the typed, introspectable successor to the legacyadict-basedFilterConfig— and the surrounding machinery that lets a filter declare its config contract in Python and emit it as a JSON Schema (draft 2020-12) the platform can ingest without pulling the filter image.Specifically:
FilterConfigBaseinopenfilter/filter_runtime/config.py. Subclasses get pydantic-settings env-var sourcing (FILTER_*prefix,__nested delimiter), declarative range/enum/pattern/format constraints, and amodel_json_schema()export.openfilter/filter_runtime/formats.py—OpenfilterSourceandVideoSourceAnnotated aliases plus their Python-side validators (FC-1.5 py).x-openfilter-managed,x-openfilter-resolve, andx-openfilter-preflightJSON Schema extension keywords, plusManaged(...)andResolve(...)field helpers. Managed fields are stripped from the operator-facing schema by default (filter exposes operator surface only; orchestrator handles managed fields).docs/declarative-config.mdcovering the typed-config contract end to end (Tier 1/2/3 migration, env-var sourcing, tagged unions, named formats, theManaged/Resolveboundary).FilterConfigBasekeep working unchanged.pydantic~=2.9andpydantic-settings~=2.6as hard deps; lock file refreshed.The build-time CLI half (
openfilter emit-schema) is stacked on top of this PR asFILTER-442.
🔍 Why is this needed?
Filter authors need a way to declare their config contract once, in Python, and have the platform pick it up at deploy time without side-loading filter images. Today the platform infers config from hand-maintained env-var docs that drift; the platform-side ingest work (Declarative Configuration Validation) is blocked on having a real schema artifact to ingest.
This PR is the SDK-side scaffolding for that contract. It ships the typed base class, the named-format aliases, and the FC-2 boundary markers that distinguish operator-visible config from platform-managed / resolved / preflight fields. Once it lands, individual filters can opt in at their own cadence (Tier 1/2 per the design doc); unmigrated filters keep working through the openfilter 1.0 cut.
Design doc:
Declarative Filter Configuration Contract — Goldenrod.2.
🧪 How was it tested?
Unit tests (37, all passing)
tests/test_filter_config_base.pycovers:FILTER_*prefixenv_nested_delimiter="__"(sam3 prompt-mode fitness test)emit_schema()and--include-managedparityManaged/Resolvefield helpers and parent/child override semanticsOpenfilterSource/VideoSourcevalidators (accept/reject + JSON Schemaformatkeyword)Integration spike: sam3-shaped config
Validated FILTER-441's load-bearing acceptance criterion — sam3's three mutually-exclusive prompt modes as a tagged union — against a sam3-shaped
FilterConfigBasesubclass (text / multi-text / exemplars prompt modes, plusManagedsourcesanddevicefields, plus representative hyperparams). Results:promptingemits a discriminated union withdiscriminator: {propertyName: "prompt_mode", mapping: {...}}andoneOfreferring to$defs/{TextPrompt,MultiTextPrompt,Exemplars}sources,device) stripped from operator-facing schema;--include-managedsurfaces them withx-openfilter-managedandx-openfilter-resolvemarkerssources.items.format == "video-source"(FC-1.5 named format round-trips)FILTER_PROMPTING__PROMPT_MODE=text FILTER_PROMPTING__TEXT_PROMPT='person walking'parses into theTextPromptvariant;FILTER_CONFIDENCE_THRESHOLD=0.7overrides the top-level defaultrequired: ["prompting"]correctly surfaces fields with no defaultThis stacks on FILTER-442 for the CLI invocation; the spike was run via
python -m openfilter.cli emit-schemafrom the FILTER-442 worktree.Tier-3 fallback
No production filter touched in this PR. Filters that don't subclass
FilterConfigBase(i.e. every existing one) are unaffected.🔗 Related Issues
openfilter emit-schemaCLI (stacked, follows this PR)🖼️ Screenshots or Logs (if applicable)
Sample emitted schema (managed field stripped by default):
✅ Checklist
git commit -s)docs/declarative-config.md— authoring guide for the typed-config contract; the parent epic FC-12 row is closed by this addition)