DUNIN7 · LOOMWORKS · RECORD
record.dunin7.com
Status Current
Path change-requests/cr-2026-155-graph-stage-2-forward-walk-dependents-v0_2.md

Loomworks — Graph Stage 2 (Forward Walk / Dependents) — Change Request — CR-2026-155 — v0.2

Version. 0.2 Date. 2026-07-28 Status. As-built amendment. v0.1 was the executed draft; v0.2 absorbs two build-time corrections (D1 predicate spelling, D2 matched_version nullability) with the v0.1 positions preserved alongside, and records the as-shipped divergences D3–D6. Build shipped on engine branch graph-stage-2-dependents at e27e1bc, pushed, awaiting merge. Supersedes v0.1; v0.1 stays filed as a standing sibling per manifest version discipline. Author. Claude.ai (drafting layer). Operator: Marvin Percival. CR number. CR-2026-155, assigned from the current sequence in loomworks-record at drafting time. Highest present at assignment: CR-2026-154, verified by Claude Code repo-wide search on 2026-07-28; no four-digit variants. Per PF-2, the executing session re-checks the sequence before any commit carries this number. Companion documents. loomworks-graph-stage-2-cr-scoping-note-v0_1 (scoping note, filed at scoping-notes/, commit bbcaa9e), loomworks-graph-stage-2-step-0-inspection-brief-v0_1 and loomworks-graph-stage-2-step-0-inspection-report-v0_1 (both at inspection-briefs/). Provenance. Executes Stage 2 of loomworks-graph-findings-implementation-strategy-v0_1. Every live-code claim in this draft is grounded in the step-0 inspection report, executed by Claude Code against loomworks-engine main at 355fe7a (the CR-2026-154 merge) on 2026-07-28. Nothing here is asserted from memory of prior chats.


Plain-language summary

Stage 2 is the forward walk: given an assertion, find what was built from it. This change request delivers two items. Item 2a adds an indexed query and endpoint — GET /engagements/{eid}/assertions/{aid}/dependents — returning every shape event in the engagement whose selected_memory_refs contains the given assertion, at any pinned version, with the matched version surfaced per result. One migration: a GIN index on shape_events_view.selected_memory_refs. Item 2b makes the supersede (revise) and retract responses carry a dependents count, so the Operator learns what was built from an assertion at the moment of the decision. Nothing is blocked, invalidated, or changed automatically — the signal is a signal, per the Operator-authority principle.

One correction from step-0 is carried into this draft: the scoping note asked step-0 to confirm selected_memory_refs never crosses engagement boundaries. It does cross — universally, but only for the seed element, and never for an assertion — so item 2a's design stands unchanged. Section 3 states the correction and the reasoning in full.

Decision needed from the Operator: none beyond approving the draft. The two decisions the report left to the draft (subclass count for 2b; honest index sizing) are settled in §5 and §7 with reasoning.

In scope: the 2a query, index, and endpoint; the 2b response field on revise and retract. Out of scope: Stages 3 and 4, relationship-vocabulary edge walking, render-event forward walk, needs_you dashboard integration, any automatic action on dependents, supersession severity.


1. Baseline

Per step-0 report §A, all figures as of 2026-07-28:


2. Decisions carried in (settled, not reopened)


3. Correction carried from step-0 — the cross-engagement question

Prior position (scoping note §2, superseded). "Step-0 confirms selected_memory_refs never crosses engagement boundaries — if it can, that is a design question for the change-request draft."

Corrected position (report §E, current). The field crosses engagement boundaries in 100% of shape events. The crossing is fully characterised: every crossing ref is a seed; every crossing ref is element 0 of the array (the shaping paths seed the list with manifestation.seed_ref before appending assertion refs — agents/shaping.py:672, engagement/shaping_skill.py:356); every crossing ref points at the administrative engagement (ADMINISTRATIVE_ENGAGEMENT_ID, engagement/bootstrap.py:26), where seeds live by design. Of 436 live refs: 33 crossing seed refs, 403 same-engagement assertion refs, zero unresolvable.

Why the correction does not change 2a's design. The two claims are distinct and must not be conflated:

  1. "selected_memory_refs crosses engagement boundaries" — true, universally, for the seed element.
  2. "An assertion-keyed dependents query would cross engagement boundaries" — false. No assertion ref crosses. Item 2a keys on an assertion id supplied by the caller; the crossing element is never an assertion, so the query's results never leave the engagement.

An engagement-scoped dependents query is therefore correct for assertions and stays scoped, consistent with every other engagement-scoped read in the engine. It would silently become wrong only if 2a were ever extended to seeds — which is guarded by a comment at the query (§4, requirement 2a-R4), not a design change.

Second finding folded in (report §B.7). The shaping agent is not the field's only writer. credit/proposal_applier.py:372 writes selected_memory_refs from the Accounting reconciliation path, referencing a proposal, not an assertion. The field is genuinely heterogeneous — seeds, assertions, proposals. The implementation consequence is the same as above: key on the caller-supplied assertion id; never enumerate refs and assume their type.


4. Item 2a — Indexed dependents query and endpoint

What exists (per report §B–C)

Requirements

2a-R1 — Migration: GIN index. One Alembic migration, next revision after 0101, creating:


op.create_index(
    "ix_shape_events_view_selected_refs",
    "shape_events_view",
    ["selected_memory_refs"],
    postgresql_using="gin",
)

Default jsonb_ops, matching both existing GIN indexes on these projection tables. Downgrade drops the index. No new table, no new event kind, no payload change, no data migration.

Honest sizing (report §C.11, correction 4). At 33 live shape events the sequential scan is trivially fast; the index buys nothing measurable today. Its value is preventing a scan that grows linearly with shape events per engagement. The migration docstring and this CR claim it exactly that way — growth prevention, not a present-day performance fix.

2a-R2 — Query. Containment query keyed on the caller-supplied assertion id:


... WHERE engagement_id = :eid
      AND selected_memory_refs @> jsonb_build_array(jsonb_build_object('id', :aid))

(Executing session derives the exact SQLAlchemy form from the live query conventions in the shape-events read path; the predicate shape above is what step-0 verified against live data at §C.11–C.12.) Per-result, the matched pinned version is extracted from the ref whose id equals the queried assertion id — never from positional assumptions, per §3's heterogeneity finding.

Correction at v0.2 (D1 — predicate spelling). The v0.1 sketch as written does not run: jsonb_build_object takes any, so asyncpg cannot infer the bind type, and :aid::text fails because SQLAlchemy's text() reads :: as a second bind parameter. Shipped form uses CAST(:aid AS text). Spelling only — the containment semantics are unchanged from what step-0 verified. The shipped implementation centralises the predicate in _DEPENDENTS_PREDICATE, shared by list_dependent_shape_events, count_dependent_shape_events, and matched_version_for, with the 2a-R4 scope-guard comment at that single site.

2a-R3 — Endpoint. GET /engagements/{eid}/assertions/{aid}/dependents, engagement-scoped, same auth posture as the existing assertion GET on the same router (api/routers/assertions.py). Response: a list built on the existing shape-event response schema used by the read-side serializers in api/routers/shape_events.py, extended additively (subclass, per the CR-2026-154 item 1c pattern) with one field per result: matched_version — the pinned version of the queried assertion in that shape event's refs. The executing session derives the base schema name and field set from the live serializer, not from this draft.

Correction at v0.2 (D2 — nullability). v0.1 typed the field int, on the strength of step-0 report §B.8 (436/436 live refs version-pinned). That conflated a live-data observation with the model contract: MemoryRef.version is optional on the live model, and redirect_assertion constructs version=None, so declaring int would 500 on a ref the model permits. Shipped and correct: matched_version: int | None. A None-version ref still matches by id and surfaces as null, consistent with §2's any-version decision. This also bounds the report's §B.8 claim for future readers: R-C2 holds in current data, not by schema contract.

2a-R4 — Scope-guard comment. At the query site, a comment stating: the engagement-scoped predicate is correct because assertion refs never cross engagement boundaries (step-0 report §E.18); if this query is ever extended to seed refs, the scoping assumption breaks — seeds live in the administrative engagement — and the extension must revisit the scoping, not widen the predicate in place.

2a-R5 — Ref-heterogeneity discipline. The implementation keys on the supplied assertion id and treats matches by containment only. It never enumerates selected_memory_refs assuming element type or position (element 0 is a cross-engagement seed in every live shape event; proposals also appear via proposal_applier).

2a-R6 — FORAY. No new attestation. The dependents query is a read; it performs no recordable action. revise and retract attest at the substrate today and are unchanged by 2b's additive field.


5. Item 2b — Supersede/retract responses surface the signal

What exists (per report §D)

Requirements

2b-R1 — One shared subclass. The report left subclass count (one shared, or one each for revise/retract) to this draft. Settled: one shared subclass, e.g. AssertionWithDependentsResponse(AssertionResponse), used by both routes. Reasoning: both routes want the identical field with identical semantics, computed the same way; two subclasses would duplicate a one-field schema with no divergence to justify it. Set aside: per-route subclasses — the right shape only if the two routes' dependents semantics ever diverge, which nothing on the horizon suggests. Exact class name aligns with live schema naming conventions at execution time.

2b-R2 — The field. dependents_count: int — the number of shape events returned by 2a's query keyed on the assertion id being revised or retracted, computed via the same query path (no second implementation). Always present on the two routes, including when zero. Count only, not an inline list: the 2a endpoint is the list; duplicating it inline couples the response to a second schema for no reader who cannot make the follow-up call. Set aside: inline list (duplicates 2a; grows the response unboundedly), and a URL pointer field (derivable from the route shape; adds nothing).

2b-R3 — Semantics unchanged. No block, no warning dialog, no required confirmation, no terminal-state change. The Operator revises or retracts exactly as today; the response says more.

2b-R4 — Four routes untouched. commit, discard, redirect, and the plain GET keep AssertionResponse unchanged, byte-for-byte at the schema level.


6. Explicitly out of scope


7. Tests

Floor (net new, exact count recorded as-shipped in implementation notes):

  1. Migration round-trip — upgrade creates ix_shape_events_view_selected_refs, downgrade drops it.
  2. Any-version match — two shape events selecting the same assertion at different pinned versions; the dependents query returns both, each carrying its own matched_version.
  3. Heterogeneity — a shape event whose refs include a seed element (element 0) and a proposal-style ref; a dependents query for an assertion in the same list returns the shape event once and matches only on the assertion id.
  4. Engagement scoping — an assertion id queried under the wrong engagement returns empty; auth posture matches the sibling assertion GET.
  5. 2b on both routesrevise and retract responses carry dependents_count, correct when >0 and when 0.
  6. Four untouched routescommit, discard, redirect, GET response schemas unchanged.

All tests on synthetic per-verification engagements only — never E0060, E0007, E0030, or the shared E0006 E2E fixture. Gate: no new failures against the pre-flight measured baseline; the Stele router-mount failure remains waived per its own front note.


8. Acceptance gates

  1. Migration lands as the single next Alembic revision; single head preserved.
  2. ix_shape_events_view_selected_refs exists with GIN/jsonb_ops on shape_events_view.selected_memory_refs; query plan for the id-only containment predicate uses it once row counts warrant (correctness gate is the predicate's results, per test 2 — the plan flip is growth-dependent and not gated).
  3. GET /engagements/{eid}/assertions/{aid}/dependents returns correct results per tests 2–4.
  4. revise and retract return the shared subclass with dependents_count; the other four AssertionResponse routes unchanged.
  5. Scope-guard comment present at the query site (2a-R4).
  6. Suite: no new failures against the pre-flight measured baseline.
  7. Implementation notes record as-shipped test counts, the Alembic revision id, and any divergence from this draft, preserved-not-smoothed.

9. Execution protocol

Standard loop. Fresh Claude Code session. Pre-flight: re-verify main at or beyond 355fe7a with clean tree; re-run the suite to convert the carried baseline (§1) into a measured one; re-check the CR sequence in loomworks-record — if anything above CR-2026-154 has landed since drafting, halt and report before any commit carries this number. Branch from main. Stage by explicit path, never git add -A. Halt before every push; report paths and SHA. If any live fact diverges from this draft's grounding (route line numbers, schema names, index inventory), halt-surface per the substrate-friction discipline rather than absorbing in flight.


10. As-shipped record (v0.2)

Build executed 2026-07-28 on engine branch graph-stage-2-dependents at e27e1bc (one commit ahead of main 355fe7a), pushed to origin, awaiting merge. All seven §8 gates met. Pre-flight held: measured baseline confirmed the carried 3408 / 68 / 1; CR sequence re-check found nothing above 154 except this CR's own filing; all nine grounding spot-checks held to the line number.

11. Changes from prior versions

v0.2 (2026-07-28). As-built amendment. Absorbs D1 (predicate spelling — CAST(:aid AS text); v0.1 sketch preserved in place at §4 2a-R2) and D2 (matched_version: int | None; v0.1's int and the reasoning error behind it preserved in place at §4 2a-R3). Adds §10 as-shipped record covering D3–D6, gate 2's measured plan, and suite deltas. No scope change; no gate change.

v0.1 (2026-07-28). First version. No prior Stage 2 CR. Carried the step-0 report's §E correction to the scoping note's §2 cross-engagement premise (stated with prior position preserved at §3) and settled the report's three draft-level items: subclass count (§5, 2b-R1: one shared), ref heterogeneity at the query (§4, 2a-R5), honest index sizing (§4, 2a-R1). Superseded by v0.2; preserved as a standing sibling.


DUNIN7 — Done In Seven LLC — Miami, Florida Loomworks — Graph Stage 2 (Forward Walk / Dependents) — Change Request — CR-2026-155 — v0.2 — 2026-07-28 Supersedes v0.1 (preserved alongside as superseded).