Skip to content

intake(idea): accept the IdeaTarget object shapes validateIdeaSubmission already supports #10064

Description

@JSONbored

⚠️ Definition of Done: this issue must be completed in full, in a single PR. Do not split this
work across multiple PRs, and do not defer any Deliverable below to a follow-up issue. A PR that
satisfies only some of the Deliverables, stubs a required test, or leaves a checkbox
partially-done does NOT resolve this issue and will be closed.

Context

The idea-intake bridge's target-repo field accepts three wire forms. validateIdeaSubmission
(packages/loopover-engine/src/idea-intake.ts:112) implements all three:

  let resolvedTarget: IdeaTarget | undefined;
  if (isNonEmptyString(input.targetRepo)) {
    // Back-compat wire form: a bare "owner/name" string.
    resolvedTarget = resolveExistingTarget(input.targetRepo, errors);
  } else if (typeof input.targetRepo === "object" && input.targetRepo !== null) {
    const target = input.targetRepo as Record<string, unknown>;
    if (target.kind === "provision") {
      resolvedTarget = { kind: "provision" };
    } else if (target.kind === "existing" && isNonEmptyString(target.repo)) {
      resolvedTarget = resolveExistingTarget(target.repo, errors);
    } else errors.push("target_repo_required");
  } else errors.push("target_repo_required");

and the exported type it validates against is object-only —
packages/loopover-engine/src/idea-intake.ts:23:

/** Where an idea's work lands: an existing repo (BYOR) or a not-yet-created one to auto-provision (#7589). */
export type IdeaTarget =
  | { kind: "existing"; repo: string }
  | { kind: "provision" };

with IdeaSubmission.targetRepo: IdeaTarget at packages/loopover-engine/src/idea-intake.ts:32.

Every surface that reaches that validator declares the field as a plain string, so two of the three forms —
including both members of the type the validator returns — are rejected at the schema boundary and never
arrive. There are four copies of the declaration, all identical:

  • packages/loopover-contract/src/tools/agent.ts:33 (IntakeIdeaInput, the loopover_intake_idea and
    loopover_plan_idea_claims MCP tool input): targetRepo: z.string().optional(),
  • packages/loopover-contract/src/api-requests.ts:168 (intakeIdeaSchema, used by
    src/api/routes.ts:3549 and :3564): targetRepo: z.string().optional(),
  • src/openapi/schemas.ts:2290 (IntakeIdeaRequestSchema, the published component):
    targetRepo: z.string().optional(),
  • src/openapi/schemas.ts:2325 (PlanIdeaClaimsRequestSchema, the sibling published component):
    targetRepo: z.string().optional(),

Concretely, POST /v1/loop/intake-idea with {"id":"i1","title":"t","body":"b","targetRepo":{"kind":"provision"}}
returns 400 invalid_intake_idea_request with a zod "expected string, received object" issue. The
{ kind: "provision" } target — the entire #7589 auto-provision path, which buildClaimPlan
(packages/loopover-engine/src/idea-intake.ts:305) has a dedicated branch for
(target.kind === "existing" ? target.repo : "") — is unreachable from every surface in the repo.
{ kind: "existing", repo }, added by #9609 specifically "so a value it produced (or any TS caller writing
against the exported IdeaSubmission type) round-trips back through it"
(packages/loopover-engine/src/idea-intake.ts:121), is unreachable too. A TS consumer holding a real
IdeaSubmission literally cannot construct a body these schemas accept, because IdeaTarget is never a
string.

This also breaks the stated design of all three schemas, which each carry the same comment. From
packages/loopover-contract/src/api-requests.ts:160:

// #6755: mirrors `IntakeIdeaInput` in @loopover/contract VERBATIM. Fields are deliberately LOOSE here for the same
// reason they are on the tool: the engine's validateIdeaSubmission owns the real bounds/format checks and returns
// the actionable error list, so an empty/malformed submission must reach the handler rather than be rejected
// upstream by the schema.

targetRepo is the one field that does not do that: instead of the actionable target_repo_required /
target_repo_malformed error the handler would return, the caller gets a schema rejection "the caller cannot
act on" — the exact outcome IntakeIdeaInput's own doc comment
(packages/loopover-contract/src/tools/agent.ts:43) says the loose typing exists to prevent.

test/unit/openapi.test.ts:320-325 asserts field-for-field parity between IntakeIdeaInput.shape and the
IntakeIdeaRequest component by top-level key name only, so the three copies are identically wrong and stay
that way.

Requirements

  • All four declarations must accept targetRepo as either a string OR an object, so that
    { kind: "provision" } and { kind: "existing", repo } reach validateIdeaSubmission and are resolved by
    it. The schemas must stay LOOSE — they must NOT re-implement the kind/repo discrimination, the
    owner/name split, or isValidRepoSegment; those checks belong to validateIdeaSubmission and must remain
    its sole responsibility, so a bad object still yields the handler's target_repo_required /
    target_repo_malformed error list rather than a zod issue.
  • The four declarations must stay byte-identical to each other, as their comments require
    (packages/loopover-contract/src/api-requests.ts:160, src/openapi/schemas.ts:2281,
    src/openapi/schemas.ts:2315).
  • Behaviour that must NOT change: the bare "owner/name" string form must keep working exactly as today; a
    missing targetRepo must keep producing target_repo_required from the handler (not a schema rejection);
    every other field's optionality and bounds; the .max(50) caps on constraints, acceptanceHints, and
    decomposition; and validateIdeaSubmission / buildTaskGraph / buildClaimPlan themselves, which are
    already correct and must not be touched.
  • The generated artifacts derived from these schemas must be regenerated and committed in the same PR:
    npm run contract:api-schemas (writes packages/loopover-contract/src/api-schemas.ts) and
    npm run ui:openapi (writes apps/loopover-ui/public/openapi.json). Both have --check variants wired
    into test:ci (contract:api-schemas:check, ui:openapi:check), so a stale artifact fails CI.

⚠️ Required pattern: packages/loopover-contract/src/tools/agent.ts:29-41 is the canonical declaration —
change it there first, then mirror it verbatim into packages/loopover-contract/src/api-requests.ts:164,
src/openapi/schemas.ts:2286 and src/openapi/schemas.ts:2320, exactly as those files' own comments say
they must. What does NOT
satisfy this issue: (a) fixing only the MCP tool schema and leaving the three REST/OpenAPI copies string-only,
which breaks the parity those files exist to preserve; (b) adding a strict discriminated-union zod schema
that duplicates validateIdeaSubmission's kind/repo/segment checks at the boundary, which moves the
error surface off the handler and re-breaks the loose-schema contract; (c) changing
validateIdeaSubmission or IdeaTarget to accept a bare string as the canonical type — the string form is
explicitly back-compat only; (d) a PR that changes the schemas without running the two generators, which
fails test:ci; (e) a test-only PR.

Deliverables

  • IntakeIdeaInput.targetRepo (packages/loopover-contract/src/tools/agent.ts:33) accepts a string or an
    object, still optional, with no discrimination logic in the schema.
  • intakeIdeaSchema.targetRepo (packages/loopover-contract/src/api-requests.ts:168),
    IntakeIdeaRequestSchema.targetRepo (src/openapi/schemas.ts:2290), and
    PlanIdeaClaimsRequestSchema.targetRepo (src/openapi/schemas.ts:2325) declare the identical shape.
  • packages/loopover-contract/src/api-schemas.ts and apps/loopover-ui/public/openapi.json regenerated
    and committed via npm run contract:api-schemas and npm run ui:openapi.
  • Test in test/unit/contract-api-requests.test.ts: intakeIdeaSchema.safeParse({ targetRepo: { kind: "provision" } }).success === true
    and intakeIdeaSchema.safeParse({ targetRepo: { kind: "existing", repo: "acme/widgets" } }).success === true,
    alongside the existing safeParse({}) case at :109 which must still pass.
  • Test in test/unit/contract-api-requests.test.ts: intakeIdeaSchema.safeParse({ targetRepo: "acme/widgets" }).success === true
    (the back-compat string form, pinned).
  • Test in packages/loopover-engine/test/idea-intake.test.ts: validateIdeaSubmission with
    targetRepo: { kind: "provision" } returns { ok: true } with idea.targetRepo deep-equal to
    { kind: "provision" }, and buildClaimPlan(buildTaskGraph(idea), idea.targetRepo) returns
    targetRepo: "".
  • Regression test in test/unit/contract-api-requests.test.ts named for this bug: a body carrying a
    malformed object target (e.g. { kind: "existing" } with no repo) passes the SCHEMA and is rejected by
    validateIdeaSubmission with target_repo_required — proving the error surface is the handler, not zod.

All Deliverables above are required in a single PR. A PR that satisfies only some of them — for example one
that widens the three schemas but does not commit the regenerated api-schemas.ts/openapi.json, or one
that adds the schema tests without the engine-side test proving the provision target flows all the way
through buildClaimPlan — does not resolve this issue.

Test Coverage Requirements

This repo enforces 99%+ Codecov patch coverage, branch-counted. vitest.config.ts's coverage.include
covers src/**/*.ts, packages/loopover-engine/src/**/*.ts, and packages/loopover-contract/src/**/*.ts
src/openapi/schemas.ts, packages/loopover-contract/src/tools/agent.ts, and
packages/loopover-contract/src/api-requests.ts are all measured and gated. apps/** is in
codecov.yml's ignore list, so the regenerated apps/loopover-ui/public/openapi.json is not gated (and is
not TypeScript). The changed lines are zod declarations with no runtime branching of their own, but the
safeParse paths they drive must be exercised in both directions: string target accepted, object target
accepted, omitted target accepted, and a non-string non-object target (e.g. a number) rejected. The engine
test lands in packages/loopover-engine/test/**; engine lines are credited by two uploads whose hits are
unioned — add the test to packages/loopover-engine/test/** as well as any root test/** coverage, or the
patch gate can still fail.

Expected Outcome

loopover_intake_idea, loopover_plan_idea_claims, POST /v1/loop/intake-idea, and
POST /v1/loop/plan-idea-claims accept every IdeaTarget the engine implements, so the #7589 provision
target and the #9609 canonical object form are reachable, and a malformed target produces the handler's
actionable error list instead of an unactionable schema rejection.

Links & Resources

  • packages/loopover-engine/src/idea-intake.ts:22-35IdeaTarget and IdeaSubmission
  • packages/loopover-engine/src/idea-intake.ts:100-158validateIdeaSubmission and the three accepted wire forms
  • packages/loopover-engine/src/idea-intake.ts:300-320buildClaimPlan's provision branch
  • packages/loopover-contract/src/tools/agent.ts:29-45IntakeIdeaInput and its loose-by-design doc
  • packages/loopover-contract/src/api-requests.ts:160-177 — the REST mirror
  • src/openapi/schemas.ts:2280-2301 and :2315-2335 — the published IntakeIdeaRequest / PlanIdeaClaimsRequest components
  • src/api/routes.ts:3547-3571 — the two REST handlers
  • src/mcp/server.ts:3888-3918 — the two MCP tool handlers
  • test/unit/openapi.test.ts:320-346 — the key-name-only parity assertion
  • Epic: dual-path repo provisioning — BYOR + APR #7589 (provision targets), engine(intake): validateIdeaSubmission rejects the IdeaTarget object shape it returns #9609 (canonical object round-trip), REST + CLI mirror for loopover_intake_idea #6755 (the REST mirror)

Metadata

Metadata

Assignees

No one assigned

    Labels

    gittensor:bugGittensor-scored bug fix — scores a 0.05x multiplier.help wantedExtra attention is needed

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions