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

Loomworks — Graph Hygiene — Step-0 Inspection Report — v0.1

Version. 0.1 Date. 2026-07-27 Status. Inspection report. Executes loomworks-graph-hygiene-step-0-inspection-brief-v0_1 against the live loomworks-engine tree and the dev database. Read-only throughout: no file in loomworks-engine, loomworks, or loomworks-ui was modified; every database statement was a SELECT. Executor. Claude Code on DUNIN7-M4. Companion documents. loomworks-graph-hygiene-step-0-inspection-brief-v0_1 (the brief), loomworks-graph-hygiene-cr-scoping-note-v0_1 (the scoping note, items 1a–1d).

All file references are relative to /Users/dunin7/loomworks-engine unless stated otherwise. Line numbers are as of the HEAD reported in A.1.


Section A — Baseline

A.1 — Branch, HEAD, working tree

Query: git branch --show-current, git rev-parse HEAD, git status --porcelain.

Against the scoping note §6. The note names 129d124 for orientation. 129d124 is "Merge Stele Phase 6b: Person-view split" (2026-06-17) and is an ancestor of main; main has advanced roughly a month and many change requests past it. Re-baselined as instructed.

A.2 — Test suite

Command: uv run pytest -q (full suite, 457s).

| | Count | |---|---| | Passed | 3379 | | Skipped | 68 | | Failed | 1 |

The suite is not green on main. The prior-session baseline named for orientation (2933 / 46 / 0) is stale in all three numbers, and the zero-failure part no longer holds.

The failure: tests/test_stele_router_mount.py::test_stele_router_mounts_and_begin_resolves_end_to_end.


resp = await client.post("/me/security/passkeys/begin")
>       assert resp.status_code == 200, resp.text
E       AssertionError: {"detail":"Authentication required (no valid session)."}
E       assert 401 == 200
tests/test_stele_router_mount.py:139

Characterisation:

Consequence for the change request: the draft cannot use "suite green" as its acceptance gate without first resolving or explicitly waiving this one. Recommend the draft name the expected baseline as 3379 / 68 / 1 with this specific test known-red, and file the failure separately.

A.3 — Alembic heads

Single head: 0101.

Query: uv run alembic heads0101 (head).

Against the scoping note §6. The note names 0086 for orientation. Live head is 0101 — fifteen revisions further on.

A.4 — Stele contract phase and CR-2026-098 queue position

Both have landed on main. Verified by git merge-base --is-ancestor <sha> main (all YES):

| Item | Commit | Date | Subject | |---|---|---|---| | CR-2026-098 v0.2 | 2685103 | 2026-06-07 | CR-2026-098 v0.2 (implementable scope): API boundary closure + schema fix | | CR-2026-098 v0.3 | e8706ee | 2026-06-07 | CR-2026-098 v0.3 (A3 resolution): remove dead spend-authorization routes | | Stele contract phase | bee2b4c | 2026-06-18 | Merge Stele contract phase (host-side teardown): persons principal-only (CR-2026-111) |

The Stele arc continued past the contract phase and is also landed: CR-2026-112 (d0f302e), CR-2026-113 (fe7c3f1), the Phase 7 engine re-point (5d92cf5, ff0c4cf, 08e89c5, 01d4aa6, 32c1a68), and CR-2026-120's decoupling of COOKIE_NAME (8310110, 68f3d89). The engine is now consuming stele as an installed package; src/loomworks/stele/ retains only __init__.py and actor.py.

Queue position. The graph-hygiene change request is not blocked by either named predecessor. What now occupies the queue ahead of it is the representatives-perimeter arc (Stage 1 1181141/0bf3336, Stage 2 f9b64d3/2a0cf71, Stage 3 965d002 through ea858b3) and Item 7a (b7fef13, merged at HEAD).


Section B — Item 1a (redirect edge)

B.5 — Redirect endpoint and operation sequence

The sequence still matches the Phase 17 CR v0.2 description. In redirect.py, numbered by the module's own comments:

| Step | Lines | What happens | |---|---|---| | 0 | 245–248 | Reject same-engagement redirect (RedirectSameEngagementError) | | 1 | 254–261 | Load source via _load_current_assertion | | 2 | 263–268 | Source state must be held | | 3 | 270–276 | Target engagement must exist | | 4 | 278–304 | Actor must hold CONTRIBUTOR or OPERATOR on the target; bearer-token (non-person) actors refused | | 5 | 310–321 | Optional file copy (_copy_file_for_redirect) | | 6 | 324–338 | Build target metadata, including the three redirect_from_* keys | | 7 | 341–349 | add_assertion — creates the held target assertion | | 8 | 354–360 | retract_assertion — retracts the source |

Transaction. The orchestrator does not open its own transaction; the surrounding request session does (redirect.py:236–241; get_db_session at src/loomworks/api/deps.py:38–55 wraps the whole request in one transaction, committing on clean return and rolling back on exception). So steps 5–8 are atomic as described.

Drift from the Phase 17 CR v0.2 description: none material. Two wording notes:

B.6 — No relationship is written today; the candidate insertion point

Confirmed: redirect.py neither imports nor calls add_relationship / _record_relationship. Its only imports from loomworks.engagement.assertions are AssertionNotFoundError, AssertionNotHeldError, _load_current_assertion, add_assertion, retract_assertion (redirect.py:39–45). The link exists solely as the three metadata keys written at redirect.py:329–335.

Candidate insertion point. The target assertion exists after line 349 (new_assertion = await add_assertion(...) returns), and the source is retracted after line 360. Both facts hold together only between line 360 and the except at 361 — i.e. src/loomworks/engagement/redirect.py:361, immediately after the retract_assertion await, still inside the try block. Placing the edge write there keeps it inside the orphan-cleanup guard and inside the request transaction. (An edge written between 349 and 354 would also be inside the transaction but would point at a source that is not yet retracted at write time; since the edge stores only refs, not state, either position produces the same stored object. Line 361 is the position that matches the scoping note's "after the target assertion is created" and reads as the terminal step.)

B.7 — add_relationship signature

src/loomworks/engagement/assertions.py:610–651:


async def add_relationship(
    *,
    engagement_id: UUID,
    source_ref: MemoryRef,
    target_ref: MemoryRef,
    vocabulary: Literal[ ...14 terms... ],
    qualifier: dict[str, Any] | None = None,
    actor: ActorRef,
    db: AsyncSession,
) -> Relationship:

B.8 — Structural answers (cross-engagement and retracted targets)

This is the brief's flagged item. Both answers are "structurally permitted, with a caveat that is about reads, not writes."

B.8a — Can a relationship target an assertion in a different engagement?

Yes at the write layer; no validation blocks it. But the edge is not readable from the other side.

Evidence:

  1. No write-side validation. add_relationship / _record_relationship (assertions.py:610–701) accept any two MemoryRefs and write the event. The HTTP route add_relationship_route (src/loomworks/api/routers/relations.py:49–71) builds both refs straight from the request body and passes them through — no existence check, no engagement check.
  2. The reference type cannot express an engagement. MemoryRef (memory/base.py:94–103) is {id, version}. A cross-engagement target is therefore recorded as a bare assertion id; the relationship object itself carries a single engagement_id, inherited from MemoryObject (memory/base.py:134) and set from the engagement_id argument (assertions.py:683).
  3. Reads are engagement-scoped. list_relationships_for_assertion (assertions.py:936–974) queries current_memory_objects with WHERE engagement_id = :engagement_id AND object_type = 'relationship' AND (payload->'source'->>'id' = :aid OR payload->'target'->>'id' = :aid). GET /engagements/{eid}/relations/{rid} (relations.py:74–116) likewise filters on engagement_id.

Consequence for 1a. A redirect edge written with engagement_id = target_engagement_id will surface on GET /engagements/{target_eid}/assertions/{target_aid}/relationships and will not surface on GET /engagements/{source_eid}/assertions/{source_aid}/relationships. The reverse choice inverts the asymmetry. There is no way, with today's MemoryRef, to make one edge visible from both ends. Resolving the far end requires the engagement id, which is exactly what metadata.redirect_from_engagement_id already carries — so the metadata keys remain load-bearing for traversal, not merely historical.

This is not a structural block. The brief's halt condition ("if either is blocked, report the blocking code") is not met: there is no blocking code. It is a design decision the change-request draft must make explicitly — which engagement owns the edge — and a limitation it should state rather than discover later.

Empirical precedent (dev database). Five relationships already have a target that does not resolve within their own engagement's projection:


SELECT count(*) FROM current_memory_objects r
WHERE r.object_type='relationship'
  AND NOT EXISTS (SELECT 1 FROM current_memory_objects t
                  WHERE t.engagement_id=r.engagement_id
                    AND t.object_id=(r.payload->'target'->>'id')::uuid);
-- 5

B.8b — Can a relationship target a retracted assertion?

Yes. No validation rejects non-current or non-committed targets. Same evidence as B.8a point 1: neither add_relationship, _record_relationship, nor add_relationship_route inspects target state. There is no state predicate anywhere on the relate path.

Empirical precedent (dev database). Four relationships already point at a retracted target:


SELECT count(*) FROM current_memory_objects r
JOIN current_memory_objects t
  ON t.engagement_id=r.engagement_id AND t.object_id=(r.payload->'target'->>'id')::uuid
WHERE r.object_type='relationship' AND t.payload->>'state'='retracted';
-- 4

Verdict on the scoping note §2 "Cross-engagement note." Neither structural block exists. 1a's halt condition does not fire.

B.9 — Existing qualifier conventions

Every production qualifier in the tree is a flat, single-level dict with snake_case string keys. Values are strings, ints, or (once) a nested model_dump. Observed keys and their sites:

| Key | Value shape | Site | |---|---|---| | relationship_kind | discriminating string (e.g. engagement_derived_from_founding_seed) | src/loomworks/engagement/wasderivedfrom_backfill.py:102 | | phase | int | wasderivedfrom_backfill.py:103 | | concept | concept:dst:<uuid> / concept:drt:<uuid> | Phase 28 explains; src/loomworks/engagement/types.py:363–369 (documented), scripts/phase_28_dst_drt_explanation_relationships.py:172 | | consideration_id | str(UUID) | src/loomworks/engagement/creation.py:317,398,911; assertion_groups.py:139; shape_confirmation.py:553; agents/summarization.py:289 | | amendment_rationale | free text | creation.py:318; assertion_groups.py:140 | | closure_terminal_state | one of the seven terminal states | src/loomworks/engagement/render.py:482; assertion_groups.py:138; shape_confirmation.py:553 | | retire_rationale | free text | render.py:483 | | purpose | discriminating string (summary_record) | agents/summarization.py:289 | | instruction_version | int | agents/summarization.py:291 | | routing_target, check_name, firing_point | discriminating strings | src/loomworks/agents/drift_detection.py:653–657, 683 | | completeness_report, completeness_override_rationale | nested dict / free text | shape_confirmation.py:555–560 |

Convention for the draft to follow. The dominant pattern for "what kind of edge is this, among several uses of the same vocabulary term" is a single discriminating string key. Two live precedents exist for exactly the 1a case, both on wasDerivedFrom:

The scoping note's illustrative {"derivation": "redirect"} matches neither existing key. {"relationship_kind": "redirect"} (or "assertion_redirect") would follow the established convention exactly. The draft should settle this against relationship_kind, not invent a third key. Recorded as a correction candidate, not a decision — the note reserves the choice for the draft.

B.10 — Redirect count in the dev database

Connection: postgresql://playground@localhost:5432/playground_dev (from .env, DATABASE_URL). All statements SELECT-only.

Headline: there are zero live redirects. The backfill has nothing to backfill in dev.

| Query | Result | |---|---| | SELECT count(*) FROM current_memory_objects WHERE object_type='assertion' AND payload->'metadata' ? 'redirect_from_assertion_id'; | 1 | | Same, joined to engagements (i.e. on a live engagement) | 0 | | SELECT count(*) FROM memory_events WHERE object_type='assertion' AND payload->'metadata' ? 'redirect_from_assertion_id'; | 0 |

Per engagement: the single projection row belongs to engagement d8468f20-3e5c-4487-9bc3-f291a2466a13, which no longer exists in the engagements table. Its detail:


target_engagement | d8468f20-3e5c-4487-9bc3-f291a2466a13   (not in engagements)
target_assertion  | 37ab2cba-02f4-4cf1-93dc-f520d5ce79a8   (state: held)
src_eid           | ee3d841e-7030-4773-b4d4-6027908663ee   (not in engagements)
src_aid           | 9a1050b5-60b9-4eb1-b679-432feb55b9c3   (state: retracted, version 2)
src_ename         | Engagement ee3d841e

**Are any redirect source assertions hard-deleted or unreachable? Yes — flagged.**


SELECT count(*) FROM memory_events WHERE object_id::text='9a1050b5-60b9-4eb1-b679-432feb55b9c3';  -- 0
SELECT count(*) FROM memory_events WHERE object_id::text='37ab2cba-02f4-4cf1-93dc-f520d5ce79a8';  -- 0
SELECT count(*) FROM memory_events WHERE engagement_id::text='ee3d841e-7030-4773-b4d4-6027908663ee';  -- 0

Both the source and the target assertion have zero events in memory_events, and both engagements are gone from engagements. This is the hard-delete path (src/loomworks/engagement/hard_delete.py), which by design removes the full memory event log for an engagement — an Operator-initiated deletion, not an erosion of non-erasure within a live engagement. Non-erasure is not violated for any surviving engagement.

But the projection was not cleaned, and this is a real (incidental) finding. hard_delete.py enumerates dependent tables from information_schema by walking foreign keys that reference engagements.id (hard_delete.py:16–33). current_memory_objects has no foreign key to engagements\d current_memory_objects shows only pk_current_memory_objects and two partial indexes, no FK constraints. So hard delete never sees it. Live consequence in dev:

| Query | Result | |---|---| | current_memory_objects rows whose engagement_id is not in engagements | 85 | | distinct such engagement ids | 20 | | total current_memory_objects rows / total memory_events rows | 896 / 1156 | | current_memory_objects rows with no matching object_id in memory_events | 106 |

This is out of scope for the graph-hygiene change request and should be filed as a separate finding, per the scoping note §6's "if step-0 surfaces a migration need, that is a finding to report, not absorb." It does, however, bind on 1a's backfill design:

> The backfill must be driven from memory_events (or from current_memory_objects joined to engagements), not from current_memory_objects alone. A naive sweep of the projection would, today, attempt to write a redirect edge on a hard-deleted engagement — an append_event against a missing engagements row, which raises EngagementNotFound (src/loomworks/memory/events.py:160–161). The verification query the draft names should carry the same join.

Backfill sizing. Zero rows in dev. The script must still be written and must still be idempotent (it will run against production, whose redirect count is not observable from here), but the dev run is a no-op and cannot serve as a functional test of the write path. The change-request draft should say so and rely on a purpose-built test rather than the dev run for evidence.

Existing edges (dev, for context): 116 relationships total — wasRevisionOf 44, relates-to 31, explains 29, wasDerivedFrom 7, hasShaping 5. No wasConsideredAndRejected edge exists anywhere in the dev database. Zero relationships reference either redirect assertion.

B.11 — The Phase 28 idempotent relationship script (backfill template)

The script the brief names does not exist under that name, and the actual template is currently broken.

Does the pattern still run against the current engine? No — it fails at import. All three scripts share the line:


from loomworks.stele.session import COOKIE_NAME, issue_session

(phase_28_dst_drt_explanation_relationships.py:48, phase_28_room_explanation_relationships.py:45, amend_loomworks_dst_drt_abbreviations.py:36)

That module was removed by the Stele extraction (ff0c4cf, "remove the 8 local loomworks.stele copies (keep actor.py)"); src/loomworks/stele/ now contains only __init__.py and actor.py. Verified:


$ uv run python -c "from loomworks.stele.session import COOKIE_NAME, issue_session"
ModuleNotFoundError: No module named 'loomworks.stele.session'

$ uv run python -c "from stele.session import issue_session; from loomworks.api.constants import COOKIE_NAME"
OK

Repair is one line per scriptissue_session moves to the installed stele.session package and COOKIE_NAME to the engine-owned loomworks.api.constants (the split CR-2026-120 made; 8310110, 68f3d89). The rest of the pattern is intact and is a good template: HTTP-level, session-minted-as-Operator, idempotent by reading the existing relationships and skipping matches (_explains_already_exists, phase_28_dst_drt_explanation_relationships.py:146–155).

A better template also exists in-tree. src/loomworks/engagement/wasderivedfrom_backfill.py (186 lines) is a library-level, Alembic-callable, idempotent relationship backfill — it writes memory_events + current_memory_objects + the engagement-version bump directly, checks for an existing edge by vocabulary + qualifier->>'relationship_kind' + source id (lines 51–66), and returns the count written. It is exactly the 1a shape (a wasDerivedFrom edge, retroactive, qualifier-discriminated) and it is not broken. The change-request draft should choose between the two deliberately; wasderivedfrom_backfill.py is the stronger candidate for a redirect edge that must be atomic with respect to the projection.


Section C — Item 1b (vocabulary single-source)

C.12 — Every definition / duplication site

Grep: grep -rn "wasConsideredAndRejected" --include=".py" --include=".md" --include="*.json" . plus the narrower hasShaping / wasDeliberateExceptionTo greps (those two terms appear only inside vocabulary lists, so they isolate the real sites from Provenance field references).

Production definition sites: four. The Phase 10-era count of four still holds — the Stele extraction and the namespace rename did not change it.

| # | Path | Symbol | Kind | |---|---|---|---| | 1 | src/loomworks/engagement/types.py:349–370 | Relationship.vocabulary | Pydantic model field Literal | | 2 | src/loomworks/engagement/assertions.py:615–630 | add_relationship(vocabulary=...) | function-signature Literal | | 3 | src/loomworks/engagement/assertions.py:659–674 | _record_relationship(vocabulary=...) | function-signature Literal | | 4 | src/loomworks/api/schemas.py:2982–3012 | AddRelationshipRequest.vocabulary | Pydantic model field Literal |

Not definition sites (do not require the refactor):

Existing guard. tests/test_vocabulary_extension_phase_11.py:44–70, test_relationship_vocabulary_has_shaping_at_all_four_sites, already asserts site_1 == site_2 == site_3 == site_4 by reflection (get_args over the four annotations), naming the same four sites. The "one new test asserting the definition sites agree" that the scoping note §3 proposes already exists. After the refactor that test becomes trivially true; the draft should decide whether to keep it as a regression guard (recommended — it would catch a future re-duplication) or replace it with a "there is only one site" assertion.

C.13 — Current vocabulary and drift status

No drift. All four sites agree exactly, including order. Verified by reflection at runtime against HEAD:


s1 = get_args(Relationship.model_fields['vocabulary'].annotation)
s2 = get_args(get_type_hints(assertions.add_relationship)['vocabulary'])
s3 = get_args(get_type_hints(assertions._record_relationship)['vocabulary'])
s4 = get_args(AddRelationshipRequest.model_fields['vocabulary'].annotation)
# each len 14; s2 == s3 == s4 == s1, order-exact

The vocabulary, verbatim from the primary model definition (src/loomworks/engagement/types.py:349–370), 14 values in declaration order:

  1. wasDerivedFrom
  2. wasRevisionOf
  3. wasInvalidatedBy
  4. wasAttributedTo
  5. wasGeneratedBy
  6. wasConsideredAndRejected
  7. relates-to
  8. corroborates
  9. wasConsideredAndAffirmed
  10. wasDeliberateExceptionTo
  11. declaresShapeType
  12. declaresRenderType
  13. hasShaping
  14. explains

Against the scoping note §3. The note says "at least thirteen." The live count is fourteen. No site has drifted; the risk 1b exists to close has not fired. 1b remains worth doing as prophylaxis, but the draft should record it as "no drift found, refactor is preventive" rather than "drift repair" — and should note the reason drift has not fired is that test_vocabulary_extension_phase_11.py has been enforcing cross-site identity since Phase 11.

C.14 — Natural single definition site

Recommendation: src/loomworks/memory/base.py.

Rationale, with evidence:

Modules that would import from it: src/loomworks/engagement/types.py (site 1), src/loomworks/engagement/assertions.py (sites 2 and 3), src/loomworks/api/schemas.py (site 4). Optionally tests/test_vocabulary_extension_phase_11.py if the guard test is rewritten.


Section D — Item 1c (contribution-time alternatives)

D.15 — Contribution entry points that create held assertions

There are two HTTP contribution surfaces and two Companion-mediated paths, plus several non-contribution internal writers.

HTTP — direct add:

| Field | Value | |---|---| | Endpoint | POST /engagements/{engagement_id}/assertions | | File | src/loomworks/api/routers/assertions.py:180–227, add_assertion_route (line 196) | | Request schema | AddAssertionRequestsrc/loomworks/api/schemas.py:2448–2510 | | Schema fields | content (required), grammar_element (default "definition"), normative_force (default "required"), source_mode, source_file_id, voice_interpretation_disabled |

HTTP — contribution surface (Phase 16):

| Field | Value | |---|---| | Endpoint | POST /engagements/{engagement_id}/contributions | | File | src/loomworks/api/routers/contributions.py:97–158, contribute_route (line 111); text path _contribute_text (line 161), file path _contribute_file (line 184) | | Request schema | ContributionRequestsrc/loomworks/api/schemas.py:3278–3304 | | Schema fields | content or file_id, exactly one (validated at contributions.py:75–94) |

Companion-mediated (Phase 42/43/45), no request schema of their own — driven by intent classification:

| Intent | File / line | Notes | |---|---|---| | add_knowledge | src/loomworks/orchestration/router.py:455 | engagement-scoped capture; hardcodes grammar_element="definition" | | remember_about_me | src/loomworks/orchestration/router.py:3345 (delegation-grant branch) and :3370 (plain fact) | personal-engagement capture |

Other add_assertion callers, not contribution surfaces (listed so the draft does not mistake them for entry points): engagement/boundary.py:247 and :903 (channel/material admission), engagement/considerations.py:661 (consideration closure), engagement/redirect.py:341 (1a's own path), engagement/creation_conversation_memory.py:275, api/routers/explanations.py:214, api/routers/uploads.py:909, credit/grant_decision_dispatcher.py:367, delegation/service.py:84.

Which request schema would the alternatives attach point extend?

Neither is a clean fit as-is, and this is the sharpest scoping question 1c faces:

Recommendation for the draft: extend AddAssertionRequest (POST /assertions) for the API-only first version. It is symmetric, it is the path scripts and the Companion can drive, and it avoids inventing a grammar-element field on ContributionRequest that the primary cannot use. This is a scoping decision, not a blocker; either choice is small.

D.16 — add_assertion signature and fan-out safety

src/loomworks/engagement/assertions.py:125–195:


async def add_assertion(
    *,
    engagement_id: UUID,
    content: str,
    grammar_element: Literal["definition", "constraint"],
    normative_force: Literal["required", "recommended", "optional"] = "required",
    actor: ActorRef,
    db: AsyncSession,
    metadata: dict[str, Any] | None = None,
) -> Assertion:

Callable multiple times within one request transaction? Yes. There is already an in-tree precedent for exactly that: engagement/boundary.py:247 calls add_assertion and then _record_relationship in the same transaction (boundary.py:247–268) — i.e. the admission path already performs 1c's shape (add + edge write, atomic). redirect.py:341–360 performs two state-machine operations in one transaction likewise.

Per-add side effects, named:

  1. Display-number assignmentassertions.py:147–170. An UPDATE engagements SET next_display_number = next_display_number + 1 … RETURNING per call. It takes the engagement row lock, which append_event re-acquires (reentrant on the same connection). Consequence for fan-out: N alternatives consume N display numbers, interleaved with the primary's. So a contribution with two alternatives burns three consecutive numbers, and the alternatives are addressable by number in the held tray ("commit held 4") like any other held item. That is arguably correct behaviour, but the draft should state it — the Operator will see the alternatives in the held tray.
  2. append_eventassertions.py:188–194src/loomworks/memory/events.py:130–245. Per call: engagement row lock, engagement-version increment, event row insert, current_memory_objects upsert (memory/projector.py:52–81), all in the caller's transaction.
  3. Post-append hooks — the one hook that fires per add. memory/events.py:237–238: fire_post_append_hooks(event, db). The registered production hook is the Phase 8 drift dispatcher, src/loomworks/engagement/post_append_dispatch.py, which dispatches two drift checks per event (per-event + engagement-as-whole). Consequence for fan-out: a contribution with N alternatives triggers 2×(N+1) drift dispatches instead of 2. Mitigating facts: dispatches are async via the injected AgentRunner; a hook exception is swallowed and append_event never fails because of a hook (events.py:234–236); and _DISPATCH_ACTIVE is a ContextVar recursion guard, so the drift agents' own writes do not re-trigger. Not an invariant break — a cost note the draft should name.

No boundary or admission hook fires. POST /assertions and POST /contributions do not route through engagement/boundary.py at all. The boundary/admission surface is a separate endpoint, POST /engagements/{eid}/channels/{cid}/material (src/loomworks/api/routers/material.py:149). Fan-out on the contribution path therefore cannot break a boundary or admission invariant, because it never enters that path.

Transaction. One request = one transaction (api/deps.py:38–55). A fan-out of primary + N alternatives + N edges either all lands or all rolls back.

D.17 — Relationship between two held assertions

Permitted today. No state check exists.

Evidence (same as B.8b): add_relationship (assertions.py:610–651) and _record_relationship (assertions.py:654–701) contain no state predicate; add_relationship_route (relations.py:49–71) builds both refs from the body and passes them through unvalidated. There is no code path anywhere that inspects state before writing a relationship.

Empirical precedent (dev database): eight relationships already connect two held assertions.


SELECT count(*) FROM current_memory_objects r
JOIN current_memory_objects s ON s.engagement_id=r.engagement_id AND s.object_id=(r.payload->'source'->>'id')::uuid
JOIN current_memory_objects t ON t.engagement_id=r.engagement_id AND t.object_id=(r.payload->'target'->>'id')::uuid
WHERE r.object_type='relationship' AND s.payload->>'state'='held' AND t.payload->>'state'='held';
-- 8

The scoping note §4's step 3 ("a wasConsideredAndRejected relationship is written from the primary to each alternative", both held) is structurally supported with no engine change.

D.18 — Sizing call (the split rule)

1c fits inside a small change request. The split rule does not fire.

The brief's split condition is "the contribution path cannot take the attach point without restructuring — for example, if contribution fan-out breaks an invariant in the boundary or admission path." Against the findings:

| Requirement | Status | Evidence | |---|---|---| | Schema extension | Additive optional field on an existing Pydantic request model | api/schemas.py:2448 / :3278 | | Fan-out (N extra add_assertion calls in one transaction) | Supported; already done in-tree | boundary.py:247–268; redirect.py:341–360; api/deps.py:38–55 | | Relationship write, held → held | Permitted, unvalidated | assertions.py:610–701; 8 live precedents | | Boundary / admission invariant | Not reachable — contribution routes bypass boundary entirely | material.py:149 is the boundary surface; contributions.py / assertions.py call add_assertion directly | | Migration | None. Relationships and assertions are existing object types; the qualifier is a free-form JSONB dict | types.py:337–371 |

Estimated shape: one optional schema field (+ one small nested model for an alternative), one loop in one route handler, one add_relationship call per alternative, and tests. On the order of 60–120 lines of production code across two or three files.

Three sizing caveats the draft must absorb (none of which triggers the split):

  1. Two contribution surfaces, four entry points. Extending one schema leaves POST /contributions and both Companion paths without the attach point. The draft must say explicitly which surface gets it and record the others as deliberately out of scope for the API-only first version — otherwise "the contribution path gains an attach point" reads as covering all four and quietly doesn't.
  2. Alternatives consume display numbers and appear in the held tray. Per D.16.1. This is Operator-visible behaviour, not a hidden implementation detail. If the intent is that alternatives should not be addressable as ordinary held items, that is a display-number or held-tray change — and that would be a restructuring boundary. The draft must decide; if it wants alternatives hidden from the held tray, 1c grows and the split rule should be re-evaluated at that point. On the minimal reading (alternatives are ordinary held assertions, marked in metadata, per the note §4 step 2), no change is needed.
  3. Drift dispatch multiplies. 2×(N+1) dispatches per contribution. Non-blocking and failure-swallowing, so not a correctness problem; worth a line in the change request.

Verdict: ship 1c inside the single change request with 1a, 1b, and 1d, subject to caveat 2 — if the Operator wants alternatives excluded from the held tray, 1c splits.


Section E — Item 1d (supersession-aware answering)

E.19 — Every path where the Companion composes an as-current answer from Memory

Enumerated by grepping src/loomworks/orchestration/ for list_assertions, current_memory_objects, memory_events, get_assertion_history, and _load_current_assertion.

| # | Path | File / function | Query it issues | |---|---|---|---| | 1 | recall_personal | router.py:1558, _route_recall_personal | list_assertions(engagement_id=personal_eid, state="committed", limit=500) — line 1593 | | 2 | ask_about_past_input | router.py:1521, _route_ask_about_past_input | list_assertions(engagement_id=engagement_id, state="committed", limit=50) — line 1537 | | 3 | show_held / held-tray resolution | router.py:~645, _resolve_held_reference | list_assertions(state="held", limit=500) — line 654 | | 4 | change_assertion, committed branch | router.py:~1315 | list_assertions(state="committed", limit=500) — line 1327 | | 5 | forget_about_me candidate set | router.py:~3500 | list_assertions(engagement_id=personal_eid, state="committed", limit=100) — line 3512 | | 6 | find_files | router.py:~975, _route_find_files | list_assertions(state=None, limit=500) — line 988, then in-Python a.state in ("held","committed") — line 995 | | 7 | correction-target matching (add_knowledge follow-on, CR-2026-145) | router.py:~245, _find_correction_target | list_assertions(state=None, limit=100) — line 255, then in-Python a.state in ("held","committed") — line 260 | | 8 | ask_about_progress | router.py:1500, _route_ask_about_progress_project_progress_counters | SELECT COUNT(*) FROM current_memory_objects … payload->>'state' = … — lines 1453, 1474 | | 9 | Tier-2 prompt block (recent notes) | prompt.py:~213, _load_recent_committed_assertions | list_assertions(state="committed", limit=MAX_ASSERTIONS_IN_CONTEXT) — line 221; total count at line 325 | | 10 | Tier decision | prompt.py:~118, _has_committed_assertions | SELECT COUNT(*) FROM current_memory_objects … payload->>'state' = 'committed' — line 122 | | 11 | personal-memory prompt block | prompt.py:~1080 | SELECT payload FROM current_memory_objects … payload->>'state' = 'committed' — line 1082 | | 12 | Companion-authored personal facts (actor-kind filtered) | router.py:~3090 | SELECT … FROM current_memory_objects with an explicit state filter — line 3105 | | 13 | inbox engagement resolution | orchestration/routers/inbox.py:182 | SELECT engagement_id FROM current_memory_objects … — resolves an id, does not compose an answer | | 14 | held-count for converse | orchestration/routers/converse.py:516–518 | list_assertions(state="held", …) |

Paths 1 and 2 are the two the scoping note names. Twelve more Memory-reading paths exist (3–14), of which 3–12 compose or shape an answer. Orientation composition reads registries and seeds, not assertions — the note's guess is correct; it is out of scope.

E.20 — Does each path filter on assertion state today?

Yes. Every one of them. There is no unfiltered as-current read in the Companion surface.

Two mechanisms do the work, and the second is the structural one:

(a) Explicit state filter. list_assertions (src/loomworks/engagement/assertions.py:793–860) applies AND payload->>'state' = :state when state is not None (lines 815–817, interpolated into both the count and the list query at lines 826 and 842). Paths 1, 2, 3, 4, 5, 9, 14 pass an explicit state. Paths 8, 10, 11, 12 write the same predicate inline in raw SQL. Paths 6 and 7 pass state=None but immediately filter in Python to ("held", "committed") (router.py:995, router.py:260) — retracted and discarded are excluded there too.

(b) Superseded prior versions are structurally absent from the current view. list_assertions reads current_memory_objects, not memory_events (docstring, assertions.py:803–807: "so each assertion is returned once at its highest version regardless of how many revisions live in the event log"). The projector upserts one row per (engagement_id, object_id) with ON CONFLICT … DO UPDATE (src/loomworks/memory/projector.py:59–81), so the view holds exactly the current version of each object. A superseded prior version cannot appear in any current_memory_objects read, filtered or not. Retraction produces a new version on the same object_id with state='retracted', which flips the single projection row — so state='committed' also excludes retracted assertions by construction.

Conclusion for 1d: this item closes as verification-only. The scoping note §5 explicitly names that as a legitimate outcome ("If every path already filters correctly, this item closes as verification-only with the tests added"). That is what step-0 found. The work reduces to adding the per-path tests.

One caveat the draft should record. The guarantee currently rests on two implicit properties rather than one named filter: (i) callers remembering to pass state=, and (ii) current_memory_objects being a current-version-only projection. Neither is enforced by a shared helper. A caller that passed state=None and forgot the Python-side filter would surface retracted assertions as current — paths 6 and 7 are one line away from exactly that. If 1d is to leave behind something Stage 4 can reuse "as a hard filter" (note §1), the draft's cheapest durable move is a named constant or a small helper (e.g. AS_CURRENT_STATES = ("held", "committed") / list_as_current_assertions(...)) that the six-plus call sites route through, plus the tests. That is still small.

E.21 — How the current view expresses state and supersession

This is the fact that makes 1d verification-only: the supersession half of the rule is enforced by the storage shape, not by a filter that could be forgotten.

E.22 — History-walking read paths that must stay exempt

These read memory_events directly (or are otherwise designed to show superseded material) and must not acquire the 1d filter:

| Path | File / function | Why exempt | |---|---|---| | GET /engagements/{eid}/assertions/{aid}/history | route api/routers/assertions.py:590–614, get_assertion_history_route; query engagement/assertions.py:757–789, get_assertion_history | Reads memory_events ordered by object_version ASC — its entire purpose is every version | | GET /engagements/{eid}/assertions/{aid}/at-version/{version} | route api/routers/assertions.py:616–644; query engagement/assertions.py:721–754, get_assertion_at_version | Addresses a specific historical version by number | | GET /engagements/{eid}/seed/induction-history | api/routers/engagements.py:1010 | Seed induction trail | | Engagement snapshot at a version | src/loomworks/memory/queries.py:61–100, state_of_engagement_at_version | Temporally coherent as-of-version snapshot; resolves each object to the version current then | | Object state at a time | src/loomworks/memory/queries.py:24, state_of_object_at_time | Same, time-addressed | | Consideration surfacing | api/routers/considerations.py:328 list_considerations_route, :382 get_consideration_route | Reads ConsiderationEpisode objects, not assertions-as-current; closure edges deliberately reference superseded material | | Relationship reads | engagement/assertions.py:936–974, list_relationships_for_assertion | Docstring is explicit: "Relationships whose current version has been superseded by a retraction-equivalent are still included — the caller decides whether to filter" | | The 1a backfill (once written) | — | Must be able to reach the retracted redirect source |

Correction to the scoping note §5. The note names "trajectory views" among the exempt paths. No trajectory read path exists in the engine. grep -rn "trajectory" --include="*.py" src/ returns only prose in docstrings and prompt text (engagement/creation.py:172, engagement/creation_conversation_memory.py:93, prompts/discovery_to_seed_extraction.py:35, api/schemas.py:3111, and others) — no function, route, or query by that name. The exemption list should name assertion history, at-version reads, seed induction history, the two memory/queries.py snapshot functions, consideration surfacing, and relationship reads.


Section F — Findings summary

Confirms scoping note

  1. 1a's operation sequence is unchanged from Phase 17 CR v0.2 — validate → optional file copy → create held target → retract source, one transaction owned by the route. engagement/redirect.py:245–360; api/deps.py:38–55. (B.5)
  2. No relationship object is written at redirect today. The link exists only as redirect_from_* metadata. redirect.py:329–335; no add_relationship import in the module. (B.6)
  3. Neither structural block the note worried about exists. A relationship may target an assertion in a different engagement, and may target a retracted assertion — there is no validation on the relate path at all. The 1a structural halt does not fire. assertions.py:610–701; relations.py:49–71; 5 and 4 live precedents respectively in dev. (B.8)
  4. The vocabulary is duplicated at four production sites, exactly as Phase 10 recorded — the Stele extraction and namespace rename did not change the count. types.py:349, assertions.py:615, assertions.py:659, schemas.py:2982. (C.12)
  5. Writing a relationship between two held assertions is permitted today. No state check anywhere; 8 live precedents in dev. (D.17)
  6. 1c fits a small change request; the split rule does not fire. Fan-out is transactionally safe, has in-tree precedent (boundary.py:247–268), and cannot break a boundary or admission invariant because the contribution routes never enter that path. No migration. (D.18)
  7. 1d's two named read paths both filter correctly todayrecall_personal and ask_about_past_input both pass state="committed". (E.19, E.20)
  8. Orientation composition is out of scope, as the note guessed — it reads registries and seeds, not assertions. (E.19)
  9. No migration is needed for any of the four items. (D.18, and nothing in A–E surfaced one.)

Corrects scoping note

  1. The baseline is stale in every respect. Main is 79262c5 (not 129d124); alembic head is 0101 (not 0086); the working tree carries one modification (uv.lock); the suite is 3379 passed / 68 skipped / 1 failed (not 2933 / 46 / 0). (A.1–A.3)

1b. The suite is not green on main. tests/test_stele_router_mount.py::test_stele_router_mounts_and_begin_resolves_end_to_end fails deterministically (401 where 200 expected), reproduces in isolation, is pre-existing, and sits outside every surface 1a–1d touches. The draft cannot use "suite green" as its acceptance gate without waiving this one explicitly. (A.2)

  1. The vocabulary has fourteen values, not "at least thirteen." Listed verbatim in C.13. (C.13)
  2. The vocabulary has NOT drifted — and the anti-drift test the note proposes already exists. All four sites are order-exact identical at runtime. tests/test_vocabulary_extension_phase_11.py:44–70 has been asserting site_1 == site_2 == site_3 == site_4 since Phase 11, which is why it hasn't drifted. 1b is preventive, not repair, and the draft should say so. (C.13)
  3. The backfill template the brief names is the wrong file, and the right one is broken. The Phase 28 relationship script is scripts/phase_28_dst_drt_explanation_relationships.py, not scripts/amend_loomworks_dst_drt_abbreviations.py. All three candidate scripts fail at import on from loomworks.stele.session import COOKIE_NAME, issue_session — that module was deleted by the Stele extraction (ff0c4cf). One-line repair each (stele.session + loomworks.api.constants). A better, unbroken, library-level template exists: src/loomworks/engagement/wasderivedfrom_backfill.py. (B.11)
  4. There are zero live redirects in the dev database. One projection row survives, but it belongs to two hard-deleted engagements and has zero events in memory_events. The backfill's dev run is a no-op and cannot serve as functional evidence. (B.10)
  5. The note's illustrative qualifier key {"derivation": "redirect"} matches no existing convention. The established discriminator for "which kind of wasDerivedFrom is this" is relationship_kind (wasderivedfrom_backfill.py:102), introduced for exactly this coexistence problem. (B.9)
  6. 1d's read-path inventory is much larger than "at least two." Fourteen Memory-reading paths exist in the Companion surface; ten-plus compose or shape an answer. All of them filter correctly. (E.19)
  7. No trajectory read path exists. The note's exemption list names one; the engine has none. Corrected list in E.22. (E.22)
  8. 1d closes as verification-only — the note allows this outcome; step-0 confirms it is the actual one. The supersession half is enforced structurally (one row per object in current_memory_objects), not by a filter. (E.20, E.21)
  9. "The contribution path" is four entry points across two request schemas, not one. AddAssertionRequest (POST /assertions) and ContributionRequest (POST /contributions), plus add_knowledge and remember_about_me. ContributionRequest has no grammar_element field, so the note's "content plus grammar element per alternative" would be asymmetric there. Recommendation: extend AddAssertionRequest. (D.15)

Blocks or splits

Nothing blocks. Nothing splits — with one conditional.

Four items the draft must decide rather than discover, none of which is a block:

  1. Which engagement owns the redirect edge. MemoryRef carries no engagement id (memory/base.py:94–103) and relationship reads are engagement-scoped (assertions.py:936–974). One edge is visible from exactly one end. The metadata keys therefore stay load-bearing for traversal, not merely historical. (B.8a)
  2. Conditional 1c split. If the Operator wants alternatives excluded from the held tray, that is a display-number / held-tray change and 1c becomes a restructuring. On the minimal reading (alternatives are ordinary held assertions marked in metadata, per note §4 step 2) it stays small. This is the one branch that could still split 1c, and it is an Operator decision, not a code fact. (D.18 caveat 2)
  3. The backfill must not be driven from current_memory_objects alone. It would attempt an append_event against a hard-deleted engagement and raise EngagementNotFound (memory/events.py:160–161). Drive from memory_events, or join to engagements. (B.10)
  4. Fan-out multiplies drift dispatch to 2×(N+1) per contribution (memory/events.py:237–238engagement/post_append_dispatch.py). Non-blocking, failure-swallowing; a cost note, not a defect. (D.16.3)

Separate finding, out of scope, filed not absorbed:

> hard_delete leaves current_memory_objects rows behind. current_memory_objects has no foreign key to engagements, and hard_delete.py discovers dependents by walking FKs that reference engagements.id (hard_delete.py:16–33). Dev currently holds 85 orphaned projection rows across 20 deleted engagements (896 projection rows vs 1156 event rows; 106 projection rows have no matching object_id in memory_events at all). Per scoping note §6 this is reported, not absorbed into the graph-hygiene change request.


DUNIN7 — Done In Seven LLC — Miami, Florida Loomworks — Graph Hygiene — Step-0 Inspection Report — v0.1 — 2026-07-27