Issue #1272: feat: record the Query Planner's caller from an optional X-LIF-Client header - #1301
Conversation
… X-LIF-Client header Every query statistics event now carries `client`. The header is optional: absent means "unknown", never a rejected query. A value that is not a short lowercase name is recorded as "invalid" rather than as itself, keeping free text and person data out of the logs. Learner Data Export sends learner-data-export and the MCP server sends semantic-search-mcp. GraphQL forwards its caller's name, or sends graphql, so MCP traffic keeps its origin through the extra hop. The job record remembers the caller for the completion event, since the orchestrator's results callback carries none. Adds graphql_client to the MCP deploy workflow's paths filter; without it, merging would not rebuild the image that sends the new header. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
bjagg
left a comment
There was a problem hiding this comment.
Approving. You anticipated the review I'd have written, so I spent it testing the implementation rather than the design.
The validator holds up under adversarial input. A caller-supplied header that becomes a logged statistic is exactly where #1269-style leaks come back, so I fuzzed normalize_client directly:
| input | recorded as |
|---|---|
learner-data-export |
itself |
graphql\n (trailing newline) |
invalid |
graphql Person:Sentinel-1234 (suffix smuggle) |
invalid |
x","org_key":"evil (JSON break-out) |
invalid |
jane.doe@stateu.edu (PII) |
invalid |
grаphql (Cyrillic а) |
invalid |
GraphQL / .hidden / 65 chars |
invalid |
| 64 chars | itself (the documented limit) |
| empty / absent | unknown |
fullmatch rather than match or a $-anchored pattern is what makes the first two rows right. $ matches before a trailing newline, and match only anchors the start. Both are pinned. Switching to match fails a test, and so does recording raw instead of the normalized value.
Safe to accept because it's observability-only. I traced every use of client through query_planner_restapi/core.py and query_planner_service/core.py. It flows into the statistics events and into LIFQueryPlannerJob, so the completion event can carry it, and nothing else. No branch, permission or routing decision reads it. That's what makes a spoofable, unauthenticated header acceptable, and I'd keep it that way. If client ever starts gating behaviour, it has to become authenticated first.
Forwarding through GraphQL is the right call, for the reason you gave: MCP → GraphQL → planner would otherwise collapse MCP traffic into graphql, and answering "who asks for what" is the whole point. Validating once, at the planner where the value is recorded, is also right, as opposed to at every hop.
The #1171 fix is real. I ran the #1274 guard against main and against this branch. graphql_client is unwatched by lif_semantic_search_mcp_server.yml on main, and watched here. Without that line the MCP image wouldn't have rebuilt, and MCP traffic would have kept reporting graphql until some unrelated change rebuilt it. Good catch; it's exactly the failure that guard exists for.
Full suite green: 876 passed.
One note for later, not blocking. Stating that lif_to_lif_adapter's org-to-org call will appear as graphql is the honest way to leave it out of scope. Once #1302 adds org_key, that traffic is at least attributable to the calling org, which may be enough.
This branch was stacked on #1301, which landed as a squash, so its copy of #1301's commit no longer matched main. A plain three-way merge produced 13 hunks across 6 files: this branch adds org_key lines directly adjacent to the client lines #1301 added, and adjacent insertions conflict even when one side is a strict superset of the other. Resolved by construction rather than hunk by hunk: the merge result is main with this PR's own commit (3845e36) applied on top, which cherry-picked onto main with no conflicts. The changed-file set against main is exactly 3845e36's 11 files. Gate: ruff, format, ty, 890 tests. Re-checked that dropping the blank-to-unknown fallback still fails a test. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…each statistic (#1302) > **Stacked on #1301 — merge #1301 first.** This branch is cut from #1301's, so until that merges this diff also shows its commit (`01138b3`). Review only `3845e36`, which is this PR. The base is `main` so CI runs. ##### Description of Change **Problem.** One Query Planner runs per organization (`query-planner-org1/2/3`), but nothing in its environment says which, so **every statistics event #341 emits collapses into one bucket across all three orgs**. `LIF_ORG_KEY` did not exist anywhere in the repo. **Solution.** A new optional `LIF_ORG_KEY`, read once by the base into `LIFQueryPlannerConfig.org_key` and recorded as `org_key` on both statistics events (`query_planned` and `query_completed`), next to #1301's `client`. - **Optional.** Unset or blank records `unknown`, and the query proceeds as before. Blank is treated as unset because that's what a CloudFormation `Value:` yields when its source is missing (the base's #1179 convention). - **One naming scheme, not two.** The value is CloudFormation's `OrganizationName` (`org1`/`org2`/`org3`), the same one that names each planner's stack, ECS service, compose container and SSM parameters. I considered `SEED_DATA_KEY`'s `advisor-demo-orgN`, but it names a sample dataset, not the organization. **Deployment config.** This is the live-environment part, so here is what it does precisely: - **No `*.params` file changes.** `cloudformation/lif-query-planner-taskdef-includes.yml` already builds values from `${OrganizationName}` (e.g. `LIF_QUERY_CACHE_URL`), so a single entry, `LIF_ORG_KEY: Fn::Sub "${OrganizationName}"`, covers all six deployed planners: `{dev,demo}-lif-query-planner-org{1,2,3}`. Each sets `OrganizationName` in its params, and all six are in `dev.aws` / `demo.aws`. The unsuffixed `*-lif-query-planner.params` files aren't deployed. - **Local:** `deployments/advisor-demo-docker/docker-compose.yml` sets `LIF_ORG_KEY: org1`/`org2`/`org3` on the three planners. - **What merging does, and doesn't do.** The deploy workflow rebuilds the image and runs `aws ecs update-service`, which redeploys with the **existing** task definition. It doesn't update the stack. So after merge, dev and demo run the new image **without** `LIF_ORG_KEY` and record `org_key: "unknown"`. That's the graceful fallback, and it's safe to leave in place. The variable arrives when someone runs, sequentially: ```bash ./aws-deploy.sh -s dev --only-stack dev-lif-query-planner-org1 # then org2, org3; same for demo ``` Nobody has run that as part of this PR. **How to test.** `uv run pytest test/components/lif/query_planner_service test/bases/lif/query_planner_restapi`. ##### Related Issues Closes #1271 Refs #341 Refs #1131 ##### Type of Change - [x] New feature (non-breaking change which adds functionality) - [x] Infrastructure/deployment change ##### Project Area(s) Affected - [x] bases/ - [x] components/ - [x] deployments/ - [x] cloudformation/ or sam/ templates - [x] test/ or e2e/ - [x] Documentation (docs/, READMEs, ARCHITECTURE.md, CLAUDE.md) --- ##### Checklist - [x] commit message follows commit guidelines (see commitlint.config.mjs) - [x] tests are included (unit and/or integration tests) - [x] code passes linting checks (`uv run ruff check`) - [x] code passes formatting checks (`uv run ruff format`) - [x] code passes type checking (`uv run ty check`) - [x] pre-commit hooks have been run successfully - [x] configuration changes: relevant folder README updated ##### Testing - [x] Automated tests added/updated **The acceptance criteria, each with its test:** - *Read by the planner and included in every event.* `test_both_events_carry_the_configured_org_key` goes from submission through the results callback. `test_query_statistics_through_the_endpoint_carry_the_org_key` runs through the real `TestClient`. - *Absence degrades gracefully.* `test_a_planner_without_an_org_key_still_serves_the_query_and_emits_unknown`. `test_unset_or_blank_org_key_falls_back_to_unknown` covers both unset and whitespace-only. - *The variable name itself.* It's read at import, so `test_org_key_is_read_from_lif_org_key` imports the base in a fresh interpreter, the same technique the timeout tests use, since `importlib.reload` is off-limits. - Both deployment files were parsed to confirm the entries land where intended: - the taskdef include's `Environment` has `LIF_ORG_KEY: {Fn::Sub: ${OrganizationName}}`; - the three compose planners get `org1`/`org2`/`org3`. The `Fn::Sub` itself can't be exercised locally. Its form is identical to the existing `LIF_QUERY_CACHE_URL` entry in the same file. **Mutation-checked.** Each of these mutants fails at least one test: - the config never receives `org_key`; - the wrong env var name; - a blank value not treated as unset; - the planned event omits it; - the completed event omits it. ##### Additional Notes - **Merge order:** after #1301, since this is stacked on it. - **Other open PRs:** #1291, #1299 and #1148 also touch `CHANGELOG.md`. No other open PR touches the taskdef include or the compose planners. - **Workflow filter:** `lif_query_planner_api.yml`'s `paths:` already includes `cloudformation/lif-query-planner-taskdef-includes.yml` and both planner bricks, so merging rebuilds the image. As above, that doesn't update the task definition. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: dereck <dereck.haskins@gmail.com> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-authored-by: Benito J. Gonzalez <bgonzalez@unicon.net>
#1301 and #1302 landed in the same files, as the PR description anticipated. Every overlap was keep-both in meaning, but three hunks had to be combined rather than stacked: - run_query takes both `client` (#1301) and `query_id` (this PR), and returns the widened type including LIFQueryPlannerPartialRecords. - the sync handler's re-run passes `client=client, query_id=...` and keeps this PR's partial-records dispatch. - datatypes.py keeps #1302's `org_key` inside LIFQueryPlannerConfig, with LIFQueryPlannerPartialRecords after it. Verified by AST that org_key is a field of the config, not of the new class. The two test files were merged per function, not by text hunk: a text union separated `_post_query`'s body from its def and a @patch from its test. This PR adds 9 + 4 functions, changes 2 that main left untouched, and adds two imports. Test counts are exact (15, 36) with no duplicates. Gate: ruff, format, ty, 902 tests. Mutants from #1301 (fullmatch), #1302 (blank org key) and this PR (503 scope, partial header, query_id on the re-run) all fail a test on the combined tree. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ng was cached (#1303) ##### Description of Change **Problem.** `LIFQueryPlannerService.run_query` has paths where it deliberately degrades instead of failing. It returns `200` with the cached records, even though they're missing fields the caller asked for, and nothing in the response tells a partial answer from a complete one (#1232). The worst case is a learner who isn't in the cache yet: if orchestrator submission fails, the answer is an empty `200`. GraphQL shows that as "no such learner", and the learner data export API turns it into **404 "Query Planner did not find any results"** (`learner_data_export_endpoints.py:134-138`), which is false. **The three paths are not the same kind of event**, so they're treated differently: | Path | What it means | Now | |---|---|---| | No source can serve the missing fields | Permanent (config); a retry won't help | `200` + `X-LIF-Partial: no_sources_available` | | Orchestrator submission failed | Transient; a retry may succeed | `200` + `X-LIF-Partial: orchestrator_submission_failed`, or **`503` if nothing was cached** | | Orchestration ran and **a source failed** (sync `/query`) | Transient; Dagster swallows it and the run still succeeds | `200` + `X-LIF-Partial: source_failed`, or **`503` if nothing was cached** | | Orchestration ran, every source succeeded, fields still missing | Most likely the learner has no data for them | Unchanged, **deliberately unmarked** | Marking that last path would flag every learner who has, say, no `EmploymentPreferences`. The flag would fire all the time and carry no information. **Solution.** - `run_query` returns a new component-local type, `LIFQueryPlannerPartialRecords(records, reason)`, on the first two paths. The handlers already dispatch on `isinstance(result, LIFQueryStatusResponse)`, so this follows the same pattern. The reason values are the `OUTCOME_*` constants the query statistics (#1273) already record for those paths, so the logs and the header use the same words. - **Source failures during orchestration** (second commit, `c239d6a`): `run_post_orchestration_results` records the failed sources on `LIFQueryPlannerJob.failed_source_ids`, and the sync handler passes the job id to the second `run_query` (`query_id=`), which returns partial records with reason `source_failed`. The evidence for this is in the comment below. - In the base, one helper (`respond_to_partial_records`) is shared by `/query` and `/query_async`. It sets the header, or raises `503` for the empty + submission-failed case. HTTP policy stays in the base. - The new type lives in `query_planner_service/datatypes.py`, not the shared `lif.datatypes` brick. Only `lif_query_planner_api` packages `query_planner_service`, and the `lif_query_planner_api.yml` `paths:` filter covers both changed bricks. **Why a header, not `206` or a wrapper body.** Both direct consumers of `/query` check for exactly `200`: `openapi_to_graphql/type_factory.py` (`== 200`) and `query_planner_client/core.py` (`!= 200` raises). A `206` would break both. A wrapper body changes `response_model=List[LIFRecord]` for every adopter. The header is purely additive. **Why the 503.** It matches #1264 / #1291: a total failure is reported as an error, not dressed up as an empty result. The export API now reports "Unable to retrieve learner data" (500) instead of the false 404. **Limitations and follow-ups (not filed; for discussion here):** - **Nobody reads the header yet.** The Advisor and MCP only see GraphQL, so the signal reaches them only once GraphQL relays it, e.g. into the response `extensions`. That's the follow-up, and it should come after #1291, which touches the same resolver. That's why this PR says `Refs #1232` rather than `Closes`. - The export API could read the header too. Deferred. - **`source_failed` is sync `/query` only.** An `/query_async` client re-POSTs after polling, which calls `run_query(first_run=True)` with no link to the job it polled, so there is nothing to attach the failure to. A `query_id` on that re-POST, or results served from the status endpoint, would be a contract change, so it's left out here. **How reviewers should test.** Stop the orchestrator (or point `LIF_ORCHESTRATOR_URL` at a closed port), then query a learner who isn't in the cache: you should get `503`. Query one who is partly cached: `200` with `X-LIF-Partial: orchestrator_submission_failed`. Ask for a field no configured source serves: `200` with `X-LIF-Partial: no_sources_available`. ##### Related Issues Refs #1232 Refs #1131 `Refs`, not `Closes`, for #1232: its third acceptance criterion (downstream consumers updated or explicitly deferred) needs the GraphQL relay above. #1204 was closed today, pointing to #1235 and #1232. ##### Type of Change - [x] New feature (non-breaking change which adds functionality) The header is additive. The `503` replaces an empty `200` in one failure case. It's recorded in `CHANGELOG.md` but not in `MIGRATION.md`; happy to add a MIGRATION entry if you count it as breaking. ##### Project Area(s) Affected - [x] bases/ - [x] components/ - [x] test/ or e2e/ - [x] API endpoints - [x] Documentation (docs/, READMEs, ARCHITECTURE.md, CLAUDE.md) --- ##### Checklist - [x] commit message follows commit guidelines (see commitlint.config.mjs) - [x] tests are included (unit and/or integration tests) - [x] code passes linting checks (`uv run ruff check`) - [x] code passes formatting checks (`uv run ruff format`) - [x] code passes type checking (`uv run ty check`) - [x] pre-commit hooks have been run successfully - [x] API changes: base (Python code) documentation in `docs/` and project README updated (both brick READMEs updated) ##### Testing - [x] Automated tests added/updated - **Component:** the no-sources and submission-failed tests now assert the partial type and its reason. A new test pins the post-orchestration path as a plain, unmarked list. - **Base:** header on a partial answer, run **through `TestClient`** so the header is shown to reach the wire; no header on a complete answer; `503` for empty + submission-failed on both endpoints; an empty no-sources answer stays a marked `200`; `/query_async` is marked too. - **Source failures:** failed sources are recorded on the job; a re-run after a job with a failed source is marked `source_failed`; a re-run after a clean job stays unmarked; the sync handler passes `query_id` through and marks the answer; empty + `source_failed` is `503`. - **Guard check:** with only the `run_query` change reverted, exactly the two path tests fail. The base tests failed before the handler change. For the second commit, reverting the component alone fails exactly its 3 new component tests. - Query Planner suites: 74 passed. `pre-commit run --files` on all 8 files (ruff, format, cspell, ty, full pytest) passed. ##### Additional Notes **Merge order with #1301 / #1302.** Those two touch the same files, and a trial merge conflicts in `query_planner_service/core.py` (the `run_query` signature: #1301 adds `client`, this adds a return type), `datatypes.py` (an import line; a new class next to #1302's `org_key` field), and tests added at the same place in both test files. **Every hunk is keep-both;** nothing overlaps in meaning. Whichever lands second gets `main` merged in (not rebased). Trial merges against #1291 and #1299 are clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: dereck <dereck.haskins@gmail.com> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-authored-by: Benito J. Gonzalez <bgonzalez@unicon.net>
… one schema build (#1314) ##### Description of Change **Problem.** `components/lif/openapi_to_graphql/type_factory.py` declared `input_type_cache` at **module level**, keyed by type name alone, and nothing ever cleared it. So the second schema built in a process reused the first schema's filter input types. Two schemas whose `Person` had entirely different queryable fields silently shared one `PersonInput`. `mutable_input_type_cache`, its sibling, was already per build. As #1293 says, this **isn't reachable in production today**: `bases/lif/api_graphql/core.py` builds the schema once, in `lifespan`. It did already bite tests, though. Two test helpers carried a `monkeypatch.setattr(type_factory, "input_type_cache", {})` workaround, and without it a test passed alone but failed in the suite. It would also turn any future schema hot-reload after an MDR change into a silent bug. **Solution.** This is the issue's suggested change. `generate_graphql_root_types` (`core.py`) now creates `input_type_cache = {}` next to `mutable_input_type_cache` and passes it to `create_input_type`. That gains an `input_type_cache` parameter, matching `create_mutable_input_type`. The module-level variable is gone. `create_input_type` isn't exported from the package (`__init__.py` exports `generate_graphql_root_types` / `generate_graphql_schema`), and `core.py` is its only caller. Both test workarounds are removed: the one the issue names in `test_core.py::TestQueryPlannerFailureReachesCaller`, and a second copy in `test_lif_client_header.py` that #1301 added. Their `_schema` helpers no longer take `monkeypatch`. **Side effects.** None in production. The service builds one schema per process, and it's built the same way. **How reviewers should test it.** ```bash uv run pytest test/components/lif/openapi_to_graphql/ -q ``` ##### Related Issues Closes #1293 Refs #1131 ##### Type of Change - [x] Bug fix (non-breaking change which fixes an issue) ##### Project Area(s) Affected - [x] components/ - [x] test/ or e2e/ --- ##### Checklist - [x] commit message follows commit guidelines (see commitlint.config.mjs) - [x] tests are included (unit and/or integration tests) - [x] code passes linting checks (`uv run ruff check`) - [x] code passes formatting checks (`uv run ruff format`) - [x] code passes type checking (`uv run ty check`) - [x] pre-commit hooks have been run successfully ##### Testing - [x] Automated tests added/updated `TestInputTypesArePerSchemaBuild` builds two schemas in one process: one where `Person` has a single queryable field `alpha`, and one where it's `beta`. It reads each schema's `PersonInput` fields by introspection. - On `main` it **fails** exactly as the issue describes: `assert {'alpha'} == {'beta'}`. The second schema reused the first's input type. - With the fix it passes. The removed workarounds are the third acceptance criterion. With them gone, all 30 tests in `test/components/lif/openapi_to_graphql/` pass in the same run, including the ones that used to need them. `pre-commit run --files` on all four changed files is green, including the full pytest suite. No README or CHANGELOG change: the component README doesn't mention the cache, and nothing changes for a caller. ##### Additional Notes **Merge order with #1311 (#1309), measured by a trial merge.** #1311's new `TestMutationFailureDoesNotLeakBody` carries a third copy of the same workaround line. 1. **Textual conflict in `test_core.py`:** both PRs append a test class at the end of the file. Keep both classes. 2. **Then, a runtime failure git can't see:** once resolved, #1311's test fails with `AttributeError: <module 'lif.openapi_to_graphql.type_factory'> has no attribute 'input_type_cache'`, because `monkeypatch.setattr` refuses a missing attribute. **Delete that one line** (`monkeypatch.setattr(type_factory, "input_type_cache", {})`) and the brick passes (31 passed in the trial). Whichever PR merges second needs both steps. `main` requires branches to be up to date, so that PR's CI will surface step 2 if it's missed. **Shared brick (#1171).** `components/lif/openapi_to_graphql` is packaged by `lif_graphql_api` only, and its deploy workflow already covers the brick. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: dereck <dereck.haskins@gmail.com> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-authored-by: Benito Gonzalez <bgonzalez@unicon.net>
…mutation errors (#1311) ##### Description of Change **Problem.** The GraphQL update mutation raised `Exception(f"Mutation failed: {response.status_code}: {response.text}")` (`components/lif/openapi_to_graphql/type_factory.py:991`). Strawberry puts that message into the `errors` entry the caller receives. The Query Planner's `/update` handler builds its 500 body from `str(e)` of any exception it catches (`bases/lif/query_planner_restapi/core.py:287-288`). So whatever a backend exception said (a driver message, a host, a username) reached whoever held a GraphQL API key. This is the leak bjagg measured on the query path in #1291, where a credential-shaped 500 reached the caller as `Query failed: 500: {"detail":"FATAL: password authentication failed for user \"lifadmin\" host=10.0.3.17"}`. His approval left the mutation's copy as a follow-up, filed as #1309. **Solution.** The same one-line change #1291 made to the query path: drop `: {response.text}` from the raised message. The `logger.error` line keeps the full body for operators, and a comment points back at the query path. The caller still gets the status (`Mutation failed: 500`), so a failed mutation stays clearly a failure. **Side effects.** Only the error text changes. The MCP `lif_mutation` tool passes on the GraphQL error, so its tool error gets shorter. The body is no longer available to the caller for debugging; it's in the GraphQL server log instead. **How reviewers should test it.** ```bash uv run pytest test/components/lif/openapi_to_graphql/test_core.py -q -k MutationFailure ``` To confirm the test guards the change: put `: {response.text}` back into the raise and the test fails on `assert "mongodb-org1" not in result.errors[0].message`. ##### Related Issues Closes #1309 Refs #1131 ##### Type of Change - [x] Bug fix (non-breaking change which fixes an issue) ##### Project Area(s) Affected - [x] components/ - [x] test/ or e2e/ - [x] Documentation (docs/, READMEs, ARCHITECTURE.md, CLAUDE.md) --- ##### Checklist - [x] commit message follows commit guidelines (see commitlint.config.mjs) - [x] tests are included (unit and/or integration tests) - [x] code passes linting checks (`uv run ruff check`) - [x] code passes formatting checks (`uv run ruff format`) - [x] code passes type checking (`uv run ty check`) - [x] pre-commit hooks have been run successfully - [x] configuration changes: relevant folder README updated ##### Testing - [x] Automated tests added/updated `TestMutationFailureDoesNotLeakBody` builds a real schema with a mutable field, stubs `httpx.AsyncClient` with the same fake the #1264 tests use, and runs `updatePerson` against a 500 whose body names `mongodb-org1:27017`. It asserts that the caller gets an error, that the error includes `500`, and that the host name isn't in it. - On `main` it **fails** on the host assertion (the message was `Mutation failed: 500: {"detail":"connection refused: mongodb-org1:27017"}`), having already passed the schema build and the `500` check. So it fails for the right reason. - With the fix it passes, along with the rest of `test/components/lif/openapi_to_graphql/`. - `pre-commit run --files` on all four changed files is green, including the full pytest suite. The `api_graphql` README's error contract now says the message carries only the status, and that the Query Planner body goes to the server log. There's also a CHANGELOG entry. ##### Additional Notes **Shared brick (#1171).** `components/lif/openapi_to_graphql` is packaged by `lif_graphql_api` only, and the GraphQL deploy workflow already covers it (the #1291 and #1301 merges both redeployed GraphQL). **Merge order.** This PR, #1310 and #1148 each add a line at the top of `CHANGELOG.md`'s `[Unreleased]` → `### Changed` list. Whichever merges later keeps both lines. There's no other file overlap with open PRs. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: dereck <dereck.haskins@gmail.com> Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Co-authored-by: Benito J. Gonzalez <bgonzalez@unicon.net>
Description of Change
Problem. The Query Planner cannot tell who is calling it. GraphQL, Learner Data Export and the Advisor (via the MCP server) all reach the same
/query, so "who asks for what", one of the questions #341's statistics exist to answer, has no answer.Solution. An optional
X-LIF-Clientrequest header, recorded asclientin every query statistics event (LIF_QUERY_STATISTICS, #341).query_planner_service,query_planner_restapi):/queryand/query_asyncread the header.statistics.normalize_client()reduces it to a safe value once, at the service boundary.query_plannedoutcomes and thequery_completedevent carry it. The orchestrator's results callback carries no caller, soLIFQueryPlannerJobremembers it from submission.query_planner_client) sendslearner-data-export.graphql_client) sendssemantic-search-mcp.openapi_to_graphql) forwards the header it received, or sendsgraphqlwhen there is none.Why GraphQL forwards rather than naming itself. The MCP server never calls the planner directly: its path is MCP → GraphQL → Query Planner. If GraphQL always sent
graphql, MCP traffic would be indistinguishable from direct GraphQL traffic at the planner, which defeats the point of the issue. Forwarding keeps the original caller. That was a deliberate choice among three options (forward / each hop names itself / record bothclientandvia).Optional, never required. A missing header is recorded as
unknown, and the query proceeds exactly as before. The planner stays usable standalone (ADR 0004).Validation, not just escaping. The value is caller-supplied and becomes a statistics dimension, so escaping it with
json.dumpsisn't enough. Anything that is not a short lowercase name ([a-z0-9][a-z0-9._-]{0,63}, full match) is recorded asinvalid, never as itself. That keeps free text out of the logs, bounds the dimension's cardinality, and means a caller cannot smuggle person data in through the header (#1269). GraphQL relays the value unvalidated, so the check happens once, in the planner, where the value is recorded.Header name. No
X-LIF-*convention exists in the repo. The only custom headers areX-API-KeyandX-API-Tenant-Schema, so nothing conflicts.Deploy workflow.
lif_semantic_search_mcp_server.yml'spaths:filter didn't includecomponents/lif/graphql_client/**(one of the #1171 gaps). Without it, merging this would not rebuild the MCP image, and MCP traffic would staygraphqluntil an unrelated rebuild. This PR adds that one line. The other changed bricks are already covered:openapi_to_graphqlbylif_graphql_api.yml,query_planner_clientbylif_learner_data_export_api.yml, and both planner bricks bylif_query_planner_api.yml.Out of scope, deliberately.
lif_to_lif_adapter's org-to-org GraphQL call is a fourth caller the issue doesn't list. It will appear at the other org asgraphql.How to test.
uv run pytest test/components/lif/query_planner_service test/bases/lif/query_planner_restapi test/components/lif/openapi_to_graphql test/components/lif/query_planner_client test/components/lif/graphql_client.Related Issues
Closes #1272
Refs #341
Refs #1131
Refs #1171
Type of Change
Project Area(s) Affected
Checklist
uv run ruff check)uv run ruff format)uv run ty check)docs/and project README updated
Testing
The acceptance criteria, each with its test:
test_every_planned_outcome_records_the_clientdrives all four outcomes, since each emits from its own call site.test_the_client_survives_to_the_completed_eventgoes from submission through the results callback.test_query_without_the_client_header_succeeds_and_still_emits_statisticsruns both/queryand/query_asyncthrough a realTestClientwith the real service; only the planner's outbound HTTP is faked.GraphQLRoutermounted asapi_graphqlmounts it, because the incoming request only reaches the resolver through the router's default context (info.context["request"]).invalid. A separate test checks the raw value never appears in the logs.Mutation-checked. Each of these mutants fails at least one test:
matchinstead offullmatch;Existing tests changed. Four
graphql_clienttests assert the exact header dict sent. They now includeX-LIF-Client; their API-key assertions are unchanged.Additional Notes
Merge order:
paths:filters miss packaged bricks — a brick-only change merges green and never rebuilds the image (9 of 11 services affected) #1171 sweep) adds the samegraphql_client/**line to the MCP workflow, in a different hunk, so there's no textual conflict. If both merge, the path is listed twice, which is harmless. Whoever merges second can drop the duplicate. This PR keeps its own line so it works whichever order they land in.type_factory.py,openapi_to_graphql/README.mdandCHANGELOG.md, in different regions. The new GraphQL tests live in their own file (test_lif_client_header.py) so they cannot collide with Issue #1264: fix: surface a Query Planner failure as a GraphQL error, not an empty result #1291's additions totest_core.py.CHANGELOG.md.🤖 Generated with Claude Code