luplo.core.ideas

CRUD operations for the ideas table.

Ideas are append-only ideation notes attached to a work unit — separate from items because their lifecycle and intent differ. There is intentionally no update_idea or delete_idea here: mistakes are recovered via redact_idea(), which preserves the audit row.

The Python API surface is the policy boundary; the database has no triggers enforcing append-only. Direct SQL is still allowed for admin cleanup.

Functions

add_idea(→ luplo.core.models.Idea)

Insert an idea attached to a work unit.

list_ideas(→ list[luplo.core.models.Idea])

List ideas for a work unit, newest first.

search_ideas(→ list[luplo.core.models.Idea])

Full-text search over ideas, project-scoped.

redact_idea(→ tuple[luplo.core.models.Idea, bool])

Mark an idea as redacted (idempotent — no-op if already redacted).

get_idea(→ luplo.core.models.Idea | None)

Fetch a single idea by id (or hex prefix). Includes redacted rows.

Module Contents

async luplo.core.ideas.add_idea(conn: psycopg.AsyncConnection[Any], *, project_id: str, work_unit_id: str, text: str, created_by: str | None = None, id: str | None = None) luplo.core.models.Idea

Insert an idea attached to a work unit.

Accepts a full UUID or 8+ hex prefix for work_unit_id. The work unit must exist, belong to project_id, and not be archived or abandoned. done is allowed (retro notes).

Status check + INSERT happen in a single SQL statement — no TOCTOU window between “I checked archived” and “I inserted”.

Raises:
  • ValidationError – empty text, cross-project WU, or WU in a status that rejects new ideas.

  • NotFoundErrorwork_unit_id does not resolve to any row.

async luplo.core.ideas.list_ideas(conn: psycopg.AsyncConnection[Any], *, work_unit_id: str, project_id: str | None = None, limit: int = 100, include_redacted: bool = False) list[luplo.core.models.Idea]

List ideas for a work unit, newest first.

Accepts a full UUID or 8+ hex prefix for work_unit_id. project_id scopes prefix resolution and is also enforced at the SELECT level so a full UUID from another project returns [] instead of leaking rows. Redacted rows are excluded by default; include_redacted=True opts back in (admin / audit flows).

async luplo.core.ideas.search_ideas(conn: psycopg.AsyncConnection[Any], *, project_id: str, query: str | None = None, tsquery: str | None = None, work_unit_id: str | None = None, author: str | None = None, since: datetime.datetime | None = None, until: datetime.datetime | None = None, include_redacted: bool = False, limit: int = 50) list[luplo.core.models.Idea]

Full-text search over ideas, project-scoped.

Two query modes (mutually exclusive):

  • query — simple dialect, glossary-expanded. Same dialect as luplo.core.search.pipeline.search() for items.

  • tsquery — raw to_tsquery expression. Caller owns synonym coverage and validity. Malformed expressions surface as psycopg.errors.SyntaxError from Postgres.

Filters: work_unit_id (narrow scope, accepts UUID or hex prefix), author (actor id), since / until (datetime — caller does string parsing). Both query and tsquery may be None for a filter-only search (e.g. “내가 이번 주 적은 아이디어”). Redacted rows excluded unless include_redacted=True.

include_redacted=True cannot be combined with query / tsquery — the body is masked at the response layer, but a text predicate on redacted rows would leak via match/no-match (a keyword oracle). Use filter-only mode for audit flows, or fetch raw text via the SaaS-side admin path.

async luplo.core.ideas.redact_idea(conn: psycopg.AsyncConnection[Any], *, idea_id: str, redacted_by: str, project_id: str | None = None) tuple[luplo.core.models.Idea, bool]

Mark an idea as redacted (idempotent — no-op if already redacted).

Append-only invariant preserved: the row remains, text is not cleared, and downstream readers filter on redacted_at IS NULL by default.

project_id (when provided) scopes prefix resolution and is enforced in the UPDATE/SELECT predicate so a full UUID from another project cannot mutate or read this row. Ambiguity errors surface only the matched id values, never the text content.

Returns (idea, newly_redacted): newly_redacted=True on first transition to redacted, False on idempotent retry. Callers can skip side-effects (audit, notifications) when False.

Raises:

NotFoundError – when idea_id does not resolve to an existing row in this project.

async luplo.core.ideas.get_idea(conn: psycopg.AsyncConnection[Any], idea_id: str, *, project_id: str | None = None) luplo.core.models.Idea | None

Fetch a single idea by id (or hex prefix). Includes redacted rows.

SaaS-side permission checks need to fetch the row before deciding whether redact is allowed — hence this is a separate helper rather than relying on list/search.

project_id scopes prefix resolution and is enforced in the SELECT predicate so a full UUID from another project returns None instead of leaking the row. Ambiguity errors surface only the matched id values.