feat(telemetry): a usage event records which project the reader was in (#4196, #3735)
CI & Build / Python lint (push) Successful in 3s
CI & Build / Plugin hooks (push) Successful in 13s
CI & Build / TypeScript typecheck (push) Successful in 54s
CI & Build / integration (push) Successful in 1m5s
CI & Build / Python tests (push) Failing after 1m15s
CI & Build / Build & push image (push) Skipped

`RetrievalLog` has carried `project_id` since it existed, so "this record
was SURFACED on project B" was always answerable. `note_usage_events`
had none, so "this record was OPENED on project B" was not — and the two
cannot be joined to recover it, because there is deliberately no session
identity server-side. NoteUsageEvent's own docstring rules that out.

That gap sat exactly on the question milestone 385 exists to answer. A
lesson's whole claim is that it reaches a session on a project it was not
written on, and step 8's acceptance is "retrieved on a different project
AND opened". Each half was answerable; the conjunction was not.

WHICH project, because the name is ambiguous and the wrong reading makes
the column useless: it is the project the READER was in, never the one
the record belongs to. The record's own project is already on the note;
copying it here would answer a question nobody asked while looking like
it answered this one.

The surfacing half is free — every arm already holds the scope it just
searched, so auto_inject, lesson_slot, the write-path arms and
enter_project now record it. process_skill_sync does not and should not:
it installs every Process the operator can reach, which is not a
project-scoped question, so a project there would be a fiction.

The pull half needs the caller, since a getter knows only what it was
handed. The five single-record getters take `project_id: int = 0` and
pass it through, following the convention `search` and `create_*`
already set. Null stays an ordinary answer meaning "not reported" — a
pull with no project is still a pull and still counts toward dead
weight; it simply cannot speak to transfer. The four REST detail views
report none for now: a human opening a record in a browser is a
different event from an agent recalling one, and #2245 left that
asymmetry deliberately undecided.

Guarded the way #2245 and #2476 taught: by source inspection, because a
parameter that was never threaded through changes no return value and
shows up only as a column that is mysteriously always null. Three
guards — the signature, the pass-through, and the arms — plus the
can-fail test rule 167 asks for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01821k5B3Ysecp9fNYs92Kuy
This commit is contained in:
2026-09-19 23:26:55 -04:00
co-authored by Claude Opus 5
parent 26a757ecfe
commit a4aae974a2
11 changed files with 286 additions and 14 deletions
@@ -0,0 +1,62 @@
"""note_usage_events records WHICH project the reader was in (#4196, #3735)
Revision ID: 0105
Revises: 0104
Create Date: 2026-09-20
Milestone 385. `RetrievalLog` has carried `project_id` from the start, so
"this record was SURFACED on project B" has always been answerable. The usage
table never had one, so "this record was OPENED on project B" never was — and
the two cannot be joined to recover it, because there is no session identity
server-side (see the NoteUsageEvent docstring, which rules that out on
purpose).
That gap sits exactly on the question milestone 385 exists to answer. A
lesson's whole claim is that it reaches a session on a project it was not
written on; step 8's acceptance is "retrieved on a different project, at the
moment it applies, AND opened". Surfacing cross-project: answerable. Opening:
answerable. Both at once: not, until this column.
WHICH PROJECT THIS IS, because the name is ambiguous and the wrong reading
makes the column useless. It is the project the READER was working in at the
moment of the event — NOT the project the record belongs to. The record's own
project is already on `notes.project_id`; copying it here would answer a
question nobody asked and quietly look like it answered this one.
NULLABLE, and not backfilled. Every existing row was written by a reader who
was somewhere, but naming it would be inventing a fact. Null means "not
reported", which is a different answer from a project id that might be wrong —
the same reasoning 0104 applied to its calibration stamps.
NULL IS ALSO THE ONGOING DEGRADED CASE, not only a historical one. A surfacing
arm always knows the project it searched; a getter only knows what its caller
passed. A pull with no project is still a pull and still counts toward "is this
dead weight" — it simply cannot answer "was it opened away from home".
THE INDEX carries `note_id` first, matching `ix_note_usage_note_event`: every
readout starts from a set of note ids and narrows, never from a project.
"""
import sqlalchemy as sa
from alembic import op
revision = "0105"
down_revision = "0104"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"note_usage_events",
sa.Column("project_id", sa.Integer(), nullable=True),
)
op.create_index(
"ix_note_usage_note_project",
"note_usage_events",
["note_id", "project_id"],
)
def downgrade() -> None:
op.drop_index("ix_note_usage_note_project", table_name="note_usage_events")
op.drop_column("note_usage_events", "project_id")