DUNIN7 · LOOMWORKS · RECORD
record.dunin7.com
Status Current
Path change-requests/cr-2026-123-current-engagement-spine-v0_3.md

Loomworks change request — Current-engagement spine (give each person a server-side "where I am"; default the converse scope to it; resolve any engagement handle — number, name, E####/UUID, Personal — to set it) — CR-2026-123 — v0.3

Version. 0.3 Date. 2026-06-26 Status. Change request — Steps 1–3 are built, committed, and suite-green on branch cr-2026-123-current-engagement-spine (engine off main 59c8249; not pushed). Step 4 is re-scoped at v0.3 to engine handle-resolution only; the spoken "open X" classifier intent moves to sibling CR-2026-124. Markdown primary (technical consumer: Claude Code). Halt-before-push; per-step commits with the suite green at each commit; explicit-path staging (never git add -A); per-push explicit Operator authorization.

What changed from v0.2. One substantive change, from the Step 4 build halt. Step 4 is re-scoped. v0.2 framed Step 4 as "only the number lookup is new; reuse _resolve_engagement_name for name and an existing 'open X' handler." Execution against engine 59c8249 found that false: _resolve_engagement_name is id→name (not the name→id "open Goosey" needs), and there is no "open X" intent to wire into (23 intents, none open/navigate). Two of three handles were new construction, not reuse — the "imagined state encoded as fact" Finding ② named, surfacing one step late. v0.3 corrects this (recorded, not smoothed, in the Step 4 re-scope block) by splitting the work (Operator decision, Option A → full Option B via the sibling): v0.3 Step 4 builds a unified engine resolver (number + name + delegated Personal/"0"/E####/UUID) feeding the Step-3 endpoint; the spoken "open X" intent is sibling CR-2026-124. v0.3 alone delivers handle resolution at the endpoint; v0.3 + CR-2026-124 = the full voice "open X" acceptance walk. All name-match and collision policies are now settled decisions grounded in live data (the Step-4 match-policy findings), not assumptions: name match = exact, case-insensitive, person-scoped; duplicate-title = ask-don't-guess (raise candidates); "open 0" = Personal. Steps 1, 2, 3, 5, 6 are unchanged from v0.2 except the Step 4 dependency in Step 5/6's walk (they now sit on the unified resolver, not the old "open X" framing).

CR number (settled at Step 0). Originally drafted as CR-2026-114; Step 0 found 114 taken (Stele P7-1 packaging floor) and the whole 114–122 band allocated (highest = 122). Renumbered to CR-2026-123 — the next free number — across filename, in-document number/title, and branch name. This is the drift the original draft's caveat anticipated.

Source grounding. Drafted from loomworks-current-engagement-spine-scoping-note-v0_2.{html,md} (scoping-notes/), which carries live engine grounding from a read-only inspection on 2026-06-26: alembic head 0094; converse handler src/loomworks/orchestration/routers/converse.py:579; host-account load at :631, companion_name read at :632, personal_engagement_id read at :637, body.project_id branch at :643. Line numbers and signatures below are carried from that note, not from memory. They are re-confirmed at Step 0, because a CR drafted at one moment executes at another and a single intervening edit can shift a line.

Seed alignment — checked, not deferred; spec reconciled at v0.6. The canonical seed is confirmed at v0.12 (candidate-seeds/loomworks/loomworks-candidate-seed-v0_12). The seed is silent on current-engagement focus and converse-path addressing — those sit below the seed, under the architecture specification, which governs when the seed is silent. The architecture specification is now at v0.6 (architecture/loomworks-architecture-specification-v0_6), amended specifically to describe this spine: v0.5 §08 framed Companion posture as "signaled by the surface the Operator is on," which covered surface-based use but not the surfaceless case (voice, standing panel) the spine serves. v0.6 §08 reconciles this — the surface signal stays the Built primary path; the carried current engagement is recorded as Planned (CR-2026-123) supplying scope when no surface signals; and ask-don't-guess at the null boundary is recorded as architectural. The spec now describes what this CR builds — the v0.1 "stale assumption" the Step 0 gate caught is resolved. The one seed-adjacent surface, the authentication framework (seed v0.12 §"Authentication framework", lines 101–141), is not touched: current_engagement_id is a focus pointer, not an identity or authentication surface, and resolution never keys on email. Companion addressing vocabulary (name + per-Operator number + E####; UUID invisible) is governed by the engagement-identity record, which this CR follows and does not reopen. No conflict with the seed or the v0.6 spec is visible. (This closes the version caveat in scoping-note §8, drafted against the older seed v0.8, and the v0.1 spec-silence open item.)

Decisions this CR rests on (settled in the scoping note §6, do not relitigate):

  1. Decision 1 = 1B. All three addressing handles resolve to an engagement the spine can focus on: name, E#### / UUID, and per-Operator number. (Grounding correction at v0.3: the v0.1/v0.2 claim that name resolution could reuse _resolve_engagement_name was false — that resolver is id→name. Name→id and number→id are both fresh build in this CR; E####/UUID/Personal delegate to the existing resolve_engagement_address. See Step 4.)
  2. Decision 2 = defaults. Focus persists until the next "open" or an explicit "close" (no session-boundary clearing — there is no session object). When focus is null and the caller omits project_id, the Companion asks which engagement — it does not silently fall back to Personal.

Plain-language summary

What this does. Today the Companion has no memory of "the engagement you're in right now." Every turn, the caller has to name the engagement — every single time. This change gives each person one server-side note that says "where you are," and teaches the Companion to write it when you say "open X" and read it when you give an instruction without naming an engagement.

The picture. Each person already has a small record (host_account) that holds things like the Companion's name and which engagement is their Personal one. We add one more line to that same record — "current engagement" — sitting right beside a line that already works exactly the same way ("Personal engagement"). It's a copy of something proven, not a new invention. And the spot in the code where the Companion would read that new line is already open on every conversation — it's reading the two lines above it already, so reading the third costs no extra database work.

What you get, in plain terms.

What stays exactly as it is. Anything that already names its engagement (passes project_id) keeps working untouched. The new memory only fires when you don't name one. This is additive and backward-compatible.

What we deliberately do NOT build (keeps it small). No "multiple places at once" — one current spot per person. No "open the second one" reference resolution yet — that's a separate later step that builds on top of this one. No session table — the per-person record is the home until juggling several open contexts is a real need.

The one care-point in execution. Two of the handle lookups are genuinely new code, not clones. The number lookup ("open 7") is scoped against memberships, filtered to the calling person. The name lookup ("open Goosey") is new too — the resolver the v0.2 draft cited for name (_resolve_engagement_name) runs the wrong direction (id→name), so name→id is fresh code with a settled match policy (exact, case-insensitive, person-scoped; ask-don't-guess on duplicate titles). Personal/"0"/E####/UUID are delegated to the existing resolve_engagement_address, not re-implemented. Everything else — the column, the migration, the converse default, the focus-write endpoint — is a clone of an existing pattern or a default added at a read site that already runs.


1. Scope

In scope.

  1. One engine field on host_account: current_engagement_id, uuid, nullable, FK → engagements(id). A structural clone of personal_engagement_id.
  2. One migration (next revision after 0094 — exact id assigned at Step 0): add the column + FK constraint; clean up/down round-trip.
  3. Converse read defaulting: when the caller omits body.project_id, default scope to host_account.current_engagement_id, read at the site where personal_engagement_id is already read (no new database load).
  4. A focus-write path: the PUT /me/current-engagement endpoint that sets/clears current_engagement_id for the person (Step 3, built).
  5. A unified engagement-handle resolver: turns any handle — number, name, E####/UUID, Personal/"0" — into an engagement id, feeding the endpoint. Delegates Personal/"0"/E####/UUID to the existing resolve_engagement_address; adds number + name on top.
  6. Per-Operator-number → engagement resolution (new): "7" → the engagement whose memberships.operator_sequence_number equals 7 for this person. And name → engagement resolution (new): a title, exact/case-insensitive/person-scoped, ask-don't-guess on duplicates.
  7. Companion "where am I" read: surface the current engagement by name + per-Operator number + E#### (UUID invisible).
  8. Decision-2 behaviour at the null-focus boundary: focus persists until next "open"/"close"; null focus + omitted project_id → the Companion asks.

Out of scope (named, not built here).


2. Live grounding carried from the scoping note (re-confirm at Step 0)

These are the facts the build rests on. Each is re-confirmed at Step 0 before any edit.

2.1 host_account is the home

current_engagement_id is a structural clone of personal_engagement_id: Mapped[UUID | None] + ForeignKey("engagements.id"), nullable.

2.2 The converse read site already exists

2.3 "open X" resolution today — the carried correction


3. Step 0 — re-ground before building

Step 0 status: COMPLETE (2026-06-26, read-only). Run by CC against engine main 59c8249 (the CR-2026-122 merge — code-only, alembic unchanged). Results:

The architecture-spec open item from v0.1 is now CLOSED. v0.1 carried an open item: Step 0 found the live spec at v0.5 (not the v0.4 assumed), and CC had not confirmed v0.5 was silent on focus/addressing before building. The deep-read found v0.5 was not silent — §08 framed posture as "signaled by the surface," and the Planned identifier-collapse touched addressing. Rather than build against that, the spec was amended to v0.6 (§08 reconciliation: surface-signal stays Built; carried current engagement is Planned for the surfaceless case; ask-don't-guess recorded). The spec now describes the spine, so the build is no longer against a stale assumption — it is against a spec that names the mechanism. No silence check remains open. At Step 1, CC simply confirms the live highest spec is v0.6 (or higher if newer) and that its §08 still describes the spine as v0.6 left it; no re-reconciliation is expected.

The original Step 0 instructions are retained below for the record.


The scoping note's §9 says a read-only Step 0 was run during scoping at alembic head 0094. **A CR drawn from this note starts from grounded state and does not need to re-run a full Step 0 — unless the engine has moved past 0094.** Step 0 here is therefore a short re-confirmation, with one decisive gate.

CC runs the following read-only checks and reports before any branch is cut or any file is edited. No edits, no migration, no commit in Step 0.

  1. Alembic head gate (decisive). Read the live alembic head.
  1. Confirm the next free CR number in change-requests/. If CR-2026-123 is taken, rename both files and the in-document number; report the chosen number.
  2. Confirm the canonical seed and architecture spec say nothing this CR contradicts: seed is v0.12 (or higher if a newer one has landed — read the highest loomworks-candidate-seed-vN_M present), architecture spec v0.6 (the spine-reconciling amendment; or higher if newer). Confirm the seed remains silent on current-engagement focus / converse-path addressing, that v0.6 §08 still describes the spine as the amendment left it, and that the authentication framework is untouched by this CR. (Seed v0.12 already checked silent; the v0.5-not-silent finding that this item originally guarded is now resolved by the v0.6 amendment — see the CLOSED note above.)
  3. Re-confirm the host-account ORM coordinates: personal_engagement_id at models.py:281, companion_name at models.py:276, and the exact class/table for host_account. The clone is authored beside personal_engagement_id.
  4. Re-confirm the converse read site: host-account load :631, personal_engagement_id read :637, body.project_id branch :643 in converse.py. If a Phase-edit shifted these, report the live line numbers; the placement (beside the personal_engagement_id read; defaulting at the project_id branch) is fixed by this CR, the coordinates are confirmed live.
  5. Re-confirm the resolution surfaces: _resolve_engagement_name at engagement/redirect.py:103 (name), the address resolver (E####/UUID), and that operator_sequence_number lives on memberships with a UNIQUE-per-person constraint. Confirm there is still no engine-side number→engagement path.

Step 0 halt condition. If the alembic head has moved and the migration's down_revision cannot be cleanly set to the live head, or if any §2 fact has diverged in a way that changes the build shape (e.g. a current_engagement_id column already exists, or the converse read site has been restructured), halt and report before cutting the branch. Re-grounding is a report-and-confirm, not a silent adjustment.

Step 0 archival commit. After the checks pass (or the re-ground is confirmed), archive this CR into the engine repo per convention (docs/phase-crs/ or the live equivalent — confirm the live location) as the branch base, and record the baseline: engine origin/main short SHA, the suite baseline (passed / skipped / failed), and the alembic head. This is the only Step 0 commit. Do not push.


4. Steps (each its own commit; suite green at each; halt before push)

Branch off main at the Step 0 archival commit. Branch name: cr-2026-123-current-engagement-spine (rename to match if the CR number changed at Step 0). Explicit-path staging at every commit — name each file on git add, never git add -A.

Step 1 — Add the field and the migration (one commit)

The schema change and its migration land together so the commit is green and self-contained.

(a) ORM. In models.py, beside personal_engagement_id (grounded at :281), add:


current_engagement_id: Mapped[UUID | None] = mapped_column(
    ForeignKey("engagements.id"), nullable=True
)

Match the exact style of the personal_engagement_id line as it reads live (column-name string, FK target spelling, Mapped[UUID | None] annotation). It is a structural clone — mirror the existing line, do not invent a new style.

(b) Migration. Create the next revision after the live head (0094 if unmoved; the live head otherwise). up: add current_engagement_id column + FK constraint to host_account. down: drop the constraint + column. The migration must round-trip clean (up then down then up).

(c) Suite + migration round-trip.

Commit (one): CR-2026-123 step 1: add host_account.current_engagement_id column + migration. Do not push.

Halt condition. If the migration cannot round-trip clean, or the FK constraint name collides, stop and report rather than commit.

Step 2 — Default the converse scope to focus (one commit)

At the converse read site (converse.py, grounded near :637/:643):

(a) Read focus beside the existing reads. Where personal_engagement_id is read off the already-loaded host_account (:637), also read current_engagement_id. No new database load — host_account is in hand.

(b) Default the scope when project_id is omitted. At the body.project_id branch (:643), resolve the effective engagement as:


engagement_id = body.project_id or host_account.current_engagement_id

The existing membership-verify and context-load then run against the resolved id. Existing callers that pass project_id are unaffected — the default only fires when project_id is omitted.

(c) Decision-2 null boundary. When both body.project_id is omitted and current_engagement_id is null: the Companion asks which engagement. It does not silently fall back to Personal. Implement the "ask" as the Companion-facing response at this boundary (the exact surface — a converse response shape vs. a Companion-side prompt — is a placement detail; the behaviour is fixed: ask, never silently default to Personal).

(d) Tests. Add tests pinning:

(e) Suite + DB-truth. Full suite green (now baseline + new tests). DB-truth in playground_dev: a converse turn with project_id omitted and current_engagement_id set resolves against the focused engagement.

Commit (one): CR-2026-123 step 2: default converse scope to host_account.current_engagement_id when project_id omitted. Do not push.

Halt condition. If defaulting cannot be added without changing behaviour for callers that pass project_id, stop and report.

Step 3 — Focus-write path: set current_engagement_id (one commit)

Add the engine operation the Companion calls on "open X" to set current_engagement_id for the calling person.

(a) The write. Set host_account.current_engagement_id for the person to a resolved engagement id. Whether this is a dedicated endpoint or folds into an existing operation is a placement detail — default: a dedicated focus-set operation, because "set where I am" is a distinct verb from anything existing; CC reports if the codebase convention favours folding it into an existing converse intent, and the Operator picks. The "close" verb sets current_engagement_id back to null (Decision 2: explicit close clears).

(b) Authorisation. The write sets focus only for the calling person, and only to an engagement that person is a member of. Reuse the existing membership-verify rather than authoring a new check.

(c) Tests. Set focus → row updated for that person only. Close → focus null. Cannot set focus to an engagement the person is not a member of.

(d) Suite + DB-truth. Full suite green. DB-truth: set focus through the operation, confirm the host_account row, then a subsequent omitted-project_id converse turn resolves against it.

Commit (one): CR-2026-123 step 3: focus-write operation (set/clear current_engagement_id). Do not push.

Halt condition. If focus-write cannot reuse the existing membership-verify, or needs a new authorisation surface, stop and report — a new auth surface is out of scope for this CR.

Step 4 — Unified handle resolver: number + name added to the existing address resolver (one commit) — re-scoped at v0.3

v0.3 re-scope (recorded, not smoothed). v0.1/v0.2 framed Step 4 as "only the number lookup is new; everything else is a clone — reuse _resolve_engagement_name for name, the address resolver for E####/UUID." Execution found that false against engine 59c8249: (1) _resolve_engagement_name (redirect.py:103) is id→name (it reads the Seed's engagement name to label a redirect), not the name→id "open Goosey" needs — name resolution does not exist; (2) there is no "open X" intent in IntentLabel (23 intents, none open/navigate/switch) to "wire into." So two of the three handles were new construction, not reuse — the exact "imagined state encoded as fact" Finding ② warns against, one step too late. This is the misstatement; it is named here and corrected, not quietly fixed.

The v0.3 split (settled with the Operator). Step 4 is re-scoped to engine resolution only. It builds a unified resolver that turns any handle — number, name, E####/UUID, Personal/"0" — into an engagement id, set through the PUT /me/current-engagement endpoint built in Step 3. The spoken "open X" intent (the classifier change that recognizes "open Goosey" as an open-an-engagement utterance) is explicitly out of scope and is the sibling CR-2026-124. v0.3 + CR-2026-124 stacked = the full voice "open X" capability (the original acceptance walk); v0.3 alone delivers handle resolution at the endpoint, tested and usable, with the spine beneath it.

Grounding (live, engine 59c8249, via the Step-4 match-policy findings):

(a) The two new lookups.

(b) The unified resolver — delegate first, add on top, explicit ordering. Build one resolver returning a bare UUID on success and raising a typed reason-carrying error on miss (reuse EngagementAddressError or a sibling the caller maps identically — do not diverge to an Optional[UUID]/None contract). It does not re-implement Personal/"0"/E####/UUID — it delegates those to resolve_engagement_address. Resolution order (settled, to resolve the bare-integer collision):

  1. Address resolver first — hand the token to resolve_engagement_address. This keeps "0", "Personal", E####, and UUIDs at their exact current meaning. In particular "open 0" → Personal (settled decision): "0" is consumed here before the number path is ever reached, so the number path cannot steal it. (A bare "0" is the only integer the address resolver claims; every other bare integer falls through.)
  2. Bare positive integer → number lookup — if the address resolver did not resolve it and the token is a bare positive integer, treat it as the per-Operator number (a).
  3. Otherwise → name lookup — treat the token as a title and run the name→id match (a).

(c) Duplicate-title policy — ask, don't guess (settled, matches Decision 2). If the name lookup finds more than one engagement with the same title under the caller, the resolver does not first-match (first-match silently picks wrong — the exact thing Decision 2's "ask, don't guess" rejects). It raises a typed ambiguous-handle error carrying the candidates (the engagement ids + their E#### + per-Operator numbers, so the caller can present a choice). At the engine-resolution layer (this CR) there is no conversation surface to ask on — so v0.3's honest behaviour is detect ambiguity and raise with candidates; the spoken "did you mean Goosey-A or Goosey-B?" disambiguation lands in CR-2026-124, where the conversation surface exists. This split is recorded so the sibling CR inherits it, not rediscovers it.

(d) Render-robustness reminder (identity record). E#### rendering must not assume exactly four digits — no hard-coded 4-digit assumption anywhere this CR touches engagement labels. (This is also the motivation behind the spec's Planned identifier collapse — see the collapse note below.)

(e) Identifier-collapse note (Finding ②) — carried forward, resolvers stay separate. Architecture spec v0.6 carries a Planned refactor (collapse display_identifier into operator_sequence_number, render E#### from it). v0.3 does not pre-adopt it: the unified resolver delegates E####/UUID to the existing address resolver and adds number + name beside it, rather than deriving E#### from the number. Reason unchanged from v0.2: the collapse is Planned not settled; pre-adopting builds against state that does not exist and touches a working resolver to chase a refactor that isn't here. When the collapse settles (separate later work), folding E#### into the number path is a clean follow-on, not a teardown — the unified resolver's structure already routes all handles to one id.

(f) Tests. Number: "7" for a member at sequence 7 → that engagement; "7" for no such membership → typed miss (not a wrong-engagement id). Name: exact case-insensitive person-scoped match → id; non-match → typed miss. Duplicate title: fabricate two same-title engagements under one person → ambiguous-handle error carrying both candidates (pins the Decision-2 policy the fixtures don't otherwise exercise). Collision: "0" → Personal (via the address resolver, not the number path); "Personal" → Personal; E#### → its engagement; a bare integer that is not 0 → number path. Person-scoping: person A's "7" and person B's "7" resolve to different engagements. Contract: success returns bare UUID; every miss raises the typed reason-carrying error (no None path).

(g) Suite + DB-truth. Full suite green. DB-truth in playground_dev: each handle form (number, name, "0"→Personal, E####) resolves through the unified resolver and sets focus via PUT /me/current-engagement.

Commit (one): CR-2026-123 step 4: unified engagement-handle resolver (number + name + delegated address) feeding current-engagement set. Do not push.

Halt condition. If the unified resolver cannot delegate Personal/"0"/E####/UUID to resolve_engagement_address without re-implementing it; if number resolution cannot be person-scoped with the live memberships shape; or if the ambiguous-title case cannot be raised as a typed candidates-carrying error — stop and report rather than commit.

Step 5 — "Where am I" read (one commit)

The Companion surfaces the current engagement on "where am I" / "what am I in".

(a) The read. Surface the focused engagement by name + per-Operator number + E#### (UUID invisible, per the identity record). Whether this is a new converse intent or a Companion-side surface read is a placement detail — default: a Companion-side read of the already-resolved focus; CC reports if a converse intent is the better fit, Operator picks.

(b) Null focus. "where am I" with null focus → the Companion says you're not in an engagement (consistent with Decision 2's ask-don't-guess posture), not a guessed Personal.

(c) Tests. Non-null focus → returns name + number + E####. Null focus → "not in an engagement" response, no guess.

(d) Suite. Full suite green.

Commit (one): CR-2026-123 step 5: "where am I" surfaces focused engagement (name + per-Operator number + E####). Do not push.

Step 6 — Full-suite regression + database-truth (no commit)

Run the complete suite. Verify against playground_dev the end-to-end walk at the resolution + endpoint level (the spoken "open X" walk belongs to sibling CR-2026-124; here each handle is exercised through the unified resolver + PUT /me/current-engagement):

  1. Resolve "Goosey" (by name) → set via endpoint → omitted-project_id converse turn resolves scope to Goosey.
  2. Resolve "E0007" (by E-label, delegated to resolve_engagement_address) → set.
  3. Resolve "7" (by number) → set to the engagement at the calling person's sequence 7.
  4. Resolve "0" and "Personal" → both resolve to Personal (delegated; the number path does not steal "0").
  5. Resolve a duplicate title (fabricated two same-title engagements under one person) → ambiguous-handle error carrying both candidates (no silent first-match).
  6. "where am I" → returns name + per-Operator number + E####.
  7. Clear (the "close" path) → focus null → next omitted-project_id turn → asks, no Personal fallback.
  8. A turn that passes project_id explicitly → behaves exactly as before (focus ignored).

Halt and report. Verification-only — no commit.


5. What must not happen in this CR


6. Halt and report

After Step 6, halt before any push. Report:

On the Operator's review and explicit authorisation, push CR-2026-123. The immediate next build is sibling CR-2026-124 — the spoken "open X" classifier intent that calls this CR's unified resolver, completing the full voice "open X" capability (Option B). After that, queue item C (reference resolution, "the second one") extends this same host_account row as a further separate CR.


7. Reversibility

Each step is one commit, independently revertible to a green suite. Step 1 (the only schema change) reverts via the migration down plus the ORM-line revert. Steps 2–5 are code-only and revert as single commits. There is never a window where the suite is un-runnable between commits — every commit lands green (the per-step suite gate enforces this).


DUNIN7 — Done In Seven LLC — Miami, Florida Current-engagement spine — change request — CR-2026-123 — v0.3 — 2026-06-26