DUNIN7 · LOOMWORKS · RECORD
record.dunin7.com
Status Current
Path inspection-briefs/loomworks-list-loading-cr-1-step0perstep-report-v0_1.md

Loomworks list-loading — CR-1 Step-0-per-step verification report — v0.1

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.


Headline — what the CR must absorb before build

Most of the CR's per-endpoint strings are CONFIRMED. Five findings materially change the build and four of the seven open decisions:

  1. Codec is generic over column NAME (CR's stated worry resolved) but NOT over column TYPE or sort DIRECTION. It hardcodes 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.
  2. D-3 has a better resolution than the CR's fallback. Every 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).
  3. Step 7 (shapings) is NOT the "clean timestamp sort" the CR assumes. Live sort is 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.
  4. D-5 (assertions COALESCE) — no supporting index exists. Confirmed verbatim sort; no functional index on the COALESCE(...) expression → a keyset WHERE on it computes per-row. The "may need a computed column → migration → re-scope" risk is real, not hypothetical.
  5. D-7 is wrong on the facts: there are TWO frontend callers, not one. useConversation.ts (Axis 11 CenterPane, uses before) and ChatView.tsx (home chat, no before). The "only caller" premise must be corrected before the breaking beforecursor swap is sequenced.

Pure-read guard: all five Group E endpoints CONFIRMED pure-read — the Memory-read-boundary classification holds.


CONTRACT-WIDE — the Group A cursor codec

Module: src/loomworks/api/routers/dashboard.py, lines 61–115. Functions:

| 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.


PER-ENDPOINT verification

Step 1 — GET /engagements (D-1)

| Sub-claim | Verdict | Live string | |---|---|---| | Live sort e.id | CONFIRMED | engagement_list.py:88 & :106ORDER BY e.id | | engagements table has created_at | CONFIRMED | migrations/versions/0001_phase_1_substrate_events.py:24-28Column("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.

Step 2 — GET /engagements/{id}/considerations

| Sub-claim | Verdict | Live string | |---|---|---| | Sort last_updated_at DESC | CONFIRMED | engagement/considerations.py:494ORDER 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.

Step 3 — GET /engagements/{id}/compositions

| Sub-claim | Verdict | Live string | |---|---|---| | Sort created_at DESC | CONFIRMED | compositions.py:382sql += " ORDER BY created_at DESC" | | state filter | CONFIRMED | compositions.py:358state_filter: str | None = Query(None, alias="state") | | id column in render_compositions_view | CONFIRMED = object_id | migrations/versions/0056_…render_compositions_view…py:97Column("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.

Step 4 — 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:111ORDER BY v.engagement_version_at_production ASC, v.object_id ASC | | Sort key is an int, not timestamp | CONFIRMED | engagement/types.py:1218engagement_version_at_production: int | | Filters present | CONFIRMED (five params) | shape_events.py:400-404declared_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.

Step 5 — GET /engagements/{id}/assertion-groups (D-3)

| Sub-claim | Verdict | Live string | |---|---|---| | Live order | CONFIRMED unspecified | assertion_groups.py:158-160groups = [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.

Step 6 — 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-70nullable=True. Production insert always sets it: manifestation.py:554derived_at=now. In-memory type types.py:1811derived_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.)*

Step 7 — GET /engagements/{id}/shapings

| Sub-claim | Verdict | Live string | |---|---|---| | Sort created_at DESC | DIVERGES | engagement/shaping_process.py:402ORDER 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.

Step 8 — GET /engagements/{id}/assertions (D-5)

| Sub-claim | Verdict | Live string | |---|---|---| | Exact sort expression | CONFIRMED VERBATIM | engagement/assertions.py:682-686ORDER 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:2915total_count: int; populated assertions.py:451total_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.

Step 9 — 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:119ORDER BY created_at ASC, object_id ASC | | total_count already present | CONFIRMED | schemas.py:5831total_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.

Step 10 — 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:111stmt.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:163return ConversationHistoryResponse(turns=turns, has_more=has_more) |

D-7 premise is factually wrong — TWO callers, not one (searched OL repo /Users/dunin7/loomworks):

  1. 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.
  2. 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 beforecursor 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.


PURE-READ GUARD — Step 0e re-confirm (five Group E endpoints)

| 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-162state_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.


Open-decision impact summary

| 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. |


Recommendation (for the Operator, not acted on)

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:

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.