Skip to content

feat(v2): Phase 4 — annotation (questions, suggestions, responses)#229

Merged
JonnyTran merged 23 commits into
developfrom
feat/v2-annotation
Jul 9, 2026
Merged

feat(v2): Phase 4 — annotation (questions, suggestions, responses)#229
JonnyTran merged 23 commits into
developfrom
feat/v2-annotation

Conversation

@JonnyTran

Copy link
Copy Markdown
Member

Phase 4 — Annotation (v2)

Builds the schema-centric annotation layer on top of the v2 records (Phases 1–3): questions (reviewable column bindings), suggestions (LLM pre-populated values), responses (human submissions), and a query-time projection view that resolves each reviewable cell. Postgres-only — no LanceDB sync. Implements spec §17; see the new §18 "Resolved during Phase 4 implementation" for as-built decisions.

What's included

  • Drop schemas.kind (§14): the kind is emergent from bindings. Migration 6393b1a01aa0.
  • Three additive v2_-prefixed tables: V2Question/V2Suggestion/V2Response (+ enums v2_*_enum), migration c1510e93882a, factories. Reuses v1 QuestionType/SuggestionType/ResponseStatus and v1 per-type value validators — not forked.
  • Validators: column-binding (existence/arity, span deferred → 422), settings-vs-type at create/update, and value/score validation reusing v1's per-type validators (table structure-only).
  • Verticals (context + API + policy): questions (/schemas/{id}/questions, /questions/{id}), suggestions (PUT|GET /records/{id}/suggestions, idempotent per (record, question)), responses (PUT|GET /records/{id}/responses, idempotent per (record, user), keyed by question name, no record.status side-effect, own-response authz).
  • Single schema-version read GET /schemas/{id}/versions/{version} (§17.5).
  • Projection view GET /projection/references/{reference:path}?workspace_id=… — resolves each cell as submitted-response(requesting user) → suggestion → none; batched queries, distinct prefix so it can't be shadowed by the Phase-3 /references/{reference:path} route.
  • No-Lance guarantee: annotation never imports the index engine; a hardened AST guard test enforces it across the whole annotation surface (context modules + all three handlers).

Key design decisions (§18)

  • Migrations are SQLite-safe (the test suite runs Alembic on SQLite): PG enums dropped via sa.Enum(name=…).drop(…, checkfirst=True), Postgres-only DDL guarded.
  • v2 naming discipline (V2* classes/enums/policies, one-directional relationships) → no collision with v1's relationship registry.
  • Question settings are validated against type at create/update and normalized to carry the "type" discriminator on store, so a settings-driven question can never be persisted-but-unusable.
  • Value dispatch is fail-closed (unmapped future QuestionType raises, never silently accepts).
  • Submitted-ranking completeness is intentionally not enforced (settings-level validation only, §17.3) — recorded as a Phase-5 item.

Testing & review

  • Full v2 + validator suite green (136 passed on SQLite). The single teardown error in the multi-directory run is the pre-existing session-scoped Elasticsearch-teardown-at-localhost:9200 infra noise (the flagged test passes in isolation); not a Phase-4 regression.
  • Executed via subagent-driven development: a fresh implementer + spec-and-quality review gate per task (all 11 approved), then a whole-branch review (no Critical/Important; mergeable), then a roborev branch review — findings fixed to convergence (jobs 120→121→122 no issues).

🤖 Generated with Claude Code

JonnyTran added 23 commits July 8, 2026 00:15
…sion, uniqueness-test factories (roborev job 101)
roborev job 103 (Low): downgrade() re-added kind with server_default="table"
but never cleared it, leaving the schema slightly divergent from the
original (Python-side model default only) after a downgrade+upgrade cycle.
Wire the questions vertical slice end-to-end: QuestionCreate/Update/Read
schemas, contexts.v2.annotation (create/list/get/update/delete backed by
QuestionBindingValidator + SchemaVersion.columns_cache), V2QuestionPolicy,
and the /schemas/{id}/questions + /questions/{id} router. Eager-loads
question.schema via selectinload for the write policies since AsyncSession
cannot lazy-load relationships outside an active greenlet.
Adds Task 7 of the v2 Phase 4 annotation vertical: SuggestionUpsert/
SuggestionRead schema, upsert_suggestion/list_suggestions context
functions (idempotent per record/question via the unique constraint
from Task 3), V2SuggestionPolicy (owner or member for read; owner or
admin+member for write), and the PUT|GET /records/{id}/suggestions
router. The PUT handler 422s when the payload's question does not
belong to the record's schema.
Adds coverage for the update.columns branch flagged by roborev job 109:
a valid rebind, an unknown-column rejection, and an arity mismatch for
a non-table question type re-running QuestionBindingValidator on update.
… job 110)

Adds a negative test asserting a workspace-member-but-non-admin annotator
gets 403 from PUT /records/{id}/suggestions, mirroring the existing
read-path negative test and guarding V2SuggestionPolicy.write's
owner-or-admin+member requirement against regressing to mere membership.
Adds annotation_ctx.upsert_response/get_response (keyed by question name,
idempotent per record+user, never mutates record.status or touches the
LanceDB index engine per spec §17.3/§17.5), V2ResponsePolicy for own-response
authz, and PUT/GET /records/{id}/responses routes.
…ex_sync' idiom

The AST guard only inspected ImportFrom.module, so `from
extralit_server.contexts.v2 import index_sync` (the exact idiom used in
api/v2/records.py and api/v2/schemas.py) slipped through undetected since
neither assertion looked at the imported alias names. Refactor detection
into _imports_index_engine(), checking bare and qualified alias names plus
relative-import forms, and add a self-test that exercises every realistic
violating and innocent import form against synthetic source snippets.
…store fail-closed value dispatch (roborev job 120)

- Add QuestionSettingsValidator (validators/v2/questions.py), reusing v1's
  QuestionSettings TypeAdapter, and call it in create_question/update_question
  after binding validation so settings-driven questions (rating/label_selection/
  multi_label_selection/ranking) with empty or type-mismatched settings fail
  loudly at create/update time instead of persisting unusable.
- Restore the terminal fail-closed `else: raise` in
  V2ResponseValueValidator.validate so an unmapped future QuestionType rejects
  rather than silently accepting an unvalidated value.
…ontradictory type (roborev job 121)

QuestionSettingsValidator injected the "type" discriminator only for
validation, while create_question/update_question persisted raw
settings and annotation-time _parsed() never injected it. A
settings-driven question created with valid settings but no "type"
key thus passed create-time validation yet was permanently unusable
at annotation time (opaque 422 on every suggestion/response).

- create_question/update_question now normalize the *stored* settings
  with the question's own type, so create.type/question.type is always
  the source of truth for the persisted discriminator.
- QuestionSettingsValidator.validate rejects an explicit "type" in the
  incoming settings that contradicts the question's real type, instead
  of silently overwriting it.
@JonnyTran JonnyTran requested a review from a team as a code owner July 8, 2026 03:06
@vercel

vercel Bot commented Jul 8, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
extralit-frontend Ignored Ignored Jul 8, 2026 3:06am

@JonnyTran JonnyTran merged commit bd9c7ba into develop Jul 9, 2026
6 checks passed
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.

1 participant