diff --git a/TECH_DEBT.md b/TECH_DEBT.md
index fbb3847..390d0d3 100644
--- a/TECH_DEBT.md
+++ b/TECH_DEBT.md
@@ -111,3 +111,61 @@
- **Trigger:** evaluar si la Mirada País/Humana también necesitan segmentar por
modalidad con frecuencia — si varias RPCs repiten el mismo JOIN, conviene
promoverla de una vez en lugar de cada RPC reimplementando el JOIN.
+
+---
+
+## [TECH-DEBT-011] Sección de pregunta inferida por posición, no modelada como dato
+
+- **Estado:** ABIERTO — decisión consciente, no un descuido. Surgió en
+ ETAPA_ADMIN (vista detallada `/admin/responses/[id]`, 27 jun 2026): el prompt
+ de etapa asumía columnas `questions.section_number`/`section_title` que NO
+ existen. Verificado contra el esquema real (`supabase/migrations/
+ 20260531000001_create_tables.sql` — única definición de `questions`,
+ `20260620000002_form_schema_v3.sql` solo agrega comentarios, ninguna
+ migración agrega esas columnas).
+- **Descripción:** hoy `questions` no tiene ningún campo que declare "esta
+ pregunta pertenece a la sección X". La sección es una fila más en la misma
+ tabla, con `type='section'`, intercalada por `position` entre las preguntas
+ reales — el código (`lib/form-engine.ts:146-148` en el wizard, y la vista
+ detallada de admin) infiere el agrupamiento recorriendo las filas en orden
+ de `position` y "abriendo" una sección nueva cada vez que encuentra una fila
+ `type='section'`. El título de la sección es el `prompt` de esa fila — no
+ hay un campo `title` separado.
+- **Por qué es deuda, no solo una curiosidad de modelado:** el motor está
+ pensado para ser un "motor de formularios de encuestas" genérico — no toda
+ encuesta futura va a tener secciones, y las que las tengan no necesariamente
+ van a querer modelarlas como filas-marcador en la misma tabla que las
+ preguntas. Hoy CUALQUIER consumidor que necesite agrupar por sección
+ (wizard, admin, y a futuro cualquier export/reporte) tiene que reimplementar
+ el mismo recorrido secuencial con el mismo supuesto implícito ("la fila
+ `type='section'` más cercana hacia atrás en `position` es la sección de esta
+ pregunta"). Si esa lógica se necesita en un tercer lugar, ya es momento de
+ promoverla a una función compartida — y si el motor crece a encuestas con
+ estructuras más ricas (subsecciones, secciones condicionales, secciones que
+ no son "preguntas" en absoluto), el modelo actual no escala sin reescribir
+ cada consumidor.
+- **Alternativa más genérica y escalable (no implementada, propuesta para
+ evaluar cuando se priorice):** sacar la sección de `questions.type` y
+ modelarla como entidad propia — opción simple: `questions.section_id uuid
+ NULL REFERENCES sections(id)` + tabla `sections(id, survey_id, title,
+ position)`; opción más liviana sin tabla nueva: `questions.section_title
+ text NULL` + `questions.section_position int NULL` poblados directamente en
+ cada pregunta (denormalizado, sin fila-marcador `type='section'` separada).
+ Cualquiera de las dos deja "pertenece a la sección X" como un hecho
+ consultable con una columna, no como un cálculo derivado de recorrer
+ `position` — y ninguna asume que toda encuesta tiene secciones (el campo es
+ nullable). Migrar implicaría: (1) nueva(s) columna(s)/tabla, (2) backfill
+ desde las filas `type='section'` existentes, (3) actualizar
+ `lib/form-engine.ts` y la vista detallada de admin para leer el dato en vez
+ de inferirlo, (4) decidir si `type='section'` se deprecia o convive.
+- **Acción tomada mientras tanto:** la vista detallada de admin
+ (`app/admin/responses/[id]/page.tsx`) replica la misma lógica de
+ `lib/form-engine.ts` (recorrido secuencial por `position`, abre sección en
+ cada `type='section'`) — no inventa columnas nuevas, no diverge del modelo
+ actual.
+- **Trigger:** si una tercera feature necesita el mismo agrupamiento, extraer
+ la lógica a un helper compartido en `lib/` en vez de triplicarla. Si el
+ producto efectivamente evoluciona a soportar surveys con estructuras de
+ sección más complejas que el caso actual, evaluar la migración a columna/
+ tabla explícita antes de seguir apilando consumidores sobre el supuesto
+ implícito.
diff --git a/app/admin/responses/[id]/page.tsx b/app/admin/responses/[id]/page.tsx
new file mode 100644
index 0000000..9ba4d38
--- /dev/null
+++ b/app/admin/responses/[id]/page.tsx
@@ -0,0 +1,274 @@
+import { createServerClient } from '@supabase/ssr'
+import { cookies } from 'next/headers'
+import Link from 'next/link'
+import { formatDate, renderValue } from '../admin-format'
+
+export const dynamic = 'force-dynamic'
+
+interface QuestionRow {
+ id: string
+ code: string
+ prompt: string
+ type: string
+ position: number
+ instance_of: string | null
+}
+
+interface AnswerRow {
+ question_id: string
+ value: unknown
+ instance_id: string
+}
+
+interface ResponseMeta {
+ id: string
+ submitted_at: string
+ qi_zone: string | null
+ qi_sector: string | null
+ qi_age_bucket: string | null
+ survey_id: string
+}
+
+interface DetailRow {
+ code: string
+ prompt: string
+ rendered: string | null
+}
+
+interface InstanceGroup {
+ instanceId: string
+ rows: DetailRow[]
+}
+
+interface SectionGroup {
+ title: string | null
+ instances: InstanceGroup[]
+}
+
+async function getSupabaseSession() {
+ const cookieStore = await cookies()
+ return createServerClient(
+ process.env.NEXT_PUBLIC_SUPABASE_URL!,
+ process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
+ {
+ cookies: {
+ getAll() {
+ return cookieStore.getAll()
+ },
+ setAll(cookiesToSet) {
+ try {
+ cookiesToSet.forEach(({ name, value, options }) =>
+ cookieStore.set(name, value, options)
+ )
+ } catch {
+ // Server Component — no puede mutar cookies, se ignora
+ }
+ },
+ },
+ }
+ )
+}
+
+function backHref(surveyId: string | undefined, page: string | undefined): string {
+ const params = new URLSearchParams()
+ if (surveyId) params.set('survey_id', surveyId)
+ if (page) params.set('page', page)
+ const qs = params.toString()
+ return `/admin/responses${qs ? `?${qs}` : ''}`
+}
+
+export default async function ResponseDetailPage({
+ params,
+ searchParams,
+}: {
+ params: Promise<{ id: string }>
+ searchParams: Promise<{ survey_id?: string; page?: string }>
+}) {
+ const { id } = await params
+ const { survey_id: backSurveyId, page: backPage } = await searchParams
+ const back = backHref(backSurveyId, backPage)
+ const supabase = await getSupabaseSession()
+
+ const { data: response, error: responseError } = await supabase
+ .from('responses')
+ .select('id, submitted_at, qi_zone, qi_sector, qi_age_bucket, survey_id')
+ .eq('id', id)
+ .maybeSingle()
+
+ if (responseError || !response) {
+ return (
+
+ ← Volver a la lista
+
Respuesta no encontrada
+
+ No existe esta respuesta o no tenés permiso para verla.
+
+
+ )
+ }
+
+ const meta = response as ResponseMeta
+
+ // Mismo filtro de sensibles que la lista (regla no negociable #4 CLAUDE.md) — NO diverge.
+ const { data: questionsData, error: questionsError } = await supabase
+ .from('questions')
+ .select('id, code, prompt, type, position, instance_of')
+ .eq('survey_id', meta.survey_id)
+ .eq('is_sensitive', false)
+ .neq('type', 'text_long')
+ .order('position', { ascending: true })
+
+ if (questionsError) {
+ return (
+