Skip to content

Commit 2864cf3

Browse files
mpstatonclaude
andcommitted
new(org-workbench, organizations): create an organization from the workbench — behind the no-match gate
The workbench could only find orgs that already exist. A "+ New organization" beside the search opens OrgCreateInline: name (+ optional website/domain) → scored candidates from resolver.candidates (slug 100 · domain 90 · fuzzy name 60 — every signal, not just the autocomplete's name-contains) → the gate ALWAYS shows. Picking a candidate just opens that org (no write); create is an explicit choice past the gate, never a silent submit. A created org seeds its domain (domain-matchable from birth) and a given website lands as its first org_link. Zero new verbs: creation rides person.affiliate's documented org-only path (no person_uuid → resolve the org, no edge, no observation). One service fix rode along: resolveOrgRow's create branch, on hitting a slug another client minted, now unions client_access — without it the caller's follow-up read couldn't see its own result (shared canonical layer, per-workspace visibility). Closes #29. Issue: context-v/issues/Org-Workbench-Needs-Create-Organization-Behind-A-No-Match-Gate.md Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UvYzx7vDWeafnkAi2nEQeb
1 parent ce2101c commit 2864cf3

6 files changed

Lines changed: 224 additions & 2 deletions

File tree

apps/org-workbench/src/App.svelte

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import { workspace } from '@augment-it/workspace';
1111
import OrgSearch from './OrgSearch.svelte';
1212
import OrgCard from './OrgCard.svelte';
13+
import OrgCreateInline from './OrgCreateInline.svelte';
1314
import { fetchOrgDetail } from './lib/org-client';
1415
import type { OrgDetail, OrgSuggestion } from './lib/types';
1516
@@ -43,6 +44,15 @@
4344
void loadOrg(s.slug);
4445
}
4546
47+
// Gated org creation (issue #29) — the ➕ opens OrgCreateInline; both a
48+
// picked candidate and a fresh create land in the same loadOrg.
49+
let creating = $state(false);
50+
51+
function onCreateOpen(org_slug: string) {
52+
creating = false;
53+
void loadOrg(org_slug);
54+
}
55+
4656
function refetch() {
4757
if (org) void loadOrg(org.slug);
4858
}
@@ -99,7 +109,20 @@
99109
<span class="ow-client">client: <strong>{client}</strong></span>
100110
<span class="ow-ws status-{status}">{status}</span>
101111
</div>
102-
<OrgSearch {client} onpick={onPick} />
112+
<div class="ow-search-row">
113+
<OrgSearch {client} onpick={onPick} />
114+
<button
115+
type="button"
116+
class="ow-add-go"
117+
title="Create an organization (gated — existing matches shown first)"
118+
onclick={() => (creating = !creating)}
119+
>
120+
{creating ? '×' : '+ New organization'}
121+
</button>
122+
</div>
123+
{#if creating}
124+
<OrgCreateInline {client} onopen={onCreateOpen} oncancel={() => (creating = false)} />
125+
{/if}
103126
</header>
104127

105128
<main class="ow-body">
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
<script lang="ts">
2+
// First-class org creation for the workbench — behind the gate. "Try to be
3+
// sure there is no match": name (+ optional website/domain) → scored
4+
// candidates from resolver.candidates (slug 100 · domain 90 · fuzzy name
5+
// 60 — every signal, not just the autocomplete's name-contains) → the gate
6+
// ALWAYS shows. Picking a candidate just OPENS that org (no write); create
7+
// is an explicit choice past the gate, never a silent submit. A created
8+
// org seeds its domain so it's domain-matchable from birth; a website URL
9+
// also lands as its first org_link.
10+
// Per context-v/issues/Org-Workbench-Needs-Create-Organization-Behind-A-No-Match-Gate.md.
11+
12+
import { fetchOrgCandidates, createOrg, addOrgLink } from './lib/org-client';
13+
import type { OrgCandidate } from './lib/types';
14+
15+
let {
16+
client,
17+
onopen,
18+
oncancel,
19+
}: {
20+
client: string;
21+
// Called with the slug to load — an existing candidate OR the new org.
22+
onopen: (org_slug: string) => void;
23+
oncancel: () => void;
24+
} = $props();
25+
26+
let name = $state('');
27+
let site = $state(''); // URL or bare domain, optional
28+
let phase = $state<'form' | 'gate' | 'writing'>('form');
29+
let candidates = $state<OrgCandidate[]>([]);
30+
let error = $state<string | null>(null);
31+
32+
function siteParts(): { url?: string; domain?: string } {
33+
const raw = site.trim();
34+
if (!raw) return {};
35+
const withProto = /^[a-z]+:\/\//i.test(raw) ? raw : `https://${raw}`;
36+
try {
37+
const u = new URL(withProto);
38+
return { url: withProto, domain: u.hostname.toLowerCase().replace(/^www\./, '') };
39+
} catch {
40+
return {};
41+
}
42+
}
43+
44+
async function findMatches(e: SubmitEvent) {
45+
e.preventDefault();
46+
if (!name.trim()) return;
47+
error = null;
48+
try {
49+
const { url } = siteParts();
50+
candidates = await fetchOrgCandidates({ name: name.trim(), url }, client);
51+
phase = 'gate'; // always gate — zero candidates still gets an explicit create
52+
} catch (err) {
53+
error = err instanceof Error ? err.message : String(err);
54+
}
55+
}
56+
57+
async function create() {
58+
phase = 'writing';
59+
error = null;
60+
try {
61+
const { url, domain } = siteParts();
62+
const r = await createOrg({ org_name: name.trim(), org_domain: domain, client });
63+
// A given website becomes the new org's first identity link — only on a
64+
// genuine create; a slug that matched an existing org keeps its lists.
65+
if (url && r.org_created) {
66+
await addOrgLink({ org_slug: r.org_slug, url, kind: 'website', client });
67+
}
68+
onopen(r.org_slug);
69+
} catch (err) {
70+
error = err instanceof Error ? err.message : String(err);
71+
phase = 'gate';
72+
}
73+
}
74+
</script>
75+
76+
<div class="ow-addperson">
77+
<form class="ow-addperson-form" onsubmit={findMatches}>
78+
<input
79+
class="ow-add-url"
80+
type="text"
81+
placeholder="Organization name (required)"
82+
bind:value={name}
83+
required
84+
disabled={phase !== 'form'}
85+
/>
86+
<input
87+
class="ow-add-url"
88+
type="text"
89+
placeholder="Website or domain (optional — strongest match signal)"
90+
bind:value={site}
91+
disabled={phase !== 'form'}
92+
/>
93+
{#if phase === 'form'}
94+
<span class="ow-addperson-actions">
95+
<button type="submit" class="ow-add-go" disabled={!name.trim()}>Find matches</button>
96+
<button type="button" class="ow-add-go" onclick={oncancel}>Cancel</button>
97+
</span>
98+
{/if}
99+
</form>
100+
101+
{#if phase === 'gate'}
102+
<div class="ow-gate">
103+
{#if candidates.length > 0}
104+
<p class="ow-gate-note">
105+
Existing organizations that might be “{name}” — open one instead of creating a duplicate:
106+
</p>
107+
<ul class="ow-gate-list">
108+
{#each candidates as c (c.slug)}
109+
<li>
110+
<button type="button" class="ow-gate-pick" onclick={() => onopen(c.slug)}>
111+
<strong>{c.complete_name ?? c.conventional_name ?? c.slug}</strong>
112+
<span class="ow-gate-headline">{c.slug}</span>
113+
<span class="ow-gate-score">
114+
{c.score} · {c.match_reason.join(', ')} ·
115+
{c.existing.org_links} links · {c.existing.media_streams} streams ·
116+
{c.existing.org_corpus} corpus
117+
</span>
118+
</button>
119+
</li>
120+
{/each}
121+
</ul>
122+
{:else}
123+
<p class="ow-gate-note">No existing organization matches “{name}”.</p>
124+
{/if}
125+
<span class="ow-addperson-actions">
126+
<button type="button" class="ow-add-go" onclick={create}>
127+
No match — create “{name.trim()}”
128+
</button>
129+
<button type="button" class="ow-add-go" onclick={() => (phase = 'form')}>Back</button>
130+
</span>
131+
</div>
132+
{:else if phase === 'writing'}
133+
<p class="ow-gate-note">creating organization…</p>
134+
{/if}
135+
{#if error}<div class="ow-error">{error}</div>{/if}
136+
</div>

apps/org-workbench/src/app.css

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@
2020
.ow-ws.status-closed, .ow-ws.status-error { background: var(--color-error-bg, #3a1717); color: var(--color-error-text, #f3a3a3); }
2121

2222
/* search */
23+
.ow-search-row { display: flex; align-items: flex-start; gap: 0.5rem; }
24+
.ow-search-row .ow-search { flex: 1; }
25+
.ow-search-row > .ow-add-go { margin-top: 0.6rem; white-space: nowrap; }
2326
.ow-search { position: relative; margin-top: 0.6rem; }
2427
.ow-search-input { width: 100%; box-sizing: border-box; background: var(--color-surface, #1c1e25); color: inherit; border: 1px solid var(--color-border, #2a2c33); border-radius: 6px; padding: 0.5rem 0.75rem; font: inherit; }
2528
.ow-search-busy { position: absolute; right: 0.75rem; top: 0.5rem; color: var(--color-text-muted, #9aa0aa); }

apps/org-workbench/src/lib/org-client.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { workspace } from '@augment-it/workspace';
77
import type {
88
OrgSuggestion,
99
OrgDetail,
10+
OrgCandidate,
1011
AffiliatedPerson,
1112
ShapedLink,
1213
PersonCandidate,
@@ -23,6 +24,42 @@ export async function searchOrgs(q: string, client: string): Promise<OrgSuggesti
2324
return r.candidates ?? [];
2425
}
2526

27+
// Scored candidates for the create gate — every signal the resolver knows
28+
// (slug/domain/fuzzy name), unlike searchOrgs's lighter name-contains.
29+
export async function fetchOrgCandidates(
30+
record: { name: string; url?: string; domains?: string[] },
31+
client: string,
32+
): Promise<OrgCandidate[]> {
33+
const r = (await workspace.invoke('resolver.candidates', { record, client })) as {
34+
ok: boolean;
35+
candidates?: OrgCandidate[];
36+
error?: string;
37+
};
38+
if (!r.ok) throw new Error(r.error || 'resolver.candidates failed');
39+
return r.candidates ?? [];
40+
}
41+
42+
// Org-only create — person.affiliate with NO person_uuid is the documented
43+
// "independent decisions" path: resolves (here: creates) the org, no edge,
44+
// no observation. Seeds domains[] from org_domain so the new org is
45+
// domain-matchable from birth.
46+
export async function createOrg(args: {
47+
org_name: string;
48+
org_domain?: string;
49+
client: string;
50+
source?: string;
51+
}): Promise<{ org_slug: string; org_created: boolean }> {
52+
const r = (await workspace.invoke('person.affiliate', {
53+
org_action: 'create',
54+
org_name: args.org_name,
55+
org_domain: args.org_domain,
56+
client: args.client,
57+
source: args.source ?? 'org-workbench',
58+
})) as { ok: boolean; org_slug?: string; org_created?: boolean; error?: string };
59+
if (!r.ok || !r.org_slug) throw new Error(r.error || 'org create failed');
60+
return { org_slug: r.org_slug, org_created: r.org_created ?? false };
61+
}
62+
2663
export async function fetchOrgDetail(org_slug: string, client: string): Promise<OrgDetail> {
2764
const r = (await workspace.invoke('organization.detail', { org_slug, client })) as {
2865
ok: boolean;

apps/org-workbench/src/lib/types.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,18 @@ export type AffiliatedPerson = {
6767
personal_corpus_count: number;
6868
};
6969

70+
// Scored org candidate (resolver.candidates) — the gate's evidence when the
71+
// operator wants to create an org: slug 100 · domain 90 · fuzzy name 60.
72+
export type OrgCandidate = {
73+
org_id: string;
74+
slug: string;
75+
complete_name: string | null;
76+
conventional_name: string | null;
77+
score: number;
78+
match_reason: string[];
79+
existing: { org_links: number; media_streams: number; org_corpus: number };
80+
};
81+
7082
// Phase 3 — the A→B launch envelope for the search-and-add remote (spec D2).
7183
// Dispatched as CustomEvent('augment-it:search-request', { detail }) AND
7284
// persisted to localStorage (see lib/search-request.ts for why both).

services/record-surrealdb-resolver/src/resolver.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -614,7 +614,18 @@ export async function resolveOrgRow(
614614
const slug = slugify(input.name);
615615
if (!slug) throw new Error('resolveOrgRow (create) requires a non-empty name');
616616
const existing = await fetchOrgBySlug(db, slug);
617-
if (existing) return { org: existing, created: false };
617+
if (existing) {
618+
// Create-intent hitting a slug another client minted: this client now
619+
// knows the org (shared canonical layer, per-workspace visibility) —
620+
// union access, or the caller's follow-up read can't see its own result.
621+
await db.query(
622+
`UPDATE $id SET
623+
client_access = array::union(client_access ?? [], [$client]),
624+
last_touched_by = $client, last_touched_at = time::now();`,
625+
{ id: existing.id, client: input.client },
626+
);
627+
return { org: existing, created: false };
628+
}
618629
const completeName = input.name.trim();
619630
const domain = input.domain?.trim().toLowerCase().replace(/^www\./, '');
620631
const createdRes = await db.query(

0 commit comments

Comments
 (0)