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.
main355fe7ac1f06582fdcc2bf350d4ee5e67e214410 (355fe7a) — the CR-2026-154 mergeuv.lock modification carried through the Stage 1 build is no longer present.)
Single head: 0101.
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.
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.
selected_memory_refs — exactly as the scoping note states.shape_events_view — the scoping note's first guess, and correct.
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.
jsonb, NOT NULL (\d shape_events_view).
{
"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.
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.
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.
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 |
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.
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."
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.
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.
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.
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.
This is the report's most consequential finding, and it corrects the scoping note.
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.
| 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:
seed. No assertion ever crosses.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).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.
Stated plainly, because the two claims are easy to conflate:
selected_memory_refs crosses engagement boundaries" — true, universally, for the seed element.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:
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.selected_memory_refs on shape_events_view. (B.5)revise. (D.13)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)render_events_view.render_content (Phase 10). The nearest is ix_shape_events_view_produced_content — same table, same migration that created it (0018), with the naming convention already set. (C.10)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)shape_events_view is a projector-maintained table, not a SQL view. Naming implies otherwise; no refresh semantics to reason about. (B.5)Nothing blocks. Nothing splits.
main is clean at 355fe7a, and the last change to touch assertions.py was CR-2026-154's own item 1c, which is landed and merged. (A.1, D.14)Three items the draft must decide rather than discover, none of them blocking:
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)selected_memory_refs. Keying on a caller-supplied assertion id sidesteps it; enumerating and type-assuming does not. (B.7, E.18)DUNIN7 — Done In Seven LLC — Miami, Florida Loomworks — Graph Stage 2 (Forward Walk / Dependents) — Step-0 Inspection Report — v0.1 — 2026-07-28