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):
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.)project_id, the Companion asks which engagement — it does not silently fall back to Personal.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.
In scope.
host_account: current_engagement_id, uuid, nullable, FK → engagements(id). A structural clone of personal_engagement_id.0094 — exact id assigned at Step 0): add the column + FK constraint; clean up/down round-trip.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).PUT /me/current-engagement endpoint that sets/clears current_engagement_id for the person (Step 3, built).resolve_engagement_address; adds number + name on top.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.E#### (UUID invisible).project_id → the Companion asks.Out of scope (named, not built here).
body.project_id callers. The default only fires when project_id is omitted.These are the facts the build rests on. Each is re-confirmed at Step 0 before any edit.
host_account is the homehost_account.personal_engagement_id — uuid, nullable, FK → engagements(id) (host_account_personal_engagement_id_fkey). ORM at models.py:281: Mapped[UUID | None].host_account.companion_name — text, NOT NULL, default 'Companion'. ORM at models.py:276.id = principals.id) → one column = one focus per person. The single-focus rule falls out of the table shape.engagements PK = id (uuid), engagements_pkey — the FK target.0094.
current_engagement_id is a structural clone of personal_engagement_id: Mapped[UUID | None] + ForeignKey("engagements.id"), nullable.
src/loomworks/orchestration/routers/converse.py:579, async def operator_converse(...).body.project_id (ConverseRequest, schemas.py:539). At converse.py:643: if body.project_id is not None: → membership verify + context load.host_account at :631; reads companion_name at :632; reads personal_engagement_id at :637. current_engagement_id is read at :637, beside personal_engagement_id, with no new database load — host_account is already loaded for this person on this call.current_engagement / focused_engagement / active_engagement returns nothing relevant — no server-side focus exists today.EngagementList.tsx:137 resolves on display_identifier ?? project_id (E#### / UUID). Not name, not per-Operator number, at the address bar.page.tsx:16) tolerates 0, Personal, E####, or a UUID.engagement/redirect.py:103 _resolve_engagement_name(...), used by the cross-engagement redirect flow — not the address resolver. Reusable.voiceRecognizer.ts:13-14 ("engagement N → expand the row whose operator_sequence_number === N"), driving EngagementList.tsx:48,55. It expands a list row; it does not resolve to an address and has no engine-side path.operator_sequence_number lives on memberships (BIGINT, NOT NULL, UNIQUE per person), the per-person-per-engagement sequence — not on host_account. (This is the handoff correction the scoping note recorded: the handoff implied the sequence might be host-side; it is not. The current_engagement_id-on-host_account conclusion is unaffected, but "open 7" by-number has no resolution path today — which is why Decision 1B's number lookup is fresh build.)
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:
0094, unmoved. Grounding holds; no re-ground needed. The migration's down_revision is cleanly 0094.models.py companion_name :276, personal_engagement_id :281, host_account __tablename__ :269; converse.py operator_converse :579, host-account load :631, companion_name read :632, personal_engagement_id read :637, body.project_id branch :643 (verbatim); redirect.py _resolve_engagement_name :103; operator_sequence_number on memberships with uq_memberships_person_operator_sequence_number (person_id, operator_sequence_number) UNIQUE + CHECK ≥ 0; engagements PK id uuid.current_engagement_id column; no server-side current/focused engagement in code; converse read site intact.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.
0094 → grounding holds; proceed using the line coordinates in §2, after confirming items 2–6 below.0094 → the engine has moved. Re-ground: re-confirm every fact in §2 against the live tree, and in particular re-confirm that the new migration's down_revision is the current head (not 0094). Report any divergence (a new column on host_account, a moved read site, a changed FK name) before proceeding. Do not assume the §2 line numbers survived the intervening revisions.change-requests/. If CR-2026-123 is taken, rename both files and the in-document number; report the chosen number.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.)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.: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._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.
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.
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.
host_account in playground_dev. Apply down; confirm clean removal. Apply up again.
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.
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:
project_id + non-null current_engagement_id → scope resolves to the focused engagement.project_id → behaves exactly as before (focus ignored when project_id is supplied).project_id + null current_engagement_id → the Companion asks; no Personal fallback; no commit lands in the wrong engagement.
(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.
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.
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):
engagements.title is TEXT, nullable, no default, no UNIQUE constraint or index (global or per-person) — and no app-level uniqueness guard. Titles are free text; duplicate titles per person are structurally allowed.resolve_engagement_address (src/loomworks/api/address_resolver.py) returns a bare UUID on success and raises EngagementAddressError(ValueError) with a human-readable reason on miss; the HTTP layer translates that to 400. It is the sole handler of Personal/"0"/E####/UUID (Personal literals = {"0","personal"}, case-insensitive, → personal_engagement_id).(a) The two new lookups.
memberships filtered to that person for the row whose operator_sequence_number equals the number; return its engagement id. Per-person uniqueness holds live (uq_memberships_person_operator_sequence_number UNIQUE + CHECK ≥ 0), so the lookup returns at most one.engagements.title for engagements the calling person is a member of. Match policy (settled decisions, recorded): exact (not fuzzy/substring), case-insensitive (lower(title) = lower(token)), person-scoped (only the caller's memberships). These are decisions, grounded in the free-text-no-constraint reality of title — the resolver owns the whole policy because the schema enforces none of it.
(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):
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.)
(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.
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.
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):
project_id converse turn resolves scope to Goosey.resolve_engagement_address) → set.E####.project_id turn → asks, no Personal fallback.project_id explicitly → behaves exactly as before (focus ignored).Halt and report. Verification-only — no commit.
project_id callers' behaviour. The default fires only on omission.E####/UUID, or Personal/"0" only — never email, per the seed's authentication framework.E#### assumption.After Step 6, halt before any push. Report:
0094, or did it move — and if it moved, the re-ground findings).project_id-passing callers are byte-for-byte unaffected.resolve_engagement_address (not re-implemented).
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.
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