Version. 0.2
Date. 2026-04-27
Author. Marvin Percival (DUNIN7), prepared via Claude.
Target. /Users/dunin7/loomworks (substrate) and /Users/dunin7/loomworks-ui (frontend) on DUNIN7-M4 (MacMini).
Priority. Standard.
Confidential. Internal.
Supersedes. CR-2026-031 v0.1. v0.1 specified Feature A (display numbers) and Feature B (redirect). CC's pre-flight against v0.1 established that Feature A is already implemented — display_number exists as a per-engagement counter (engagements.next_display_number, UPDATE ... RETURNING), assignment at commit is live, the five founding assertions are numbered, and the behavioral acceptance items hold. v0.2 removes Feature A from scope and corrects pre-flight expectations that diverged from the actual codebase.
Companion to. Phase 17 scoping note v0.1, methodology v0.18, Playground Specification v0.10, Phase 3 CR v0.2, Phase 14 CR v0.1, Phase 16 CR v0.1 plus three amendments.
Status. Second draft. Feature A removed (already implemented). Pre-flight expectations corrected per CC findings. Steps renumbered. Migration removed (no schema change needed for Feature B).
Phase 17 adds two contribution-workflow enhancements to the Memory room.
Feature A — Assertion display numbers. Already implemented. The display_number field exists on assertions, assigned at commit via a per-engagement counter row (engagements.next_display_number). The five founding Loomworks assertions are numbered 1–5. The frontend already displays numbers in the provenance line. v0.2 retains Feature A's acceptance items (Section 12, items 3–6) as verification-only — CC confirms they pass during pre-flight. No new code for Feature A.
Feature B — Redirect. A held assertion can be redirected from one engagement to another. This is a single atomic operation with two outcomes: the held assertion in the source engagement is retracted with rationale "Redirected to [target engagement name]", and a new held assertion is created in the target engagement with the same content, metadata, and (if file-based) a copy of the uploaded file. The person must have contributor access on both engagements. The two assertions are independent from that point forward — different UUIDs, different event histories, different engagements. Redirect provenance metadata records the origin.
The remaining work is: one new substrate endpoint (redirect with file copy), frontend additions (Redirect button on held cards, engagement picker modal, redirect provenance display). No migration required — redirect provenance lives in the assertion's existing metadata dict.
Phase 17 is grounded in three layers, consulted in order.
Layer 1 — Methodology document v0.18. Non-erasure discipline (Section 3.4): the redirect retraction in the source engagement records the rationale, preserving the assertion's history. Memory as accumulated engagement knowledge: redirect enables the staging-engagement workflow where raw contributions are sorted to their correct homes.
Layer 2 — Reference Design v0.1.2. S-1 (journey is information): redirect provenance records where an assertion came from and why it moved.
Layer 3 — Playground Specification v0.10. R-A28 (non-erasure): retracted assertions and their history remain walkable. R-B1 (assertion transitions): redirect uses Retract (source) and Add (target) — both existing transitions, composed into a new compound operation.
phase-16-memory-contribution-ui. 1101 tests passed, 2 environment-gated skips.phase-16-memory-contribution-ui. Lint + tsc + build clean. Ten surfaces.CC confirms before Step 1:
uv run pytest -v reports the expected baseline (1101 passed, 2 skipped). If the count differs, stop and reconcile.alembic heads shows a single head.current_memory_objects view uses JSONB payload (not flat columns). The view surfaces assertion data including display_number from the JSONB payload. CC confirms the view structure.uploaded_files table exists with columns including: id, engagement_id, uploaded_by_person_id, original_filename, content_type, file_size_bytes, storage_path, created_at. CC confirms exact column names.data/files/{engagement_id}/{file_id}{ext}.display_number values. The five founding Loomworks assertions have numbers 1–5. Committing a new assertion assigns the next number. This confirms Feature A is already working — no Feature A code is written in Phase 17.retract_assertion function is located. Redirect calls it for the source assertion.add_assertion function is located. Redirect calls it for the target assertion.get_resolved_actor dependency (or equivalent person-session auth) is located. The redirect endpoint uses the same auth pattern as existing assertion endpoints. The path parameter is {source_eid}, which the existing middleware resolves against.
Archive this CR to docs/phase-crs/phase-17-cr-redirect-and-display-numbers-v0_2.md at Step 0 before Step 1 begins. If v0.1 exists in docs/phase-crs/ (uncommitted), replace it with v0.2.
Display numbers are already in the codebase. The implementation uses engagements.next_display_number with UPDATE ... RETURNING — atomic without a MAX-scan, and the counter only increments, never decrements. This is a better approach than v0.1's SELECT MAX + 1 ... FOR UPDATE specification. The behavioral outcomes are identical: sequential, never reused, per-engagement, stable across retraction. No further work.
Redirect is a single endpoint: POST /engagements/{source_eid}/assertions/{aid}/redirect with body { "target_engagement_id": UUID }. The endpoint performs both operations (retract in source, create in target) within a single database transaction.
File copy on redirect happens synchronously within the redirect endpoint. shutil.copy2 copies the file bytes from source to target storage. A new uploaded_files row is created with a new UUID, the target engagement_id, and the same content metadata. Synchronous is correct for files up to 50 MB.
The redirect engagement picker uses the existing GET /me/memberships endpoint. The frontend filters to contributor/operator memberships excluding the current engagement. No new endpoint needed.
The new held assertion in the target carries:
Carried over from the original assertion (copied):
source_mode — how the original content entered (text, voice, pdf, image).source_file_id — the new file ID in the target engagement (not the source file ID), if file-based.extraction_method — how content was extracted from the file.original_filename — the uploaded file's name.gps — GPS coordinates from image EXIF, if present.Added for redirect lineage:
redirect_from_engagement_id: str — UUID of the source engagement.redirect_from_assertion_id: str — UUID of the source assertion.redirect_from_engagement_name: str — human-readable name of the source engagement, for display without a lookup.
The source_file_id is updated to point to the new copy in the target engagement, preserving full engagement independence.
No implementation work. Acceptance items 3–6 in Section 12 verify the existing implementation during pre-flight. If any item fails, stop and investigate before proceeding with Feature B.
The v0.1 CR specified this feature in detail; the implementation diverged only in mechanism (counter row vs. MAX-scan), not behavior. The divergence is documented in v0.1's implementation record and acknowledged here.
Redirect operates on held assertions only (S2). Committed assertions cannot be redirected. The endpoint returns 409 (Conflict) if the source assertion is not in state held.
6.2.1 Validate source assertion. Load the assertion from the source engagement. Confirm state is held. If not held, return 409.
6.2.2 Validate target engagement. Confirm the target engagement exists. If not, return 404. Confirm the target is not the same as the source. If same, return 422.
6.2.3 Validate person's membership on target. Query memberships for the current person on the target engagement. Confirm designations include 'contributor' or 'operator'. If not, return 403. The person's membership on the source engagement is already confirmed by the get_resolved_actor dependency, which is keyed on the path's {source_eid}.
6.2.4 Copy file (if file-based). If the source assertion has a source_file_id in its metadata:
a. Look up the uploaded_files row for the source file.
b. Generate a new UUID for the target file.
c. Determine the target storage path: data/files/{target_eid}/{new_file_id}{ext}.
d. Create the target engagement's file directory if it does not exist (os.makedirs(..., exist_ok=True)).
e. Copy the file: shutil.copy2(source_path, target_path).
f. Create a new uploaded_files row: new UUID, target engagement_id, same uploaded_by_person_id, same content_type, original_filename, file_size_bytes, new storage_path, created_at set to now.
6.2.5 Build target metadata. Merge the source assertion's metadata with redirect provenance keys:
target_metadata = {
**source_assertion.metadata,
"redirect_from_engagement_id": str(source_engagement_id),
"redirect_from_assertion_id": str(source_assertion_id),
"redirect_from_engagement_name": source_engagement.engagement_name,
}
# Update source_file_id to the new file's ID (if file-based)
if new_file_id:
target_metadata["source_file_id"] = str(new_file_id)
6.2.6 Create held assertion in target. Call add_assertion with the source assertion's content, grammar_element, normative_force, and the target engagement_id. The actor is the current person. Then update the new assertion's metadata and source_mode fields to carry the built target metadata. The assertion enters the target engagement in held state, awaiting independent commit or discard.
6.2.7 Retract source assertion. Call retract_assertion with rationale "Redirected to {target_engagement_name}". The retraction is recorded in the source engagement's event log per non-erasure discipline.
6.2.8 Return response. Return the new held assertion in the target engagement, including the redirect provenance metadata.
| Condition | HTTP status | Body |
|-----------|-------------|------|
| Source assertion not found | 404 | {"detail": "Assertion not found"} |
| Source assertion not held | 409 | {"detail": "Only held assertions can be redirected"} |
| Target engagement not found | 404 | {"detail": "Target engagement not found"} |
| No contributor access on target | 403 | {"detail": "You do not have contributor access on the target engagement"} |
| Source and target are the same engagement | 422 | {"detail": "Cannot redirect to the same engagement"} |
| File copy failure (I/O error) | 500 | {"detail": "File copy failed during redirect"} |
The redirect produces events in two engagements:
assertion_retracted event (from the retract_assertion call). Rationale includes the target engagement name. Advances the source engagement's version.assertion_added event (from the add_assertion call). The assertion's metadata carries redirect provenance. Advances the target engagement's version.Both events are recorded within a single database transaction. If either fails, the entire operation rolls back. If a file was copied before the failure, the target file is cleaned up (try/except around the file copy with cleanup in the except path).
The file copy happens before the database operations complete (it must, because the new file ID goes into the target assertion's metadata). If the database transaction fails after the file copy, the orphaned target file must be removed. The implementation wraps the entire redirect in a try/except: on exception, if the target file exists, delete it, then re-raise.
The redirect engagement picker on the frontend fetches the person's memberships from GET /me/memberships (Phase 14 endpoint). The response includes engagement details (ID, name) and designations per membership. The frontend:
'contributor' or 'operator'.If the filtered list is empty (the person has contributor access only on the current engagement), the Redirect button does not appear on held cards. Per "only show what is available": if there is nowhere to redirect to, the action does not exist on the surface.
POST /engagements/{source_eid}/assertions/{aid}/redirect
Auth: get_resolved_actor (person-session auth, keyed on {source_eid}). Membership on target engagement checked within the endpoint logic (Section 6.2.3).
Request body:
class RedirectAssertionRequest(BaseModel):
target_engagement_id: UUID
Response body: AssertionResponse — the new held assertion in the target engagement, including redirect provenance in metadata.
Response codes: 200 (success), 403 (no contributor access on target), 404 (assertion or target engagement not found), 409 (assertion not held), 422 (same engagement).
No existing endpoints are modified in v0.2. The commit endpoint already assigns display numbers. The assertion list and assertion detail endpoints already return display_number.
Held assertion cards (Phase 16 Section 15.3) gain a Redirect action. The card's action row becomes:
{components.button-primary}. Unchanged.{components.button-secondary}. New. Appears between Commit and Discard.{components.button-ghost}. Unchanged.
The Redirect button appears only when the person has contributor access on at least one other engagement. The check: at page load, fetch GET /me/memberships, filter to contributor/operator memberships excluding the current engagement. If the filtered list is non-empty, Redirect buttons appear. If empty, they do not.
Clicking the Redirect button opens a modal. The modal follows the standard modal treatment from the brand guide (modal surface on backdrop, modal header, modal body, modal actions).
Modal content:
{typography.caption}.{typography.h3}.{typography.body-em}.{typography.caption}.{components.button-ghost}. Closes the modal.{components.button-primary}. Disabled until an engagement is selected. Calls the redirect endpoint.
Selection: Clicking an engagement card selects it (highlighted with {colors.vellum} background). Clicking the same card deselects it. Only one engagement can be selected at a time.
After redirect: The modal closes. The held assertion card is removed from the current engagement's held list (it has been retracted). A brief toast or status message confirms: "Assertion redirected to [target engagement name]." The assertion list refreshes.
When a held assertion in the target engagement was created via redirect, its provenance line includes the redirect origin:
Marvin Percival · just now · Typed · Redirected from [source engagement name]
The "Redirected from [name]" segment is derived from metadata.redirect_from_engagement_name. It appears in {typography.caption} with {colors.ink-faint}, consistent with other provenance metadata.
test_redirect_held_assertion — Create a text assertion (held) in engagement A. Redirect to engagement B. Verify: source assertion is retracted with rationale "Redirected to [B name]". Target assertion exists in engagement B, state held, same content. Target metadata includes redirect_from_engagement_id, redirect_from_assertion_id, redirect_from_engagement_name.
test_redirect_with_file — Upload a file in engagement A. Create a file-based assertion (held). Redirect to engagement B. Verify: file exists in engagement B's storage directory with a new UUID. New uploaded_files row in the database with target engagement ID. Source file unchanged. Target assertion's source_file_id points to the new file.
test_redirect_requires_held — Commit an assertion. Attempt redirect. Verify 409 response.
test_redirect_requires_target_membership — Create a person without membership on the target engagement. Attempt redirect. Verify 403 response.
test_redirect_same_engagement — Attempt to redirect to the same engagement. Verify 422 response.
test_redirect_target_not_found — Attempt to redirect to a nonexistent engagement ID. Verify 404 response.
test_redirect_preserves_metadata — Create an image assertion with GPS metadata. Redirect it. Verify the target assertion carries the GPS metadata, source_mode, extraction_method, and original_filename. Verify redirect provenance keys are added.
test_redirect_event_log — Redirect an assertion. Query the source engagement's event log — verify assertion_retracted event with redirect rationale. Query the target engagement's event log — verify assertion_added event. Both events carry correct actor and timestamps.
test_redirect_file_cleanup_on_failure — Mock a database failure after file copy. Verify the copied file is cleaned up (not left orphaned in the target directory).
Auto-mode posture: Steps 1–2 auto, Checkpoint A. Steps 3–4 auto, Checkpoint B (final).
Step 0 — Pre-flight and CR archival.
Archive this CR to docs/phase-crs/phase-17-cr-redirect-and-display-numbers-v0_2.md. If v0.1 exists in docs/phase-crs/ (uncommitted), replace it. Run pre-flight checks (Section 3.2), including Feature A verification (Section 3.2 item 6).
Verification: 1101 tests passed, 2 skipped. Alembic single head. Feature A confirmed working. CR archived.
Commit: Phase 17 step 0: CR archival and pre-flight.
Step 1 — Redirect endpoint and file copy.
Implement POST /engagements/{source_eid}/assertions/{aid}/redirect (Section 6, Section 8.1). Implement file copy utility with cleanup-on-failure (Section 6.5). Add RedirectAssertionRequest schema. Write all redirect tests (Section 10.1).
Verification: uv run pytest -v green.
Commit: Phase 17 step 1: redirect endpoint.
Checkpoint A — Substrate work complete. Operator confirms before frontend work begins. All redirect tests pass. Redirect endpoint functional with file copy, membership validation, and error handling.
Step 2 — Redirect button and engagement picker.
Add the Redirect button to held assertion cards (Section 9.1). Build the engagement picker modal (Section 9.2). Wire to the redirect endpoint. Handle success (remove card, show confirmation) and error states. Add redirect provenance display for redirected-in assertions (Section 9.3).
Verification: lint + tsc + build clean. Operator can redirect a held assertion to another engagement. The held card disappears from the source. The assertion appears held in the target engagement's Memory room.
Commit (frontend repo): Phase 17 step 2: redirect button and engagement picker.
Step 3 — Implementation notes and tagging.
Create docs/phase-impl-notes/phase-17-implementation-notes-v0_1.md recording: what Phase 17 built, any findings surfaced during execution, any divergences from this CR.
Verification: file exists and is reviewable.
Commit: Phase 17 step 3: implementation notes.
Checkpoint B — Final. Both repos green. Tag both repos as phase-17-redirect-and-display-numbers.
Phase 17 is accepted when:
#1, #2, etc.).
On acceptance: tag both repos as phase-17-redirect-and-display-numbers. Write implementation notes.
POST /engagements/{eid}/assertions/{aid}/redirect.data/files/{engagement_id}/ directories.
> Read the Change Request document at the path I supply below. This is
> CR-2026-031 v0.2, the Phase 17 Change Request (second version;
> supersedes v0.1 which specified Feature A work that turned out to
> already be implemented). You are the executing agent named in the CR.
>
> CR path: ~/Downloads/phase-17-cr-redirect-and-display-numbers-v0_2.md
> (confirm the latest approved version if more than one is present in
> Downloads).
>
> v0.2 drafts against the Phase 17 scoping note v0.1
> (in project knowledge; scoping note has six settled decisions S1–S6).
> Feature A (display numbers) is already implemented — v0.2 verifies
> it at pre-flight and proceeds to Feature B (redirect) only.
>
> Code baseline: tag phase-16-memory-contribution-ui on both repos.
> Substrate: 1101 tests, 2 skips. Frontend: lint + tsc + build clean.
>
> Run pre-flight (Step 0) per Section 3.2. The Step 0 checklist includes:
> Feature A verification (item 6); current_memory_objects JSONB view
> structure; uploaded_files columns (uploaded_by_person_id,
> file_size_bytes, created_at); retract_assertion and add_assertion
> function locations; get_resolved_actor dependency.
>
> Per Section 3.3: archive this CR to
> docs/phase-crs/phase-17-cr-redirect-and-display-numbers-v0_2.md
> at Step 0 before Step 1 begins. Replace v0.1 if present.
>
> Per Section 11, four steps with two checkpoints. Auto-mode posture:
> Steps 1–2 accept auto-mode-proceed; Checkpoint A halts until Operator
> confirms. Steps 3–4 auto; Checkpoint B (final) halts for tagging.
>
> No migration required — redirect provenance lives in the assertion's
> existing metadata dict.
>
> Pre-flight surprises (Section 3.2 ground-truth divergence) stop
> execution at Step 0 and drive a CR v0.3 revision; do not proceed
> through divergence.
>
> Implementation notes at Step 3:
> docs/phase-impl-notes/phase-17-implementation-notes-v0_1.md
> absorbs execution-time surprises and findings.
v0.2 (2026-04-27). Feature A (display numbers) removed from scope — already implemented in the codebase via per-engagement counter row (engagements.next_display_number). Pre-flight expectations corrected: current_memory_objects is JSONB-payload view (not flat columnar); uploaded_files columns corrected to uploaded_by_person_id, file_size_bytes, created_at; test baseline updated to 1101 passed, 2 skipped. Migration removed (no schema change for Feature B). Steps renumbered: four steps, two checkpoints (was six steps, two checkpoints). Feature A acceptance items retained as verification-only (items 3–6). Auth note clarified: get_resolved_actor keyed on path {source_eid}.
v0.1 (2026-04-27). Initial draft. Full Phase 17 scope: display numbers (Feature A) and redirect (Feature B). Five construction decisions (D1–D5) resolving five handoff notes (N1–N5). Migration 0041 for display_number column. Six steps, two checkpoints. Superseded by v0.2 after CC pre-flight established Feature A already implemented.
DUNIN7 — Done In Seven LLC — Miami, Florida Phase 17: Redirect and Display Numbers — CR v0.2 — 2026-04-27