You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
⚠️ 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:
letresolvedTarget: IdeaTarget|undefined;if(isNonEmptyString(input.targetRepo)){// Back-compat wire form: a bare "owner/name" string.resolvedTarget=resolveExistingTarget(input.targetRepo,errors);}elseif(typeofinput.targetRepo==="object"&&input.targetRepo!==null){consttarget=input.targetRepoasRecord<string,unknown>;if(target.kind==="provision"){resolvedTarget={kind: "provision"};}elseif(target.kind==="existing"&&isNonEmptyString(target.repo)){resolvedTarget=resolveExistingTarget(target.repo,errors);}elseerrors.push("target_repo_required");}elseerrors.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). */exporttypeIdeaTarget=|{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-35 — IdeaTarget and IdeaSubmission
packages/loopover-engine/src/idea-intake.ts:100-158 — validateIdeaSubmission and the three accepted wire forms
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:and the exported type it validates against is object-only —
packages/loopover-engine/src/idea-intake.ts:23:with
IdeaSubmission.targetRepo: IdeaTargetatpackages/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, theloopover_intake_ideaandloopover_plan_idea_claimsMCP tool input):targetRepo: z.string().optional(),packages/loopover-contract/src/api-requests.ts:168(intakeIdeaSchema, used bysrc/api/routes.ts:3549and: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-ideawith{"id":"i1","title":"t","body":"b","targetRepo":{"kind":"provision"}}returns
400 invalid_intake_idea_requestwith a zod "expected string, received object" issue. The{ kind: "provision" }target — the entire #7589 auto-provision path, whichbuildClaimPlan(
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 writingagainst 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 realIdeaSubmissionliterally cannot construct a body these schemas accept, becauseIdeaTargetis never astring.
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:targetRepois the one field that does not do that: instead of the actionabletarget_repo_required/target_repo_malformederror the handler would return, the caller gets a schema rejection "the caller cannotact 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-325asserts field-for-field parity betweenIntakeIdeaInput.shapeand theIntakeIdeaRequestcomponent by top-level key name only, so the three copies are identically wrong and staythat way.
Requirements
targetRepoas either a string OR an object, so that{ kind: "provision" }and{ kind: "existing", repo }reachvalidateIdeaSubmissionand are resolved byit. The schemas must stay LOOSE — they must NOT re-implement the
kind/repodiscrimination, theowner/name split, or
isValidRepoSegment; those checks belong tovalidateIdeaSubmissionand must remainits sole responsibility, so a bad object still yields the handler's
target_repo_required/target_repo_malformederror list rather than a zod issue.(
packages/loopover-contract/src/api-requests.ts:160,src/openapi/schemas.ts:2281,src/openapi/schemas.ts:2315)."owner/name"string form must keep working exactly as today; amissing
targetRepomust keep producingtarget_repo_requiredfrom the handler (not a schema rejection);every other field's optionality and bounds; the
.max(50)caps onconstraints,acceptanceHints, anddecomposition; andvalidateIdeaSubmission/buildTaskGraph/buildClaimPlanthemselves, which arealready correct and must not be touched.
npm run contract:api-schemas(writespackages/loopover-contract/src/api-schemas.ts) andnpm run ui:openapi(writesapps/loopover-ui/public/openapi.json). Both have--checkvariants wiredinto
test:ci(contract:api-schemas:check,ui:openapi:check), so a stale artifact fails CI.Deliverables
IntakeIdeaInput.targetRepo(packages/loopover-contract/src/tools/agent.ts:33) accepts a string or anobject, 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), andPlanIdeaClaimsRequestSchema.targetRepo(src/openapi/schemas.ts:2325) declare the identical shape.packages/loopover-contract/src/api-schemas.tsandapps/loopover-ui/public/openapi.jsonregeneratedand committed via
npm run contract:api-schemasandnpm run ui:openapi.test/unit/contract-api-requests.test.ts:intakeIdeaSchema.safeParse({ targetRepo: { kind: "provision" } }).success === trueand
intakeIdeaSchema.safeParse({ targetRepo: { kind: "existing", repo: "acme/widgets" } }).success === true,alongside the existing
safeParse({})case at:109which must still pass.test/unit/contract-api-requests.test.ts:intakeIdeaSchema.safeParse({ targetRepo: "acme/widgets" }).success === true(the back-compat string form, pinned).
packages/loopover-engine/test/idea-intake.test.ts:validateIdeaSubmissionwithtargetRepo: { kind: "provision" }returns{ ok: true }withidea.targetRepodeep-equal to{ kind: "provision" }, andbuildClaimPlan(buildTaskGraph(idea), idea.targetRepo)returnstargetRepo: "".test/unit/contract-api-requests.test.tsnamed for this bug: a body carrying amalformed object target (e.g.
{ kind: "existing" }with norepo) passes the SCHEMA and is rejected byvalidateIdeaSubmissionwithtarget_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 onethat 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'scoverage.includecovers
src/**/*.ts,packages/loopover-engine/src/**/*.ts, andpackages/loopover-contract/src/**/*.ts—src/openapi/schemas.ts,packages/loopover-contract/src/tools/agent.ts, andpackages/loopover-contract/src/api-requests.tsare all measured and gated.apps/**is incodecov.yml's ignore list, so the regeneratedapps/loopover-ui/public/openapi.jsonis not gated (and isnot TypeScript). The changed lines are zod declarations with no runtime branching of their own, but the
safeParsepaths they drive must be exercised in both directions: string target accepted, object targetaccepted, 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 areunioned — add the test to
packages/loopover-engine/test/**as well as any roottest/**coverage, or thepatch gate can still fail.
Expected Outcome
loopover_intake_idea,loopover_plan_idea_claims,POST /v1/loop/intake-idea, andPOST /v1/loop/plan-idea-claimsaccept everyIdeaTargetthe engine implements, so the #7589 provisiontarget 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-35—IdeaTargetandIdeaSubmissionpackages/loopover-engine/src/idea-intake.ts:100-158—validateIdeaSubmissionand the three accepted wire formspackages/loopover-engine/src/idea-intake.ts:300-320—buildClaimPlan's provision branchpackages/loopover-contract/src/tools/agent.ts:29-45—IntakeIdeaInputand its loose-by-design docpackages/loopover-contract/src/api-requests.ts:160-177— the REST mirrorsrc/openapi/schemas.ts:2280-2301and:2315-2335— the publishedIntakeIdeaRequest/PlanIdeaClaimsRequestcomponentssrc/api/routes.ts:3547-3571— the two REST handlerssrc/mcp/server.ts:3888-3918— the two MCP tool handlerstest/unit/openapi.test.ts:320-346— the key-name-only parity assertionvalidateIdeaSubmissionrejects theIdeaTargetobject shape it returns #9609 (canonical object round-trip), REST + CLI mirror for loopover_intake_idea #6755 (the REST mirror)