Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
e68c6fd
docs(spec): record Phase 4 (annotation v2) design decisions (§17)
JonnyTran Jul 8, 2026
6a9e71c
docs(plan): Phase 4 v2 annotation implementation plan
JonnyTran Jul 8, 2026
a2ef1cd
docs(plan): fix Task 8 required-question test, projection route colli…
JonnyTran Jul 8, 2026
d2334eb
refactor(v2): drop schemas.kind (emergent from bindings, spec §14)
JonnyTran Jul 8, 2026
5bc2c3e
fix(v2): reset schemas.kind server_default in migration downgrade
JonnyTran Jul 8, 2026
99e387c
feat(v2): add V2Question/V2Suggestion/V2Response models
JonnyTran Jul 8, 2026
6c7f8e6
feat(v2): migration + factories for v2 annotation tables
JonnyTran Jul 8, 2026
d083ca3
feat(v2): question column-binding validator
JonnyTran Jul 8, 2026
ea54b89
feat(v2): value validators reusing v1 per-type validators
JonnyTran Jul 8, 2026
1a90997
feat(v2): questions context, API, and policy
JonnyTran Jul 8, 2026
e99814b
feat(v2): suggestions context, API, and policy
JonnyTran Jul 8, 2026
5fb2505
test(v2): cover update_question column re-validation (roborev job 109)
JonnyTran Jul 8, 2026
28334d3
test(v2): cover suggestion write-authz for non-admin members (roborev…
JonnyTran Jul 8, 2026
de217b6
feat(v2): responses context, API, and own-response policy
JonnyTran Jul 8, 2026
1ae433f
feat(v2): single schema-version read endpoint (§17.5)
JonnyTran Jul 8, 2026
c9318a6
test(v2): add non-member forbidden-path coverage for responses routes…
JonnyTran Jul 8, 2026
b79c33d
feat(v2): projection view (resolve response->suggestion per cell)
JonnyTran Jul 8, 2026
418dc8f
test(v2): guard annotation against index-engine imports; suite green
JonnyTran Jul 8, 2026
974dc7d
fix(v2): harden no-index-import guard to catch 'from ...v2 import ind…
JonnyTran Jul 8, 2026
c680ef7
test(v2): close no-index guard gap + cover response/projection edge b…
JonnyTran Jul 8, 2026
313b21f
fix(v2): validate question settings against type at create/update; re…
JonnyTran Jul 8, 2026
1ca23aa
fix(v2): normalize question settings discriminator on store; reject c…
JonnyTran Jul 8, 2026
b63a251
docs(spec): §18 resolved during Phase 4 (annotation) implementation
JonnyTran Jul 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,028 changes: 2,028 additions & 0 deletions docs/superpowers/plans/2026-07-08-v2-annotation.md

Large diffs are not rendered by default.

265 changes: 265 additions & 0 deletions docs/superpowers/specs/2026-06-27-schema-centric-data-model-design.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
"""drop schemas.kind

Revision ID: 6393b1a01aa0
Revises: 8136bc88ee3a
Create Date: 2026-07-08 00:43:19.096246

"""

import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision = "6393b1a01aa0"
down_revision = "8136bc88ee3a"
branch_labels = None
depends_on = None


def upgrade() -> None:
op.drop_column("schemas", "kind")
# `kind` is emergent from question/column bindings (spec §14), not a stored discriminator.
sa.Enum(name="schema_kind_enum").drop(op.get_bind(), checkfirst=True)


def downgrade() -> None:
schema_kind = sa.Enum("singleton", "table", name="schema_kind_enum")
schema_kind.create(op.get_bind(), checkfirst=True)
op.add_column("schemas", sa.Column("kind", schema_kind, nullable=False, server_default="table"))
# The original column had no DB-level server_default (only a Python-side model default);
# drop it post-backfill so downgrade restores the exact prior DDL.
with op.batch_alter_table("schemas") as batch_op:
batch_op.alter_column("kind", server_default=None)
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
"""create v2 annotation tables

Revision ID: c1510e93882a
Revises: 6393b1a01aa0
Create Date: 2026-07-08 01:04:40.245236

"""

import sqlalchemy as sa
from alembic import op

# revision identifiers, used by Alembic.
revision = "c1510e93882a"
down_revision = "6393b1a01aa0"
branch_labels = None
depends_on = None


def upgrade() -> None:
op.create_table(
"v2_questions",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("schema_id", sa.Uuid(), nullable=False),
sa.Column("name", sa.String(), nullable=False),
sa.Column("title", sa.Text(), nullable=False),
sa.Column("description", sa.Text(), nullable=True),
sa.Column(
"type",
sa.Enum(
"text",
"rating",
"label_selection",
"multi_label_selection",
"ranking",
"span",
"table",
name="v2_question_type_enum",
),
nullable=False,
),
sa.Column("columns", sa.JSON(), nullable=False),
sa.Column("settings", sa.JSON(), nullable=False),
sa.Column("required", sa.Boolean(), nullable=False),
sa.Column("inserted_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["schema_id"], ["schemas.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("schema_id", "name", name="v2_question_schema_id_name_uq"),
)
op.create_index(op.f("ix_v2_questions_schema_id"), "v2_questions", ["schema_id"], unique=False)
op.create_index(op.f("ix_v2_questions_name"), "v2_questions", ["name"], unique=False)
op.create_index(op.f("ix_v2_questions_type"), "v2_questions", ["type"], unique=False)

op.create_table(
"v2_suggestions",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("record_id", sa.Uuid(), nullable=False),
sa.Column("question_id", sa.Uuid(), nullable=False),
sa.Column("value", sa.JSON(), nullable=False),
sa.Column("score", sa.JSON(), nullable=True),
sa.Column("agent", sa.String(), nullable=True),
sa.Column(
"type",
sa.Enum("model", "human", "selection", name="v2_suggestion_type_enum"),
nullable=True,
),
sa.Column("inserted_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["record_id"], ["v2_records.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["question_id"], ["v2_questions.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("record_id", "question_id", name="v2_suggestion_record_id_question_id_uq"),
)
op.create_index(op.f("ix_v2_suggestions_record_id"), "v2_suggestions", ["record_id"], unique=False)
op.create_index(op.f("ix_v2_suggestions_question_id"), "v2_suggestions", ["question_id"], unique=False)
op.create_index(op.f("ix_v2_suggestions_type"), "v2_suggestions", ["type"], unique=False)

op.create_table(
"v2_responses",
sa.Column("id", sa.Uuid(), nullable=False),
sa.Column("record_id", sa.Uuid(), nullable=False),
sa.Column("user_id", sa.Uuid(), nullable=False),
sa.Column("values", sa.JSON(), nullable=True),
sa.Column(
"status",
sa.Enum("draft", "submitted", "discarded", name="v2_response_status_enum"),
nullable=False,
),
sa.Column("inserted_at", sa.DateTime(), nullable=False),
sa.Column("updated_at", sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(["record_id"], ["v2_records.id"], ondelete="CASCADE"),
sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
sa.PrimaryKeyConstraint("id"),
sa.UniqueConstraint("record_id", "user_id", name="v2_response_record_id_user_id_uq"),
)
op.create_index(op.f("ix_v2_responses_record_id"), "v2_responses", ["record_id"], unique=False)
op.create_index(op.f("ix_v2_responses_user_id"), "v2_responses", ["user_id"], unique=False)
op.create_index(op.f("ix_v2_responses_status"), "v2_responses", ["status"], unique=False)


def downgrade() -> None:
op.drop_index(op.f("ix_v2_responses_status"), table_name="v2_responses")
op.drop_index(op.f("ix_v2_responses_user_id"), table_name="v2_responses")
op.drop_index(op.f("ix_v2_responses_record_id"), table_name="v2_responses")
op.drop_table("v2_responses")

op.drop_index(op.f("ix_v2_suggestions_type"), table_name="v2_suggestions")
op.drop_index(op.f("ix_v2_suggestions_question_id"), table_name="v2_suggestions")
op.drop_index(op.f("ix_v2_suggestions_record_id"), table_name="v2_suggestions")
op.drop_table("v2_suggestions")

op.drop_index(op.f("ix_v2_questions_type"), table_name="v2_questions")
op.drop_index(op.f("ix_v2_questions_name"), table_name="v2_questions")
op.drop_index(op.f("ix_v2_questions_schema_id"), table_name="v2_questions")
op.drop_table("v2_questions")

sa.Enum(name="v2_response_status_enum").drop(op.get_bind(), checkfirst=True)
sa.Enum(name="v2_suggestion_type_enum").drop(op.get_bind(), checkfirst=True)
sa.Enum(name="v2_question_type_enum").drop(op.get_bind(), checkfirst=True)
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from extralit_server.api.policies.v1.schema_policy import SchemaPolicy
from extralit_server.api.policies.v1.suggestion_policy import SuggestionPolicy
from extralit_server.api.policies.v1.user_policy import UserPolicy
from extralit_server.api.policies.v1.v2_annotation_policy import V2QuestionPolicy, V2ResponsePolicy, V2SuggestionPolicy
from extralit_server.api.policies.v1.vector_settings_policy import VectorSettingsPolicy
from extralit_server.api.policies.v1.webhook_policy import WebhookPolicy
from extralit_server.api.policies.v1.workspace_policy import WorkspacePolicy
Expand All @@ -29,6 +30,9 @@
"SchemaPolicy",
"SuggestionPolicy",
"UserPolicy",
"V2QuestionPolicy",
"V2ResponsePolicy",
"V2SuggestionPolicy",
"VectorSettingsPolicy",
"WebhookPolicy",
"WorkspacePolicy",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
from extralit_server.api.policies.v1.commons import PolicyAction
from extralit_server.models import User
from extralit_server.models.v2 import Schema, V2Question, V2Record


class V2QuestionPolicy:
@classmethod
def list(cls, schema: Schema) -> PolicyAction:
async def is_allowed(actor: User) -> bool:
return actor.is_owner or await actor.is_member(schema.workspace_id)

return is_allowed

get = list

@classmethod
def create(cls, schema: Schema) -> PolicyAction:
async def is_allowed(actor: User) -> bool:
return actor.is_owner or (actor.is_admin and await actor.is_member(schema.workspace_id))

return is_allowed

@classmethod
def _write(cls, question: V2Question) -> PolicyAction:
# `question.schema` must be eagerly loaded (see the router's `selectinload` option) —
# AsyncSession does not support implicit lazy-loading outside an active greenlet.
async def is_allowed(actor: User) -> bool:
return actor.is_owner or (actor.is_admin and await actor.is_member(question.schema.workspace_id))

return is_allowed

update = _write
delete = _write


class V2SuggestionPolicy:
@classmethod
def read(cls, record: "V2Record") -> PolicyAction:
# `record.schema` must be eagerly loaded (see the router's `selectinload` option) —
# AsyncSession does not support implicit lazy-loading outside an active greenlet.
async def is_allowed(actor: User) -> bool:
return actor.is_owner or await actor.is_member(record.schema.workspace_id)

return is_allowed

@classmethod
def write(cls, record: "V2Record") -> PolicyAction:
async def is_allowed(actor: User) -> bool:
return actor.is_owner or (actor.is_admin and await actor.is_member(record.schema.workspace_id))

return is_allowed


class V2ResponsePolicy:
"""Own-response authz (spec §17.5), ported from v1 ResponsePolicy with the workspace
resolved via record.schema.workspace_id."""

@classmethod
def read(cls, record: "V2Record") -> PolicyAction:
async def is_allowed(actor: User) -> bool:
return actor.is_owner or await actor.is_member(record.schema.workspace_id)

return is_allowed

@classmethod
def upsert_own(cls, record: "V2Record") -> PolicyAction:
# PUT writes the current user's own response; any workspace member (incl. annotators)
# may write their own, matching v1 (actor.id == response.user_id).
async def is_allowed(actor: User) -> bool:
return actor.is_owner or await actor.is_member(record.schema.workspace_id)

return is_allowed
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
from datetime import datetime
from typing import Any
from uuid import UUID

from pydantic import BaseModel, ConfigDict

from extralit_server.enums import ResponseStatus, SuggestionType


class SuggestionUpsert(BaseModel):
question_id: UUID
value: Any
score: float | list[float] | None = None
agent: str | None = None
type: SuggestionType | None = None


class SuggestionRead(BaseModel):
model_config = ConfigDict(from_attributes=True)

id: UUID
record_id: UUID
question_id: UUID
value: Any
score: float | list[float] | None
agent: str | None
type: SuggestionType | None
inserted_at: datetime
updated_at: datetime


class Suggestions(BaseModel):
items: list[SuggestionRead]


class ResponseUpsert(BaseModel):
values: dict[str, dict[str, Any]] | None = None # {question_name: {"value": ...}}
status: ResponseStatus


class ResponseRead(BaseModel):
model_config = ConfigDict(from_attributes=True)

id: UUID
record_id: UUID
user_id: UUID
values: dict[str, Any] | None
status: ResponseStatus
inserted_at: datetime
updated_at: datetime
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
from typing import Any, Literal
from uuid import UUID

from pydantic import BaseModel


class ProjectionCell(BaseModel):
question_name: str
value: Any | None = None
source: Literal["response", "suggestion"] | None = None # None => neither exists yet


class ProjectionRecord(BaseModel):
record_id: UUID
schema_id: UUID
reference: str
cells: list[ProjectionCell]


class ProjectionView(BaseModel):
reference: str
records: list[ProjectionRecord]
total_records: int
47 changes: 47 additions & 0 deletions extralit-server/src/extralit_server/api/schemas/v2/questions.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
from datetime import datetime
from typing import Any
from uuid import UUID

from pydantic import BaseModel, ConfigDict, Field

from extralit_server.enums import QuestionType

QUESTION_COLUMNS_MIN = 1


class QuestionCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=200)
title: str = Field(..., min_length=1)
description: str | None = None
type: QuestionType
columns: list[str] = Field(..., min_length=QUESTION_COLUMNS_MIN)
settings: dict[str, Any] = Field(default_factory=dict)
required: bool = False


class QuestionUpdate(BaseModel):
title: str | None = None
description: str | None = None
columns: list[str] | None = None
settings: dict[str, Any] | None = None
required: bool | None = None


class QuestionRead(BaseModel):
model_config = ConfigDict(from_attributes=True)

id: UUID
schema_id: UUID
name: str
title: str
description: str | None
type: QuestionType
columns: list[str]
settings: dict[str, Any]
required: bool
inserted_at: datetime
updated_at: datetime


class Questions(BaseModel):
items: list[QuestionRead]
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,13 @@

from pydantic import BaseModel, ConfigDict, Field, constr

from extralit_server.enums import SchemaKind, SchemaStatus
from extralit_server.enums import SchemaStatus

SchemaName = constr(min_length=1, max_length=200)


class SchemaCreate(BaseModel):
name: SchemaName
kind: SchemaKind = SchemaKind.table
workspace_id: UUID
settings: dict[str, Any] = Field(default_factory=dict)

Expand Down Expand Up @@ -49,7 +48,6 @@ class SchemaRead(BaseModel):

id: UUID
name: str
kind: SchemaKind
status: SchemaStatus
current_version_id: UUID | None
settings: dict[str, Any]
Expand Down
Loading
Loading