Code review · P3 Task 2, the SQLite store and its discipline¶
review-code on autopilot/P3-t02, base 00a7059, 2026-10-30. The step is P1 Task 2. It adds:
spectio_server.store.schema: the 15 tables of 0.0.1, as SQLAlchemy Core metadata;- one Alembic revision,
0001_initial_store; spectio_server.store.db:default_db_dir(),open_db(),fs_type()andUnsafeDatabase;spectio_server.store.writer.Writer, the one writer;packages/spectio-server/tests/test_store.py.
The reviewers were code-reviewer and security-reviewer, independent and in parallel. Format: README.md.
| Round | Head | code-reviewer | security-reviewer |
|---|---|---|---|
| 1 | 2db4559 (the step) |
changes (0 blockers, 2 majors, 4 minors) | changes (0 blockers, 3 majors, 5 minors) |
| 2 | 36796dd (the round-1 fixes) |
changes (0 blockers, 1 major, 2 minors) | changes (0 blockers, 1 major, 3 minors) |
| 3 | c4a7e2f (the round-2 fixes) |
approve (no new finding) | changes (0 blockers, 1 major, 1 minor) |
| 4 | d140832 (the round-3 fixes) |
not run (the change is a security fix, reviewed by security-reviewer) |
approve (no new finding) |
Four rounds, not two: this broke the rule. The review-code skill (.claude/skills/review-code/SKILL.md) and docs/playbooks/autopilot.md set the step blocked with an inbox entry after two rounds. Rounds 3 and 4 ran instead, so that two security majors (SEC-P3T2-09 from round 2, SEC-P3T2-13 from round 3) were fixed inside the step rather than left open. Thomas has given no exception for this; it is recorded in inbox #75.
Test command, passing at af8b4bf (47 tests in this file, 91 in the workspace):
uv run pytest packages/spectio-server/tests/test_store.py -v
CODE step=P3-t02 review=approve b/M/m=0/3/6 security=approve b/M/m=0/5/9 rounds=4 simplify=changed -16/+9 tests=pass
code-simplifier ran on d140832 (commit af8b4bf, −16/+9 in db.py): the connect-hook factory _on_connect(pins) became a closure inside open_db. The tests pass after it.
Findings¶
The commit messages of the fix rounds name no finding. Where the round summary names a finding, its row says what it was and how it was fixed. For the other minors of a round, one row lists the changes of that round's commit that are not the fixes of its majors.
| Id | Severity | Finding | Status |
|---|---|---|---|
| CR-P3T2-01 | major | principals.kind allowed only user, service and agent. api §6 also names runner and system. |
fixed in 36796dd: the CHECK and the migration list all five kinds; created_by is nullable only so that the first principal, system, can exist (test_principal_kinds) |
| CR-P3T2-02 | major | Timestamps were not UTC both ways: a value read back from SQLite had no offset, and a naive value was accepted on write. | fixed in 36796dd: UTCDateTime converts an aware value to UTC, refuses a naive one, and reads back with tzinfo=UTC; the migration names the stored type, DateTime(timezone=True) (test_timestamps_are_utc) |
| SEC-P3T2-01 | major | The database files took the process umask, 0644 under umask 022. dev-env §15.3 req. 5 asks for 0600. | fixed in 36796dd: spectio.db is created with O_CREAT|O_EXCL|O_NOFOLLOW at 0600 and never chmod-ed; the WAL and SHM files inherit the mode; existing files must be regular files of the user with no group or other bits (test_database_files_private) |
| SEC-P3T2-02 | major | open_db trusted db_dir: a directory of another user, a 0755 directory or a symlinked spectio.db was accepted. |
fixed in 36796dd: db_dir must be a 0700 directory of the user, checked on the opened directory, with no fallback for an explicit directory; a symlinked database file is refused (test_open_db_refuses_unsafe_paths) |
| SEC-P3T2-03 | major | The file system type came from the longest prefix in /proc/self/mounts. With stacked mounts the first one won, and an autofs point that had not mounted yet looked local. |
fixed in 36796dd: the last mount in /proc/self/mountinfo whose device is the path's st_dev, else the longest mount point; autofs is refused, so a point that has not mounted fails closed (test_refuses_network_fs, 5 cases, one of them a stack; test_fs_type_falls_back_to_mount_point). The fs_type docstring says the first stat triggers an automount; stat(2) says AT_NO_AUTOMOUNT is implied since Linux 4.11, so it does not for the last component (open point on the Progress page) |
| SEC-P3T2-04 | minor | The network file system deny-list: an unreadable table was accepted, and the list may miss types. | partly fixed: fail closed on Linux when mountinfo is unreadable (36796dd) or names no mount for the path (c4a7e2f); autofs added (36796dd). Extending the deny-list beyond the plan's list goes to /apply-findings; which type names to add is [to be validated: type names] |
| CR-P3T2-03 to CR-P3T2-06 | minor | Round 1, code. | fixed in 36796dd. The commit's other changes: the writer thread, when it dies, fails the job it ran and every queued job, and refuses new ones (test_writer_death_fails_queued_jobs); a test proves the writer opens BEGIN IMMEDIATE (test_writer_begins_immediate); created is also true when the file has no Alembic revision (test_open_db_created_when_never_migrated); the engine URL is built with URL.create, so a path is never parsed as a URL (test_open_db_path_is_not_a_url); CHECK values must match [a-z_-]+ before they are spliced into SQL (test_check_values_are_safe); a test compares the migration with the metadata (test_migration_matches_schema) |
| SEC-P3T2-05 to SEC-P3T2-08 | minor | Round 1, security. | fixed in 36796dd, with the changes listed above; also: a $TMPDIR that is relative, or writable by others without the sticky bit, is refused with a warning and /tmp is used (test_default_db_dir_base); the squatter warning says "uid unknown" instead of failing when the path cannot be read |
| CR-P3T2-07 | major | Nothing checked that the migration's CHECK constraints match the metadata: Alembic's autogenerate compares CHECK constraints only when this is enabled, and then by name only (Alembic docs, "Detecting CHECK Constraints", read 2026-10-30), so the two could drift. | fixed in c4a7e2f: test_migration_matches_schema also compares, table by table, the stored CHECK constraints (inspect().get_check_constraints) with the declared ones |
| SEC-P3T2-09 | major | The directory could be swapped after the checks: a user who could write a parent (the owner of a sticky base, for one) could move db_dir away and put another directory and database in its place, and the next pooled connection would open it. |
fixed in c4a7e2f: every parent up to / must be owned by root or the user and not writable by group or others unless sticky (_check_parents; OpenSSH's rule in misc.c safe_path, read 2026-10-30, has no sticky exception); the device and inode of db_dir and spectio.db are pinned after the checks and compared on every new connection (test_open_db_checks_parents, test_swapped_database_refused_on_connect) |
| CR-P3T2-08 | minor | The interface differs from the plan's (mountinfo= for mounts=, the added preconditions of open_db), and nothing records it. |
fixed by the Progress page: Differences from the plan's interface; P1's text changes in the next /apply-findings pass (inbox #75) |
| CR-P3T2-09 | minor | Round 2, code. | fixed in c4a7e2f. The commit's other changes: a debug log line when no mount matches the device and the mount point decides; a warning only when a $TMPDIR that was set is refused (test_default_db_dir_without_tmpdir) |
| SEC-P3T2-10 to SEC-P3T2-12 | minor | Round 2, security. | fixed in c4a7e2f, with the changes listed above; also: an empty or unmatched mountinfo is refused on Linux (test_mountinfo_empty_refused_on_linux); a sticky base must be owned by root or the user (test_sticky_base_needs_a_trusted_owner); an unsafe /tmp is refused with a message that names --db-dir |
| SEC-P3T2-13 | major | A symlink in the middle of the path, inside a directory others can write, escaped the parent walk, which follows realpath. Flipping the link between connections opened another database. |
fixed in d140832: the parent is canonicalised once with realpath, only that path is used afterwards, the private-directory check runs on it again, and the pins are checked on the first raw connection too (test_symlink_inside_db_dir_is_resolved_once) |
| SEC-P3T2-14 | minor | In a user namespace without a root mapping, unmapped owners show as the overflow uid. A refusal message then named an owner that cannot be checked. | fixed in d140832: the overflow uid (/proc/sys/kernel/overflowuid, 65534 by default, kernel sysctl docs) is named as such in the message and never trusted (test_overflow_uid_is_named) |