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.
main79262c59ab032179532dbfcafc49693c711d30b5 (79262c5, "Merge Item 7a into main: standing-invitation claim_url (engine)", 2026-07-19)uv.lock ( M uv.lock). No other modifications, no untracked files. The inspection did not touch it.
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.
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:
uv run pytest tests/test_stele_router_mount.py -q) reproduces it: 1 failed, 1 passed.main at 79262c5. Nothing in this inspection wrote to the tree.src/loomworks/api/deps.py session resolution, plausibly the session-epoch check landed at 529e632, deps.py:638–654), not in assertions, relationships, contributions, or the Companion read paths. Root cause was not diagnosed — that would exceed this brief's read-only scope.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.
Single head: 0101.
Query: uv run alembic heads → 0101 (head).
Against the scoping note §6. The note names 0086 for orientation. Live head is 0101 — fifteen revisions further on.
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).
POST /engagements/{engagement_id}/assertions/{assertion_id}/redirect — src/loomworks/api/routers/assertions.py:406–490, function redirect_assertion_route (defined at line 424).src/loomworks/engagement/redirect.py:225, function redirect_assertion.
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:
{source_eid}/{aid}; the live path parameters are {engagement_id}/{assertion_id}.redirect.py:19–24) explains why (the new file id has to be in the target metadata), and an orphan-cleanup except path (redirect.py:361–373) removes the copied file if anything downstream throws.
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.)
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:
typing Literal union of 14 string terms, repeated verbatim (see C.12).dict[str, Any]; None is normalised to {} before delegation (assertions.py:648). No key validation, no schema, no per-vocabulary constraint.MemoryRef — src/loomworks/memory/base.py:94–103, exactly two fields, id: UUID and version: int | None (None = current-pointing, per R-A30). MemoryRef carries no engagement id._record_relationship (assertions.py:654–701), which constructs the Relationship and calls append_event(..., event_kind="relationship_added"). It performs no validation of any kind — it does not check that the source or target object exists, that either is in the same engagement, or what state either is in.This is the brief's flagged item. Both answers are "structurally permitted, with a caveat that is about reads, not writes."
Yes at the write layer; no validation blocks it. But the edge is not readable from the other side.
Evidence:
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.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).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
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.
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:
relationship_kind (wasderivedfrom_backfill.py:102) — chosen precisely so the Phase 8 retroactive binding "coexists cleanly with other wasDerivedFrom uses" (wasderivedfrom_backfill.py:34–35). This is the closest structural analogue to the redirect edge and the strongest candidate.purpose (agents/summarization.py:289) — same idea on relates-to.
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.
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.
The script the brief names does not exist under that name, and the actual template is currently broken.
scripts/amend_loomworks_dst_drt_abbreviations.py. That file exists but is an amendment script, not a relationship script.scripts/phase_28_dst_drt_explanation_relationships.py and scripts/phase_28_room_explanation_relationships.py. The former's own docstring (line 32–33) names amend_loomworks_dst_drt_abbreviations.py as the pattern it mirrors, which is presumably where the brief's reference came from.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 script — issue_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.
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):
src/loomworks/memory/base.py:119 — Provenance.wasConsideredAndRejected: list[MemoryRef]. This is a provenance field named after the vocabulary term, not a member of the vocabulary union. Same for src/loomworks/engagement/wasderivedfrom_backfill.py:88 and the five migrations that write literal provenance dicts (0034, 0035, 0037, 0043, 0058) — all are "wasConsideredAndRejected": [] provenance keys.pytest.mark.parametrize inputs rather than as vocabulary definitions: tests/test_api_relate.py:14–21 (6 terms), tests/test_phase_28_explanations.py:213–221 (6 terms), tests/test_vocabulary_extension.py:35–56 (split into PHASE_3_TERMS / NEW_TERMS / PHASE_9_TERMS frozensets), tests/test_phase_9_literal_extensions.py:79, tests/helpers/loomworks_engagement.py:233,464, and assorted provenance-dict fixtures. These are coverage lists, not sources of truth; 1b does not need to touch them, though the draft may choose to.docs/ — many historical CR copies (Phase 3, 4, 5, 9, 10, 28) and docs/api/api-reference.md:2277 (generated). Documentation, not code.
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.
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:
wasDerivedFromwasRevisionOfwasInvalidatedBywasAttributedTowasGeneratedBywasConsideredAndRejectedrelates-tocorroborateswasConsideredAndAffirmedwasDeliberateExceptionTodeclaresShapeTypedeclaresRenderTypehasShapingexplains
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.
Recommendation: src/loomworks/memory/base.py.
Rationale, with evidence:
memory/base.py:17–23 defines ActorKind = Literal["contributor", "agent", "person", "companion"] with the comment: "The single source of truth for the four driver kinds an ActorRef may carry — referenced wherever the kind is constrained … so the write and read sides cannot drift apart again." That is 1b's exact motivation, already solved once, in this module. A RelationshipVocabulary alias beside ActorKind is the consistent move.memory/base.py imports nothing from loomworks — it is the lowest layer. engagement/types.py:19 already imports ActorRef, MemoryObject, MemoryRef from it. api/schemas.py:16 already imports ActorKind from it. Both future importers therefore need no new dependency edge.engagement/types.py (where Relationship lives), also works — api/schemas.py:2437 and :4851 already import from loomworks.engagement.types — but it is the heavier module (2101 lines, imports executor, schedule, grammars) and puts a shared vocabulary behind an engagement-layer import that the API schema module would then need for this one symbol. memory/base.py is the cleaner home.
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.
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 | AddAssertionRequest — src/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 | ContributionRequest — src/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:
ContributionRequest is the surface the scoping note's language points at ("the contribution path"), but it is the thinner schema. It carries no grammar_element and no normative_force at all — _contribute_text hardcodes grammar_element="definition", normative_force="optional" (contributions.py:171–172) and _contribute_file hardcodes the same (contributions.py:271–272). The note's "content plus grammar element per alternative" would give alternatives a classification field the primary contribution on that same endpoint cannot express. That asymmetry is odd but not blocking.AddAssertionRequest already carries content, grammar_element, and normative_force, so an alternatives: list[AlternativeItem] field there is symmetric and natural. But POST /assertions is the lower-level API path, not "the contribution surface."
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.
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:
assertions.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.append_event — assertions.py:188–194 → src/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.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.
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.
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):
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.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.
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.
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.
Assertion.state — serialised into the JSONB payload and read as payload->>'state'. Values: held, committed, retracted, discarded (the Literal on list_assertions, assertions.py:796).current_memory_objects.current_version (column) mirrors payload->>'version'.Assertion.was_revision_of / provenance.wasRevisionOf, a MemoryRef to the prior version (memory/base.py:117; e.g. assertions.py:588,596). Each revision is a new version of the same object_id, not a new object.current_memory_objects is keyed PRIMARY KEY (engagement_id, object_id) (confirmed via \d current_memory_objects) and upserted on conflict, so it physically holds one row per object. Prior versions exist only in memory_events, addressable by (object_id, object_version) — that table has uq_memory_events_object_version UNIQUE (object_id, object_version).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.
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.
engagement/redirect.py:245–360; api/deps.py:38–55. (B.5)redirect_from_* metadata. redirect.py:329–335; no add_relationship import in the module. (B.6)assertions.py:610–701; relations.py:49–71; 5 and 4 live precedents respectively in dev. (B.8)types.py:349, assertions.py:615, assertions.py:659, schemas.py:2982. (C.12)boundary.py:247–268), and cannot break a boundary or admission invariant because the contribution routes never enter that path. No migration. (D.18)recall_personal and ask_about_past_input both pass state="committed". (E.19, E.20)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)
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)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)memory_events. The backfill's dev run is a no-op and cannot serve as functional evidence. (B.10){"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)current_memory_objects), not by a filter. (E.20, E.21)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)Nothing blocks. Nothing splits — with one conditional.
Four items the draft must decide rather than discover, none of which is a block:
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)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)memory/events.py:237–238 → engagement/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