0060 Lmcp B10 Provider Filter Dsl
ADR-0060: Provider filter DSL semantics + config placement — LMCP-B10
- Date: 2026-07-03
- Status: accepted
- Deciders: Gerald Dagher (owner), Construct maintainers (cx-architect)
- Relates to: ADR-0052 (unified extension/provider manifest —
configSchema), ADR-0053 (living graph — observation nodes), ADR-0056 (policy/approval/identity model) - Tracking: LMCP-B10-DEC. Blocks LMCP-B11 (enforce filters at poll/read), LMCP-B12 (
construct provider add|configure). Soft-blocks LMCP-B14, LMCP-P1-DEC (filter-block reference).
Problem
Users cannot scope what a connected provider feeds Construct. Two facts from the current repo:
lib/providers/contract.mjsdefinesvalidateAllowlist()plus a per-provider allowlist schema (github → repoAllowlist/repoAllowGlob,atlassian-jira → projectAllowlist,atlassian-confluence → spaceAllowlist,slack → channelAllowlist,salesforce → objectAllowlist). Greppinglib/for callers returns zero — the allowlist is dead code. Nothing enforces it during polling.- There is no field-level filtering (assignee/status/priority/label/updated-since) anywhere except
lib/embed/providers/jira.mjs, which supportsproject/projects/jql/fieldsper source. That is the only filtering precedent and it is Jira-specific.
Consequence: a connected Jira instance ingests everything the credential can see. A PM cannot say "only project ABC, only issues assigned to my team." Provider config errors surface silently at poll time (or not at all), not at configure time.
Decision
Adopt a portable normalized filter core with native-query passthrough as an escape hatch, enforced provider-side when possible and plane-side always, configured in project scope and validated against the manifest configSchema.
1. Filter grammar
A provider filter block has two layers. The normalized layer is the portable contract every provider kind honors; the passthrough layer is an optional provider-native escape hatch.
{
"scope": { // per-kind container allowlist (which containers are visible)
"projects": ["ABC"], // jira project keys / salesforce objects
"repos": ["org/app"], // github repos (exact)
"repoGlob": ["org/*"], // github repos (glob)
"channels": ["C123"], // slack channels
"spaces": ["ENG"] // confluence spaces
},
"predicates": { // normalized field-level filter (portable core)
"assignee": ["me", "team:platform"],
"statusCategory": ["in-progress"], // normalized: to-do | in-progress | done
"priority": ["high", "highest"],
"label": ["security"],
"updatedSince": "P14D" // ISO-8601 duration or absolute timestamp
},
"nativeQuery": "project = ABC AND sprint in openSprints()" // optional passthrough
}
scopereplaces the dead*Allowlist/*AllowGlobfields incontract.mjswith one normalized container. The existing per-provider field names map ontoscopekeys;validateAllowlist()is rewritten to validatescopeand is finally given callers (LMCP-B11).predicatesis the portable core: a small, closed, normalized field-predicate set —assignee,statusCategory,priority,label,updatedSince. Normalized values (e.g.statusCategory∈ {to-do, in-progress, done}) mean the same filter expresses identically across Jira, GitHub, Salesforce. This is the subset LMCP-B11 guarantees on every provider.nativeQueryis the escape hatch: when a provider has a richer native query language (Jira JQL, GitHub search syntax), the operator may pass it through verbatim. It is provider-specific and non-portable by construction. When bothpredicatesandnativeQueryare present,nativeQueryis applied provider-side andpredicatesis applied as an additional plane-side AND — the effective filter is their conjunction, never their union (least privilege).
Rejected: a single new query DSL invented by Construct. It would duplicate JQL/GitHub-search poorly and force operators to learn a fourth dialect. The normalized subset + passthrough gives portability where it matters and full power where the provider already offers it.
2. Config placement
The filter block lives in project scope, not in the extension manifest.
- The manifest (ADR-0052
configSchema) declares which filter keys a provider kind accepts and their types — it is the schema, the shape contract. A Jira manifest'sconfigSchemapermitsscope.projects,predicates.*,nativeQuery; a Slack manifest permitsscope.channels,predicates.label/updatedSince, nonativeQuery. - The instance filter values live in
.cxproject config, on the per-source record already present inlib/embed/config.mjssources[](which today carriesprovider,refs,intervalMs). A newfilterfield on each source holds the block above. - At configure time (
construct provider add|configure, LMCP-B12), the filter block is validated against the resolved manifestconfigSchema. A filter that references a key the manifest does not permit is a configure-time error with the offending key named — not a silent poll-time no-op.
Rationale: the schema is a property of the provider kind (ships with the manifest, same for everyone); the values are a property of the engagement (this project, this team's scope) and belong in the git-tracked .cx config where they are reviewable.
3. Enforcement point
Two-tier, defense-in-depth:
- Provider-side pushdown (preferred). When the provider supports server-side filtering, the filter is compiled into the request:
scope+predicates+nativeQuery→ JQL for Jira, search qualifiers for GitHub, API query params elsewhere. Pushdown minimizes data transfer and blast radius. - Plane-side post-fetch predicate (universal fallback). After fetch, before any observation is created, the normalized
predicatesandscopeare re-evaluated in-process. This is mandatory even when pushdown ran — it is the backstop for providers with no server-side filtering and the enforcement of thepredicates-∧-nativeQueryconjunction.
Invariant: a filtered-out item never becomes an observation. The filter runs strictly before observation creation in the poll/read path (lib/embed poll loop), so a dropped item leaves no trace in the living graph (ADR-0053). Absence of pushdown degrades efficiency, never correctness.
4. Audit shape
Each poll records, in the poll's telemetry/trace record, a filterApplied block:
{
"filterApplied": {
"scope": { /* effective scope */ },
"predicates": { /* effective predicates */ },
"nativeQuery": "…", // present only if passthrough used
"pushdown": true, // did provider-side pushdown run
"fetched": 214, // items returned by provider
"admitted": 12 // items that passed the plane-side predicate
}
}
fetched vs admitted makes over-broad credentials and mis-scoped filters visible: a large gap means the credential sees far more than the engagement needs. This block is the auditable record that the least-privilege intent was actually enforced.
Filter block JSON schema stub
Ships at lib/providers/filter-schema.mjs (or .json), referenced by per-kind manifest configSchema via $ref:
{
"$id": "construct:provider-filter",
"type": "object",
"additionalProperties": false,
"properties": {
"scope": {
"type": "object",
"additionalProperties": false,
"properties": {
"projects": { "type": "array", "items": { "type": "string" } },
"repos": { "type": "array", "items": { "type": "string" } },
"repoGlob": { "type": "array", "items": { "type": "string" } },
"channels": { "type": "array", "items": { "type": "string" } },
"spaces": { "type": "array", "items": { "type": "string" } }
}
},
"predicates": {
"type": "object",
"additionalProperties": false,
"properties": {
"assignee": { "type": "array", "items": { "type": "string" } },
"statusCategory": { "type": "array", "items": { "enum": ["to-do", "in-progress", "done"] } },
"priority": { "type": "array", "items": { "type": "string" } },
"label": { "type": "array", "items": { "type": "string" } },
"updatedSince": { "type": "string" }
}
},
"nativeQuery": { "type": "string" }
}
}
Alternatives considered
- Invent a Construct filter DSL (uniform grammar across all providers). Rejected: reimplements JQL/GitHub-search worse and adds a dialect operators must learn; the normalized subset covers the common portable cases and passthrough covers the rest.
- Filter values in the manifest. Rejected: the manifest ships with the provider and is the same for every consumer; per-engagement scope belongs in reviewable
.cxproject config. - Plane-side filtering only (no pushdown). Rejected on efficiency and blast radius — pulling an entire Jira instance to drop most of it in-process is wasteful and exposes data the engagement never needed to touch. Pushdown when available, plane-side always.
- Keep the
*Allowlistfields as-is and just add callers. Rejected: they are container-only, cannot express field-level predicates, and are per-provider ad hoc.scopesubsumes them under one normalized shape.
Consequences
validateAllowlist()inlib/providers/contract.mjsis rewritten to validate thescopeblock and is wired into the poll/read path — the dead code becomes live enforcement (LMCP-B11).- A PM can scope a company Jira to one project and their team's issues; a filtered-out issue never enters the graph.
- Provider config errors (unknown filter key) surface at configure time with the key named (LMCP-B12), not silently at poll time.
- Every poll carries a
filterAppliedaudit block;fetchedvsadmittedexposes over-broad credentials. - LMCP-B11 (enforce filters at poll/read) and LMCP-B12 (
construct provider add|configurewith manifest-validated filter config) are unblocked. LMCP-B14 and LMCP-P1-DEC reference this filter block.