Document: loomworks-list-loading-cr-1-step0perstep-report-v0_1
Version: v0.1
Status: complete — READ-ONLY verification pass
Date: 2026-06-27
By: Claude Code (CC) on DUNIN7-M4
Verifies: loomworks-list-loading-cr-1-engine-cursor-contract-v0_1 (CR draft, in Downloads — not yet staged)
Baseline: loomworks-list-loading-step-0-report-v0_1 (record inspection-briefs/)
Repos read: loomworks-engine (handlers + query modules + migrations + models), loomworks (OL frontend — caller search)
> Read-only. No code changed, no migrations, no commits, no pushes. Every claim below is verified against live source with file:line. Verdicts: CONFIRMED / DIVERGES (with the live string) / NOT-FOUND.
Most of the CR's per-endpoint strings are CONFIRMED. Five findings materially change the build and four of the seven open decisions:
datetime (isoformat/fromisoformat) and a DESC < boundary. This forces a codec generalisation for D-2 (shape-events int version), D-4 (null derived_at can't encode), and Step 9 (renders ASC needs >). It is also module-private inside dashboard.py. This is the central contract finding.AssertionGroup in the snapshot carries its own created_at + id (via MemoryObject base). So a real composite (group.created_at, group.id) keyset is available — not the degenerate id-only fallback (b). But the endpoint materialises the entire engagement snapshot in Python, so cursor-slicing doesn't reduce the wholesale cost (perf note).ORDER BY (payload ->> 'created_at') DESC — a JSON-payload text expression with no id tiebreak — sharing D-5's expression-index problem, not the simplest Group D upgrade.COALESCE(...) expression → a keyset WHERE on it computes per-row. The "may need a computed column → migration → re-scope" risk is real, not hypothetical.useConversation.ts (Axis 11 CenterPane, uses before) and ChatView.tsx (home chat, no before). The "only caller" premise must be corrected before the breaking before→cursor swap is sequenced.Pure-read guard: all five Group E endpoints CONFIRMED pure-read — the Memory-read-boundary classification holds.
Module: src/loomworks/api/routers/dashboard.py, lines 61–115.
Functions:
_encode_cursor(ts: datetime, item_id: str) -> str (61) — json.dumps({"ts": ts.isoformat(), "id": str(item_id)}) → base64.urlsafe_b64encode (65–69)_decode_cursor(cursor: str) -> tuple[datetime, str] (72) — datetime.fromisoformat(data["ts"]) (78); 400 on any failure_cursor_clause(alias_ts: str, alias_id: str, cursor) -> str (88) — emits AND ({alias_ts}, {alias_id}) < (:cursor_ts, :cursor_id) (95)_cursor_params(cursor) -> dict (98)_next_cursor(items, *, limit, ts_attr, id_attr) -> str|None (106) — _encode_cursor(getattr(tail, ts_attr), getattr(tail, id_attr)) (115)
| Sub-claim | Verdict | Live string / note |
|---|---|---|
| Codec serialises the passed-in sort column, not a hardcoded created_at | CONFIRMED (column-name generic) | _cursor_clause takes alias_ts/alias_id as params (used live with started_at/item_id, surfaced_at/item_id, completed_at/artifact_id); _next_cursor takes ts_attr/id_attr. No created_at literal anywhere. The CR's primary worry does not bite. |
| Codec is reusable as-is across all 10 endpoints | DIVERGES — three real gaps | See below. |
Codec divergence 1 — type-bound to datetime. _encode_cursor types ts: datetime and calls ts.isoformat(); _decode_cursor calls datetime.fromisoformat(data["ts"]). The cursor value MUST be a datetime. → D-2 (shape-events sorts on an int version) cannot use this codec unencoded; → D-4 (a null derived_at is not a datetime) cannot be encoded at all. Reusing the codec across D-2/D-4 requires generalising it to carry a non-datetime / nullable sort key (e.g. store the raw JSON value, or a typed/null-rank-tagged key). This is the codec generalisation the CR's §5 standing-flag anticipated — now confirmed mandatory, not optional.
Codec divergence 2 — direction-bound to DESC. _cursor_clause hardcodes < (strict-less-than). Step 9 (renders, ASC oldest-first) needs >. The clause helper must gain a direction parameter (or a > sibling) — confirming the CR's D-6 note that "the boundary comparison is > not <," but the shared codec does not support it today.
Codec divergence 3 — module-private location. All five helpers are _-prefixed inside api/routers/dashboard.py. Reuse across considerations.py, compositions.py, shape_events.py, assertion_groups.py, manifestations.py, shapings.py, assertions.py, renders.py, and orchestration/routers/conversation_history.py means either importing private symbols cross-router or lifting the codec to a shared module (e.g. api/pagination.py). Recommend the lift; name it in the CR as a step-0 structural task.
GET /engagements (D-1)
| Sub-claim | Verdict | Live string |
|---|---|---|
| Live sort e.id | CONFIRMED | engagement_list.py:88 & :106 — ORDER BY e.id |
| engagements table has created_at | CONFIRMED | migrations/versions/0001_phase_1_substrate_events.py:24-28 — Column("created_at", TIMESTAMP(timezone=True), nullable=False, server_default=now()) |
| Response model | CONFIRMED | EngagementListResponse (engagement_list.py:51) |
D-1 resolves: engagements.created_at exists and is non-null (server default now()). The CR's lean (a) — sort by engagement created_at, newest-first, id tiebreak — is clean. No fallback to membership timestamp needed.
GET /engagements/{id}/considerations
| Sub-claim | Verdict | Live string |
|---|---|---|
| Sort last_updated_at DESC | CONFIRMED | engagement/considerations.py:494 — ORDER BY last_updated_at DESC |
| Filter triggering_reason | CONFIRMED | considerations.py:330 |
| Filter current_stage | CONFIRMED | considerations.py:331 |
| Filter terminal_state | CONFIRMED | considerations.py:332 |
| Filter since | CONFIRMED | considerations.py:333 (datetime | None) |
| Response model / total_count today | CONFIRMED / absent | ConsiderationListResponse (schemas.py:4466-4472) — only considerations, no total_count |
Clean. Composite (last_updated_at, object_id), all four filters preserved. No divergence.
GET /engagements/{id}/compositions
| Sub-claim | Verdict | Live string |
|---|---|---|
| Sort created_at DESC | CONFIRMED | compositions.py:382 — sql += " ORDER BY created_at DESC" |
| state filter | CONFIRMED | compositions.py:358 — state_filter: str | None = Query(None, alias="state") |
| id column in render_compositions_view | CONFIRMED = object_id | migrations/versions/0056_…render_compositions_view…py:97 — Column("object_id", UUID, primary_key=True) |
| id column present in current ORDER BY tail | DIVERGES | Live ORDER BY is created_at DESC only — no id tiebreak today. The keyset upgrade must add object_id explicitly: ORDER BY created_at DESC, object_id DESC. |
| Response model / total_count today | CONFIRMED / absent | CompositionListResponse field items (schemas.py:7615-7621), no total_count |
CR step 3's bracketed instruction ("confirm the id column … or add created_at, <id> explicitly") resolves to the add branch: id is object_id, currently absent from the sort tail.
GET /engagements/{id}/shape-events (D-2)
| Sub-claim | Verdict | Live string |
|---|---|---|
| Sort engagement_version_at_production ASC, object_id ASC | CONFIRMED | engagement/shape_events_view_query.py:111 — ORDER BY v.engagement_version_at_production ASC, v.object_id ASC |
| Sort key is an int, not timestamp | CONFIRMED | engagement/types.py:1218 — engagement_version_at_production: int |
| Filters present | CONFIRMED (five params) | shape_events.py:400-404 — declared_shape_type_id, state, engagement_version_from (ge=0), engagement_version_to (ge=0), self_consumer. (CR counted "version range" as one filter; it is two params.) |
| Response model | CONFIRMED | ShapeEventListResponse (shape_events.py:389) |
D-2 confirmed real and now sharper: the sort is an int version and the sort is ASC. So shape-events hits both codec divergences — non-datetime key (div 1) and ascending direction (div 2). Lean (a) "composite (engagement_version_at_production, object_id)" stands, but it cannot ride the current codec unmodified. This is the single endpoint that most stresses the "(sort_key, id)" generalisation.
GET /engagements/{id}/assertion-groups (D-3)
| Sub-claim | Verdict | Live string |
|---|---|---|
| Live order | CONFIRMED unspecified | assertion_groups.py:158-160 — groups = [obj for obj in snapshot.values() if isinstance(obj, AssertionGroup)]; dict-insertion order, no sort |
| Per-group timestamp available? | DIVERGES from CR framing — better option exists | The snapshot is dict[UUID, MemoryObject] (memory/queries.py). AssertionGroup subclasses MemoryObject, which carries created_at: datetime and id: UUID (memory/base.py:114,118). So the group's own creation timestamp is natively present on every snapshot object — a real composite (group.created_at, group.id) keyset is feasible. The CR's option (a) as worded ("most-recent constituent event's created_at") is NOT cheap (would walk members), but option (a′) — the group object's own created_at — is free. |
| Response model | CONFIRMED | AssertionGroupListResponse (schemas.py:4619-4625) |
D-3 resolves to a new lean (a′): sort by (group.created_at, group.id) (newest group first), not the degenerate id-only fallback (b). The CR should adopt (a′).
Perf note (surface): the handler builds the entire engagement snapshot via state_of_engagement_at_version(version=1012) then filters in Python. Adding an API-layer cursor does not reduce** that cost — the wholesale snapshot is materialised regardless, and the cursor merely slices the resulting list. True keyset efficiency would require pushing sort+limit into SQL, which the snapshot architecture does not do. CR-1's pure-read posture is intact, but the pagination here is cosmetic-over-a-full-load, not a cost reduction. Flag for the Operator — it may be acceptable (assertion-groups are not the highest-volume list), but it should be a knowing acceptance.
GET /engagements/{id}/manifestations (D-4)
| Sub-claim | Verdict | Live string |
|---|---|---|
| Sort derived_at DESC NULLS LAST | CONFIRMED | engagement/manifestation.py:613 |
| Pagination limit (def 50, ≤500) + offset | CONFIRMED | manifestations.py:455-456 |
| derived_at nullable? | CONFIRMED nullable in schema; effectively non-null in production | Schema: migrations/…/0026_…py:67-70 — nullable=True. Production insert always sets it: manifestation.py:554 — derived_at=now. In-memory type types.py:1811 — derived_at: datetime (non-null). |
| Response model / total_count | CONFIRMED / absent | ManifestationListResponse (schemas.py:6115-6120), no total_count |
D-4 likely collapses to a simple (derived_at, object_id) composite — production never writes null. Residual risk: the column permits null, so legacy/test rows could carry it. Source can't prove zero null rows; the CR should verify against playground_dev at the Step-6 build (a SELECT count() WHERE derived_at IS NULL). If zero, drop the null-rank-flag machinery; if non-zero, keep it. (Note: a null sort key also can't be carried by the datetime-bound codec — div 1 again — reinforcing the verify-then-decide order.)*
GET /engagements/{id}/shapings
| Sub-claim | Verdict | Live string |
|---|---|---|
| Sort created_at DESC | DIVERGES | engagement/shaping_process.py:402 — ORDER BY (payload ->> 'created_at') DESC. The sort key is a JSON-payload text extraction, not a table column, and is not cast to timestamptz (lexical text ordering), and has no id tiebreak. |
| Pagination limit (def 50, ≤500) + offset | CONFIRMED | shapings.py:321-322 |
| Response model / total_count | CONFIRMED / absent | ShapingListResponse (schemas.py:6417-6422), no total_count |
Significant divergence from the CR's "clean timestamp sort — the simplest Group D upgrade." Shapings is not simple: (a) the sort key is (payload ->> 'created_at'), a JSON expression sharing D-5's index/keyset-cost concern; (b) it is text-ordered, not timestamptz; (c) it has no id tiebreak today. Step 7 should be reclassified alongside Step 8 (expression-sort steps), not as the easy one. Recommend a new open decision D-8 (shapings payload-sort cursor): encode the same (payload ->> 'created_at') value into the cursor and apply the identical expression (+ added object_id tiebreak) in the boundary, with the same indexability flag as D-5.
GET /engagements/{id}/assertions (D-5)
| Sub-claim | Verdict | Live string |
|---|---|---|
| Exact sort expression | CONFIRMED VERBATIM | engagement/assertions.py:682-686 — ORDER BY COALESCE((payload->>'created_at')::timestamptz, last_updated_at) DESC, object_id ASC |
| Functional index on the COALESCE expression | NOT-FOUND | No CREATE INDEX / op.create_index on the expression anywhere in migrations/versions/. A keyset WHERE re-applying COALESCE(...) computes per-row, unindexed — potentially expensive on large assertion sets. |
| total_count already present | CONFIRMED | schemas.py:2915 — total_count: int; populated assertions.py:451 — total_count=total |
| Pagination limit (def 50, ≤200) + offset | CONFIRMED | assertions.py:430-431 |
D-5 risk confirmed real. No supporting index exists; the mixed direction (DESC, ASC) compounds it. If the Operator wants indexed keyset performance, that needs an expression/computed-column index = migration = outside CR-1's pure-read posture → re-scope. If unindexed per-row evaluation is acceptable at current volume, CR-1 stays pure-read with a noted perf ceiling. This is an Operator call, surfaced.
GET /engagements/{id}/renders (D-6)
| Sub-claim | Verdict | Live string |
|---|---|---|
| Sort created_at ASC, object_id ASC (oldest-first) | CONFIRMED | engagement/render_events_view_query.py:119 — ORDER BY created_at ASC, object_id ASC |
| total_count already present | CONFIRMED | schemas.py:5831 — total_count: int; populated renders.py:678 |
| Pagination limit (def 50, ≤500) + offset | CONFIRMED | renders.py:636-637 |
| Response model | CONFIRMED | RenderEventListResponse (renders.py:619) |
D-6 lean (preserve ASC) confirmed sound — the ASC sort is deliberate (chronological render sequence, has its own id tiebreak already). The only consequence: the shared codec's < boundary must become > for this endpoint (codec div 2). Direction-agnostic cursor needed.
GET /operator/conversation-history (D-7)
| Sub-claim | Verdict | Live string |
|---|---|---|
| before: Optional[datetime] = Query(default=None) | CONFIRMED | orchestration/routers/conversation_history.py:70 |
| Boundary WHERE created_at < before (strict, ts-only) | CONFIRMED | conversation_history.py:111 — stmt.where(ConverseTurnRow.created_at < before) |
| ORDER BY created_at DESC, id DESC | CONFIRMED | conversation_history.py:115-117 |
| Response ConversationHistoryResponse(turns, has_more), no total_count | CONFIRMED | conversation_history.py:163 — return ConversationHistoryResponse(turns=turns, has_more=has_more) |
D-7 premise is factually wrong — TWO callers, not one (searched OL repo /Users/dunin7/loomworks):
src/app/operator/engagement/[engagement_address]/useConversation.ts:138,169 — Axis 11 CenterPane. Initial load limit=50 (no before); pagination passes before (:169). This is the before-using caller.src/components/chat/ChatView.tsx:162-163 — home chat surface. Initial load only, project_id&limit=50, no before.
Both route through fetchConversationHistory() / converse.ts (which supports before at :53).
Consequence for D-7: the breaking before→cursor swap (lean a) touches two call sites in CR-2, though only useConversation.ts actually passes before. ChatView.tsx would be unaffected by removing before (it never sends it) but IS a second consumer of the endpoint's response shape — so adding total_count/next_cursor is safe-additive for it. The CR's "the ONLY caller is the Axis 11 frontend" sentence must be corrected; the decision itself (lean a, replace before, coordinate in CR-2) survives the correction but its blast radius is two files.
| Endpoint | Verdict | Evidence |
|---|---|---|
| GET /engagements | PURE-READ CONFIRMED | engagement_list.py:79-132 — SELECT-only (engagements ⨝ memberships; memory_events deserialise read); no session.add/commit/INSERT/UPDATE |
| GET /…/assertion-groups | PURE-READ CONFIRMED | assertion_groups.py:153-162 — state_of_engagement_at_version read-only snapshot; Python filter; no writes |
| GET /…/considerations | PURE-READ CONFIRMED | considerations.py:338-347 — read-only list_considerations + serialise; no writes |
| GET /…/shape-events | PURE-READ CONFIRMED | shape_events.py:416-434 — read-only list_shape_events + title resolve; no writes |
| GET /…/compositions | PURE-READ CONFIRMED | compositions.py:367-388 — single session.execute(text(SELECT…)); no INSERT/UPDATE |
No FORAY/provenance writes on any GET path. (Every MemoryObject carries a provenance field that is read and returned — reading provenance is not touching/mutating a thread.) The Axis-11 Memory-read-boundary classification holds for all ten endpoints. CR-1's pure-read posture is intact.
| D | CR's lean | Verification verdict |
|---|---|---|
| D-1 | engagement created_at, newest-first | CONFIRMED viable — column exists, non-null. Adopt lean (a). |
| D-2 | composite (version, id), generalise cursor to (sort_key,id) | CONFIRMED needed — int sort + ASC; requires codec type+direction generalisation. |
| D-3 | per-group latest-event ts if cheap, else id-only | REVISE — group's own created_at is free (option a′); use (group.created_at, group.id). Plus snapshot-materialisation perf note. |
| D-4 | null-rank flag; collapses if non-null | Lean collapse likely — production always sets derived_at; verify zero null rows on playground_dev, then drop the flag. |
| D-5 | encode coalesced value; surface if expensive | CONFIRMED expensive risk — no functional index exists; migration-or-accept Operator call. |
| D-6 | preserve ASC | CONFIRMED sound — ASC deliberate; needs > codec direction (div 2). |
| D-7 | replace before with cursor in CR-2 | Premise corrected — two callers, not one; decision survives, blast radius is two OL files. |
| D-8 (new) | — | Shapings sorts on (payload->>'created_at') (text, no id tiebreak) — expression-sort like D-5, not a clean column. Add as a decision. |
CR-1 v0.1 is sound in posture (pure-read holds, no endpoint writes) and mostly accurate in strings, but three drafting facts need correction before approval:
created_at; D-7's "only caller" is a two-caller correction.
Halt. No edits, no CR redraft, no commit. This report dropped in Downloads; canonical copy staged to loomworks-record/inspection-briefs/ — staged, not committed, awaiting Operator authorisation.
DUNIN7 — Done In Seven LLC — Miami, Florida Loomworks list-loading — CR-1 Step-0-per-step verification report — v0.1 — 2026-06-27 Read-only verification. Ten endpoints checked live; codec + four decisions revised; pure-read classification confirmed intact.