Skip to content

Commit c6c3824

Browse files
committed
feat(workspace, resolver, strategy-curator), test(prove-didi-auth): curator liveness — domain/source mutations broadcast to every connected session
Step 6 of the humain-vc unlock build order. Aniel adds a link, Michael's screen updates without a refresh, and vice versa — two people in the same tenant, broadcast-to-all-sessions is the whole liveness model, no presence/cursors/CRDTs needed. Backend (services/record-surrealdb-resolver/src/domains.ts, services/workspace/src/ws.ts): - registerDomainHandlers gained a broadcast() helper (fire-and-forget nc.publish, same pattern as workspaces.ts's workspace.active.changed), called after domain.create/retype and source.add/update/remove/ extract.add each commit — domain.created, domain.retyped, source.added, source.updated, source.removed, extract.added, payload carries slugs + client_slug(s) + actor. - Those six subjects added to ws.ts's BROADCAST_SUBJECTS. Frontend (apps/strategy-curator): - App.svelte watches workspace.events, dedups by seq (record-collector's App.svelte set this precedent — an $effect re-fires on every reactive dependency, not just new events), refetches strategies on domain events or sources on source/extract events, scoped to the viewer's active client + domain. - curation.svelte.ts gained refreshSources() — re-fetches without resetting focus/tags, unlike select()'s user-driven switch-domain reset. Verification (scripts/prove-didi-auth.mjs): - New LIVENESS=1 mode, same shape as GATE=1/ATTRIBUTION=1 — two independently-authenticated WS sessions against the live local stack; session A invokes domain.create then source.add, session B (idle) asserted to receive the matching broadcasts with no polling. Passed both against rebuilt workspace-service + record-surrealdb-resolver containers. Test rows/files cleaned up after, same discipline as step 4. apps/strategy-curator (svelte-check) and both touched services (tsc --noEmit) are clean. Files changed: - services/record-surrealdb-resolver/src/domains.ts - services/workspace/src/ws.ts - apps/strategy-curator/src/App.svelte - apps/strategy-curator/src/curation.svelte.ts - scripts/prove-didi-auth.mjs - context-v/plans/Build-Order-Humain-VC-Unlock-Flow.md - changelog/2026-07-08_01_Curator-Liveness-Domain-And-Source-Mutations-Broadcast-Live.md
1 parent 09a6562 commit c6c3824

7 files changed

Lines changed: 314 additions & 21 deletions

File tree

apps/strategy-curator/src/App.svelte

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
<script lang="ts">
22
import { onMount } from 'svelte';
3+
import { workspace } from '@augment-it/workspace';
34
import { curation } from './curation.svelte';
45
import StrategyPicker from './StrategyPicker.svelte';
56
import SourceList from './SourceList.svelte';
@@ -10,6 +11,46 @@
1011
onMount(() => {
1112
curation.init();
1213
});
14+
15+
// Curator liveness (Build-Order Step 6): two people in the same tenant
16+
// see each other's domain/source edits without a refresh. Broadcasts
17+
// land on workspace.events (ws.ts's BROADCAST_SUBJECTS); dedup by seq
18+
// the same way record-collector's App.svelte does, so each event is
19+
// handled exactly once regardless of how many reactive deps re-fire
20+
// this effect. domain.retyped carries client_slugs (plural — a domain
21+
// can span clients); every other subject carries client_slug (singular).
22+
let lastProcessedSeq = -1;
23+
$effect(() => {
24+
const ev = workspace.events[workspace.events.length - 1];
25+
if (!ev || ev.seq <= lastProcessedSeq) return;
26+
lastProcessedSeq = ev.seq;
27+
28+
const payload = ev.payload as {
29+
client_slug?: string;
30+
client_slugs?: string[];
31+
domain_slug?: string;
32+
type?: string;
33+
old_type?: string;
34+
};
35+
36+
if (ev.subject === 'domain.created' || ev.subject === 'domain.retyped') {
37+
const inThisClient =
38+
ev.subject === 'domain.retyped'
39+
? (payload.client_slugs ?? []).includes(curation.clientSlug ?? '')
40+
: payload.client_slug === curation.clientSlug;
41+
const touchesActiveType = payload.type === curation.domainType || payload.old_type === curation.domainType;
42+
if (inThisClient && touchesActiveType) void curation.loadStrategies();
43+
} else if (
44+
ev.subject === 'source.added' ||
45+
ev.subject === 'source.updated' ||
46+
ev.subject === 'source.removed' ||
47+
ev.subject === 'extract.added'
48+
) {
49+
if (payload.client_slug === curation.clientSlug && payload.domain_slug === curation.activeSlug) {
50+
void curation.refreshSources();
51+
}
52+
}
53+
});
1354
</script>
1455

1556
<div class="sc-app">

apps/strategy-curator/src/curation.svelte.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,20 @@ class CurationState {
461461
const i = this.sources.findIndex((x) => x.source_uuid === s.source_uuid);
462462
if (i >= 0) this.sources[i] = withSlug(s);
463463
}
464+
465+
// Re-fetch the active domain's sources without resetting focus/tags —
466+
// the curator-liveness path (App.svelte's workspace.events effect, Step
467+
// 6) calls this when a REMOTE session's mutation lands, as opposed to
468+
// select() which is the user-driven "switch domain" path and resets
469+
// focus deliberately.
470+
async refreshSources(): Promise<void> {
471+
if (!this.activeSlug) return;
472+
const r = await this.call<{ sources: Source[] }>('domain.assemble', { type: this.domainType, slug: this.activeSlug, client_slug: this.clientSlug });
473+
if (!r) return;
474+
const nextSources = (r.sources ?? []).map(withSlug);
475+
this.sources = nextSources;
476+
if (this.focusIdx >= nextSources.length) this.focusIdx = Math.max(0, nextSources.length - 1);
477+
}
464478
}
465479

466480
// Belt-and-suspenders: if a source arrives without source_slug but with a
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
---
2+
date_created: 2026-07-08
3+
date_modified: 2026-07-08
4+
title: "Curator liveness — domain and source mutations broadcast live, proven with two independent sessions"
5+
lede: "Step 6 of the humain-vc unlock build order: Aniel adds a link, Michael's screen updates without a refresh, and vice versa. The resolver now broadcasts after every domain/source mutation commits; the curator surface refetches on the ones that touch its active view."
6+
publish: true
7+
authors:
8+
- Michael Staton
9+
augmented_with:
10+
- Claude Code on Claude Sonnet 5
11+
files_changed:
12+
- services/record-surrealdb-resolver/src/domains.ts
13+
- services/workspace/src/ws.ts
14+
- apps/strategy-curator/src/App.svelte
15+
- apps/strategy-curator/src/curation.svelte.ts
16+
- scripts/prove-didi-auth.mjs
17+
- context-v/plans/Build-Order-Humain-VC-Unlock-Flow.md
18+
tags:
19+
- Progress-Update
20+
- Curator-Liveness
21+
- Augment-It
22+
- Didi-Platform
23+
- humain-vc
24+
- NATS
25+
---
26+
27+
## Why Care?
28+
29+
Flow 1's whole point is two people working the same thesis corpus side by side. Without this step, "Aniel adds a link → Michael's screen updates" required a manual refresh — fine solo, friction the moment two people are actually in the room together. Step 6 closes that gap the cheap way: broadcast-to-all-sessions within one tenant, no presence, no cursors, no CRDTs — just an event and a refetch.
30+
31+
## What's New?
32+
33+
- **Six mutations now broadcast on commit**: `domain.created`, `domain.retyped`, `source.added`, `source.updated`, `source.removed`, `extract.added`. All owned by `services/record-surrealdb-resolver/src/domains.ts`'s `registerDomainHandlers` — the single service that already runs each mutation's full DB-plus-filesystem lifecycle, so it's the one place that actually knows a mutation succeeded before announcing it. Payload carries the domain/source slugs, `client_slug` (or `client_slugs` for retype, which can span clients), and `actor`.
34+
- **`services/workspace/src/ws.ts`** forwards those six subjects to every connected session alongside the existing `record_set.*` / `prompt.*` / `response.*` / `workspace.active.changed` broadcasts — no new plumbing needed there, just six more subject strings.
35+
- **`apps/strategy-curator/src/App.svelte`** watches `workspace.events`, dedups by `seq` (record-collector's App.svelte set this precedent — a naive `$effect` re-fires on every reactive dependency, not just new events), and calls `loadStrategies()` for domain events or the new `refreshSources()` for source/extract events — scoped to the viewer's active client + domain so a broadcast from someone else's workspace or thesis is a no-op.
36+
- **`refreshSources()`** is new on the curator's state singleton: re-fetches the active domain's sources without resetting focus or the tag vocabulary, unlike `select()` (the user-driven "switch domain" path, which resets both on purpose).
37+
38+
## The Story
39+
40+
Verifying this without two literal browser windows: added a `LIVENESS=1` mode to `scripts/prove-didi-auth.mjs`, following the same pattern as step 3's `GATE=1` and step 4's `ATTRIBUTION=1`. Two independently-authenticated WS sessions open against the live local stack — session A invokes `domain.create` then `source.add`; session B, which never invokes anything, is asserted to receive the `domain.created` and `source.added` broadcast frames with matching payloads, no polling. Both passed against `docker compose`'s live `workspace-service` + `record-surrealdb-resolver` containers (rebuilt for this change). Test domain and source cleaned up from SurrealDB and the humain-vc filesystem afterward — same discipline step 4 established.
41+
42+
```mermaid
43+
sequenceDiagram
44+
participant A as Session A (Aniel)
45+
participant R as resolver (domains.ts)
46+
participant W as workspace-service (ws.ts)
47+
participant B as Session B (Michael)
48+
A->>R: source.add
49+
R->>R: DB write + content-ingest file write
50+
R->>W: nc.publish('source.added', {...})
51+
W->>B: EventFrame (broadcast)
52+
B->>B: refreshSources()
53+
```
54+
55+
`apps/strategy-curator` (`svelte-check`) and both touched services (`tsc --noEmit`) are clean.
56+
57+
## What's Next
58+
59+
Step 7 — instance posture + sign-in wall: when the workspace-service reports `DIDI_AUTH=required` and the session has no `didi_id`, the shell should render the sign-in panel as a full pre-auth wall instead of mounting remotes, and hide the WorkspaceSwitcher when the instance is pinned to one client.
60+
61+
## Related
62+
63+
- `context-v/plans/Build-Order-Humain-VC-Unlock-Flow.md` — Step 6, now done
64+
- `context-v/plans/Unlock-Humain-VC-Team-Access-To-Augment-It.md` (ai-labs level) — the scope of record, item 8
65+
- `context-v/specs/Workspaces-as-Tenant-Primitive.md` — the tenant-aware envelope Step 4's actor attribution and this step's broadcasts both build on

context-v/plans/Build-Order-Humain-VC-Unlock-Flow.md

Lines changed: 48 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@
22
title: "Build order: the humain-vc unlock flow, step by step"
33
lede: "The execution sequence for Flow 1 (Michael + Aniel, side-by-side thesis corpus building on a hosted augment-it) — each step names its repo, files, and verification so any fresh session can pick up mid-sequence. The strategy and scope cuts live in the ai-labs plan; this is the how."
44
date_created: 2026-07-06
5-
date_modified: 2026-07-06
5+
date_modified: 2026-07-08
66
authors:
77
- Michael Staton
88
augmented_with:
99
- Claude Code on Claude Fable 5
10-
semantic_version: 0.0.1.0
10+
- Claude Code on Claude Sonnet 5
11+
semantic_version: 0.0.2.0
1112
status: Ready
1213
tags:
1314
- Plan
@@ -26,20 +27,27 @@ tags:
2627
> deliberately-NOT-built list. Read it first; this doc only sequences.
2728
> Identity spec of record: `ai-labs/context-v/specs/Id-Didi-Sh-Identity-Service.md`.
2829
29-
## State as of writing (2026-07-06, end of day — verify, don't assume)
30+
## State as of writing (2026-07-08 — verify, don't assume)
3031

3132
- **Live URLs:** `https://id.didi.sh` (identity service on Fly — full
3233
magic-link loop operator-clicked in production; Resend domain-verified,
3334
sender `no-reply@didi.sh`), `https://didi.sh` + `www` (the `site/`
3435
conversion surface on Vercel), the GitHub splash.
35-
- **Steps 1–5 DONE** (see their sections): real email, orgs + memberships
36+
- **Steps 1–6 DONE** (see their sections): real email, orgs + memberships
3637
seeded local AND prod (Michael = superuser, 3 addresses; Aniel pends his
3738
address), the membership gate proven (4401 / admitted / 4403), the
3839
actor attribution envelope proven live (created_by/updated_by on
39-
domains/sources/source_usages + corpus frontmatter), and thesis
40+
domains/sources/source_usages + corpus frontmatter), thesis
4041
vocabulary (Corpora Curator rename, operator-defined + per-workspace-
4142
default domain type, `domain.retype` migration — `consumer-immunology`
42-
is now `thesis:consumer-immunology`).
43+
is now `thesis:consumer-immunology`), and curator liveness (domain/source
44+
mutations broadcast over NATS; the curator surface refetches on events
45+
from a second session — proven via `LIVENESS=1` on the prove script).
46+
- **Interleaved but separate:** `feature/augment-affiliations` (the
47+
Augment-From-Affiliations MVP, `context-v/specs/Augment-From-Affiliations.md`)
48+
shipped and merged into `rebuild/turbo-rsbuild` on 2026-07-08, between
49+
steps 5 and 6 of this sequence — a different flow, same repo, not part of
50+
this build order.
4351
- augment-it workspace-service verifies `didi_session` on WS upgrade
4452
(`services/workspace/src/didi.ts`); shell has the DidiBadge sign-in AND
4553
a "Flows" jumbo popdown ("Build Corpora" → strategyCurator, full-screen);
@@ -54,7 +62,8 @@ tags:
5462
repeatable check either way.
5563
- The DO droplet (167.172.42.247) is prepped: Coolify removed, 2GB swap,
5664
Docker 28, ports 80/443 free, SSH via the id_rsa_nopass key.
57-
- **NEXT: step 6** (curator liveness), then 7–8, then the deploy tail.
65+
- **NEXT: step 7** (instance posture + sign-in wall), then 8, then the
66+
deploy tail.
5867

5968
Steps 1–8 are local, each verifiable on the laptop; 9–12 are the deploy
6069
tail. Steps marked ⚑ need an operator decision or action first.
@@ -197,20 +206,39 @@ more general than the original sketch:
197206
frontmatter, verified by direct SurrealDB query and `cat`); reach-edu
198207
untouched, still resolves `'strategy'`.
199208

200-
## Step 6 — Curator liveness (augment-it)
201-
202-
- Resolver + content-ingest handlers publish NATS events after mutations:
203-
`domain.created`, `domain.retyped`, `source.added`, `source.updated`,
204-
`source.removed`, `extract.added` (payload: slugs + client_id + actor).
205-
- Add those subjects to `BROADCAST_SUBJECTS` in
209+
## Step 6 — Curator liveness (augment-it) ✅ DONE 2026-07-08
210+
211+
Done as sketched, with the broadcast owned by the resolver alone (the single
212+
service that already runs each mutation's full DB + content-ingest
213+
lifecycle end to end, so it's the one place that knows a mutation actually
214+
succeeded) rather than split across resolver and content-ingest:
215+
216+
- `services/record-surrealdb-resolver/src/domains.ts`'s
217+
`registerDomainHandlers` gained a `broadcast(subject, payload)` helper
218+
(fire-and-forget `nc.publish`, same pattern as `workspaces.ts`'s
219+
`workspace.active.changed`) called after each of the six mutations
220+
commits: `domain.created`, `domain.retyped`, `source.added`,
221+
`source.updated`, `source.removed`, `extract.added` — payload carries
222+
the domain/source slugs, `client_slug` (or `client_slugs` for retype,
223+
which can span clients), and `actor`.
224+
- Those six subjects added to `BROADCAST_SUBJECTS` in
206225
`services/workspace/src/ws.ts`.
207-
- `apps/strategy-curator/src/curation.svelte.ts`: subscribe via the
208-
workspace singleton's event stream; refetch the affected list on events
209-
for the active domain/client (skip events from own invokes if double-
210-
render annoys; correctness first).
211-
- **Verify:** two browser windows, both on humain-vc; add a source in one;
212-
the other's list updates without refresh. This is the Flow-1 step-4
213-
acceptance, locally.
226+
- `apps/strategy-curator/src/App.svelte` (not `curation.svelte.ts` — an
227+
`$effect` needs a component, and `record-collector`'s App.svelte already
228+
set the precedent) watches `workspace.events`, dedups by `seq` the same
229+
way `record-collector` does, and calls the state singleton's
230+
`loadStrategies()` (domain events, type-and-client-scoped) or new
231+
`refreshSources()` (source/extract events, domain-and-client-scoped —
232+
refetches without resetting focus/tags, unlike the user-driven `select()`).
233+
- **Verify:** ran the protocol-level equivalent of "two browser windows" —
234+
a new `LIVENESS=1` mode in `scripts/prove-didi-auth.mjs` opens two
235+
independently-authenticated WS sessions against the live local stack;
236+
session A invokes `domain.create` then `source.add`; session B (idle,
237+
never invokes) asserted receipt of `domain.created` then `source.added`
238+
with matching payloads, no polling. Passed both. `apps/strategy-curator`
239+
(`svelte-check`) and the two touched services (`tsc --noEmit`) all clean.
240+
Test domain + source cleaned up from SurrealDB and the humain-vc
241+
filesystem after the run, same discipline as step 4's ATTRIBUTION mode.
214242

215243
## Step 7 — Instance posture + sign-in wall (augment-it, shell)
216244

scripts/prove-didi-auth.mjs

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,59 @@ if (process.env.ATTRIBUTION === '1') {
109109
process.exit(0);
110110
}
111111

112+
// ── LIVENESS MODE (build-order step 6) — curator liveness proof ────────────
113+
// Two independently-connected WS sessions, both signed in (the same protocol
114+
// two browser windows use): session A invokes domain.create then source.add;
115+
// session B — which never invokes anything — must receive the domain.created
116+
// and source.added broadcast EventFrames without polling. This is the
117+
// protocol-level equivalent of "two browser windows, add a source in one,
118+
// the other's list updates without refresh."
119+
if (process.env.LIVENESS === '1') {
120+
const client_slug = process.env.LIVENESS_CLIENT ?? 'humain-vc';
121+
step('LIVENESS 1. sign in, open two independent sessions');
122+
const jwt = await signInAs(EMAIL);
123+
const sessionA = await openSession(WS_URL, { Cookie: `didi_session=${jwt}` });
124+
const sessionB = await openSession(WS_URL, { Cookie: `didi_session=${jwt}` });
125+
console.log('session A + session B both connected and signed in ✓');
126+
127+
step('LIVENESS 2. session A: domain.create — session B must see domain.created');
128+
const domainSlug = `liveness-proof-${Date.now().toString(36)}`;
129+
const domainWait = waitForEvent(sessionB, 'domain.created', 10_000);
130+
const domainResult = await invokeOn(sessionA, 'domain.create', {
131+
type: 'thesis',
132+
slug: domainSlug,
133+
title: 'Liveness proof',
134+
client_slug,
135+
tags: [],
136+
});
137+
if (!domainResult.ok) fail(`domain.create failed: ${domainResult.error}`);
138+
const domainEvent = await domainWait;
139+
console.log('session B received domain.created:', JSON.stringify(domainEvent.payload));
140+
if (domainEvent.payload.slug !== domainSlug || domainEvent.payload.client_slug !== client_slug) {
141+
fail(`domain.created payload mismatch: ${JSON.stringify(domainEvent.payload)}`);
142+
}
143+
144+
step('LIVENESS 3. session A: source.add — session B must see source.added');
145+
const sourceWait = waitForEvent(sessionB, 'source.added', 10_000);
146+
const sourceResult = await invokeOn(sessionA, 'source.add', {
147+
url: `https://example.com/liveness-proof-${Date.now()}`,
148+
domain_type: 'thesis',
149+
domain_slug: domainSlug,
150+
client_slug,
151+
});
152+
if (!sourceResult.ok) fail(`source.add failed: ${sourceResult.error}`);
153+
const sourceEvent = await sourceWait;
154+
console.log('session B received source.added:', JSON.stringify(sourceEvent.payload));
155+
if (sourceEvent.payload.domain_slug !== domainSlug || sourceEvent.payload.client_slug !== client_slug) {
156+
fail(`source.added payload mismatch: ${JSON.stringify(sourceEvent.payload)}`);
157+
}
158+
159+
sessionA.ws.close();
160+
sessionB.ws.close();
161+
console.log('\n\x1b[32mCURATOR LIVENESS PROVEN (domain.created + source.added broadcast to a second session)\x1b[0m');
162+
process.exit(0);
163+
}
164+
112165
// ── GATE MODE (build-order step 3) — runs ONLY the gate tests ──────────────
113166
// The base steps below assume DIDI_AUTH=optional; gate mode assumes the
114167
// container is running with:
@@ -258,6 +311,69 @@ function wsInvoke(url, headers, capability, args) {
258311
}).catch((err) => fail(err.message));
259312
}
260313

314+
// Opens a WS connection and resolves once the session frame lands, keeping
315+
// the socket open (unlike firstFrame, which closes immediately) so LIVENESS
316+
// mode can invoke on it and/or listen for later broadcast EventFrames.
317+
function openSession(url, headers) {
318+
return new Promise((resolve, reject) => {
319+
const ws = new WebSocket(url, { headers });
320+
const listeners = new Set();
321+
const timer = setTimeout(() => {
322+
ws.terminate();
323+
reject(new Error('timeout waiting for session frame'));
324+
}, 8000);
325+
ws.on('message', (raw) => {
326+
const frame = JSON.parse(raw.toString('utf8'));
327+
if (frame.kind === 'session') {
328+
clearTimeout(timer);
329+
resolve({ ws, listeners });
330+
return;
331+
}
332+
for (const fn of listeners) fn(frame);
333+
});
334+
ws.on('error', (err) => {
335+
clearTimeout(timer);
336+
reject(err);
337+
});
338+
}).catch((err) => fail(err.message));
339+
}
340+
341+
// Sends one invoke frame on an already-open session and resolves with its
342+
// matching result frame's `result` (or throws via the session's own error
343+
// path) — does NOT close the socket, so the caller can keep listening.
344+
function invokeOn(session, capability, args) {
345+
return new Promise((resolve, reject) => {
346+
const id = `live_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 6)}`;
347+
const timer = setTimeout(() => reject(new Error(`timeout waiting for result of ${capability}`)), 15000);
348+
const onFrame = (frame) => {
349+
if (frame.kind === 'result' && frame.id === id) {
350+
clearTimeout(timer);
351+
session.listeners.delete(onFrame);
352+
resolve(frame);
353+
}
354+
};
355+
session.listeners.add(onFrame);
356+
session.ws.send(JSON.stringify({ kind: 'invoke', id, capability, args }));
357+
}).catch((err) => fail(err.message));
358+
}
359+
360+
// Resolves with the first EventFrame on `session` whose subject matches, or
361+
// rejects (via fail) after timeoutMs — the "browser B's list updates without
362+
// refresh" assertion at the protocol level.
363+
function waitForEvent(session, subject, timeoutMs) {
364+
return new Promise((resolve, reject) => {
365+
const timer = setTimeout(() => reject(new Error(`timeout waiting for ${subject} broadcast`)), timeoutMs);
366+
const onFrame = (frame) => {
367+
if (frame.kind === 'event' && frame.subject === subject) {
368+
clearTimeout(timer);
369+
session.listeners.delete(onFrame);
370+
resolve(frame);
371+
}
372+
};
373+
session.listeners.add(onFrame);
374+
}).catch((err) => fail(err.message));
375+
}
376+
261377
function firstFrame(url, headers) {
262378
return new Promise((resolve, reject) => {
263379
const ws = new WebSocket(url, { headers });

0 commit comments

Comments
 (0)