Skip to content

Issue #1272: feat: record the Query Planner's caller from an optional X-LIF-Client header - #1301

Merged
bjagg merged 2 commits into
mainfrom
issue-1272-x-lif-client-header
Sep 24, 2026
Merged

bjagg merged 2 commits into
mainfrom
issue-1272-x-lif-client-header

Conversation

@dereck-symmetry

Copy link
Copy Markdown
Contributor
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-Client request header, recorded as client in every query statistics event (LIF_QUERY_STATISTICS, #341).

  • Planner (query_planner_service, query_planner_restapi):
    • /query and /query_async read the header.
    • statistics.normalize_client() reduces it to a safe value once, at the service boundary.
    • All four query_planned outcomes and the query_completed event carry it. The orchestrator's results callback carries no caller, so LIFQueryPlannerJob remembers it from submission.
  • Callers:
    • Learner Data Export (query_planner_client) sends learner-data-export.
    • The MCP server (graphql_client) sends semantic-search-mcp.
    • GraphQL (openapi_to_graphql) forwards the header it received, or sends graphql when 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 both client and via).

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.dumps isn't enough. Anything that is not a short lowercase name ([a-z0-9][a-z0-9._-]{0,63}, full match) is recorded as invalid, 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 are X-API-Key and X-API-Tenant-Schema, so nothing conflicts.

Deploy workflow. lif_semantic_search_mcp_server.yml's paths: filter didn't include components/lif/graphql_client/** (one of the #1171 gaps). Without it, merging this would not rebuild the MCP image, and MCP traffic would stay graphql until an unrelated rebuild. This PR adds that one line. The other changed bricks are already covered: openapi_to_graphql by lif_graphql_api.yml, query_planner_client by lif_learner_data_export_api.yml, and both planner bricks by lif_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 as graphql.

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
  • New feature (non-breaking change which adds functionality)
  • Infrastructure/deployment change
Project Area(s) Affected
  • bases/
  • components/
  • test/ or e2e/
  • API endpoints
  • Documentation (docs/, READMEs, ARCHITECTURE.md, CLAUDE.md)

Checklist
  • commit message follows commit guidelines (see commitlint.config.mjs)
  • tests are included (unit and/or integration tests)
  • code passes linting checks (uv run ruff check)
  • code passes formatting checks (uv run ruff format)
  • code passes type checking (uv run ty check)
  • pre-commit hooks have been run successfully
  • API changes: base (Python code) documentation in docs/
    and project README updated
Testing
  • Automated tests added/updated

The acceptance criteria, each with its test:

  • The planner records the caller in every event, defaulting to unknown. test_every_planned_outcome_records_the_client drives all four outcomes, since each emits from its own call site. test_the_client_survives_to_the_completed_event goes from submission through the results callback.
  • A query without the header still succeeds and still emits statistics. test_query_without_the_client_header_succeeds_and_still_emits_statistics runs both /query and /query_async through a real TestClient with the real service; only the planner's outbound HTTP is faked.
  • Each caller sets the header. There's one test per caller. GraphQL's run through a real FastAPI app with GraphQLRouter mounted as api_graphql mounts it, because the incoming request only reaches the resolver through the router's default context (info.context["request"]).
  • Validation. Person data, JSON injection, an embedded newline plus a fake statistics marker, 65 characters, uppercase, a leading dash, and whitespace are each recorded as invalid. A separate test checks the raw value never appears in the logs.

Mutation-checked. Each of these mutants fails at least one test:

  • the base drops the header;
  • one outcome's call site forgets the client;
  • the job doesn't remember it;
  • no validation;
  • match instead of fullmatch;
  • GraphQL never forwards;
  • GraphQL sends no header;
  • Learner Data Export sends none;
  • the MCP client sends none.

Existing tests changed. Four graphql_client tests assert the exact header dict sent. They now include X-LIF-Client; their API-key assertions are unchanged.

Additional Notes

Merge order:

🤖 Generated with Claude Code

… 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 bjagg left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@bjagg
bjagg merged commit 6d7327e into main Sep 24, 2026
4 checks passed
@bjagg
bjagg deleted the issue-1272-x-lif-client-header branch September 24, 2026 19:48
bjagg added a commit that referenced this pull request Sep 24, 2026
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>
bjagg added a commit that referenced this pull request Sep 24, 2026
…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>
bjagg added a commit that referenced this pull request Sep 24, 2026
#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>
bjagg added a commit that referenced this pull request Sep 24, 2026
…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>
bjagg added a commit that referenced this pull request Sep 26, 2026
… 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>
bjagg added a commit that referenced this pull request Sep 26, 2026
…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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Query Planner cannot identify its caller: add an optional X-LIF-Client header

2 participants