Skip to content

fix(lists): add ProjectListsStore and fix ProjectListEntriesStore - #2183

Closed
jaylfc wants to merge 1 commit into
devfrom
exec/tsk-jos7xm
Closed

fix(lists): add ProjectListsStore and fix ProjectListEntriesStore#2183
jaylfc wants to merge 1 commit into
devfrom
exec/tsk-jos7xm

Conversation

@jaylfc

@jaylfc jaylfc commented Jul 28, 2026

Copy link
Copy Markdown
Owner

Autonomous build of board card tsk-jos7xm.

Create lists_store.py with ProjectListsStore (list CRUD) and
ProjectListEntriesStore (entry lifecycle, ordering, reorder, next-position).
Register lst and ent id prefixes. Add unit tests for both stores covering
add/get round-trip, position ordering, original_text immutability on update,
delete, reorder, and sequential _get_next_position.

Files:
tests/projects/test_lists_store.py | 218 ++++++++++++++++++++++++++++++
tinyagentos/projects/ids.py | 2 +-
tinyagentos/projects/lists_store.py | 262 ++++++++++++++++++++++++++++++++++++
3 files changed, 481 insertions(+), 1 deletion(-)

Summary by CodeRabbit

  • New Features

    • Added project lists with titles, descriptions, statuses, and timestamps.
    • Added list entries with text, categories, statuses, completion tracking, authorship, and ordering.
    • Added filtering, updating, deleting, and reordering for list entries.
    • Added automatic identifiers and sequential entry positions.
  • Tests

    • Added coverage for list and entry creation, retrieval, updates, deletion, filtering, and reordering.

Create lists_store.py with ProjectListsStore (list CRUD) and
ProjectListEntriesStore (entry lifecycle, ordering, reorder, next-position).
Register lst and ent id prefixes. Add unit tests for both stores covering
add/get round-trip, position ordering, original_text immutability on update,
delete, reorder, and sequential _get_next_position.
@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds asynchronous SQLite-backed stores for project lists and list entries, including CRUD operations, filtering, timestamps, ordering, reordering, generated IDs, and comprehensive pytest coverage.

Changes

Project lists persistence

Layer / File(s) Summary
List storage and lifecycle
tinyagentos/projects/ids.py, tinyagentos/projects/lists_store.py, tests/projects/test_lists_store.py
Adds lst identifiers, the project-list schema, list CRUD operations, timestamp updates, project scoping, and lifecycle tests.
Entry storage and querying
tinyagentos/projects/lists_store.py, tests/projects/test_lists_store.py
Adds the entry schema and APIs for creation, retrieval, filtered listing, updates, deletion, ordering, and persistence validation.
Entry ordering operations
tinyagentos/projects/lists_store.py, tests/projects/test_lists_store.py
Adds bulk reordering and sequential position calculation with corresponding tests.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Caller
  participant ProjectListsStore
  participant ProjectListEntriesStore
  participant SQLite
  Caller->>ProjectListsStore: create or update project list
  ProjectListsStore->>SQLite: persist project_lists row
  SQLite-->>ProjectListsStore: return list record
  Caller->>ProjectListEntriesStore: add, filter, or reorder entries
  ProjectListEntriesStore->>SQLite: persist or query project_list_entries
  SQLite-->>ProjectListEntriesStore: return ordered entry records
  ProjectListEntriesStore-->>Caller: return entry results
Loading

Possibly related PRs

  • jaylfc/taOS#2144: Implements the same Lists S1 persistence layer and related entry-store operations.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main changes: adding ProjectListsStore and updating ProjectListEntriesStore.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch exec/tsk-jos7xm

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gitar-bot

gitar-bot Bot commented Jul 28, 2026

Copy link
Copy Markdown

Important

You are using the Gitar free plan. Upgrade to unlock code review, CI analysis, auto-apply, custom automations, and more.

Gitar

@qodo-code-review

Copy link
Copy Markdown

PR Summary by Qodo

Add ProjectListsStore and stabilize ProjectListEntriesStore behaviors

🐞 Bug fix ✨ Enhancement 🧪 Tests 🕐 40+ Minutes

Grey Divider

AI Description

• Introduce a SQLite-backed ProjectListsStore for project list CRUD.
• Implement/fix ProjectListEntriesStore for entry lifecycle, ordering, and reordering.
• Register new ID prefix and add async unit tests for list/entry invariants.
Diagram

graph TD
  T[/"Unit tests"/] --> LS["ProjectListsStore"] --> BS["BaseStore"] --> DB[("SQLite DB files")]
  T --> ES["ProjectListEntriesStore"] --> BS
  LS --> IDS["ids.new_id"]
  ES --> IDS
  subgraph Legend
    direction LR
    _test[/Tests/] ~~~ _mod[Module] ~~~ _db[(Database)]
  end
Loading
High-Level Assessment

The following are alternative approaches to this PR:

1. Auto-assign position in add_entry() via _get_next_position()
  • ➕ Prevents accidental position collisions when callers omit position
  • ➕ Centralizes ordering logic in one place
  • ➖ Adds an extra query per insert (MAX(position))
  • ➖ May need transactional protection under concurrency
2. Use fractional/ranked ordering (e.g., LexoRank-style) for reorders
  • ➕ Reordering can avoid updating many rows
  • ➕ Scales better for frequent drag-and-drop style reorders
  • ➖ More complex ordering logic and edge cases (rebalancing)
  • ➖ Harder to debug compared to simple integers
3. Make reorder_entries() a single transaction + executemany
  • ➕ Lower round-trips; clearer atomic intent
  • ➕ Easier to reason about partial failures
  • ➖ Slightly more implementation complexity
  • ➖ May require BaseStore helper methods depending on current DB wrapper

Recommendation: The PR’s approach (simple integer positions + explicit reorder updates) is appropriate for an initial implementation and is well-covered by tests. The main strategic improvement to consider is defaulting add_entry(position=None) to _get_next_position() (or removing the optionality) to avoid duplicate positions. If reorder frequency grows, consider batching updates (executemany) and/or a ranked ordering scheme.

Files changed (3) +481 / -1

Enhancement (2) +263 / -1
ids.pyRegister 'lst' ID prefix +1/-1

Register 'lst' ID prefix

• Adds the 'lst' prefix to the allowed ID_PREFIXES set so list IDs can be generated/validated consistently alongside existing prefixes.

tinyagentos/projects/ids.py

lists_store.pyAdd ProjectListsStore and implement ProjectListEntriesStore persistence +262/-0

Add ProjectListsStore and implement ProjectListEntriesStore persistence

• Adds a new SQLite schema and store for project lists with create/get/list/update/delete operations and timestamps. Implements a list entry store with insert/get/list (filtered by list/status/category), update (without mutating original_text), delete, bulk reorder by position, and a helper to compute next position.

tinyagentos/projects/lists_store.py

Tests (1) +218 / -0
test_lists_store.pyAdd async unit tests for list and entry stores +218/-0

Add async unit tests for list and entry stores

• Introduces pytest-asyncio fixtures and coverage for ProjectListsStore CRUD and ProjectListEntriesStore behaviors. Tests validate ID prefixes, scoping by project/list/status, ordering by position, original_text immutability on updates, deletion, reorder, and sequential next-position calculation.

tests/projects/test_lists_store.py

@jaylfc

jaylfc commented Jul 28, 2026

Copy link
Copy Markdown
Owner Author

Reviewed. The staging here is legitimate and I am fine with it, but I want to say why explicitly, because on the surface this looks like a PR I closed a few hours ago.

ProjectListsStore is added with zero references in app.py — no lifespan wiring, so nothing constructs it. That is the exact shape of #2172, which I closed tonight for shipping a StrikeStore that never ran. So I checked before applying the rule mechanically, and this one is different in the two ways that matter:

  1. It has tests. tests/projects/test_lists_store.py. tsk-glxi4e [OPEN] Make 3-strike card QUARANTINE loud (it currently v #2172 had none.
  2. The wiring has a named successor card. tsk-epm5m3 ("Lists S1b: routes + scopes + middleware allowlist") explicitly covers the lifespan and app.state work. tsk-glxi4e [OPEN] Make 3-strike card QUARANTINE loud (it currently v #2172 had no follow-on; it was simply incomplete.

And this PR fixes a module that does not parse, which is strictly worse than an unwired store, so landing it improves dev either way.

One request, small but do it: add a trailer to the PR body making the staging explicit and auditable:

Store-Unwired-Intentionally: ProjectListsStore, wiring lands in tsk-epm5m3 (Lists S1b)

That turns "this store is unreachable" from something a reviewer has to notice into something the author declared. I am carding a CI check for exactly this (tsk-n3w5mh), and that trailer is the escape hatch it will honour, so writing it now means this PR keeps passing once the check exists rather than becoming its first false positive.

On the dependency, so it cannot quietly drop: until tsk-epm5m3 lands, ProjectListsStore is unreachable at runtime. That is acceptable for a slice, but if S1b stalls this becomes dead code by default rather than by decision. If it has not moved in a day, ping me and I will re-prioritise it rather than let the store sit orphaned.

CI is still running (shards pending, Kilo and CodeRabbit in progress). I will arm auto-merge once required checks are green, assuming no bot turns up something substantive.

@jaylfc

jaylfc commented Jul 28, 2026

Copy link
Copy Markdown
Owner Author

nemotron-ultra-orB review

VERDICT: LGTM with minor concerns

  • tinyagentos/projects/lists_store.py:180 — delete_entry returns cursor.rowcount == 1 but should return cursor.rowcount > 0 for consistency with delete_list (line 133) and to handle potential duplicate IDs gracefully
  • tinyagentos/projects/lists_store.py:257 — _get_next_position uses list_id or "" in query but the column is NOT NULL; if list_id=None is passed, it matches empty string instead of NULL — could cause incorrect position calculation if entries exist with empty string list_id
  • tinyagentos/projects/lists_store.py:193 — reorder_entries does not validate that all entries belong to the same list; could allow cross-list reordering
  • tests/projects/test_lists_store.py:213 — test_list_entries_scoped_to_list_and_status calls list_entries(status="closed") without list_id but the method signature requires project_id first positional arg; test passes due to keyword arg but is confusing
  • tinyagentos/projects/ids.py:3 — Added "lst" prefix but no validation that it's unique among prefixes (it is)

Automated first-pass review by the nemotron-ultra-orB lane. The lead still reviews before merge.

@jaylfc

jaylfc commented Jul 28, 2026

Copy link
Copy Markdown
Owner Author

nemotron-ultra-kilo review

VERDICT: Functional implementation with several correctness and safety concerns needing attention.

  • tinyagentos/projects/lists_store.py:63-67 — SQL injection risk via f-string interpolation of column names in update_list; though columns are hardcoded here, the pattern is unsafe and should use a whitelist or parameterized approach.

  • tinyagentos/projects/lists_store.py:63-67 — Same f-string pattern in update_entry (line 183-187) for dynamic SET clause.

  • tinyagentos/projects/lists_store.py:207-214 — reorder_entries executes multiple UPDATEs without a transaction; partial failure leaves inconsistent positions. Wrap in async with self._db.execute("BEGIN") / commit/rollback.

  • tinyagentos/projects/lists_store.py:216-224 — _get_next_position passes list_id or "" but schema has list_id TEXT NOT NULL; None becomes empty string which won't match any rows, returning 0 incorrectly. Should require list_id or handle NULL differently.

  • tinyagentos/projects/lists_store.py:17 — Missing foreign key constraint from project_list_entries.list_idproject_lists(id) and no cascading delete; deleting a list orphans entries.

  • tinyagentos/projects/lists_store.py:211 — delete_entry returns cursor.rowcount == 1 while delete_list uses > 0; inconsistent, prefer > 0 for PK deletes.

  • tinyagentos/projects/lists_store.py:38 — Missing composite index on (list_id, position) for ORDER BY position ASC in list_entries.

  • tinyagentos/projects/lists_store.py:97 — add_entry defaults position=0 when None provided; no auto-assignment via _get_next_position, forcing callers to manage positions manually.

  • tests/projects/test_lists_store.py — No tests for concurrent reorder race conditions, foreign key enforcement, cascade delete behavior, or empty/whitespace text handling.
    VERDICT: Functional implementation with several correctness and safety concerns needing attention.

  • tinyagentos/projects/lists_store.py:63-67 — SQL injection risk via f-string interpolation of column names in update_list; though columns are hardcoded here, the pattern is unsafe and should use a whitelist or parameterized approach.

  • tinyagentos/projects/lists_store.py:63-67 — Same f-string pattern in update_entry (line 183-187) for dynamic SET clause.

  • tinyagentos/projects/lists_store.py:207-214 — reorder_entries executes multiple UPDATEs without a transaction; partial failure leaves inconsistent positions. Wrap in async with self._db.execute("BEGIN") / commit/rollback.

  • tinyagentos/projects/lists_store.py:216-224 — _get_next_position passes list_id or "" but schema has list_id TEXT NOT NULL; None becomes empty string which won't match any rows, returning 0 incorrectly. Should require list_id or handle NULL differently.

  • tinyagentos/projects/lists_store.py:17 — Missing foreign key constraint from project_list_entries.list_idproject_lists(id) and no cascading delete; deleting a list orphans entries.

  • tinyagentos/projects/lists_store.py:211 — delete_entry returns cursor.rowcount == 1 while delete_list uses > 0; inconsistent, prefer > 0 for PK deletes.

  • tinyagentos/projects/lists_store.py:38 — Missing composite index on (list_id, position) for ORDER BY position ASC in list_entries.

  • tinyagentos/projects/lists_store.py:97 — add_entry defaults position=0 when None provided; no auto-assignment via _get_next_position, forcing callers to manage positions manually.

  • tests/projects/test_lists_store.py — No tests for concurrent reorder race conditions, foreign key enforcement, cascade delete behavior, or empty/whitespace text handling.

Automated first-pass review by the nemotron-ultra-kilo lane. The lead still reviews before merge.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tinyagentos/projects/lists_store.py`:
- Around line 127-153: Update add_entry to call _get_next_position(project_id,
list_id) when position is None, storing the returned value instead of defaulting
to 0; preserve explicitly supplied positions. Extend
tests/projects/test_lists_store.py to create entries without positions and
verify they receive distinct sequential positions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 36091bf9-9064-497e-be67-dfc81c93edc4

📥 Commits

Reviewing files that changed from the base of the PR and between 630177b and 26ed84c.

📒 Files selected for processing (3)
  • tests/projects/test_lists_store.py
  • tinyagentos/projects/ids.py
  • tinyagentos/projects/lists_store.py

Comment on lines +127 to +153
async def add_entry(
self,
list_id: str,
project_id: str,
text: str,
original_text: str,
author_kind: str,
author_id: str,
category: str | None = None,
position: int | None = None,
) -> dict:
entry_id = new_id("ent")
now = time.time()

await self._db.execute(
"INSERT INTO project_list_entries "
"(id, list_id, project_id, text, original_text, category, status, "
"done, author_kind, author_id, position, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
entry_id, list_id, project_id, text, original_text,
category, "new", 0, author_kind, author_id,
position if position is not None else 0, now, now,
),
)
await self._db.commit()
return await self.get_entry(entry_id)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Generate a position when callers omit one.

Line 149 always stores 0 for omitted positions, so multiple entries in the same list tie at position 0. Use _get_next_position(project_id, list_id) instead, and extend tests/projects/test_lists_store.py to create entries without explicit positions.

Proposed fix
         entry_id = new_id("ent")
         now = time.time()
+        if position is None:
+            position = await self._get_next_position(project_id, list_id)

         await self._db.execute(
@@
-                position if position is not None else 0, now, now,
+                position, now, now,
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
async def add_entry(
self,
list_id: str,
project_id: str,
text: str,
original_text: str,
author_kind: str,
author_id: str,
category: str | None = None,
position: int | None = None,
) -> dict:
entry_id = new_id("ent")
now = time.time()
await self._db.execute(
"INSERT INTO project_list_entries "
"(id, list_id, project_id, text, original_text, category, status, "
"done, author_kind, author_id, position, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
entry_id, list_id, project_id, text, original_text,
category, "new", 0, author_kind, author_id,
position if position is not None else 0, now, now,
),
)
await self._db.commit()
return await self.get_entry(entry_id)
async def add_entry(
self,
list_id: str,
project_id: str,
text: str,
original_text: str,
author_kind: str,
author_id: str,
category: str | None = None,
position: int | None = None,
) -> dict:
entry_id = new_id("ent")
now = time.time()
if position is None:
position = await self._get_next_position(project_id, list_id)
await self._db.execute(
"INSERT INTO project_list_entries "
"(id, list_id, project_id, text, original_text, category, status, "
"done, author_kind, author_id, position, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
entry_id, list_id, project_id, text, original_text,
category, "new", 0, author_kind, author_id,
position, now, now,
),
)
await self._db.commit()
return await self.get_entry(entry_id)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tinyagentos/projects/lists_store.py` around lines 127 - 153, Update add_entry
to call _get_next_position(project_id, list_id) when position is None, storing
the returned value instead of defaulting to 0; preserve explicitly supplied
positions. Extend tests/projects/test_lists_store.py to create entries without
positions and verify they receive distinct sequential positions.

@qodo-code-review

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (4) 📘 Rule violations (0) 📜 Skill insights (2)

Context used
✅ Compliance rules (platform): 35 rules

Grey Divider


Action required

1. Entry position always 0 🐞 Bug ≡ Correctness
Description
ProjectListEntriesStore.add_entry() writes position=0 whenever the caller omits position, so
multiple entries will share the same position and won’t naturally append by position. This defeats
the store’s own ordering rule (ORDER BY position, created_at) and leaves the existing
_get_next_position() helper unused.
Code

tinyagentos/projects/lists_store.py[R141-150]

+        await self._db.execute(
+            "INSERT INTO project_list_entries "
+            "(id, list_id, project_id, text, original_text, category, status, "
+            "done, author_kind, author_id, position, created_at, updated_at) "
+            "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
+            (
+                entry_id, list_id, project_id, text, original_text,
+                category, "new", 0, author_kind, author_id,
+                position if position is not None else 0, now, now,
+            ),
Relevance

⭐⭐⭐ High

Deterministic correctness fix: default position=0 breaks ordering/append semantics and leaves helper
unused.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
add_entry currently sets omitted positions to 0, while list_entries explicitly orders by position,
and the module already includes a helper to compute the next position. This combination demonstrates
that omitted-position inserts will not append as intended.

tinyagentos/projects/lists_store.py[127-153]
tinyagentos/projects/lists_store.py[165-192]
tinyagentos/projects/lists_store.py[255-262]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`add_entry()` defaults `position` to 0 when the caller does not supply one. This causes repeated inserts to collide on position and undermines the intended positional ordering.

### Issue Context
The store already defines `_get_next_position()` and `list_entries()` sorts by `position ASC, created_at ASC`, so the natural behavior when `position` is omitted should be to append at the end by assigning the next position.

### Fix Focus Areas
- tinyagentos/projects/lists_store.py[127-153]
- tinyagentos/projects/lists_store.py[165-192]
- tinyagentos/projects/lists_store.py[255-262]

### Suggested fix
In `add_entry()`, when `position is None`, compute `position = await self._get_next_position(project_id, list_id)` and insert using that value (optionally within a transaction if concurrent inserts are expected).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Project list stores not wired 📜 Skill insight ⌂ Architecture
Description
The new ProjectListsStore and ProjectListEntriesStore are added but are not
instantiated/attached to app.state in the application lifecycle, so they cannot be used by routes
via request.app.state and won’t be initialized/closed with the app. This will lead to runtime
errors or missing functionality once routes attempt to use these stores.
Code

tinyagentos/projects/lists_store.py[R24-124]

+class ProjectListsStore(BaseStore):
+    SCHEMA = LISTS_SCHEMA
+
+    async def create_list(
+        self,
+        project_id: str,
+        title: str,
+        created_by: str,
+        description: str = "",
+        status: str = "active",
+    ) -> dict:
+        list_id = new_id("lst")
+        now = time.time()
+        await self._db.execute(
+            "INSERT INTO project_lists "
+            "(id, project_id, title, description, status, created_by, created_at, updated_at) "
+            "VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
+            (list_id, project_id, title, description, status, created_by, now, now),
+        )
+        await self._db.commit()
+        return await self.get_list(list_id)
+
+    async def get_list(self, list_id: str) -> dict | None:
+        async with self._db.execute(
+            "SELECT * FROM project_lists WHERE id = ?", (list_id,)
+        ) as cur:
+            row = await cur.fetchone()
+            if row is None:
+                return None
+            keys = [d[0] for d in cur.description]
+            return dict(zip(keys, row))
+
+    async def list_lists(self, project_id: str) -> list[dict]:
+        async with self._db.execute(
+            "SELECT * FROM project_lists WHERE project_id = ? ORDER BY created_at ASC",
+            (project_id,),
+        ) as cur:
+            rows = await cur.fetchall()
+            keys = [d[0] for d in cur.description]
+            return [dict(zip(keys, r)) for r in rows]
+
+    async def update_list(
+        self,
+        list_id: str,
+        title: str | None = None,
+        description: str | None = None,
+        status: str | None = None,
+    ) -> dict | None:
+        sets = []
+        params = []
+        if title is not None:
+            sets.append("title = ?")
+            params.append(title)
+        if description is not None:
+            sets.append("description = ?")
+            params.append(description)
+        if status is not None:
+            sets.append("status = ?")
+            params.append(status)
+        if not sets:
+            return await self.get_list(list_id)
+        sets.append("updated_at = ?")
+        params.append(time.time())
+        params.append(list_id)
+        await self._db.execute(
+            f"UPDATE project_lists SET {', '.join(sets)} WHERE id = ?", params
+        )
+        await self._db.commit()
+        return await self.get_list(list_id)
+
+    async def delete_list(self, list_id: str) -> bool:
+        cursor = await self._db.execute(
+            "DELETE FROM project_lists WHERE id = ?", (list_id,)
+        )
+        await self._db.commit()
+        return cursor.rowcount > 0
+
+
+class ProjectListEntriesStore(BaseStore):
+    SCHEMA = """
+    CREATE TABLE IF NOT EXISTS project_list_entries (
+        id TEXT PRIMARY KEY,
+        list_id TEXT NOT NULL,
+        project_id TEXT NOT NULL,
+        text TEXT NOT NULL,
+        original_text TEXT NOT NULL,
+        category TEXT,
+        status TEXT NOT NULL DEFAULT 'new',
+        done INTEGER NOT NULL DEFAULT 0,
+        author_kind TEXT NOT NULL,
+        author_id TEXT NOT NULL,
+        edited_by TEXT,
+        position INTEGER NOT NULL,
+        created_at REAL NOT NULL,
+        updated_at REAL NOT NULL
+    );
+    CREATE INDEX IF NOT EXISTS idx_entries_list ON project_list_entries(list_id);
+    CREATE INDEX IF NOT EXISTS idx_entries_project ON project_list_entries(project_id);
+    CREATE INDEX IF NOT EXISTS idx_entries_list_project ON project_list_entries(list_id, project_id);
+    CREATE INDEX IF NOT EXISTS idx_entries_status ON project_list_entries(project_id, status);
+    """
Relevance

⭐⭐ Medium

Architectural wiring change; no clear precedent whether new stores must be added to app.state
immediately.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 2185458 requires that new stores be instantiated and attached to app.state in the
app lifespan. The PR adds the new store classes, but tinyagentos/app.py wires other project stores
(imports/instantiation, init/close, and app.state assignments) without any corresponding wiring
for these new stores.

tinyagentos/projects/lists_store.py[24-262]
tinyagentos/app.py[396-418]
tinyagentos/app.py[1596-1606]
Skill: taos-development-skill

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
New stores `ProjectListsStore` and `ProjectListEntriesStore` are introduced but are not wired into the FastAPI app lifecycle (`create_app` + lifespan) via `app.state`.

## Issue Context
The codebase pattern is: instantiate store objects in `create_app`, attach them to `app.state.<name>`, `await store.init()` inside the lifespan startup sequence, and `await store.close()` during lifespan shutdown.

## Fix Focus Areas
- tinyagentos/app.py[396-418]
- tinyagentos/app.py[503-590]
- tinyagentos/app.py[1411-1448]
- tinyagentos/app.py[1596-1606]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

3. Cursor metadata after close 🐞 Bug ☼ Reliability
Description
ProjectListEntriesStore.get_entry() accesses cur.description after exiting the cursor context
manager, making row-to-dict conversion depend on cursor metadata remaining available after the
cursor is closed. This is inconsistent with other stores and can break entry reads depending on
cursor behavior.
Code

tinyagentos/projects/lists_store.py[R155-163]

+    async def get_entry(self, entry_id: str) -> dict | None:
+        async with self._db.execute(
+            "SELECT * FROM project_list_entries WHERE id = ?", (entry_id,)
+        ) as cur:
+            row = await cur.fetchone()
+            if row is None:
+                return None
+        keys = [d[0] for d in cur.description]
+        return dict(zip(keys, row))
Relevance

⭐⭐⭐ High

Low-risk reliability fix: compute cur.description within context manager like other stores to avoid
cursor-metadata issues.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
In lists_store.py, get_entry computes keys after the async with block, unlike ProjectStore which
returns within the context manager (or caches cur.description before exit). This inconsistency
shows get_entry relies on cursor metadata surviving past context exit.

tinyagentos/projects/lists_store.py[155-163]
tinyagentos/projects/project_store.py[185-193]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`ProjectListEntriesStore.get_entry()` reads `cur.description` after leaving `async with self._db.execute(...) as cur:`. That pattern depends on cursor metadata still being readable after the context manager closes the cursor.

### Issue Context
Other stores either (a) return the row inside the context or (b) copy `cur.description` to a local variable before leaving the context.

### Fix Focus Areas
- tinyagentos/projects/lists_store.py[155-163]

### Suggested fix
Move `keys = ...` and the `dict(zip(...))` construction inside the `async with` block, or capture `desc = cur.description` inside the block and use it after the block.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


4. Next-position None never matches 🐞 Bug ≡ Correctness
Description
ProjectListEntriesStore._get_next_position() accepts list_id=None but still filters with `list_id =
?` using an empty-string substitute, so calls with None will always return 0 regardless of existing
entries. Either list_id should be required, or the SQL should omit the list_id predicate when
list_id is None.
Code

tinyagentos/projects/lists_store.py[R255-260]

+    async def _get_next_position(self, project_id: str, list_id: str | None = None) -> int:
+        async with self._db.execute(
+            "SELECT MAX(position) + 1 FROM project_list_entries "
+            "WHERE project_id = ? AND list_id = ?",
+            (project_id, list_id or ""),
+        ) as cur:
Relevance

⭐⭐⭐ High

Deterministic correctness: list_id None path can never match; require list_id or conditional SQL.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The method explicitly uses list_id or "" while the WHERE clause always includes list_id = ?, so
passing None forces a non-matching list_id value and yields 0 even if entries exist.

tinyagentos/projects/lists_store.py[255-262]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`_get_next_position(project_id, list_id=None)` currently binds `""` for `list_id` but still executes `... WHERE project_id = ? AND list_id = ?`, which can never match normal rows (schema stores real list IDs).

### Issue Context
The function signature suggests `None` is a supported input; the current SQL doesn’t implement any meaningful `None` behavior.

### Fix Focus Areas
- tinyagentos/projects/lists_store.py[255-262]

### Suggested fix
Choose one:
1) Make `list_id` required (remove `| None` and default), and always query with `list_id = ?`, OR
2) If `None` means “any list in project”, build SQL conditionally:
  - when `list_id is None`: `WHERE project_id = ?`
  - else: `WHERE project_id = ? AND list_id = ?`
Add/adjust a unit test to lock in the intended semantics.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Informational

5. Flaky updated_at assertion 🐞 Bug ☼ Reliability
Description
test_update_list_changes_fields asserts updated_at is strictly greater than the prior value without
controlling clock resolution, so create/update can share the same timestamp on coarse time sources.
This can intermittently fail CI while the implementation remains correct for >= semantics.
Code

tests/projects/test_lists_store.py[R52-61]

+@pytest.mark.asyncio
+async def test_update_list_changes_fields(lists_store):
+    l = await lists_store.create_list(
+        project_id="prj-1", title="Original", created_by="u"
+    )
+    updated = await lists_store.update_list(l["id"], title="Renamed", status="archived")
+    assert updated["title"] == "Renamed"
+    assert updated["status"] == "archived"
+    assert updated["updated_at"] > l["updated_at"]
+
Relevance

⭐⭐⭐ High

Team often accepts test-hardening to avoid flaky assertions and CI nondeterminism.

PR-#1542
PR-#449

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The test compares two timestamps produced back-to-back and requires a strict increase, which is not
guaranteed by the code (it calls time.time() twice).

tests/projects/test_lists_store.py[52-61]
tinyagentos/projects/lists_store.py[65-92]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
The test asserts `updated_at` strictly increases across two `time.time()` calls without guaranteeing a measurable time delta.

### Issue Context
On some systems/configurations, consecutive `time.time()` calls can return the same value.

### Fix Focus Areas
- tests/projects/test_lists_store.py[52-61]

### Suggested fix
Prefer a deterministic approach:
- Relax to `>=` if strict monotonicity is not required, OR
- Monkeypatch `time.time()` to return controlled increasing values during the test (avoids adding sleeps).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. ProjectListsStore missing MIGRATIONS 📜 Skill insight ⌂ Architecture
Description
ProjectListsStore subclasses BaseStore but does not define a MIGRATIONS class attribute as
required by the store contract. This makes schema upgrade behavior inconsistent across stores and
can lead to missed/unclear migration intent.
Code

tinyagentos/projects/lists_store.py[R24-26]

+class ProjectListsStore(BaseStore):
+    SCHEMA = LISTS_SCHEMA
+
Relevance

⭐ Low

Close rejection precedent: adding MIGRATIONS to satisfy BaseStore contract was rejected previously.

PR-#2122

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
PR Compliance ID 2185172 requires new store classes to subclass BaseStore and define both SCHEMA
and MIGRATIONS. In tinyagentos/projects/lists_store.py, ProjectListsStore defines SCHEMA but
does not define MIGRATIONS.

tinyagentos/projects/lists_store.py[24-26]
Skill: taos-development-skill

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`ProjectListsStore` does not define the required `MIGRATIONS` class attribute.

## Issue Context
Per the store compliance rule, every store subclass must explicitly declare both `SCHEMA` and `MIGRATIONS` (even if `MIGRATIONS` is an empty list).

## Fix Focus Areas
- tinyagentos/projects/lists_store.py[24-26]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

Qodo Logo

Comment on lines +24 to +124
class ProjectListsStore(BaseStore):
SCHEMA = LISTS_SCHEMA

async def create_list(
self,
project_id: str,
title: str,
created_by: str,
description: str = "",
status: str = "active",
) -> dict:
list_id = new_id("lst")
now = time.time()
await self._db.execute(
"INSERT INTO project_lists "
"(id, project_id, title, description, status, created_by, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
(list_id, project_id, title, description, status, created_by, now, now),
)
await self._db.commit()
return await self.get_list(list_id)

async def get_list(self, list_id: str) -> dict | None:
async with self._db.execute(
"SELECT * FROM project_lists WHERE id = ?", (list_id,)
) as cur:
row = await cur.fetchone()
if row is None:
return None
keys = [d[0] for d in cur.description]
return dict(zip(keys, row))

async def list_lists(self, project_id: str) -> list[dict]:
async with self._db.execute(
"SELECT * FROM project_lists WHERE project_id = ? ORDER BY created_at ASC",
(project_id,),
) as cur:
rows = await cur.fetchall()
keys = [d[0] for d in cur.description]
return [dict(zip(keys, r)) for r in rows]

async def update_list(
self,
list_id: str,
title: str | None = None,
description: str | None = None,
status: str | None = None,
) -> dict | None:
sets = []
params = []
if title is not None:
sets.append("title = ?")
params.append(title)
if description is not None:
sets.append("description = ?")
params.append(description)
if status is not None:
sets.append("status = ?")
params.append(status)
if not sets:
return await self.get_list(list_id)
sets.append("updated_at = ?")
params.append(time.time())
params.append(list_id)
await self._db.execute(
f"UPDATE project_lists SET {', '.join(sets)} WHERE id = ?", params
)
await self._db.commit()
return await self.get_list(list_id)

async def delete_list(self, list_id: str) -> bool:
cursor = await self._db.execute(
"DELETE FROM project_lists WHERE id = ?", (list_id,)
)
await self._db.commit()
return cursor.rowcount > 0


class ProjectListEntriesStore(BaseStore):
SCHEMA = """
CREATE TABLE IF NOT EXISTS project_list_entries (
id TEXT PRIMARY KEY,
list_id TEXT NOT NULL,
project_id TEXT NOT NULL,
text TEXT NOT NULL,
original_text TEXT NOT NULL,
category TEXT,
status TEXT NOT NULL DEFAULT 'new',
done INTEGER NOT NULL DEFAULT 0,
author_kind TEXT NOT NULL,
author_id TEXT NOT NULL,
edited_by TEXT,
position INTEGER NOT NULL,
created_at REAL NOT NULL,
updated_at REAL NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_entries_list ON project_list_entries(list_id);
CREATE INDEX IF NOT EXISTS idx_entries_project ON project_list_entries(project_id);
CREATE INDEX IF NOT EXISTS idx_entries_list_project ON project_list_entries(list_id, project_id);
CREATE INDEX IF NOT EXISTS idx_entries_status ON project_list_entries(project_id, status);
"""

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Project list stores not wired 📜 Skill insight ⌂ Architecture

The new ProjectListsStore and ProjectListEntriesStore are added but are not
instantiated/attached to app.state in the application lifecycle, so they cannot be used by routes
via request.app.state and won’t be initialized/closed with the app. This will lead to runtime
errors or missing functionality once routes attempt to use these stores.
Agent Prompt
## Issue description
New stores `ProjectListsStore` and `ProjectListEntriesStore` are introduced but are not wired into the FastAPI app lifecycle (`create_app` + lifespan) via `app.state`.

## Issue Context
The codebase pattern is: instantiate store objects in `create_app`, attach them to `app.state.<name>`, `await store.init()` inside the lifespan startup sequence, and `await store.close()` during lifespan shutdown.

## Fix Focus Areas
- tinyagentos/app.py[396-418]
- tinyagentos/app.py[503-590]
- tinyagentos/app.py[1411-1448]
- tinyagentos/app.py[1596-1606]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +155 to +163
async def get_entry(self, entry_id: str) -> dict | None:
async with self._db.execute(
"SELECT * FROM project_list_entries WHERE id = ?", (entry_id,)
) as cur:
row = await cur.fetchone()
if row is None:
return None
keys = [d[0] for d in cur.description]
return dict(zip(keys, row))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

3. Cursor metadata after close 🐞 Bug ☼ Reliability

ProjectListEntriesStore.get_entry() accesses cur.description after exiting the cursor context
manager, making row-to-dict conversion depend on cursor metadata remaining available after the
cursor is closed. This is inconsistent with other stores and can break entry reads depending on
cursor behavior.
Agent Prompt
### Issue description
`ProjectListEntriesStore.get_entry()` reads `cur.description` after leaving `async with self._db.execute(...) as cur:`. That pattern depends on cursor metadata still being readable after the context manager closes the cursor.

### Issue Context
Other stores either (a) return the row inside the context or (b) copy `cur.description` to a local variable before leaving the context.

### Fix Focus Areas
- tinyagentos/projects/lists_store.py[155-163]

### Suggested fix
Move `keys = ...` and the `dict(zip(...))` construction inside the `async with` block, or capture `desc = cur.description` inside the block and use it after the block.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +141 to +150
await self._db.execute(
"INSERT INTO project_list_entries "
"(id, list_id, project_id, text, original_text, category, status, "
"done, author_kind, author_id, position, created_at, updated_at) "
"VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
(
entry_id, list_id, project_id, text, original_text,
category, "new", 0, author_kind, author_id,
position if position is not None else 0, now, now,
),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

4. Entry position always 0 🐞 Bug ≡ Correctness

ProjectListEntriesStore.add_entry() writes position=0 whenever the caller omits position, so
multiple entries will share the same position and won’t naturally append by position. This defeats
the store’s own ordering rule (ORDER BY position, created_at) and leaves the existing
_get_next_position() helper unused.
Agent Prompt
### Issue description
`add_entry()` defaults `position` to 0 when the caller does not supply one. This causes repeated inserts to collide on position and undermines the intended positional ordering.

### Issue Context
The store already defines `_get_next_position()` and `list_entries()` sorts by `position ASC, created_at ASC`, so the natural behavior when `position` is omitted should be to append at the end by assigning the next position.

### Fix Focus Areas
- tinyagentos/projects/lists_store.py[127-153]
- tinyagentos/projects/lists_store.py[165-192]
- tinyagentos/projects/lists_store.py[255-262]

### Suggested fix
In `add_entry()`, when `position is None`, compute `position = await self._get_next_position(project_id, list_id)` and insert using that value (optionally within a transaction if concurrent inserts are expected).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +255 to +260
async def _get_next_position(self, project_id: str, list_id: str | None = None) -> int:
async with self._db.execute(
"SELECT MAX(position) + 1 FROM project_list_entries "
"WHERE project_id = ? AND list_id = ?",
(project_id, list_id or ""),
) as cur:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Remediation recommended

5. Next-position none never matches 🐞 Bug ≡ Correctness

ProjectListEntriesStore._get_next_position() accepts list_id=None but still filters with `list_id =
?` using an empty-string substitute, so calls with None will always return 0 regardless of existing
entries. Either list_id should be required, or the SQL should omit the list_id predicate when
list_id is None.
Agent Prompt
### Issue description
`_get_next_position(project_id, list_id=None)` currently binds `""` for `list_id` but still executes `... WHERE project_id = ? AND list_id = ?`, which can never match normal rows (schema stores real list IDs).

### Issue Context
The function signature suggests `None` is a supported input; the current SQL doesn’t implement any meaningful `None` behavior.

### Fix Focus Areas
- tinyagentos/projects/lists_store.py[255-262]

### Suggested fix
Choose one:
1) Make `list_id` required (remove `| None` and default), and always query with `list_id = ?`, OR
2) If `None` means “any list in project”, build SQL conditionally:
   - when `list_id is None`: `WHERE project_id = ?`
   - else: `WHERE project_id = ? AND list_id = ?`
Add/adjust a unit test to lock in the intended semantics.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +52 to +61
@pytest.mark.asyncio
async def test_update_list_changes_fields(lists_store):
l = await lists_store.create_list(
project_id="prj-1", title="Original", created_by="u"
)
updated = await lists_store.update_list(l["id"], title="Renamed", status="archived")
assert updated["title"] == "Renamed"
assert updated["status"] == "archived"
assert updated["updated_at"] > l["updated_at"]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Informational

6. Flaky updated_at assertion 🐞 Bug ☼ Reliability

test_update_list_changes_fields asserts updated_at is strictly greater than the prior value without
controlling clock resolution, so create/update can share the same timestamp on coarse time sources.
This can intermittently fail CI while the implementation remains correct for >= semantics.
Agent Prompt
### Issue description
The test asserts `updated_at` strictly increases across two `time.time()` calls without guaranteeing a measurable time delta.

### Issue Context
On some systems/configurations, consecutive `time.time()` calls can return the same value.

### Fix Focus Areas
- tests/projects/test_lists_store.py[52-61]

### Suggested fix
Prefer a deterministic approach:
- Relax to `>=` if strict monotonicity is not required, OR
- Monkeypatch `time.time()` to return controlled increasing values during the test (avoids adding sleeps).

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

row = await cur.fetchone()
if row is None:
return None
keys = [d[0] for d in cur.description]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: cur.description accessed outside async with block

get_entry reads cur.description after the cursor context manager exits (line 162), while get_list reads it inside the block (line 53). If the cursor is closed on exit, cur.description may be unavailable, causing a runtime error. Move the key extraction inside the async with block to match get_list.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

await self._db.commit()
return cursor.rowcount == 1

async def reorder_entries(self, project_id: str, entries: list[dict]) -> None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: reorder_entries is not atomic — partial reorders can persist

Multiple UPDATE statements are executed in a loop without an explicit transaction. If an error occurs mid-loop, already-executed writes remain committed while later ones are lost, leaving entries in a partially reordered state. Wrap the loop in BEGIN/ROLLBACK/COMMIT (see todo_store.py:reorder_items for the established pattern).


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

(
entry_id, list_id, project_id, text, original_text,
category, "new", 0, author_kind, author_id,
position if position is not None else 0, now, now,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: add_entry defaults position to 0, risking collision

When position=None, all entries receive position=0. The private _get_next_position helper exists but is never called by add_entry, so callers that omit position create duplicate positions and break ordering. Either wire _get_next_position into add_entry or remove the optionality.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

async with self._db.execute(
"SELECT MAX(position) + 1 FROM project_list_entries "
"WHERE project_id = ? AND list_id = ?",
(project_id, list_id or ""),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WARNING: _get_next_position converts None list_id to empty string

list_id or "" silently turns a missing list_id into "", which could match entries with an empty list_id and return an incorrect next position. Raise ValueError or skip the list_id filter when list_id is None.


Reply with @kilocode-bot fix it to have Kilo Code address this issue.

@kilo-code-bot

kilo-code-bot Bot commented Jul 28, 2026

Copy link
Copy Markdown

Code Review Summary

Status: 4 Warnings Found | Recommendation: Address before merge

Overview

Severity Count
CRITICAL 0
WARNING 4
SUGGESTION 0
Issue Details (click to expand)

WARNING

File Line Issue
tinyagentos/projects/lists_store.py 162 cur.description accessed outside async with block
tinyagentos/projects/lists_store.py 246 reorder_entries is not atomic — partial reorders can persist
tinyagentos/projects/lists_store.py 149 add_entry defaults position to 0, risking collision
tinyagentos/projects/lists_store.py 259 _get_next_position converts None list_id to empty string
Files Reviewed (3 files)
  • tinyagentos/projects/lists_store.py - 4 warnings
  • tinyagentos/projects/ids.py
  • tests/projects/test_lists_store.py

Fix these issues in Kilo Cloud


Reviewed by step-3.7-flash · Input: 93.7K · Output: 16.6K · Cached: 608K

@jaylfc

jaylfc commented Jul 28, 2026

Copy link
Copy Markdown
Owner Author

nemotron-super review

VERDICT: Blocking issue found: position defaults to 0 instead of next position in list.

  • tinyagentos/projects/lists_store.py:149

Automated first-pass review by the nemotron-super lane. The lead still reviews before merge.

@jaylfc

jaylfc commented Jul 28, 2026

Copy link
Copy Markdown
Owner Author

Not arming auto-merge. I said I would once checks were green if no bot found anything substantive. Checks are green, and Qodo found something substantive that I verified against the code. It is real, and slightly worse than reported.

Every entry added without an explicit position gets position 0. lists_store.py:149:

position if position is not None else 0,

So three entries added with the default land at positions 0, 0, 0. A list store whose entire job is ordered entries silently produces an unordered list, and nothing errors. Callers that do not pass position (which is the natural way to call add_entry(list_id, project_id, text, ...)) get the broken behaviour by default.

And the fix is already written but never wired. _get_next_position exists at line 255 and has zero call sites:

$ grep -n "_get_next_position(" lists_store.py
255:    async def _get_next_position(self, project_id: str, list_id: str | None = None) -> int:

That is the same orphan pattern as #2172, one level down: the right code exists, nothing reaches it. It is also why the tests pass — they presumably pass explicit positions, so the default path is never exercised.

Fix: default to the next position rather than 0.

if position is None:
    position = await self._get_next_position(project_id, list_id)

And a test that would have caught it, which matters more than the fix: add three entries without passing position, then assert the three positions are distinct and ascending. Run it against the current code first and confirm it FAILS. If your existing tests pass explicit positions everywhere, they are testing the caller rather than the store.

Two smaller things while you are in there:

  • The other bot findings (4 total) are worth reading rather than dismissing; I only verified this one.
  • Still missing the trailer I asked for. Add Store-Unwired-Intentionally: ProjectListsStore, wiring lands in tsk-epm5m3 so the unwired store is declared rather than noticed.

Everything I said earlier stands: the staging is legitimate, the tests are a genuine improvement on #2172, and this fixes a module that does not parse. Fix the position default and I will merge it.

@jaylfc

jaylfc commented Jul 28, 2026

Copy link
Copy Markdown
Owner Author

Holding this rather than merging. All four warnings are real, I checked each against the branch, and one of them is described wrongly by the bot in a way that matters.

This is a brand new store that nothing depends on yet, so the cost of fixing now is a short delay, and the cost of merging is four known defects sitting in a foundation other code gets built on. That trade is not close.

1. add_entry gives every entry position 0 (line ~149).

position if position is not None else 0,

_get_next_position() exists twenty lines below and is never called from add_entry. So every entry added without an explicit position collides at 0 and ordering is undefined. Both Qodo and Kilo flagged this independently. The fix is to default to await self._get_next_position(project_id, list_id).

2. _get_next_position cannot see NULL-list entries (line ~259).

"WHERE project_id = ? AND list_id = ?", (project_id, list_id or "")

list_id or "" turns None into the empty string, but rows created with a NULL list_id store NULL, and list_id = '' never matches NULL in SQL. So for the unfiled case it always scans an empty set and returns 0, which feeds directly back into defect 1. Needs an IS NULL branch rather than coercion to "".

3. get_entry reads cur.description after the cursor context has exited (line ~162).

async with self._db.execute(...) as cur:
    row = await cur.fetchone()
    if row is None:
        return None
keys = [d[0] for d in cur.description]

The keys line is outside the async with, so it touches the cursor after __aexit__ has closed it. Move it inside.

4. reorder_entries is not atomic, but not for the reason given (line ~246).

Kilo says "partial reorders can persist". As written the loop issues every UPDATE inside one implicit transaction and commits once, so a clean run is atomic and that description is not right. The actual hazard is the failure path: if one UPDATE raises, the earlier ones stay pending with no rollback, and the next unrelated commit() anywhere in the process flushes a half-applied reorder. Worth fixing, and worth fixing as try/except with an explicit rollback() rather than by restructuring something that already works.

I am pointing this out because "not atomic" would send someone rewriting the commit structure, which is not the bug.

On the green. 19/19 passed with all four of these present, so the tests do not cover position assignment for a second entry, do not cover the NULL-list path, and do not exercise the reorder failure path. Whatever fixes the above should add those, and the position test in particular must fail against this commit.

Fix-forward card to follow, and this PR closes in favour of it.

@jaylfc

jaylfc commented Jul 28, 2026

Copy link
Copy Markdown
Owner Author

Superseding my 03:59 hold: no separate fix-forward card, @hognek fix-forward on THIS branch instead, same batch as the others. The fix is small and the rest of the slice is sound.

Required, both verified still present at head 26ed84c:

  1. lists_store.py:149: add_entry defaults position to 0 instead of calling _get_next_position, so every entry added without an explicit position lands at 0 and ordering is undefined for the natural call pattern. _get_next_position has zero call sites. Fix: if position is None, take the next position. Mandatory test: add 3 entries without positions, assert distinct ascending positions, and confirm it fails against the current head first. The existing 14 tests all pass explicit positions, which is exactly why they mask this.

  2. lists_store.py:255: _get_next_position binds list_id or '' on the None path, which always scans an empty set and returns 0. The column is NOT NULL so there are no NULL rows to worry about (correcting my own 03:59 wording); simplest fix is to make list_id required.

Also add the PR-body trailer from my earlier comment: Store-Unwired-Intentionally: ProjectListsStore, wiring lands in tsk-epm5m3.

Adjudicated bot findings so you do not chase ghosts: the cur.description-after-close warning is a false positive on CPython sqlite3 (move it inside the block as a style nit if you like); the nemotron SQL-injection claim is false (columns hardcoded, values parameterized); kilo's reorder atomicity claim is wrong as stated but the real hazard is an exception mid-loop leaving a half-applied reorder in the open transaction for a later unrelated commit to flush: try/except + rollback is the fix. Reorder payload validation vs the todo_store pattern can ride along or go to a follow-up.

@jaylfc

jaylfc commented Jul 28, 2026

Copy link
Copy Markdown
Owner Author

@hognek nudge: head is still 26ed84c, unchanged since my 15:16 fix-forward routing, and this PR is holding a throttle slot. The two required fixes are small and unchanged: lists_store.py:149 add_entry must call _get_next_position when position is None (it currently defaults every entry to 0, and _get_next_position has zero call sites), and lists_store.py:255 _get_next_position should take a required list_id rather than binding list_id or empty-string on the None path. Plus the mandatory test: add three entries without positions, assert distinct ascending positions, proven failing against the current head first.

For the record so it is not re-attempted the wrong way: a board card asking a free lane to fix-forward this branch cannot work (the harness builds a fresh worktree and its own branch), so I have closed that card. This one needs you or me.

@hognek

hognek commented Jul 28, 2026

Copy link
Copy Markdown
Contributor

Superseded by #2200

jaylfc pushed a commit that referenced this pull request Jul 28, 2026
- add_entry: replace hardcoded 0 default with await _get_next_position()
- _get_next_position: make list_id a required param, remove dead or '' binding
- New test: 3 entries without positions assert distinct ascending [0,1,2]
  (test fails against current head where all get position 0)

Closes #2183
@jaylfc

jaylfc commented Jul 28, 2026

Copy link
Copy Markdown
Owner Author

Superseded by #2200, now merged (squash of fix/lists-add-entry-position). #2200 carries both lists_store fixes plus the ids.py lst prefix; verified at code level with red-first test proofs before merge. Follow-ups from the review (store wiring + route-layer validation, reorder list scoping, position atomicity) are being carded on the board rather than reopened here.

@jaylfc jaylfc closed this Jul 28, 2026
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.

2 participants