Gym Twin merge requests: Talk threads scoped to git, reviewed in 3D
No new module. Talk's page key gains a change, so a thread is either Live (main, the gym Fernando sees) or one merge request's review, and page_access answers a review with its existing owner-only rule. CI builds every MR commit into a review line in gym_twin's Revisions, which the Gym Viewer and HUD open as the merge request page in 3D. gym_twin's Mirror, which already copies Live threads to Rocket.Chat, copies review threads to GitLab MR discussions, where the Agent Loop already turns a note into a commit and a reply. N in the viewer opens a HUD draft at the crosshair with a snapshot of the view. Talk stores an image once and artifacts_host serves it by hash, so the Mirror sends links and both chats render the same copy. Merging deploys main, and Revisions tells Fernando's threads what changed. Hand-published preview pages go away.
Modules
Seams
PageRef.change (HUD ⇄ Talk ⇄ page_access): the page key every thread, socket and Grant already uses gains change. null is Live, the gym Fernando sees; !N is that merge request's review. page_access answers a review page with the owner-only policy it already has.
Anchor view (Gym Viewer ⇄ Talk): as today: extra.view {layout, build, sha} and the placement. A review thread is outdated when its placement differs between view.build and the head's build.
Release line (CI → Revisions): release.json gains line and base. Line main flips the live site as today; line !N only adds a commit to that review.
Mirror sink (Talk ⇄ Mirror ⇄ GitLab): the Rocket.Chat mirror's Store takes a second sink: a review thread is one MR discussion (gl:<note> beside rc:<id>). Live threads still go to Rocket.Chat only.
MR note → commit (GitLab ⇄ Agent Loop): as is: the harness routes MR notes to the MR's agent, which pushes to the branch and replies.
Draft (N) (Gym Viewer → HUD → Talk): N calls __hud.compose({anchor, images}), beside the existing openAt: the anchor at the crosshair and a snapshot of the canvas. The HUD shows the snapshot in its composer; nothing reaches Talk until Send, which uploads it and starts the thread. Cancel stores nothing.
Image (HUD → Talk → artifacts_host; Mirror → RC, GitLab): an image is uploaded once and stored by its sha256; a post carries ImageRefs. The Mirror sends only the link: Rocket.Chat as image_url, GitLab as , so both render the one CDN copy. The URL works without login, because Rocket.Chat's mobile app and link previews carry no cdn cookie: the hash is the secret. An image someone uploads in Rocket.Chat or GitLab needs their login to fetch, so the Mirror copies it into Talk once.
Merge notice (Revisions → Talk): a main release that names mr !N posts “Changed in <title>” on the Live threads the change log cites (its entries gain threads).
Flow
Types
~PageRef · the key of every thread
site, route, subpath
as today
+ change: "!N" | null
null = Live; !1510 = that MR's review
=Grant · what a caller may do on a page
view, notes, comment, owner
Live
the page's share: Fernando and Austin comment
Review
OWNER_ONLY, the policy page_access already has
=Anchor · where a 3D thread points
target, label
"scene3d", the item's name
extra.view
{layout, build, sha, revision}: the commit on screen
extra.placement
{id, local}: follows the item across builds
extra.point, camera
fallback position and the view
~Release · what a pipeline hands Revisions
sha, short, at, title, mr
as deploy.sh writes today
+ line
"main" | "!N"
+ base
the main commit the MR is compared against
+Line · a sequence of commits in Revisions
+ name
main | !N
+ commits: Commit[]
only commits whose builds changed
+ head: Commit
=Commit · one git commit as the gym shows it
sha, short
never shown alone
title, at, mr
the subject is what the UI shows
builds: {layout: BuildId}
=BuildId · content address of one layout's build
16 hex
sha256 of scene.glb, collision.json, checks.json, canonical manifest
no sha inside
the MR head and its squash on main share one BuildId
+ChangeSet · the diff between two builds of a layout
+ base, head: BuildId
+ added: id[]
highlighted
+ removed: {id, bounds}[]
ghost boxes
+ moved: {id, from, to}[]
with a trail
+ changed: {id, component}[]
same place, new geometry
~ChangeLogEntry · one answered comment in the page's change log
id, title, response, status, layouts
as R2's change-log.json
+ threads: ThreadId[]
the Live threads it answers
~MirrorLink · the mirror Store's pairing of a thread
thread → rc:<tmid>
a Live thread's Rocket.Chat root
+ thread → gl:<discussion>
a review thread's MR discussion
post n ⇄ message id
each post sent once, either way
+Draft · what N hands the HUD's composer
+ anchor: Anchor
the viewer's anchorer at the crosshair
+ images: Blob[]
the canvas snapshot; shown, not uploaded
+ text
empty; the cursor waits there
~HudApi · the window.__hud a page talks to
extend, setFacet, facet, openAt
as today
+ compose(draft)
opens the composer with the draft
+ImageRef · one stored image, by content
+ sha256
its name and its secret
+ url
cdn.molab.life/talk-media/<sha256>.<ext>
+ type, width, height
image/png | jpeg | webp
~Post · one message in a thread
n, author, text, at
as today
+ images: ImageRef[]
shown under the text; rendering is out of scope
origin
rc:<id> or gl:<note> when it came from a mirror
−PreviewPage · a hand-published shary-walkthrough--<name>