DUNIN7 · LOOMWORKS · RECORD
record.dunin7.com
Status Current
Path inspection-briefs/loomworks-graph-stage-2-step-0-inspection-report-v0_1.md

Loomworks — Graph Stage 2 (Forward Walk / Dependents) — Step-0 Inspection Report — v0.1

Version. 0.1 Date. 2026-07-28 Status. Inspection report. Executes loomworks-graph-stage-2-step-0-inspection-brief-v0_1 against the live loomworks-engine tree and the dev database. Read-only throughout: no file modified in any code repo, no builds run, no code executed, every database statement a SELECT or EXPLAIN. Executor. Claude Code on DUNIN7-M4. Companion documents. loomworks-graph-stage-2-step-0-inspection-brief-v0_1 (the brief), loomworks-graph-stage-2-cr-scoping-note-v0_1 (the scoping note, items 2a and 2b).

File references are relative to /Users/dunin7/loomworks-engine. Line numbers are as of the HEAD in A.1.


Section A — Baseline

A.1 — Branch, HEAD, working tree

A.2 — Alembic

Single head: 0101.

A.3 — Suite baseline — CARRIED, NOT MEASURED

3408 passed / 68 skipped / 1 failed, from the post-merge run on 355fe7a during the CR-2026-154 build (2026-07-28). The suite was not re-run in this session — the brief's posture forbids builds and code execution. Treat these figures as of that run.

The one failure is tests/test_stele_router_mount.py::test_stele_router_mounts_and_begin_resolves_end_to_end, waived per its own front note and unrelated to any Stage 2 surface. The scoping note §4 carries this forward correctly.

A.4 — Stage 1 landed, and does not gate Stage 2

ab94716 is an ancestor of main; 355fe7a carries tag graph-hygiene-v0_1. Verified rather than assumed.

Independence confirmed structurally, which is what the scoping note §1 asked for. Stage 1 wrote to Relationship objects — a distinct object_type projected into current_memory_objects. Stage 2's field lives on shape_events_view, a separate projection table, written by a separate projector path. The two stages share no write path and no table. Nothing in Stage 1 needs to be understood to build Stage 2.


Section B — Item 2a: the field and its home

B.5 — Field name and containing table: both confirmed

Definition: migrations/versions/0018_phase_9_shape_events_view_and_shaping_jobs.py:82. Model: src/loomworks/engagement/types.py:1255.

Note the name: shape_events_view is a projector-maintained table, not a SQL view (migration 0018 header: "a projector-maintained shape-events-specific projection table"). An index on it is an ordinary table index — no materialized-view refresh semantics to reason about.

B.6 — SQL type, nullability, element structure


{
    "id": "11111111-1111-4111-8111-000000000001",
    "version": 5
}

Exactly two keys across the whole corpus — a SELECT count(DISTINCT jsonb_object_keys(...)) over all 436 refs returns 2 (id and version). No element carries anything else. This is the flat, uniform shape an id-only containment predicate needs.

B.7 — Writers

Four production writers, all constructing the field the same way:

| Site | Role | |---|---| | src/loomworks/agents/shaping.py:712 | The shaping agent — the main production path | | src/loomworks/engagement/shaping_skill.py:405 | The shaping skill | | src/loomworks/engagement/shape_confirmation.py:218 | Carries prior.selected_memory_refs forward unchanged through confirmation | | src/loomworks/credit/proposal_applier.py:372 | Reconciliation specialist — writes a single-element list [MemoryRef(id=proposal.id, version=proposal.version)], self_consumer=True |

Plus the projector (src/loomworks/memory/projector.py:191, 221, 240, 274) which writes the column from the event payload, and two read-side serializers in src/loomworks/api/routers/shape_events.py.

One finding worth carrying to the draft. The scoping note describes the field as holding "version-pinned MemoryRefs the shaping agent selected." The proposal_applier writer is a second, non-shaping producer — the Accounting engagement's reconciliation path, writing a ref to a proposal, not an assertion. It does not break 2a, but it means "shape events select assertions" is not universally true, and a dependents query must not assume every ref is an assertion. Both the shaping paths seed the list with manifestation.seed_ref (shaping.py:672, shaping_skill.py:356) before appending assertion refs.

B.8 — Version-pinning confirmed in practice

All 436 refs are version-pinned. Zero are current-pointing.


SELECT count(*) AS total_refs,
       count(*) FILTER (WHERE r ? 'version' AND r->>'version' IS NOT NULL) AS version_pinned,
       count(*) FILTER (WHERE NOT (r ? 'version') OR r->>'version' IS NULL) AS current_pointing
FROM shape_events_view s CROSS JOIN LATERAL jsonb_array_elements(s.selected_memory_refs) r;
-- 436 | 436 | 0

R-C2 holds in live data. This matters directly for the scoping note's version-matching decision (§2): because every ref is pinned, an id-only match genuinely is an "any-version" match, and the matched version is always available to surface per result. The recommended semantics are implementable exactly as written.


Section C — Index state

C.9 — Every index on shape_events_view

| Index | Type | Columns | |---|---|---| | shape_events_view_pkey | btree (PK) | object_id | | ix_shape_events_view_engagement_state | btree | engagement_id, state | | ix_shape_events_view_engagement_type | btree | engagement_id, declared_shape_type_object_id | | ix_shape_events_view_produced_content | gin | produced_shape_content | | ix_shape_events_view_self_consumer | btree | engagement_id, self_consumer | | uq_shape_events_view_display_number | btree (unique, partial) | engagement_id, declared_shape_type_object_id, display_number |

C.10 — No GIN on selected_memory_refs; a closer precedent than the scoping note names

There is no index of any kind on selected_memory_refs. The migration is needed, as the scoping note anticipated.

A closer precedent exists than the one the scoping note cites. The note names Phase 10's render_events_view.render_content (migrations/versions/0022_phase_10_render_events_view_and_render_jobs.py:135–141). That is real, but the nearest precedent is on the same table, from the same migration that created it:


# GIN on produced_shape_content supports R-C13.1-style projection
# queries that filter by content-shape.
op.create_index(
    "ix_shape_events_view_produced_content",
    "shape_events_view",
    ["produced_shape_content"],
    postgresql_using="gin",
)

migrations/versions/0018_phase_9_shape_events_view_and_shaping_jobs.py:118–125

So the new index is not a new pattern on this table; it is a second GIN index on a second JSONB column of a table that already has one, with a naming convention (ix_shape_events_view_<column>) already set. Suggested name by that convention: ix_shape_events_view_selected_refs.

C.11 — Query plan today: sequential scan

Against a real assertion that is genuinely selected:


EXPLAIN (ANALYZE, COSTS OFF, TIMING OFF, SUMMARY OFF)
SELECT object_id, engagement_id FROM shape_events_view
WHERE selected_memory_refs @> '[{"id":"07e29096-5abb-49c6-925d-ef7449c33e2e"}]'::jsonb;

 Seq Scan on shape_events_view (actual rows=2 loops=1)
   Filter: (selected_memory_refs @> '[{"id": "..."}]'::jsonb)
   Rows Removed by Filter: 31

Correct results, sequential scan. At 33 rows the scan is trivially fast, so the index buys nothing measurable in dev today — its value is entirely about growth. The draft should size it that way and avoid implying a present-day performance fix; the honest claim is "prevents a scan that grows linearly with shape events per engagement," not "makes the query fast."

C.12 — Id-only containment is semantically correct

Confirmed by construction and by result. @> '[{"id": …}]' matches an array element that contains that key/value pair regardless of its version — which is exactly the any-version semantics the scoping note recommends. The query above returned the 2 shape events selecting that assertion, across differing pinned versions.

Default GIN (jsonb_ops) supports @>, and is what both existing GIN indexes on these projection tables use. No jsonb_path_ops variant, no expression index, and no computed or generated column is needed — the scoping note flagged that as a possibility (§2) and it does not arise. The flat two-key element shape (B.6) is what makes this clean.


Section D — Item 2b: the attach point

D.13 — The supersede and retract routes

The scoping note is right not to assume "supersede" is its own terminal state. It is not.

| Concept | Route | Function | Response model | |---|---|---|---| | Supersede | POST /engagements/{eid}/assertions/{aid}/revise | revise_assertion_route, api/routers/assertions.py:404 | AssertionResponse | | Retract | POST /engagements/{eid}/assertions/{aid}/retract | retract_assertion_route, api/routers/assertions.py:434 | AssertionResponse |

Supersession is revise. Per the route's own description: "produces a new held version with new content … The prior version stays in the assertion's history; the revision becomes the current version, held and awaiting commit." There is no separate supersede terminal state for assertions. (amended_superseded exists as a state, but on shape events, not assertions — engagement/types.py:1240–1247.)

Retract moves the assertion to retracted, requires a rationale, preserves history.

D.14 — Both response models are shared — this is 2b's real constraint

AssertionResponse backs six routes:

| Method | Route | | |---|---|---| | POST | .../assertions/{aid}/commit | | | POST | .../assertions/{aid}/discard | | | POST | .../assertions/{aid}/revise | 2b target | | POST | .../assertions/{aid}/retract | 2b target | | POST | .../assertions/{aid}/redirect | | | GET | .../assertions/{aid} | |

Adding a dependents summary field directly to AssertionResponse would put it on all six — including commit, discard, redirect, and the plain GET, where it is meaningless and would have to be computed or stubbed on every call.

D.15 — The precedent applies directly, and it is one stage old

CR-2026-154 item 1c hit this exact question and solved it. It needed to add alternative_ids to the add-assertion response, found AssertionResponse shared across many routes, and introduced a subclass:


class AddAssertionResponse(AssertionResponse):
    """A superset of AssertionResponse rather than a new field on it, because
    that model is shared by every assertion-returning route and
    alternative_ids is meaningless on most of them."""
    alternative_ids: list[UUID] = Field(default_factory=list, ...)

src/loomworks/api/schemas.py, with the route's response_model re-pointed at the subclass (api/routers/assertions.py:186).

The pattern is purely additive: callers get the previous body plus one field. Recommendation for the draft: follow it. A ReviseAssertionResponse / RetractAssertionResponse subclass pair — or one shared subclass carrying the dependents summary, used by both — keeps the field off the four routes that do not want it. Whether one subclass or two is a draft-level call; the precedent settles the shape, not the count.


Section E — The cross-engagement question

This is the report's most consequential finding, and it corrects the scoping note.

E.16 — selected_memory_refs DOES cross engagement boundaries — in every shape event

The scoping note §2 asks step-0 to "confirm selected_memory_refs never crosses engagement boundaries." It cannot be confirmed. It crosses, systematically:


SELECT count(*) AS cross_engagement_refs
FROM shape_events_view s
CROSS JOIN LATERAL jsonb_array_elements(s.selected_memory_refs) AS r
JOIN current_memory_objects c ON c.object_id = (r->>'id')::uuid
WHERE c.engagement_id <> s.engagement_id;
-- 33

33 crossing refs across 33 shape events — one per shape event, universally.

E.17 — Characterised precisely: it is always the seed, always the administrative engagement, always element 0

| Referenced object type | Crossing refs | Shape events | Distinct owning engagements | |---|---|---|---| | seed | 33 | 33 | 1 |

And for contrast, the same-engagement refs:

| Referenced object type | Same-engagement refs | |---|---| | assertion | 403 |

Three facts pin it down:

  1. Every crossing ref is a seed. No assertion ever crosses.
  2. Every crossing ref is selected_memory_refs[0] — a SELECT count(*) over first-elements that are cross-engagement seeds returns 33, matching the total. This is the shaping paths seeding the list with manifestation.seed_ref before appending assertions (agents/shaping.py:672, engagement/shaping_skill.py:356).
  3. Every crossing ref points at the administrative engagement, 00000000-0000-0000-0000-000000000001 (ADMINISTRATIVE_ENGAGEMENT_ID, engagement/bootstrap.py:26). Confirmed in both the projection and the event log — the seed 5ec7ce30-… has all 9 of its memory_events rows under that engagement id, and its current_memory_objects row likewise. Seeds live in the administrative engagement; shape events in ordinary engagements reference them across that boundary by design.

No ref is unresolvable — a check for refs matching no projection row returns 0.

E.18 — It does not affect item 2a's scope

Stated plainly, because the two claims are easy to conflate:

So the correction does not change 2a's design and does not raise the design question the scoping note reserved for the draft. Two consequences the draft should still absorb:

  1. An engagement-scoped dependents query is correct for assertions and can stay scoped, which keeps it consistent with every other engagement-scoped read in the engine. It would silently be wrong only if 2a were ever extended to seeds — worth a comment at the query, not a design change.
  2. A dependents query must not assume every ref is an assertion. Between the seed element (all 33 shape events) and the proposal_applier writer (B.7, refs a proposal), the field is genuinely heterogeneous. Keying on the assertion id supplied by the caller handles this naturally; enumerating refs and assuming their type does not.

Section F — Findings summary

Confirms scoping note

  1. Field name and table are exactly as statedselected_memory_refs on shape_events_view. (B.5)
  2. Version-pinning holds in live data — 436/436 refs pinned, zero current-pointing, so the recommended any-version-match-with-version-surfaced semantics are implementable as written. (B.8)
  3. No GIN index exists on the field — the anticipated migration is real and is the only one needed. (C.10)
  4. Id-only containment is clean — no computed or generated column needed, which the note flagged as a possible complication. (C.12)
  5. Stage 1 does not gate Stage 2 — verified structurally: separate tables, separate projector paths, no shared write path. (A.4)
  6. "Supersede" is not a distinct terminal state — the note was right not to assume it. It is revise. (D.13)
  7. The GIN-on-JSONB precedent is real. (C.10)

Corrects scoping note

  1. selected_memory_refs DOES cross engagement boundaries — in 100% of shape events. The note asks step-0 to confirm it never does. It always does, for the seed element, always pointing at the administrative engagement. But it does not affect 2a's scope, because no assertion ref ever crosses. (E.16–E.18)
  2. A closer GIN precedent exists than the one cited. The note names render_events_view.render_content (Phase 10). The nearest is ix_shape_events_view_produced_contentsame table, same migration that created it (0018), with the naming convention already set. (C.10)
  3. The shaping agent is not the only writer. credit/proposal_applier.py:372 writes selected_memory_refs from the reconciliation path, referencing a proposal. The field is not exclusively "assertions the shaping agent selected," and a dependents implementation should not assume ref homogeneity. (B.7)
  4. The index buys nothing measurable today. 33 shape events; the current sequential scan removes 31 rows and is trivially fast. Its value is preventing linear growth, not fixing present latency — the draft should claim it that way. (C.11)
  5. shape_events_view is a projector-maintained table, not a SQL view. Naming implies otherwise; no refresh semantics to reason about. (B.5)

Blocks or splits

Nothing blocks. Nothing splits.

Three items the draft must decide rather than discover, none of them blocking:

  1. One subclass or two for 2b. AssertionResponse is shared across six routes, so the field cannot go on it directly. CR-2026-154 item 1c's AddAssertionResponse subclass is the in-tree precedent and is one stage old. Whether revise and retract share one dependents-bearing subclass or take one each is a draft call. (D.14–D.15)
  2. Ref heterogeneity at the query. Seeds and proposals both appear in selected_memory_refs. Keying on a caller-supplied assertion id sidesteps it; enumerating and type-assuming does not. (B.7, E.18)
  3. How to size the index honestly in the CR. See correction 4.

DUNIN7 — Done In Seven LLC — Miami, Florida Loomworks — Graph Stage 2 (Forward Walk / Dependents) — Step-0 Inspection Report — v0.1 — 2026-07-28