AI-Solve Questionnaire — Engine Contract#
Status: Draft contract for the AI engine (backend) developer Date: 2026-06-25 Owner (frontend/appview): Miko Audience: Engine service developer
1. Context#
Feature: on an issue page, a logged-in user can start an AI-solve workflow. The AI engine generates a branching questionnaire about what kind of solution should be implemented. Many users answer it; when the engine detects consensus, it generates the solution and opens a pull request authored as its own AT-Protocol / Tangled user.
The appview (frontend) is a thin UI. The engine owns all logic: questionnaire generation, answer aggregation, consensus detection, code generation, and PR authoring.
The appview talks to the engine with exactly two HTTP calls, both made server-side from the appview (the engine base URL is never exposed to the browser):
GETthe questionnaire for an issue.POSTa user's completed answer-set.
The branching is walked client-side in the appview after the single GET — the
engine does not serve one-question-at-a-time. It returns the whole questionnaire once.
Everything after the POST (consensus, codegen, PR) is internal to the engine. The PR
appears on the issue page because the engine authors a normal sh.tangled.repo.pull
record that references the issue — it rides the existing PR/reference ingestion. No third
call is required for that.
2. The questionnaire structure#
The questionnaire is a tree of nested sequences, not a next-pointer graph. This is
the core of the contract — please implement to this shape.
Design rationale#
The questionnaire must support, modularly:
- Sub-questions that are only asked when a particular option is chosen.
- Regular questions that are always asked, in order — including after a branching question's sub-questions have been answered.
A flat next-pointer graph models this poorly: every branch leaf must manually point back
to the shared follow-up question to re-converge, so adding one always-asked question means
editing every leaf. Instead we use recursion:
An option may carry its own ordered list of follow-up questions (
followups). That list has the same shape as the top-level list. When the user finishes afollowupslist, traversal automatically returns ("pops") to the parent sequence and continues with the next item. Re-convergence is free; no manual wiring.
One node type, recursive at any depth, one renderer/walker.
Schema#
// Questionnaire (root)
{
"issue": "at://…/sh.tangled.repo.issue/…",
"version": 2,
"introduction": {
"project": "What the repo is…",
"issue": "What this issue asks…",
"approach": "How the questionnaire guides toward a PR…"
},
"items": [ /* ordered array of Question */ ]
}
// Question
{
"id": "scope",
"prompt": "Short headline question",
"context": "Why we ask this now — bridges from intro or parent branch",
"explanation": "Extended tradeoffs and repo-specific detail",
"options": [ /* array of Option, >= 2 */ ]
}
// Option — label only (no separate value field)
{
"label": "Full detailed description of this choice",
"followups": [ /* optional: array of Question, same shape as items */ ]
}
Field rules
| Field | Type | Required | Notes |
|---|---|---|---|
issue |
string (AT-URI) | yes | Echoes the issue this questionnaire is for. |
version |
integer | yes | Schema version; 2 for now. |
introduction |
object | yes | project, issue, approach — narrative setup shown before questions. |
items |
Question[] | yes | Top-level ordered sequence. Non-empty. |
Question.id |
string | yes | Globally unique across the whole tree. Stable across re-fetches. |
Question.prompt |
string | yes | Short headline (plain text). |
Question.context |
string | yes | Bridges logically from intro/parent; must chain narratively. |
Question.explanation |
string | yes | Extended detail on tradeoffs and repo facts. |
Question.options |
Option[] | yes | At least 2 options. |
Option.label |
string | yes | Full option text — detailed description, not a terse button label. |
Option.followups |
Question[] | no | Omit or [] = no sub-questions. |
Worked example#
{
"issue": "at://did:plc:abc/sh.tangled.repo.issue/3lk2…",
"version": 2,
"introduction": {
"project": "A small CLI tool for…",
"issue": "Add a flag to…",
"approach": "First we pick where the fix lives, then shared test preferences."
},
"items": [
{
"id": "scope",
"prompt": "Where should the fix live?",
"context": "The issue touches both the CLI and core library — we need to pick a home first.",
"explanation": "A new module keeps concerns isolated; extending util is faster but couples the change.",
"options": [
{
"label": "Create a new module dedicated to this feature, imported by the CLI entrypoint.",
"followups": [
{
"id": "mod_name_style",
"prompt": "Module naming style?",
"context": "Because you chose a new module, naming should match repo conventions.",
"explanation": "Flat names match existing `util_*` files; nested packages group related commands.",
"options": [
{ "label": "Flat single file at repo root (e.g. `pairing.nu`)." },
{ "label": "Nested under an existing package directory." }
]
}
]
},
{ "label": "Extend the existing shared util module — smallest diff, reuses exports." }
]
},
{
"id": "tests",
"prompt": "Add tests?",
"context": "Regardless of where the fix lives, we need agreement on test coverage.",
"explanation": "The repo has unit tests in `tests/` but no integration harness for hardware.",
"options": [
{ "label": "Yes — add unit tests for the new code path." },
{ "label": "No — manual verification only for this change." }
]
}
]
}
Behaviour:
- A user who picks New module is asked
mod_name_style, thentests. - A user who picks Existing util skips
mod_name_styleand goes straight totests. testsis always asked regardless of thescopebranch.
Traversal semantics (how the frontend walks it)#
The appview walks the tree depth-first with a stack. The engine doesn't need to run this, but it defines exactly which questions a given user sees and the order answers are recorded:
stack = [ (items, 0) ]
while stack not empty:
(list, i) = stack.top
if i >= len(list): stack.pop(); continue
q = list[i]
present q to user; user picks option at index `i`
record answer { questionId: q.id, optionIndex: i }
stack.top.i += 1 # move past q in its own frame
if opt.followups is non-empty:
stack.push( (opt.followups, 0) ) # dive into sub-questions first
# done when the stack is empty
3. API contract#
Base URL, auth, and exact paths are TBD between engine and appview (see Open Questions). Shapes below are the contract.
3.1 GET questionnaire#
Fetch (or generate-and-cache) the questionnaire for an issue.
Request
GET /questionnaire?issue=<at-uri>
| Param | Type | Notes |
|---|---|---|
issue |
string (AT-URI) | The sh.tangled.repo.issue record URI. |
Response 200 OK — a Questionnaire object (Section 2).
- The same issue should return a stable questionnaire (same
ids) across calls so that answers from different users are comparable. Generate once, cache, return cached. 404if the issue is unknown to the engine;503if generation is still in progress (the appview will show a "preparing…" state and let the user retry).
3.2 POST answers#
Submit one user's completed answer-set.
Request
POST /answers
Content-Type: application/json
{
"issue": "at://…/sh.tangled.repo.issue/…",
"did": "did:plc:…", // the answering user, from the appview's auth session
"version": 1, // questionnaire version the answers were collected against
"answers": [
{ "questionId": "scope", "optionIndex": 0 },
{ "questionId": "mod_name_style", "optionIndex": 1 },
{ "questionId": "tests", "optionIndex": 0 }
]
}
answersis the flat, ordered list of{ questionId, optionIndex }the user actually traversed (only the questions they were shown). Order = traversal order.questionIds are globally unique, so the engine can reconstruct full context without nesting in the payload.didis supplied server-side by the appview from the authenticated session — the engine can trust it as the identity the appview vouches for. It is never taken from the browser/client.
Response 200 OK (body optional; the appview ignores it beyond status).
Idempotency / re-answering: a user may submit more than once (they re-open the wizard
and change their mind). The engine should dedupe by did and treat the latest submission
as that user's answer. Define whether resubmission is allowed after consensus is locked.
4. Out of scope for these two calls (engine-internal)#
- Aggregating answers across users and detecting consensus.
- Generating the solution / code.
- Authoring the PR as the engine's own AT-Proto user — author a
sh.tangled.repo.pullrecord that references the issue (e.g. via the issue AT-URI in the pull's references / body) so the appview's existing reference-link rendering surfaces it on the issue page.
No additional appview→engine call is needed to display the solution/PR; it arrives via normal record ingestion.
5. Optional future extension — reusable question groups (do NOT build yet)#
If two different branches ever need the same sub-questionnaire, add a reference item rather than duplicating questions:
// top-level, alongside "items"
"library": {
"test-prefs": [ /* Question[] */ ]
}
// usable anywhere an item is expected
{ "ref": "test-prefs" }
The recursive walker resolves { "ref": … } against library and otherwise behaves
identically. Not required for v1 — the schema simply leaves room for it. Flagged here so the
engine doesn't bake in an assumption that blocks it later.
6. Open questions to confirm with the appview developer#
- Issue identifier: AT-URI (assumed here) vs. the numeric per-repo issue id? AT-URI is globally unambiguous; confirm the engine can resolve it.
- Base URL / auth: how does the appview authenticate to the engine (service token, mTLS, shared network)? What are the real paths?
- Caching/staleness: is the questionnaire generated once per issue and frozen, or can it
change (e.g. if the issue body is edited)? If it can change, how do we avoid invalidating
in-flight answers (the
versionfield is here to support this). - Resubmission after consensus: allowed, or should the appview hide the wizard once the engine reports a solution exists? (Note: with only two calls, the appview has no "status" endpoint — it infers "solved" from the linked PR. Confirm that's acceptable, or we add a lightweight status signal.)
- PR ↔ issue linkage: confirm the engine sets the pull record's references to the issue AT-URI so the appview's existing linked-PR rendering picks it up.