Skip to content

control-plane-api: GraphQL createDiscover mutation and discover(id) query - #3464

Draft
bbartman wants to merge 6 commits into
masterfrom
bbartman/graphql-discover-api
Draft

control-plane-api: GraphQL createDiscover mutation and discover(id) query#3464
bbartman wants to merge 6 commits into
masterfrom
bbartman/graphql-discover-api

Conversation

@bbartman

@bbartman bbartman commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Implements the unified plan in #3453 (comment of 2026-09-04): a GraphQL createDiscover mutation and discover(id) polling query in control-plane-api, replacing the PostgREST insert-and-poll surface for user-initiated discovers. The agent's DiscoverExecutor and the existing PostgREST path are unchanged and coexist.

Landing as the plan's six-commit sequence, each compiling and tested on its own:

  • 1. Move JobStatus from the agent into models::discovers
  • 2. Add DiscoverStatusType with SCREAMING_SNAKE_CASE GraphQL names and From<&JobStatus>
  • 3. discover(id) query (Q1鈥換6, S1)
  • 4. createDiscover mutation with synchronous SpecEdit and data-plane gates (C1鈥揅9, A1鈥揂5)
  • 5. Validation-error tests (V1鈥揤8)
  • 6. Agent end-to-end tests (E1鈥揈4)

Deviations from the plan

  • Plan A3 does not hold under current code. An editor bundle alone passes the SpecEdit gate but cannot reach the public data plane: legacy read on ops/dp/public/ requires the Viewer bits including ViewDataPlanePrivateNetworking, and a Delegate edge passes through only the bits its parent holds, which Editor lacks. The executor makes the identical check, so this predates the mutation and also affects publishing. The test and the SDL description record the actual behaviour; section 6 of the plan needs amending, or the plane's required capability needs a separate product decision.
  • endpointConfig is Json<async_graphql::Value>, not the plan's Json<Box<RawValue>>, which cannot be parsed from GraphQL input. The ordered map preserves field order, pinned by a stored-row snapshot.
  • Data-plane check evaluates the authorization outcome directly rather than through verify_authorization, so a terminal denial is the same "not found" as an absent plane while a stale-Snapshot denial still returns 307. A literal verify_authorization would have made the two cases distinguishable, against the plan's own intent.
  • Commit 5 carries a two-line resolver fix the V3 test exposed: an unprocessed tag has NULL protocol, which the non-null decode rejected with a database error instead of the intended message.
  • Fixtures: the bob_co fixture needs sso_tenant for Bob's user row, so the discover tests insert Bob, Carol, and a service account inline.
  • sqlx cache: commit 1 flipped two connector_status? nullability entries based on a non-fresh local database; commit 3 reverts them. The cache must be regenerated from a freshly reset database.

Closes #3453.

The GraphQL discover API needs to map the stored `discovers.job_status`
into a schema enum, so the type must live where both the agent's executor
and control-plane-api can name it. Serde attributes are unchanged: the
camelCase `type` tags are a PostgREST wire contract, and the new snapshot
test pins every variant. `is_success` becomes a plain public function.

Also refreshes two stale `.sqlx` entries whose `connector_status?` column
became nullable under current migrations; `ci:sqlx-check` failed at HEAD.

Part of #3453.
A unit-variant projection of `discovers::JobStatus`, which `async_graphql::Enum`
requires and which the data-carrying `Success` variant cannot satisfy. Serde
names stay camelCase so a stored `job_status->>'type'` parses into it directly;
only the GraphQL names are SCREAMING_SNAKE_CASE, matching the newer hand-written
status enums. Variants follow the schema's order. The wire-tag test now also
snapshots the tag-to-variant mapping, pinning agreement with `From<&JobStatus>`.

The SDL is unchanged: nothing in the schema reaches the type yet.

Part of #3453.
Reads one user-initiated discover by id. The `discovers` table has no
`user_id` of its own and this API queries Postgres on a privileged
connection where row-level security does not apply, so the join to
`drafts` on the caller's id is the access check. A missing id, another
owner's discover, and a draft-cascaded deletion are all the same null, so
ids cannot probe for other users' discovers or their `logsToken`.

The SDL gains `scalar Capture`, `Discover`, `DiscoverStatusType`, and
`QueryRoot.discover`. The read path lands first so the mutation's tests
can use it.

Regenerating `.sqlx/` from a freshly reset database reverts the two
`connector_status?` nullability flips shipped in 3ca088f: those came
from a local database whose planner state differed after test runs, not
from stale migrations. `ci:sqlx-check` passes against master's values.

Part of #3453.
Queues a user-initiated discover by writing the same `discovers` row the
PostgREST insert writes today, so the existing trigger enqueues the
executor's task and both paths coexist. The mutation gates synchronously
on what the executor would otherwise reject after queuing: the capture
name is validated, the caller must hold SpecEdit on it and read on the
data plane, and the connector tag must exist, be successfully processed,
and be a capture connector. A given draft must belong to the caller;
otherwise a caller-owned draft is created.

Two deviations from the plan in #3453. The data-plane check evaluates the
authorization outcome directly rather than through `verify_authorization`,
so a terminal denial is the same "not found" as an absent plane while a
stale-Snapshot denial still returns 307; a literal `verify_authorization`
would have made the two distinguishable. And `endpointConfig` is taken as
`Json<async_graphql::Value>` rather than `Json<Box<RawValue>>`, which
cannot be parsed from GraphQL input; the ordered map preserves field order,
which the stored-row snapshot pins.

The plan's A3 case does not hold under current code: an `editor` bundle
alone passes the SpecEdit gate but cannot reach the public data plane,
because legacy `read` on `ops/dp/public/` requires Viewer bits including
ViewDataPlanePrivateNetworking and a Delegate edge passes through only the
bits its parent holds. The executor makes the identical check. The test
and the SDL description record the actual behaviour.

The SDL gains `MutationRoot.createDiscover`.

Part of #3453.
One snapshot of every synchronous rejection: invalid capture name, unknown,
unprocessed, and non-capture connector tags, unknown data plane, not-owned
and missing drafts (asserted byte-identical, so a draft id cannot probe for
other users' drafts), and an unauthenticated caller. Neither a discover nor
a draft row is written by any of them.

The unprocessed-tag case exposed a defect in the mutation: `protocol` is
null until a tag's spec job succeeds, so `protocol = 'capture'` was null and
the non-null decode failed with a database error instead of the intended
message. The comparison is now coalesced to false.

Part of #3453.
`createDiscover` runs against the real schema in-process, the real
`DiscoverExecutor` polls the task the row's trigger enqueued, and
`discover(id)` reports the outcome. Covers a discover into a new draft, a
re-discover that keeps a draft's existing specs and merges bindings, a
connector failure surfacing as DISCOVER_FAILED with `draft_errors`, and an
unauthorized capture rejected synchronously with no task enqueued.

The connector tag id is obtained the way a client would, through
`connector(imageName).spec(imageTag).id`, so removing that field will
correctly fail these tests until the mutation's input changes with it.

Part of #3453.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

GraphQL discover API

1 participant