From 0f475f44d80eb360164bba2b751c48280802ab99 Mon Sep 17 00:00:00 2001
From: couragehong
Date: Sat, 11 Jul 2026 13:05:31 +0900
Subject: [PATCH 01/20] feat(routing): receive IVF centroids and return cluster
assignments
runed never dials the index engine, but inserts need a plaintext IVF
cluster assignment computed next to the embedding. The centroid set is
relayed runespace -> vault -> rune-mcp -> here:
- SetCentroids (client-stream): header + id-ordered batches, validated
against the daemon's embedding dim, installed atomically, persisted to
$RUNED_HOME/cache (gob, atomic rename) so restarts skip the re-push.
- Embed/EmbedBatch with_route: responses carry cluster_id and the
centroid_set_version the assignment was routed against; requests fail
fast with FAILED_PRECONDITION until a set is loaded.
- Info exposes centroid_set_version so callers can detect staleness.
- internal/route: max-inner-product assignment (lowest-id ties),
mirroring runespace-go-sdk's insert routing exactly.
Co-Authored-By: Claude Fable 5
---
cmd/runed/main.go | 12 +
gen/runed/v1/runed.pb.go | 472 +++++++++++++++++++++++++++---
gen/runed/v1/runed_grpc.pb.go | 54 +++-
internal/route/route.go | 105 +++++++
internal/route/route_test.go | 78 +++++
internal/server/centroids.go | 88 ++++++
internal/server/centroids_test.go | 140 +++++++++
internal/server/server.go | 37 ++-
proto/runed/v1/runed.proto | 57 ++++
9 files changed, 989 insertions(+), 54 deletions(-)
create mode 100644 internal/route/route.go
create mode 100644 internal/route/route_test.go
create mode 100644 internal/server/centroids.go
create mode 100644 internal/server/centroids_test.go
diff --git a/cmd/runed/main.go b/cmd/runed/main.go
index 4012cbe..60b5f43 100644
--- a/cmd/runed/main.go
+++ b/cmd/runed/main.go
@@ -53,6 +53,7 @@ import (
"github.com/CryptoLabInc/runed/internal/backend"
"github.com/CryptoLabInc/runed/internal/bootstrap"
"github.com/CryptoLabInc/runed/internal/ipc"
+ "github.com/CryptoLabInc/runed/internal/route"
"github.com/CryptoLabInc/runed/internal/server"
"google.golang.org/grpc"
)
@@ -126,6 +127,17 @@ func run() error {
// the multi-minute install window. SetBackend below flips Health
// to STATUS_OK once llama-server is up.
srv := server.New(daemonVersion)
+ srv.SetCentroidCacheDir(paths.Cache)
+ // Restore the IVF centroid set from the disk cache so a restarted daemon
+ // serves with_route immediately; a missing/corrupt cache just means
+ // routing waits for the next SetCentroids push from rune-mcp.
+ if cs, err := route.Load(paths.Cache); err == nil {
+ if err := srv.LoadCentroids(cs); err != nil {
+ logger.Warn("centroid cache rejected", "err", err)
+ } else {
+ logger.Info("centroid cache restored", "version", cs.Version, "nlist", len(cs.Vectors))
+ }
+ }
lis, err := ipc.Listen(sockPath)
if err != nil {
return fmt.Errorf("ipc listen: %w", err)
diff --git a/gen/runed/v1/runed.pb.go b/gen/runed/v1/runed.pb.go
index 7743c39..999fbb9 100644
--- a/gen/runed/v1/runed.pb.go
+++ b/gen/runed/v1/runed.pb.go
@@ -136,7 +136,11 @@ type EmbedRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Input text. Maximum length is InfoResponse.max_text_length characters.
// Exceeding the limit returns INVALID_ARGUMENT.
- Text string `protobuf:"bytes,1,opt,name=text,proto3" json:"text,omitempty"`
+ Text string `protobuf:"bytes,1,opt,name=text,proto3" json:"text,omitempty"`
+ // When true the response also carries the vector's IVF cluster assignment
+ // (cluster_id + centroid_set_version). Requires a centroid set loaded via
+ // SetCentroids; otherwise the call fails with FAILED_PRECONDITION.
+ WithRoute bool `protobuf:"varint,2,opt,name=with_route,json=withRoute,proto3" json:"with_route,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -178,13 +182,27 @@ func (x *EmbedRequest) GetText() string {
return ""
}
+func (x *EmbedRequest) GetWithRoute() bool {
+ if x != nil {
+ return x.WithRoute
+ }
+ return false
+}
+
type EmbedResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
// L2-normalized embedding. The server always normalizes output
// (llama.cpp default); no opt-out. Length equals InfoResponse.vector_dim.
- Vector []float32 `protobuf:"fixed32,1,rep,packed,name=vector,proto3" json:"vector,omitempty"`
- unknownFields protoimpl.UnknownFields
- sizeCache protoimpl.SizeCache
+ Vector []float32 `protobuf:"fixed32,1,rep,packed,name=vector,proto3" json:"vector,omitempty"`
+ // IVF hard single assignment (0..nlist-1). Only set when the request asked
+ // for routing (with_route); the vector is already normalized so the
+ // assignment metric is max inner product, matching runespace insert routing.
+ ClusterId uint32 `protobuf:"varint,2,opt,name=cluster_id,json=clusterId,proto3" json:"cluster_id,omitempty"`
+ // Version (content hash) of the centroid set the assignment was routed
+ // against. Callers echo this to the index engine so a stale set is rejected.
+ CentroidSetVersion string `protobuf:"bytes,3,opt,name=centroid_set_version,json=centroidSetVersion,proto3" json:"centroid_set_version,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
}
func (x *EmbedResponse) Reset() {
@@ -224,11 +242,27 @@ func (x *EmbedResponse) GetVector() []float32 {
return nil
}
+func (x *EmbedResponse) GetClusterId() uint32 {
+ if x != nil {
+ return x.ClusterId
+ }
+ return 0
+}
+
+func (x *EmbedResponse) GetCentroidSetVersion() string {
+ if x != nil {
+ return x.CentroidSetVersion
+ }
+ return ""
+}
+
type EmbedBatchRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Input texts. Batch size must not exceed InfoResponse.max_batch_size.
// Each text individually bound by InfoResponse.max_text_length.
- Texts []string `protobuf:"bytes,1,rep,name=texts,proto3" json:"texts,omitempty"`
+ Texts []string `protobuf:"bytes,1,rep,name=texts,proto3" json:"texts,omitempty"`
+ // Same as EmbedRequest.with_route, applied to every embedding in the batch.
+ WithRoute bool `protobuf:"varint,2,opt,name=with_route,json=withRoute,proto3" json:"with_route,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -270,6 +304,13 @@ func (x *EmbedBatchRequest) GetTexts() []string {
return nil
}
+func (x *EmbedBatchRequest) GetWithRoute() bool {
+ if x != nil {
+ return x.WithRoute
+ }
+ return false
+}
+
type EmbedBatchResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
// One EmbedResponse per input text, same order as the request.
@@ -364,9 +405,12 @@ type InfoResponse struct {
// INVALID_ARGUMENT.
MaxTextLength int32 `protobuf:"varint,4,opt,name=max_text_length,json=maxTextLength,proto3" json:"max_text_length,omitempty"`
// Maximum batch size accepted by EmbedBatch.
- MaxBatchSize int32 `protobuf:"varint,5,opt,name=max_batch_size,json=maxBatchSize,proto3" json:"max_batch_size,omitempty"`
- unknownFields protoimpl.UnknownFields
- sizeCache protoimpl.SizeCache
+ MaxBatchSize int32 `protobuf:"varint,5,opt,name=max_batch_size,json=maxBatchSize,proto3" json:"max_batch_size,omitempty"`
+ // Version of the currently loaded IVF centroid set; empty when none is
+ // loaded (with_route requests fail until SetCentroids delivers one).
+ CentroidSetVersion string `protobuf:"bytes,6,opt,name=centroid_set_version,json=centroidSetVersion,proto3" json:"centroid_set_version,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
}
func (x *InfoResponse) Reset() {
@@ -434,6 +478,13 @@ func (x *InfoResponse) GetMaxBatchSize() int32 {
return 0
}
+func (x *InfoResponse) GetCentroidSetVersion() string {
+ if x != nil {
+ return x.CentroidSetVersion
+ }
+ return ""
+}
+
type HealthRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
unknownFields protoimpl.UnknownFields
@@ -658,29 +709,329 @@ func (x *ShutdownResponse) GetAccepted() bool {
return false
}
+type SetCentroidsRequest struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ // Types that are valid to be assigned to Payload:
+ //
+ // *SetCentroidsRequest_Header
+ // *SetCentroidsRequest_Batch
+ Payload isSetCentroidsRequest_Payload `protobuf_oneof:"payload"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *SetCentroidsRequest) Reset() {
+ *x = SetCentroidsRequest{}
+ mi := &file_runed_v1_runed_proto_msgTypes[10]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *SetCentroidsRequest) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*SetCentroidsRequest) ProtoMessage() {}
+
+func (x *SetCentroidsRequest) ProtoReflect() protoreflect.Message {
+ mi := &file_runed_v1_runed_proto_msgTypes[10]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use SetCentroidsRequest.ProtoReflect.Descriptor instead.
+func (*SetCentroidsRequest) Descriptor() ([]byte, []int) {
+ return file_runed_v1_runed_proto_rawDescGZIP(), []int{10}
+}
+
+func (x *SetCentroidsRequest) GetPayload() isSetCentroidsRequest_Payload {
+ if x != nil {
+ return x.Payload
+ }
+ return nil
+}
+
+func (x *SetCentroidsRequest) GetHeader() *CentroidSetHeader {
+ if x != nil {
+ if x, ok := x.Payload.(*SetCentroidsRequest_Header); ok {
+ return x.Header
+ }
+ }
+ return nil
+}
+
+func (x *SetCentroidsRequest) GetBatch() *CentroidBatch {
+ if x != nil {
+ if x, ok := x.Payload.(*SetCentroidsRequest_Batch); ok {
+ return x.Batch
+ }
+ }
+ return nil
+}
+
+type isSetCentroidsRequest_Payload interface {
+ isSetCentroidsRequest_Payload()
+}
+
+type SetCentroidsRequest_Header struct {
+ Header *CentroidSetHeader `protobuf:"bytes,1,opt,name=header,proto3,oneof"`
+}
+
+type SetCentroidsRequest_Batch struct {
+ Batch *CentroidBatch `protobuf:"bytes,2,opt,name=batch,proto3,oneof"`
+}
+
+func (*SetCentroidsRequest_Header) isSetCentroidsRequest_Payload() {}
+
+func (*SetCentroidsRequest_Batch) isSetCentroidsRequest_Payload() {}
+
+// CentroidSetHeader opens the stream: identity and shape of the set.
+type CentroidSetHeader struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Version string `protobuf:"bytes,1,opt,name=version,proto3" json:"version,omitempty"` // content hash (sha256); must be non-empty
+ Dim uint32 `protobuf:"varint,2,opt,name=dim,proto3" json:"dim,omitempty"` // must equal InfoResponse.vector_dim
+ Nlist uint32 `protobuf:"varint,3,opt,name=nlist,proto3" json:"nlist,omitempty"` // total centroid count that will follow
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *CentroidSetHeader) Reset() {
+ *x = CentroidSetHeader{}
+ mi := &file_runed_v1_runed_proto_msgTypes[11]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *CentroidSetHeader) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*CentroidSetHeader) ProtoMessage() {}
+
+func (x *CentroidSetHeader) ProtoReflect() protoreflect.Message {
+ mi := &file_runed_v1_runed_proto_msgTypes[11]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use CentroidSetHeader.ProtoReflect.Descriptor instead.
+func (*CentroidSetHeader) Descriptor() ([]byte, []int) {
+ return file_runed_v1_runed_proto_rawDescGZIP(), []int{11}
+}
+
+func (x *CentroidSetHeader) GetVersion() string {
+ if x != nil {
+ return x.Version
+ }
+ return ""
+}
+
+func (x *CentroidSetHeader) GetDim() uint32 {
+ if x != nil {
+ return x.Dim
+ }
+ return 0
+}
+
+func (x *CentroidSetHeader) GetNlist() uint32 {
+ if x != nil {
+ return x.Nlist
+ }
+ return 0
+}
+
+// CentroidBatch carries centroids in id order (append order == cluster id).
+type CentroidBatch struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Centroids []*Centroid `protobuf:"bytes,1,rep,name=centroids,proto3" json:"centroids,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *CentroidBatch) Reset() {
+ *x = CentroidBatch{}
+ mi := &file_runed_v1_runed_proto_msgTypes[12]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *CentroidBatch) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*CentroidBatch) ProtoMessage() {}
+
+func (x *CentroidBatch) ProtoReflect() protoreflect.Message {
+ mi := &file_runed_v1_runed_proto_msgTypes[12]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use CentroidBatch.ProtoReflect.Descriptor instead.
+func (*CentroidBatch) Descriptor() ([]byte, []int) {
+ return file_runed_v1_runed_proto_rawDescGZIP(), []int{12}
+}
+
+func (x *CentroidBatch) GetCentroids() []*Centroid {
+ if x != nil {
+ return x.Centroids
+ }
+ return nil
+}
+
+type Centroid struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Id uint32 `protobuf:"varint,1,opt,name=id,proto3" json:"id,omitempty"`
+ Vec []float32 `protobuf:"fixed32,2,rep,packed,name=vec,proto3" json:"vec,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *Centroid) Reset() {
+ *x = Centroid{}
+ mi := &file_runed_v1_runed_proto_msgTypes[13]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *Centroid) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*Centroid) ProtoMessage() {}
+
+func (x *Centroid) ProtoReflect() protoreflect.Message {
+ mi := &file_runed_v1_runed_proto_msgTypes[13]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use Centroid.ProtoReflect.Descriptor instead.
+func (*Centroid) Descriptor() ([]byte, []int) {
+ return file_runed_v1_runed_proto_rawDescGZIP(), []int{13}
+}
+
+func (x *Centroid) GetId() uint32 {
+ if x != nil {
+ return x.Id
+ }
+ return 0
+}
+
+func (x *Centroid) GetVec() []float32 {
+ if x != nil {
+ return x.Vec
+ }
+ return nil
+}
+
+type SetCentroidsResponse struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Version string `protobuf:"bytes,1,opt,name=version,proto3" json:"version,omitempty"` // version now active
+ Nlist uint32 `protobuf:"varint,2,opt,name=nlist,proto3" json:"nlist,omitempty"` // centroids loaded
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *SetCentroidsResponse) Reset() {
+ *x = SetCentroidsResponse{}
+ mi := &file_runed_v1_runed_proto_msgTypes[14]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *SetCentroidsResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*SetCentroidsResponse) ProtoMessage() {}
+
+func (x *SetCentroidsResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_runed_v1_runed_proto_msgTypes[14]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use SetCentroidsResponse.ProtoReflect.Descriptor instead.
+func (*SetCentroidsResponse) Descriptor() ([]byte, []int) {
+ return file_runed_v1_runed_proto_rawDescGZIP(), []int{14}
+}
+
+func (x *SetCentroidsResponse) GetVersion() string {
+ if x != nil {
+ return x.Version
+ }
+ return ""
+}
+
+func (x *SetCentroidsResponse) GetNlist() uint32 {
+ if x != nil {
+ return x.Nlist
+ }
+ return 0
+}
+
var File_runed_v1_runed_proto protoreflect.FileDescriptor
const file_runed_v1_runed_proto_rawDesc = "" +
"\n" +
- "\x14runed/v1/runed.proto\x12\bruned.v1\"\"\n" +
+ "\x14runed/v1/runed.proto\x12\bruned.v1\"A\n" +
"\fEmbedRequest\x12\x12\n" +
- "\x04text\x18\x01 \x01(\tR\x04text\"+\n" +
+ "\x04text\x18\x01 \x01(\tR\x04text\x12\x1d\n" +
+ "\n" +
+ "with_route\x18\x02 \x01(\bR\twithRoute\"|\n" +
"\rEmbedResponse\x12\x1a\n" +
- "\x06vector\x18\x01 \x03(\x02B\x02\x10\x01R\x06vector\")\n" +
+ "\x06vector\x18\x01 \x03(\x02B\x02\x10\x01R\x06vector\x12\x1d\n" +
+ "\n" +
+ "cluster_id\x18\x02 \x01(\rR\tclusterId\x120\n" +
+ "\x14centroid_set_version\x18\x03 \x01(\tR\x12centroidSetVersion\"H\n" +
"\x11EmbedBatchRequest\x12\x14\n" +
- "\x05texts\x18\x01 \x03(\tR\x05texts\"M\n" +
+ "\x05texts\x18\x01 \x03(\tR\x05texts\x12\x1d\n" +
+ "\n" +
+ "with_route\x18\x02 \x01(\bR\twithRoute\"M\n" +
"\x12EmbedBatchResponse\x127\n" +
"\n" +
"embeddings\x18\x01 \x03(\v2\x17.runed.v1.EmbedResponseR\n" +
"embeddings\"\r\n" +
- "\vInfoRequest\"\xc9\x01\n" +
+ "\vInfoRequest\"\xfb\x01\n" +
"\fInfoResponse\x12%\n" +
"\x0edaemon_version\x18\x01 \x01(\tR\rdaemonVersion\x12%\n" +
"\x0emodel_identity\x18\x02 \x01(\tR\rmodelIdentity\x12\x1d\n" +
"\n" +
"vector_dim\x18\x03 \x01(\x05R\tvectorDim\x12&\n" +
"\x0fmax_text_length\x18\x04 \x01(\x05R\rmaxTextLength\x12$\n" +
- "\x0emax_batch_size\x18\x05 \x01(\x05R\fmaxBatchSize\"\x0f\n" +
+ "\x0emax_batch_size\x18\x05 \x01(\x05R\fmaxBatchSize\x120\n" +
+ "\x14centroid_set_version\x18\x06 \x01(\tR\x12centroidSetVersion\"\x0f\n" +
"\rHealthRequest\"\xa9\x04\n" +
"\x0eHealthResponse\x127\n" +
"\x06status\x18\x01 \x01(\x0e2\x1f.runed.v1.HealthResponse.StatusR\x06status\x12%\n" +
@@ -707,14 +1058,31 @@ const file_runed_v1_runed_proto_rawDesc = "" +
"\x0fShutdownRequest\x12#\n" +
"\rgrace_seconds\x18\x01 \x01(\x05R\fgraceSeconds\".\n" +
"\x10ShutdownResponse\x12\x1a\n" +
- "\baccepted\x18\x01 \x01(\bR\baccepted2\xc8\x02\n" +
+ "\baccepted\x18\x01 \x01(\bR\baccepted\"\x88\x01\n" +
+ "\x13SetCentroidsRequest\x125\n" +
+ "\x06header\x18\x01 \x01(\v2\x1b.runed.v1.CentroidSetHeaderH\x00R\x06header\x12/\n" +
+ "\x05batch\x18\x02 \x01(\v2\x17.runed.v1.CentroidBatchH\x00R\x05batchB\t\n" +
+ "\apayload\"U\n" +
+ "\x11CentroidSetHeader\x12\x18\n" +
+ "\aversion\x18\x01 \x01(\tR\aversion\x12\x10\n" +
+ "\x03dim\x18\x02 \x01(\rR\x03dim\x12\x14\n" +
+ "\x05nlist\x18\x03 \x01(\rR\x05nlist\"A\n" +
+ "\rCentroidBatch\x120\n" +
+ "\tcentroids\x18\x01 \x03(\v2\x12.runed.v1.CentroidR\tcentroids\"0\n" +
+ "\bCentroid\x12\x0e\n" +
+ "\x02id\x18\x01 \x01(\rR\x02id\x12\x14\n" +
+ "\x03vec\x18\x02 \x03(\x02B\x02\x10\x01R\x03vec\"F\n" +
+ "\x14SetCentroidsResponse\x12\x18\n" +
+ "\aversion\x18\x01 \x01(\tR\aversion\x12\x14\n" +
+ "\x05nlist\x18\x02 \x01(\rR\x05nlist2\x99\x03\n" +
"\fRunedService\x128\n" +
"\x05Embed\x12\x16.runed.v1.EmbedRequest\x1a\x17.runed.v1.EmbedResponse\x12G\n" +
"\n" +
"EmbedBatch\x12\x1b.runed.v1.EmbedBatchRequest\x1a\x1c.runed.v1.EmbedBatchResponse\x125\n" +
"\x04Info\x12\x15.runed.v1.InfoRequest\x1a\x16.runed.v1.InfoResponse\x12;\n" +
"\x06Health\x12\x17.runed.v1.HealthRequest\x1a\x18.runed.v1.HealthResponse\x12A\n" +
- "\bShutdown\x12\x19.runed.v1.ShutdownRequest\x1a\x1a.runed.v1.ShutdownResponseB\x8f\x01\n" +
+ "\bShutdown\x12\x19.runed.v1.ShutdownRequest\x1a\x1a.runed.v1.ShutdownResponse\x12O\n" +
+ "\fSetCentroids\x12\x1d.runed.v1.SetCentroidsRequest\x1a\x1e.runed.v1.SetCentroidsResponse(\x01B\x8f\x01\n" +
"\fcom.runed.v1B\n" +
"RunedProtoP\x01Z2github.com/CryptoLabInc/runed/gen/runed/v1;runedv1\xa2\x02\x03RXX\xaa\x02\bRuned.V1\xca\x02\bRuned\\V1\xe2\x02\x14Runed\\V1\\GPBMetadata\xea\x02\tRuned::V1b\x06proto3"
@@ -731,40 +1099,50 @@ func file_runed_v1_runed_proto_rawDescGZIP() []byte {
}
var file_runed_v1_runed_proto_enumTypes = make([]protoimpl.EnumInfo, 2)
-var file_runed_v1_runed_proto_msgTypes = make([]protoimpl.MessageInfo, 10)
+var file_runed_v1_runed_proto_msgTypes = make([]protoimpl.MessageInfo, 15)
var file_runed_v1_runed_proto_goTypes = []any{
- (HealthResponse_Status)(0), // 0: runed.v1.HealthResponse.Status
- (HealthResponse_Phase)(0), // 1: runed.v1.HealthResponse.Phase
- (*EmbedRequest)(nil), // 2: runed.v1.EmbedRequest
- (*EmbedResponse)(nil), // 3: runed.v1.EmbedResponse
- (*EmbedBatchRequest)(nil), // 4: runed.v1.EmbedBatchRequest
- (*EmbedBatchResponse)(nil), // 5: runed.v1.EmbedBatchResponse
- (*InfoRequest)(nil), // 6: runed.v1.InfoRequest
- (*InfoResponse)(nil), // 7: runed.v1.InfoResponse
- (*HealthRequest)(nil), // 8: runed.v1.HealthRequest
- (*HealthResponse)(nil), // 9: runed.v1.HealthResponse
- (*ShutdownRequest)(nil), // 10: runed.v1.ShutdownRequest
- (*ShutdownResponse)(nil), // 11: runed.v1.ShutdownResponse
+ (HealthResponse_Status)(0), // 0: runed.v1.HealthResponse.Status
+ (HealthResponse_Phase)(0), // 1: runed.v1.HealthResponse.Phase
+ (*EmbedRequest)(nil), // 2: runed.v1.EmbedRequest
+ (*EmbedResponse)(nil), // 3: runed.v1.EmbedResponse
+ (*EmbedBatchRequest)(nil), // 4: runed.v1.EmbedBatchRequest
+ (*EmbedBatchResponse)(nil), // 5: runed.v1.EmbedBatchResponse
+ (*InfoRequest)(nil), // 6: runed.v1.InfoRequest
+ (*InfoResponse)(nil), // 7: runed.v1.InfoResponse
+ (*HealthRequest)(nil), // 8: runed.v1.HealthRequest
+ (*HealthResponse)(nil), // 9: runed.v1.HealthResponse
+ (*ShutdownRequest)(nil), // 10: runed.v1.ShutdownRequest
+ (*ShutdownResponse)(nil), // 11: runed.v1.ShutdownResponse
+ (*SetCentroidsRequest)(nil), // 12: runed.v1.SetCentroidsRequest
+ (*CentroidSetHeader)(nil), // 13: runed.v1.CentroidSetHeader
+ (*CentroidBatch)(nil), // 14: runed.v1.CentroidBatch
+ (*Centroid)(nil), // 15: runed.v1.Centroid
+ (*SetCentroidsResponse)(nil), // 16: runed.v1.SetCentroidsResponse
}
var file_runed_v1_runed_proto_depIdxs = []int32{
3, // 0: runed.v1.EmbedBatchResponse.embeddings:type_name -> runed.v1.EmbedResponse
0, // 1: runed.v1.HealthResponse.status:type_name -> runed.v1.HealthResponse.Status
1, // 2: runed.v1.HealthResponse.phase:type_name -> runed.v1.HealthResponse.Phase
- 2, // 3: runed.v1.RunedService.Embed:input_type -> runed.v1.EmbedRequest
- 4, // 4: runed.v1.RunedService.EmbedBatch:input_type -> runed.v1.EmbedBatchRequest
- 6, // 5: runed.v1.RunedService.Info:input_type -> runed.v1.InfoRequest
- 8, // 6: runed.v1.RunedService.Health:input_type -> runed.v1.HealthRequest
- 10, // 7: runed.v1.RunedService.Shutdown:input_type -> runed.v1.ShutdownRequest
- 3, // 8: runed.v1.RunedService.Embed:output_type -> runed.v1.EmbedResponse
- 5, // 9: runed.v1.RunedService.EmbedBatch:output_type -> runed.v1.EmbedBatchResponse
- 7, // 10: runed.v1.RunedService.Info:output_type -> runed.v1.InfoResponse
- 9, // 11: runed.v1.RunedService.Health:output_type -> runed.v1.HealthResponse
- 11, // 12: runed.v1.RunedService.Shutdown:output_type -> runed.v1.ShutdownResponse
- 8, // [8:13] is the sub-list for method output_type
- 3, // [3:8] is the sub-list for method input_type
- 3, // [3:3] is the sub-list for extension type_name
- 3, // [3:3] is the sub-list for extension extendee
- 0, // [0:3] is the sub-list for field type_name
+ 13, // 3: runed.v1.SetCentroidsRequest.header:type_name -> runed.v1.CentroidSetHeader
+ 14, // 4: runed.v1.SetCentroidsRequest.batch:type_name -> runed.v1.CentroidBatch
+ 15, // 5: runed.v1.CentroidBatch.centroids:type_name -> runed.v1.Centroid
+ 2, // 6: runed.v1.RunedService.Embed:input_type -> runed.v1.EmbedRequest
+ 4, // 7: runed.v1.RunedService.EmbedBatch:input_type -> runed.v1.EmbedBatchRequest
+ 6, // 8: runed.v1.RunedService.Info:input_type -> runed.v1.InfoRequest
+ 8, // 9: runed.v1.RunedService.Health:input_type -> runed.v1.HealthRequest
+ 10, // 10: runed.v1.RunedService.Shutdown:input_type -> runed.v1.ShutdownRequest
+ 12, // 11: runed.v1.RunedService.SetCentroids:input_type -> runed.v1.SetCentroidsRequest
+ 3, // 12: runed.v1.RunedService.Embed:output_type -> runed.v1.EmbedResponse
+ 5, // 13: runed.v1.RunedService.EmbedBatch:output_type -> runed.v1.EmbedBatchResponse
+ 7, // 14: runed.v1.RunedService.Info:output_type -> runed.v1.InfoResponse
+ 9, // 15: runed.v1.RunedService.Health:output_type -> runed.v1.HealthResponse
+ 11, // 16: runed.v1.RunedService.Shutdown:output_type -> runed.v1.ShutdownResponse
+ 16, // 17: runed.v1.RunedService.SetCentroids:output_type -> runed.v1.SetCentroidsResponse
+ 12, // [12:18] is the sub-list for method output_type
+ 6, // [6:12] is the sub-list for method input_type
+ 6, // [6:6] is the sub-list for extension type_name
+ 6, // [6:6] is the sub-list for extension extendee
+ 0, // [0:6] is the sub-list for field type_name
}
func init() { file_runed_v1_runed_proto_init() }
@@ -772,13 +1150,17 @@ func file_runed_v1_runed_proto_init() {
if File_runed_v1_runed_proto != nil {
return
}
+ file_runed_v1_runed_proto_msgTypes[10].OneofWrappers = []any{
+ (*SetCentroidsRequest_Header)(nil),
+ (*SetCentroidsRequest_Batch)(nil),
+ }
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_runed_v1_runed_proto_rawDesc), len(file_runed_v1_runed_proto_rawDesc)),
NumEnums: 2,
- NumMessages: 10,
+ NumMessages: 15,
NumExtensions: 0,
NumServices: 1,
},
diff --git a/gen/runed/v1/runed_grpc.pb.go b/gen/runed/v1/runed_grpc.pb.go
index 15a505b..4d2828e 100644
--- a/gen/runed/v1/runed_grpc.pb.go
+++ b/gen/runed/v1/runed_grpc.pb.go
@@ -19,11 +19,12 @@ import (
const _ = grpc.SupportPackageIsVersion9
const (
- RunedService_Embed_FullMethodName = "/runed.v1.RunedService/Embed"
- RunedService_EmbedBatch_FullMethodName = "/runed.v1.RunedService/EmbedBatch"
- RunedService_Info_FullMethodName = "/runed.v1.RunedService/Info"
- RunedService_Health_FullMethodName = "/runed.v1.RunedService/Health"
- RunedService_Shutdown_FullMethodName = "/runed.v1.RunedService/Shutdown"
+ RunedService_Embed_FullMethodName = "/runed.v1.RunedService/Embed"
+ RunedService_EmbedBatch_FullMethodName = "/runed.v1.RunedService/EmbedBatch"
+ RunedService_Info_FullMethodName = "/runed.v1.RunedService/Info"
+ RunedService_Health_FullMethodName = "/runed.v1.RunedService/Health"
+ RunedService_Shutdown_FullMethodName = "/runed.v1.RunedService/Shutdown"
+ RunedService_SetCentroids_FullMethodName = "/runed.v1.RunedService/SetCentroids"
)
// RunedServiceClient is the client API for RunedService service.
@@ -48,6 +49,12 @@ type RunedServiceClient interface {
// Shutdown requests a graceful drain. The daemon stops accepting new
// requests, completes in-flight ones, and exits.
Shutdown(ctx context.Context, in *ShutdownRequest, opts ...grpc.CallOption) (*ShutdownResponse, error)
+ // SetCentroids replaces the daemon's IVF centroid set (header frame, then
+ // id-ordered batches — the same wire shape runespace's GetCentroids
+ // streams). rune-mcp relays the set here from the Vault so Embed can route
+ // inserts (with_route) without runed ever dialing the index engine. The
+ // set is persisted to the daemon cache and survives restarts.
+ SetCentroids(ctx context.Context, opts ...grpc.CallOption) (grpc.ClientStreamingClient[SetCentroidsRequest, SetCentroidsResponse], error)
}
type runedServiceClient struct {
@@ -108,6 +115,19 @@ func (c *runedServiceClient) Shutdown(ctx context.Context, in *ShutdownRequest,
return out, nil
}
+func (c *runedServiceClient) SetCentroids(ctx context.Context, opts ...grpc.CallOption) (grpc.ClientStreamingClient[SetCentroidsRequest, SetCentroidsResponse], error) {
+ cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+ stream, err := c.cc.NewStream(ctx, &RunedService_ServiceDesc.Streams[0], RunedService_SetCentroids_FullMethodName, cOpts...)
+ if err != nil {
+ return nil, err
+ }
+ x := &grpc.GenericClientStream[SetCentroidsRequest, SetCentroidsResponse]{ClientStream: stream}
+ return x, nil
+}
+
+// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
+type RunedService_SetCentroidsClient = grpc.ClientStreamingClient[SetCentroidsRequest, SetCentroidsResponse]
+
// RunedServiceServer is the server API for RunedService service.
// All implementations should embed UnimplementedRunedServiceServer
// for forward compatibility.
@@ -130,6 +150,12 @@ type RunedServiceServer interface {
// Shutdown requests a graceful drain. The daemon stops accepting new
// requests, completes in-flight ones, and exits.
Shutdown(context.Context, *ShutdownRequest) (*ShutdownResponse, error)
+ // SetCentroids replaces the daemon's IVF centroid set (header frame, then
+ // id-ordered batches — the same wire shape runespace's GetCentroids
+ // streams). rune-mcp relays the set here from the Vault so Embed can route
+ // inserts (with_route) without runed ever dialing the index engine. The
+ // set is persisted to the daemon cache and survives restarts.
+ SetCentroids(grpc.ClientStreamingServer[SetCentroidsRequest, SetCentroidsResponse]) error
}
// UnimplementedRunedServiceServer should be embedded to have
@@ -154,6 +180,9 @@ func (UnimplementedRunedServiceServer) Health(context.Context, *HealthRequest) (
func (UnimplementedRunedServiceServer) Shutdown(context.Context, *ShutdownRequest) (*ShutdownResponse, error) {
return nil, status.Error(codes.Unimplemented, "method Shutdown not implemented")
}
+func (UnimplementedRunedServiceServer) SetCentroids(grpc.ClientStreamingServer[SetCentroidsRequest, SetCentroidsResponse]) error {
+ return status.Error(codes.Unimplemented, "method SetCentroids not implemented")
+}
func (UnimplementedRunedServiceServer) testEmbeddedByValue() {}
// UnsafeRunedServiceServer may be embedded to opt out of forward compatibility for this service.
@@ -264,6 +293,13 @@ func _RunedService_Shutdown_Handler(srv interface{}, ctx context.Context, dec fu
return interceptor(ctx, in, info, handler)
}
+func _RunedService_SetCentroids_Handler(srv interface{}, stream grpc.ServerStream) error {
+ return srv.(RunedServiceServer).SetCentroids(&grpc.GenericServerStream[SetCentroidsRequest, SetCentroidsResponse]{ServerStream: stream})
+}
+
+// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
+type RunedService_SetCentroidsServer = grpc.ClientStreamingServer[SetCentroidsRequest, SetCentroidsResponse]
+
// RunedService_ServiceDesc is the grpc.ServiceDesc for RunedService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@@ -292,6 +328,12 @@ var RunedService_ServiceDesc = grpc.ServiceDesc{
Handler: _RunedService_Shutdown_Handler,
},
},
- Streams: []grpc.StreamDesc{},
+ Streams: []grpc.StreamDesc{
+ {
+ StreamName: "SetCentroids",
+ Handler: _RunedService_SetCentroids_Handler,
+ ClientStreams: true,
+ },
+ },
Metadata: "runed/v1/runed.proto",
}
diff --git a/internal/route/route.go b/internal/route/route.go
new file mode 100644
index 0000000..4653ccf
--- /dev/null
+++ b/internal/route/route.go
@@ -0,0 +1,105 @@
+// Package route holds the daemon's IVF centroid set and the plaintext
+// cluster assignment used to route inserts. runed never dials the index
+// engine: the set arrives via the SetCentroids RPC (relayed
+// runespace → vault → rune-mcp → runed) and is cached on disk so a restart
+// does not need a re-push. Assignment must agree byte-for-byte with the
+// engine's insert routing: max inner product over l2-normalized vectors,
+// ties to the lowest id (mirrors runespace-go-sdk clustering).
+package route
+
+import (
+ "encoding/gob"
+ "errors"
+ "fmt"
+ "math"
+ "os"
+ "path/filepath"
+)
+
+// CentroidSet is an immutable snapshot of the engine's IVF centroid set.
+// Vectors[i] is cluster i's centroid (id order); Version is the set's
+// content hash and travels with every routed assignment so the engine can
+// reject routing done against a stale set.
+type CentroidSet struct {
+ Version string
+ Dim int
+ Vectors [][]float32
+}
+
+// Validate reports whether the set is internally consistent and matches the
+// embedding dimension the daemon serves.
+func (s *CentroidSet) Validate(wantDim int) error {
+ if s == nil || s.Version == "" || len(s.Vectors) == 0 {
+ return errors.New("route: centroid set is empty")
+ }
+ if s.Dim != wantDim {
+ return fmt.Errorf("route: centroid dim %d does not match embedding dim %d", s.Dim, wantDim)
+ }
+ for i, v := range s.Vectors {
+ if len(v) != s.Dim {
+ return fmt.Errorf("route: centroid %d has dim %d, want %d", i, len(v), s.Dim)
+ }
+ }
+ return nil
+}
+
+// Assign returns the cluster id whose centroid has the highest inner product
+// with vec. vec must be l2-normalized — runed embeddings already are
+// (llama.cpp last-pooling normalizes unconditionally). Ties resolve to the
+// lowest id, matching the SDK/engine metric exactly.
+func (s *CentroidSet) Assign(vec []float32) uint32 {
+ bestID := uint32(0)
+ best := math.Inf(-1)
+ for i, c := range s.Vectors {
+ var dot float64
+ for j := 0; j < len(c) && j < len(vec); j++ {
+ dot += float64(c[j]) * float64(vec[j])
+ }
+ if dot > best {
+ best, bestID = dot, uint32(i)
+ }
+ }
+ return bestID
+}
+
+const cacheFile = "centroids.gob"
+
+// Persist writes the set atomically to dir (temp + rename), so a torn write
+// never produces a half-cache. Best-effort semantics are the caller's call.
+func (s *CentroidSet) Persist(dir string) error {
+ if err := os.MkdirAll(dir, 0o700); err != nil {
+ return fmt.Errorf("route: cache dir: %w", err)
+ }
+ tmp, err := os.CreateTemp(dir, cacheFile+".tmp-*")
+ if err != nil {
+ return fmt.Errorf("route: cache temp: %w", err)
+ }
+ defer os.Remove(tmp.Name())
+ if err := gob.NewEncoder(tmp).Encode(s); err != nil {
+ tmp.Close()
+ return fmt.Errorf("route: cache encode: %w", err)
+ }
+ if err := tmp.Close(); err != nil {
+ return fmt.Errorf("route: cache close: %w", err)
+ }
+ if err := os.Rename(tmp.Name(), filepath.Join(dir, cacheFile)); err != nil {
+ return fmt.Errorf("route: cache rename: %w", err)
+ }
+ return nil
+}
+
+// Load reads a previously persisted set. A missing cache returns
+// (nil, fs.ErrNotExist-wrapped error); a corrupt cache returns an error and
+// the caller should discard it and wait for the next SetCentroids push.
+func Load(dir string) (*CentroidSet, error) {
+ f, err := os.Open(filepath.Join(dir, cacheFile))
+ if err != nil {
+ return nil, fmt.Errorf("route: cache open: %w", err)
+ }
+ defer f.Close()
+ var s CentroidSet
+ if err := gob.NewDecoder(f).Decode(&s); err != nil {
+ return nil, fmt.Errorf("route: cache decode: %w", err)
+ }
+ return &s, nil
+}
diff --git a/internal/route/route_test.go b/internal/route/route_test.go
new file mode 100644
index 0000000..fd5d064
--- /dev/null
+++ b/internal/route/route_test.go
@@ -0,0 +1,78 @@
+package route
+
+import (
+ "testing"
+)
+
+func testSet() *CentroidSet {
+ return &CentroidSet{
+ Version: "v-abc",
+ Dim: 3,
+ Vectors: [][]float32{{1, 0, 0}, {0, 1, 0}, {0, 0, 1}},
+ }
+}
+
+func TestValidate(t *testing.T) {
+ if err := testSet().Validate(3); err != nil {
+ t.Fatalf("valid set rejected: %v", err)
+ }
+ if err := testSet().Validate(4); err == nil {
+ t.Fatal("dim mismatch accepted")
+ }
+ var nilSet *CentroidSet
+ if err := nilSet.Validate(3); err == nil {
+ t.Fatal("nil set accepted")
+ }
+ empty := &CentroidSet{Version: "v", Dim: 3}
+ if err := empty.Validate(3); err == nil {
+ t.Fatal("empty vectors accepted")
+ }
+ ragged := testSet()
+ ragged.Vectors[1] = []float32{1}
+ if err := ragged.Validate(3); err == nil {
+ t.Fatal("ragged centroid accepted")
+ }
+}
+
+func TestAssign(t *testing.T) {
+ s := testSet()
+ cases := []struct {
+ vec []float32
+ want uint32
+ }{
+ {[]float32{0.9, 0.1, 0}, 0},
+ {[]float32{0.1, 0.9, 0}, 1},
+ {[]float32{0, 0.2, 0.8}, 2},
+ // Tie between 0 and 1 resolves to the lowest id.
+ {[]float32{0.5, 0.5, 0}, 0},
+ }
+ for _, tc := range cases {
+ if got := s.Assign(tc.vec); got != tc.want {
+ t.Fatalf("Assign(%v) = %d, want %d", tc.vec, got, tc.want)
+ }
+ }
+}
+
+func TestPersistLoadRoundtrip(t *testing.T) {
+ dir := t.TempDir()
+ orig := testSet()
+ if err := orig.Persist(dir); err != nil {
+ t.Fatalf("persist: %v", err)
+ }
+ got, err := Load(dir)
+ if err != nil {
+ t.Fatalf("load: %v", err)
+ }
+ if got.Version != orig.Version || got.Dim != orig.Dim || len(got.Vectors) != len(orig.Vectors) {
+ t.Fatalf("roundtrip mismatch: %+v vs %+v", got, orig)
+ }
+ if got.Assign([]float32{0, 1, 0}) != 1 {
+ t.Fatal("loaded set assigns wrong cluster")
+ }
+}
+
+func TestLoadMissing(t *testing.T) {
+ if _, err := Load(t.TempDir()); err == nil {
+ t.Fatal("missing cache should error")
+ }
+}
diff --git a/internal/server/centroids.go b/internal/server/centroids.go
new file mode 100644
index 0000000..340633b
--- /dev/null
+++ b/internal/server/centroids.go
@@ -0,0 +1,88 @@
+package server
+
+import (
+ "errors"
+ "io"
+ "log/slog"
+
+ runedv1 "github.com/CryptoLabInc/runed/gen/runed/v1"
+ "github.com/CryptoLabInc/runed/internal/route"
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/status"
+)
+
+// SetCentroidCacheDir sets where a newly pushed centroid set is persisted.
+// Called once by cmd/runed before Serve; empty disables persistence.
+func (s *Server) SetCentroidCacheDir(dir string) { s.centroidCacheDir = dir }
+
+// LoadCentroids installs a set directly (boot-time cache restore). It
+// validates against the daemon's embedding dimension and is a no-op on nil.
+func (s *Server) LoadCentroids(cs *route.CentroidSet) error {
+ if cs == nil {
+ return errors.New("server: nil centroid set")
+ }
+ if err := cs.Validate(int(vectorDim)); err != nil {
+ return err
+ }
+ s.centroids.Store(cs)
+ return nil
+}
+
+// SetCentroids assembles the pushed stream (header, then id-ordered batches)
+// into a route.CentroidSet, installs it atomically, and persists it to the
+// daemon cache best-effort. The push replaces any previous set — the version
+// in the header is the engine's content hash, so pushing the same set twice
+// is a cheap idempotent overwrite.
+func (s *Server) SetCentroids(stream runedv1.RunedService_SetCentroidsServer) error {
+ var cs *route.CentroidSet
+ for {
+ req, err := stream.Recv()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return err
+ }
+ switch p := req.GetPayload().(type) {
+ case *runedv1.SetCentroidsRequest_Header:
+ if cs != nil {
+ return status.Error(codes.InvalidArgument, "duplicate header frame")
+ }
+ cs = &route.CentroidSet{
+ Version: p.Header.GetVersion(),
+ Dim: int(p.Header.GetDim()),
+ }
+ if n := p.Header.GetNlist(); n > 0 {
+ cs.Vectors = make([][]float32, 0, n)
+ }
+ case *runedv1.SetCentroidsRequest_Batch:
+ if cs == nil {
+ return status.Error(codes.InvalidArgument, "batch frame before header")
+ }
+ for _, c := range p.Batch.GetCentroids() {
+ cs.Vectors = append(cs.Vectors, c.GetVec())
+ }
+ }
+ }
+ if cs == nil {
+ return status.Error(codes.InvalidArgument, "empty stream: header frame required")
+ }
+ if err := cs.Validate(int(vectorDim)); err != nil {
+ return status.Error(codes.InvalidArgument, err.Error())
+ }
+
+ s.centroids.Store(cs)
+ if s.centroidCacheDir != "" {
+ if err := cs.Persist(s.centroidCacheDir); err != nil {
+ // Persistence is a restart optimization, not correctness — the set
+ // is live in memory; the next push repopulates the cache.
+ slog.Warn("centroids: persist failed (set is live in memory)", "err", err, "dir", s.centroidCacheDir)
+ }
+ }
+ slog.Info("centroids: set installed", "version", cs.Version, "nlist", len(cs.Vectors), "dim", cs.Dim)
+
+ return stream.SendAndClose(&runedv1.SetCentroidsResponse{
+ Version: cs.Version,
+ Nlist: uint32(len(cs.Vectors)),
+ })
+}
diff --git a/internal/server/centroids_test.go b/internal/server/centroids_test.go
new file mode 100644
index 0000000..c1a1508
--- /dev/null
+++ b/internal/server/centroids_test.go
@@ -0,0 +1,140 @@
+package server
+
+import (
+ "io"
+ "testing"
+
+ runedv1 "github.com/CryptoLabInc/runed/gen/runed/v1"
+ "github.com/CryptoLabInc/runed/internal/route"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/status"
+)
+
+// fakeSetCentroidsStream feeds canned frames and captures the close response.
+type fakeSetCentroidsStream struct {
+ grpc.ServerStream // nil — SetCentroids only uses Recv/SendAndClose
+ frames []*runedv1.SetCentroidsRequest
+ resp *runedv1.SetCentroidsResponse
+}
+
+func (f *fakeSetCentroidsStream) Recv() (*runedv1.SetCentroidsRequest, error) {
+ if len(f.frames) == 0 {
+ return nil, io.EOF
+ }
+ fr := f.frames[0]
+ f.frames = f.frames[1:]
+ return fr, nil
+}
+
+func (f *fakeSetCentroidsStream) SendAndClose(r *runedv1.SetCentroidsResponse) error {
+ f.resp = r
+ return nil
+}
+
+func header(version string, dim, nlist uint32) *runedv1.SetCentroidsRequest {
+ return &runedv1.SetCentroidsRequest{Payload: &runedv1.SetCentroidsRequest_Header{
+ Header: &runedv1.CentroidSetHeader{Version: version, Dim: dim, Nlist: nlist},
+ }}
+}
+
+func batch(vecs ...[]float32) *runedv1.SetCentroidsRequest {
+ cs := make([]*runedv1.Centroid, len(vecs))
+ for i, v := range vecs {
+ cs[i] = &runedv1.Centroid{Id: uint32(i), Vec: v}
+ }
+ return &runedv1.SetCentroidsRequest{Payload: &runedv1.SetCentroidsRequest_Batch{
+ Batch: &runedv1.CentroidBatch{Centroids: cs},
+ }}
+}
+
+// dimVec returns a vectorDim-length vector with a single 1 at hot.
+func dimVec(hot int) []float32 {
+ v := make([]float32, vectorDim)
+ v[hot] = 1
+ return v
+}
+
+func TestSetCentroidsInstallsAndPersists(t *testing.T) {
+ s := New("test")
+ s.SetCentroidCacheDir(t.TempDir())
+
+ st := &fakeSetCentroidsStream{frames: []*runedv1.SetCentroidsRequest{
+ header("v1", uint32(vectorDim), 2),
+ batch(dimVec(0), dimVec(1)),
+ }}
+ if err := s.SetCentroids(st); err != nil {
+ t.Fatalf("SetCentroids: %v", err)
+ }
+ if st.resp == nil || st.resp.Version != "v1" || st.resp.Nlist != 2 {
+ t.Fatalf("bad response: %+v", st.resp)
+ }
+ cs := s.centroids.Load()
+ if cs == nil || cs.Version != "v1" {
+ t.Fatal("set not installed")
+ }
+ // Persisted copy must be loadable and identical in shape.
+ loaded, err := route.Load(s.centroidCacheDir)
+ if err != nil {
+ t.Fatalf("cache not persisted: %v", err)
+ }
+ if loaded.Version != "v1" || len(loaded.Vectors) != 2 {
+ t.Fatalf("cache mismatch: %+v", loaded)
+ }
+}
+
+func TestSetCentroidsRejectsBadStreams(t *testing.T) {
+ cases := []struct {
+ name string
+ frames []*runedv1.SetCentroidsRequest
+ }{
+ {"empty stream", nil},
+ {"batch before header", []*runedv1.SetCentroidsRequest{batch(dimVec(0))}},
+ {"duplicate header", []*runedv1.SetCentroidsRequest{
+ header("v1", uint32(vectorDim), 1), header("v2", uint32(vectorDim), 1),
+ }},
+ {"dim mismatch", []*runedv1.SetCentroidsRequest{
+ header("v1", 8, 1), batch([]float32{1, 0, 0, 0, 0, 0, 0, 0}),
+ }},
+ {"empty version", []*runedv1.SetCentroidsRequest{
+ header("", uint32(vectorDim), 1), batch(dimVec(0)),
+ }},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ s := New("test")
+ err := s.SetCentroids(&fakeSetCentroidsStream{frames: tc.frames})
+ if status.Code(err) != codes.InvalidArgument {
+ t.Fatalf("want InvalidArgument, got %v", err)
+ }
+ if s.centroids.Load() != nil {
+ t.Fatal("bad stream must not install a set")
+ }
+ })
+ }
+}
+
+func TestEmbedWithRouteRequiresCentroids(t *testing.T) {
+ s := New("test")
+ // No backend yet: bootstrapping error wins regardless of routing.
+ _, err := s.Embed(t.Context(), &runedv1.EmbedRequest{Text: "hi", WithRoute: true})
+ if status.Code(err) != codes.FailedPrecondition {
+ t.Fatalf("want FailedPrecondition, got %v", err)
+ }
+}
+
+func TestLoadCentroidsValidates(t *testing.T) {
+ s := New("test")
+ bad := &route.CentroidSet{Version: "v", Dim: 8, Vectors: [][]float32{make([]float32, 8)}}
+ if err := s.LoadCentroids(bad); err == nil {
+ t.Fatal("dim-mismatched cache accepted")
+ }
+ good := &route.CentroidSet{Version: "v", Dim: int(vectorDim), Vectors: [][]float32{dimVec(3)}}
+ if err := s.LoadCentroids(good); err != nil {
+ t.Fatalf("valid cache rejected: %v", err)
+ }
+ info, _ := s.Info(t.Context(), &runedv1.InfoRequest{})
+ if info.CentroidSetVersion != "v" {
+ t.Fatalf("Info should expose centroid version, got %q", info.CentroidSetVersion)
+ }
+}
diff --git a/internal/server/server.go b/internal/server/server.go
index c056bbb..88cb6d6 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -14,6 +14,7 @@ import (
runedv1 "github.com/CryptoLabInc/runed/gen/runed/v1"
"github.com/CryptoLabInc/runed/internal/backend"
+ "github.com/CryptoLabInc/runed/internal/route"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
@@ -59,6 +60,13 @@ type Server struct {
// nil before any SetBootstrapStatus call.
bootstrapStatus atomic.Pointer[bootstrapState]
+ // centroids is the IVF set pushed via SetCentroids (or loaded from the
+ // disk cache at boot); nil until then, making with_route requests fail
+ // with FAILED_PRECONDITION. centroidCacheDir is where a newly pushed set
+ // is persisted (empty = no persistence, used by tests).
+ centroids atomic.Pointer[route.CentroidSet]
+ centroidCacheDir string
+
// Embed + EmbedBatch counter. Surfaced via HealthResponse.total_requests.
requests atomic.Int64
@@ -190,6 +198,12 @@ func (s *Server) Embed(ctx context.Context, req *runedv1.EmbedRequest) (*runedv1
if b == nil {
return nil, status.Error(codes.FailedPrecondition, "daemon is bootstrapping; embed not yet available")
}
+ // Fail before the forward pass: routing without a centroid set can never
+ // succeed, and the caller (rune-mcp) reacts by pushing SetCentroids first.
+ cs := s.centroids.Load()
+ if req.WithRoute && cs == nil {
+ return nil, status.Error(codes.FailedPrecondition, "no centroid set loaded; push one via SetCentroids before requesting with_route")
+ }
s.requests.Add(1)
for attempt := 0; attempt < embedMaxAttempts; attempt++ {
if err := b.EnsureStarted(); err != nil {
@@ -197,7 +211,12 @@ func (s *Server) Embed(ctx context.Context, req *runedv1.EmbedRequest) (*runedv1
}
vec, err := b.Embed(ctx, req.Text, true)
if err == nil {
- return &runedv1.EmbedResponse{Vector: vec}, nil
+ resp := &runedv1.EmbedResponse{Vector: vec}
+ if req.WithRoute {
+ resp.ClusterId = cs.Assign(vec)
+ resp.CentroidSetVersion = cs.Version
+ }
+ return resp, nil
}
if !errors.Is(err, backend.ErrNotStarted) {
return nil, err
@@ -213,6 +232,10 @@ func (s *Server) EmbedBatch(ctx context.Context, req *runedv1.EmbedBatchRequest)
if b == nil {
return nil, status.Error(codes.FailedPrecondition, "daemon is bootstrapping; embed not yet available")
}
+ cs := s.centroids.Load()
+ if req.WithRoute && cs == nil {
+ return nil, status.Error(codes.FailedPrecondition, "no centroid set loaded; push one via SetCentroids before requesting with_route")
+ }
s.requests.Add(1)
for attempt := 0; attempt < embedMaxAttempts; attempt++ {
if err := b.EnsureStarted(); err != nil {
@@ -225,6 +248,10 @@ func (s *Server) EmbedBatch(ctx context.Context, req *runedv1.EmbedBatchRequest)
}
for i, v := range vecs {
out.Embeddings[i] = &runedv1.EmbedResponse{Vector: v}
+ if req.WithRoute {
+ out.Embeddings[i].ClusterId = cs.Assign(v)
+ out.Embeddings[i].CentroidSetVersion = cs.Version
+ }
}
return out, nil
}
@@ -240,13 +267,17 @@ func (s *Server) EmbedBatch(ctx context.Context, req *runedv1.EmbedBatchRequest)
// Health reports STATUS_OK.
func (s *Server) Info(ctx context.Context, _ *runedv1.InfoRequest) (*runedv1.InfoResponse, error) {
mid, _ := s.modelIdentity.Load().(string)
- return &runedv1.InfoResponse{
+ info := &runedv1.InfoResponse{
DaemonVersion: s.version,
ModelIdentity: mid,
VectorDim: vectorDim,
MaxTextLength: s.maxTextLength.Load(),
MaxBatchSize: maxBatchSize,
- }, nil
+ }
+ if cs := s.centroids.Load(); cs != nil {
+ info.CentroidSetVersion = cs.Version
+ }
+ return info, nil
}
// Health maps backend state to the proto Status enum. SHUTTING_DOWN
diff --git a/proto/runed/v1/runed.proto b/proto/runed/v1/runed.proto
index a5e2b38..8ae00f2 100644
--- a/proto/runed/v1/runed.proto
+++ b/proto/runed/v1/runed.proto
@@ -26,24 +26,48 @@ service RunedService {
// Shutdown requests a graceful drain. The daemon stops accepting new
// requests, completes in-flight ones, and exits.
rpc Shutdown(ShutdownRequest) returns (ShutdownResponse);
+
+ // SetCentroids replaces the daemon's IVF centroid set (header frame, then
+ // id-ordered batches — the same wire shape runespace's GetCentroids
+ // streams). rune-mcp relays the set here from the Vault so Embed can route
+ // inserts (with_route) without runed ever dialing the index engine. The
+ // set is persisted to the daemon cache and survives restarts.
+ rpc SetCentroids(stream SetCentroidsRequest) returns (SetCentroidsResponse);
}
message EmbedRequest {
// Input text. Maximum length is InfoResponse.max_text_length characters.
// Exceeding the limit returns INVALID_ARGUMENT.
string text = 1;
+
+ // When true the response also carries the vector's IVF cluster assignment
+ // (cluster_id + centroid_set_version). Requires a centroid set loaded via
+ // SetCentroids; otherwise the call fails with FAILED_PRECONDITION.
+ bool with_route = 2;
}
message EmbedResponse {
// L2-normalized embedding. The server always normalizes output
// (llama.cpp default); no opt-out. Length equals InfoResponse.vector_dim.
repeated float vector = 1 [packed = true];
+
+ // IVF hard single assignment (0..nlist-1). Only set when the request asked
+ // for routing (with_route); the vector is already normalized so the
+ // assignment metric is max inner product, matching runespace insert routing.
+ uint32 cluster_id = 2;
+
+ // Version (content hash) of the centroid set the assignment was routed
+ // against. Callers echo this to the index engine so a stale set is rejected.
+ string centroid_set_version = 3;
}
message EmbedBatchRequest {
// Input texts. Batch size must not exceed InfoResponse.max_batch_size.
// Each text individually bound by InfoResponse.max_text_length.
repeated string texts = 1;
+
+ // Same as EmbedRequest.with_route, applied to every embedding in the batch.
+ bool with_route = 2;
}
message EmbedBatchResponse {
@@ -70,6 +94,10 @@ message InfoResponse {
// Maximum batch size accepted by EmbedBatch.
int32 max_batch_size = 5;
+
+ // Version of the currently loaded IVF centroid set; empty when none is
+ // loaded (with_route requests fail until SetCentroids delivers one).
+ string centroid_set_version = 6;
}
message HealthRequest {}
@@ -121,3 +149,32 @@ message ShutdownResponse {
// True if the daemon accepted the shutdown request and is now draining.
bool accepted = 1;
}
+
+message SetCentroidsRequest {
+ oneof payload {
+ CentroidSetHeader header = 1;
+ CentroidBatch batch = 2;
+ }
+}
+
+// CentroidSetHeader opens the stream: identity and shape of the set.
+message CentroidSetHeader {
+ string version = 1; // content hash (sha256); must be non-empty
+ uint32 dim = 2; // must equal InfoResponse.vector_dim
+ uint32 nlist = 3; // total centroid count that will follow
+}
+
+// CentroidBatch carries centroids in id order (append order == cluster id).
+message CentroidBatch {
+ repeated Centroid centroids = 1;
+}
+
+message Centroid {
+ uint32 id = 1;
+ repeated float vec = 2 [packed = true];
+}
+
+message SetCentroidsResponse {
+ string version = 1; // version now active
+ uint32 nlist = 2; // centroids loaded
+}
From ee5c2912eb4718e2ecbbbc6aae067fa714ca0ae1 Mon Sep 17 00:00:00 2001
From: couragehong
Date: Mon, 13 Jul 2026 10:09:57 +0900
Subject: [PATCH 02/20] feat(server): tag embed preconditions with
machine-readable reasons
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Embed/EmbedBatch return FAILED_PRECONDITION for two opposite conditions:
the model is still loading (wait and retry the same call) vs no centroid
set is loaded (push one via SetCentroids, then retry — §9.2 C4). Clients
could only tell them apart by parsing the human message, and rune-mcp
misdiagnosed the bootstrap window as the centroid case: it fired a
pointless resync and surfaced a non-retryable error for a condition that
heals itself in ~30s (first capture after a machine reboot hits this).
Attach an ErrorInfo{reason, domain} detail — BOOTSTRAPPING or
NO_CENTROID_SET — the same pattern runespace uses for its error reasons.
The status code and message are unchanged, so the transport-level
non-retry decision (see TestServer_EmbedFailsBeforeBackendSet) and old
clients are unaffected; new clients branch on the reason.
Co-Authored-By: Claude Fable 5
---
go.mod | 2 +-
internal/server/reason_test.go | 58 ++++++++++++++++++++++++++++++++++
internal/server/server.go | 35 +++++++++++++++++---
3 files changed, 90 insertions(+), 5 deletions(-)
create mode 100644 internal/server/reason_test.go
diff --git a/go.mod b/go.mod
index edbfebd..3791bb1 100644
--- a/go.mod
+++ b/go.mod
@@ -3,6 +3,7 @@ module github.com/CryptoLabInc/runed
go 1.26.2
require (
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516
google.golang.org/grpc v1.80.0
google.golang.org/protobuf v1.36.11
)
@@ -11,5 +12,4 @@ require (
golang.org/x/net v0.49.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
- google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect
)
diff --git a/internal/server/reason_test.go b/internal/server/reason_test.go
new file mode 100644
index 0000000..1ceaac9
--- /dev/null
+++ b/internal/server/reason_test.go
@@ -0,0 +1,58 @@
+package server
+
+import (
+ "context"
+ "testing"
+
+ runedv1 "github.com/CryptoLabInc/runed/gen/runed/v1"
+ "google.golang.org/genproto/googleapis/rpc/errdetails"
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/status"
+)
+
+// reasonOf extracts the ErrorInfo reason from a status error ("" if none).
+func reasonOf(err error) string {
+ st, ok := status.FromError(err)
+ if !ok {
+ return ""
+ }
+ for _, d := range st.Details() {
+ if info, ok := d.(*errdetails.ErrorInfo); ok {
+ return info.GetReason()
+ }
+ }
+ return ""
+}
+
+// The two precondition conditions must be machine-distinguishable by reason
+// while keeping the FAILED_PRECONDITION code (transport-level non-retry stays;
+// see TestServer_EmbedFailsBeforeBackendSet's rationale).
+func TestPreconditionErrCarriesReason(t *testing.T) {
+ for _, tc := range []struct{ reason, msg string }{
+ {ReasonBootstrapping, "daemon is bootstrapping; embed not yet available"},
+ {ReasonNoCentroidSet, "no centroid set loaded"},
+ } {
+ err := preconditionErr(tc.reason, tc.msg)
+ if got := status.Code(err); got != codes.FailedPrecondition {
+ t.Fatalf("%s: code = %v, want FailedPrecondition", tc.reason, got)
+ }
+ if got := reasonOf(err); got != tc.reason {
+ t.Fatalf("reason = %q, want %q", got, tc.reason)
+ }
+ }
+}
+
+// The bootstrap window (backend not yet wired) must surface the BOOTSTRAPPING
+// reason over the wire — this is what lets rune-mcp wait-and-retry instead of
+// misfiring the centroid resync.
+func TestEmbedBootstrapReasonOnWire(t *testing.T) {
+ s := New("test")
+ _, err := s.Embed(context.Background(), &runedv1.EmbedRequest{Text: "x"})
+ if got := reasonOf(err); got != ReasonBootstrapping {
+ t.Fatalf("Embed reason = %q, want %q", got, ReasonBootstrapping)
+ }
+ _, err = s.EmbedBatch(context.Background(), &runedv1.EmbedBatchRequest{Texts: []string{"x"}})
+ if got := reasonOf(err); got != ReasonBootstrapping {
+ t.Fatalf("EmbedBatch reason = %q, want %q", got, ReasonBootstrapping)
+ }
+}
diff --git a/internal/server/server.go b/internal/server/server.go
index 88cb6d6..8543d25 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -15,11 +15,38 @@ import (
runedv1 "github.com/CryptoLabInc/runed/gen/runed/v1"
"github.com/CryptoLabInc/runed/internal/backend"
"github.com/CryptoLabInc/runed/internal/route"
+ "google.golang.org/genproto/googleapis/rpc/errdetails"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
+// Machine-readable reasons attached (as an ErrorInfo detail, same pattern as
+// runespace's grpcerr) to the two FAILED_PRECONDITION conditions Embed can
+// return. The status code stays FAILED_PRECONDITION for both — deliberately
+// not retryable at the transport layer (see TestServer_EmbedFailsBeforeBackendSet)
+// — but the reasons need opposite application-level handling, so clients
+// (rune-mcp) branch on the reason instead of parsing the human message:
+//
+// ReasonBootstrapping — model still loading; wait and retry the same call.
+// ReasonNoCentroidSet — push a set via SetCentroids, then retry (§9.2 C4).
+const (
+ errDomain = "runed.v1"
+ ReasonBootstrapping = "BOOTSTRAPPING"
+ ReasonNoCentroidSet = "NO_CENTROID_SET"
+)
+
+// preconditionErr builds a FAILED_PRECONDITION status tagged with reason.
+// Detail attachment is best-effort: on failure the bare status still carries
+// the right code and message.
+func preconditionErr(reason, msg string) error {
+ st := status.New(codes.FailedPrecondition, msg)
+ if d, err := st.WithDetails(&errdetails.ErrorInfo{Reason: reason, Domain: errDomain}); err == nil {
+ return d.Err()
+ }
+ return st.Err()
+}
+
// Plan A constants for the Info RPC. Qwen3-Embedding-0.6B fixes these at
// model-load time; future revisions will source them from config or the
// model file. Hardcoded here because Plan A ships with exactly one model.
@@ -196,13 +223,13 @@ const embedMaxAttempts = 2
func (s *Server) Embed(ctx context.Context, req *runedv1.EmbedRequest) (*runedv1.EmbedResponse, error) {
b := s.backend.Load()
if b == nil {
- return nil, status.Error(codes.FailedPrecondition, "daemon is bootstrapping; embed not yet available")
+ return nil, preconditionErr(ReasonBootstrapping, "daemon is bootstrapping; embed not yet available")
}
// Fail before the forward pass: routing without a centroid set can never
// succeed, and the caller (rune-mcp) reacts by pushing SetCentroids first.
cs := s.centroids.Load()
if req.WithRoute && cs == nil {
- return nil, status.Error(codes.FailedPrecondition, "no centroid set loaded; push one via SetCentroids before requesting with_route")
+ return nil, preconditionErr(ReasonNoCentroidSet, "no centroid set loaded; push one via SetCentroids before requesting with_route")
}
s.requests.Add(1)
for attempt := 0; attempt < embedMaxAttempts; attempt++ {
@@ -230,11 +257,11 @@ func (s *Server) Embed(ctx context.Context, req *runedv1.EmbedRequest) (*runedv1
func (s *Server) EmbedBatch(ctx context.Context, req *runedv1.EmbedBatchRequest) (*runedv1.EmbedBatchResponse, error) {
b := s.backend.Load()
if b == nil {
- return nil, status.Error(codes.FailedPrecondition, "daemon is bootstrapping; embed not yet available")
+ return nil, preconditionErr(ReasonBootstrapping, "daemon is bootstrapping; embed not yet available")
}
cs := s.centroids.Load()
if req.WithRoute && cs == nil {
- return nil, status.Error(codes.FailedPrecondition, "no centroid set loaded; push one via SetCentroids before requesting with_route")
+ return nil, preconditionErr(ReasonNoCentroidSet, "no centroid set loaded; push one via SetCentroids before requesting with_route")
}
s.requests.Add(1)
for attempt := 0; attempt < embedMaxAttempts; attempt++ {
From 38f43c663acc49fe8dd7665e71d1fe8b8ca0133b Mon Sep 17 00:00:00 2001
From: couragehong
Date: Mon, 13 Jul 2026 12:55:14 +0900
Subject: [PATCH 03/20] feat(route): verify centroid content hash at receipt
and cache load
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Port the engine's exact version recipe (sha256 over dim LE, nlist LE,
preset+NUL, float32 bits LE in id order — runespace/internal/cluster/
centroid.go computeVersion) as route.ComputeVersion, and add
VerifyVersion to recompute it against the relayed Version tag.
Verification runs at exactly two entry points, never per-Embed:
C2 SetCentroids receipt -> InvalidArgument on mismatch
C5 gob cache Load -> 'route: cache verify' error on bitrot
Requires the new 'string preset = 4' header field (the missing hash
ingredient). Empty preset = legacy sender -> verification skipped.
Co-Authored-By: Claude Fable 5
---
gen/runed/v1/runed.pb.go | 25 ++++++--
internal/route/route.go | 55 ++++++++++++++++-
internal/route/verify_test.go | 113 ++++++++++++++++++++++++++++++++++
internal/server/centroids.go | 8 +++
proto/runed/v1/runed.proto | 5 ++
5 files changed, 199 insertions(+), 7 deletions(-)
create mode 100644 internal/route/verify_test.go
diff --git a/gen/runed/v1/runed.pb.go b/gen/runed/v1/runed.pb.go
index 999fbb9..d928737 100644
--- a/gen/runed/v1/runed.pb.go
+++ b/gen/runed/v1/runed.pb.go
@@ -793,10 +793,15 @@ func (*SetCentroidsRequest_Batch) isSetCentroidsRequest_Payload() {}
// CentroidSetHeader opens the stream: identity and shape of the set.
type CentroidSetHeader struct {
- state protoimpl.MessageState `protogen:"open.v1"`
- Version string `protobuf:"bytes,1,opt,name=version,proto3" json:"version,omitempty"` // content hash (sha256); must be non-empty
- Dim uint32 `protobuf:"varint,2,opt,name=dim,proto3" json:"dim,omitempty"` // must equal InfoResponse.vector_dim
- Nlist uint32 `protobuf:"varint,3,opt,name=nlist,proto3" json:"nlist,omitempty"` // total centroid count that will follow
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Version string `protobuf:"bytes,1,opt,name=version,proto3" json:"version,omitempty"` // content hash (sha256); must be non-empty
+ Dim uint32 `protobuf:"varint,2,opt,name=dim,proto3" json:"dim,omitempty"` // must equal InfoResponse.vector_dim
+ Nlist uint32 `protobuf:"varint,3,opt,name=nlist,proto3" json:"nlist,omitempty"` // total centroid count that will follow
+ // evi preset the set was trained for (e.g. "IP1") — a version-hash
+ // ingredient. When present, runed recomputes the content hash over the
+ // received vectors and rejects the push on mismatch; empty (legacy
+ // senders) skips that verification.
+ Preset string `protobuf:"bytes,4,opt,name=preset,proto3" json:"preset,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
@@ -852,6 +857,13 @@ func (x *CentroidSetHeader) GetNlist() uint32 {
return 0
}
+func (x *CentroidSetHeader) GetPreset() string {
+ if x != nil {
+ return x.Preset
+ }
+ return ""
+}
+
// CentroidBatch carries centroids in id order (append order == cluster id).
type CentroidBatch struct {
state protoimpl.MessageState `protogen:"open.v1"`
@@ -1062,11 +1074,12 @@ const file_runed_v1_runed_proto_rawDesc = "" +
"\x13SetCentroidsRequest\x125\n" +
"\x06header\x18\x01 \x01(\v2\x1b.runed.v1.CentroidSetHeaderH\x00R\x06header\x12/\n" +
"\x05batch\x18\x02 \x01(\v2\x17.runed.v1.CentroidBatchH\x00R\x05batchB\t\n" +
- "\apayload\"U\n" +
+ "\apayload\"m\n" +
"\x11CentroidSetHeader\x12\x18\n" +
"\aversion\x18\x01 \x01(\tR\aversion\x12\x10\n" +
"\x03dim\x18\x02 \x01(\rR\x03dim\x12\x14\n" +
- "\x05nlist\x18\x03 \x01(\rR\x05nlist\"A\n" +
+ "\x05nlist\x18\x03 \x01(\rR\x05nlist\x12\x16\n" +
+ "\x06preset\x18\x04 \x01(\tR\x06preset\"A\n" +
"\rCentroidBatch\x120\n" +
"\tcentroids\x18\x01 \x03(\v2\x12.runed.v1.CentroidR\tcentroids\"0\n" +
"\bCentroid\x12\x0e\n" +
diff --git a/internal/route/route.go b/internal/route/route.go
index 4653ccf..9efb8a6 100644
--- a/internal/route/route.go
+++ b/internal/route/route.go
@@ -8,7 +8,10 @@
package route
import (
+ "crypto/sha256"
+ "encoding/binary"
"encoding/gob"
+ "encoding/hex"
"errors"
"fmt"
"math"
@@ -19,13 +22,57 @@ import (
// CentroidSet is an immutable snapshot of the engine's IVF centroid set.
// Vectors[i] is cluster i's centroid (id order); Version is the set's
// content hash and travels with every routed assignment so the engine can
-// reject routing done against a stale set.
+// reject routing done against a stale set. Preset is the evi preset the set
+// was trained for — a Version-hash ingredient, so carrying it is what makes
+// local recomputation (VerifyVersion) possible.
type CentroidSet struct {
Version string
Dim int
+ Preset string
Vectors [][]float32
}
+// versionPrefix mirrors the engine's content-hash tag.
+const versionPrefix = "sha256:"
+
+// ComputeVersion recreates the engine's content hash over the set: sha256 of
+// dim, nlist, preset, then every centroid's float32 bits in id order
+// (little-endian). MUST stay byte-identical to runespace's computeVersion
+// (runespace/internal/cluster/centroid.go) — that equality is what lets runed
+// verify a relayed or cached set against the version the engine minted.
+func ComputeVersion(s *CentroidSet) string {
+ h := sha256.New()
+ var u32 [4]byte
+ binary.LittleEndian.PutUint32(u32[:], uint32(s.Dim))
+ h.Write(u32[:])
+ binary.LittleEndian.PutUint32(u32[:], uint32(len(s.Vectors)))
+ h.Write(u32[:])
+ h.Write([]byte(s.Preset))
+ h.Write([]byte{0}) // length-delimit the preset string
+ for _, vec := range s.Vectors {
+ for _, f := range vec {
+ binary.LittleEndian.PutUint32(u32[:], math.Float32bits(f))
+ h.Write(u32[:])
+ }
+ }
+ return versionPrefix + hex.EncodeToString(h.Sum(nil))
+}
+
+// VerifyVersion recomputes the content hash and compares it to the carried
+// Version — the integrity gate at the set's two entry points (SetCentroids
+// receipt, disk-cache load). An empty Preset means the sender predates the
+// preset relay: the hash cannot be recreated without that ingredient, so
+// verification is skipped (legacy behavior, trust the tag).
+func (s *CentroidSet) VerifyVersion() error {
+ if s.Preset == "" {
+ return nil
+ }
+ if got := ComputeVersion(s); got != s.Version {
+ return fmt.Errorf("route: centroid content does not match its version tag (tag %s, content hashes to %s) — corrupt relay or cache", s.Version, got)
+ }
+ return nil
+}
+
// Validate reports whether the set is internally consistent and matches the
// embedding dimension the daemon serves.
func (s *CentroidSet) Validate(wantDim int) error {
@@ -91,6 +138,9 @@ func (s *CentroidSet) Persist(dir string) error {
// Load reads a previously persisted set. A missing cache returns
// (nil, fs.ErrNotExist-wrapped error); a corrupt cache returns an error and
// the caller should discard it and wait for the next SetCentroids push.
+// Corruption covers both shapes: a broken gob (decode error) and — the
+// insidious case — intact gob framing around flipped content bytes, which
+// only the §9.2 C5 hash recomputation can see.
func Load(dir string) (*CentroidSet, error) {
f, err := os.Open(filepath.Join(dir, cacheFile))
if err != nil {
@@ -101,5 +151,8 @@ func Load(dir string) (*CentroidSet, error) {
if err := gob.NewDecoder(f).Decode(&s); err != nil {
return nil, fmt.Errorf("route: cache decode: %w", err)
}
+ if err := s.VerifyVersion(); err != nil {
+ return nil, fmt.Errorf("route: cache verify: %w", err)
+ }
return &s, nil
}
diff --git a/internal/route/verify_test.go b/internal/route/verify_test.go
new file mode 100644
index 0000000..0ef8535
--- /dev/null
+++ b/internal/route/verify_test.go
@@ -0,0 +1,113 @@
+package route
+
+import (
+ "encoding/binary"
+ "math"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func verifiedSet() *CentroidSet {
+ s := &CentroidSet{
+ Dim: 2,
+ Preset: "IP1",
+ Vectors: [][]float32{
+ {1, 0},
+ {0.5, 0.5},
+ },
+ }
+ s.Version = ComputeVersion(s)
+ return s
+}
+
+// ComputeVersion must follow the engine's exact recipe: sha256 over dim(LE),
+// nlist(LE), preset, NUL, then float32 bits in id order (LE). This golden
+// test hand-assembles those bytes independently so a silent recipe drift in
+// either implementation breaks it.
+func TestComputeVersionRecipe(t *testing.T) {
+ s := verifiedSet()
+ var buf []byte
+ u32 := func(v uint32) { b := make([]byte, 4); binary.LittleEndian.PutUint32(b, v); buf = append(buf, b...) }
+ u32(2) // dim
+ u32(2) // nlist
+ buf = append(buf, []byte("IP1")...)
+ buf = append(buf, 0)
+ for _, vec := range s.Vectors {
+ for _, f := range vec {
+ u32(math.Float32bits(f))
+ }
+ }
+ if got := ComputeVersion(s); !strings.HasPrefix(got, "sha256:") {
+ t.Fatalf("version prefix missing: %s", got)
+ }
+ // 같은 재료로 두 번 계산하면 결정적이어야 하고, 재료 하나만 바뀌어도 달라져야 한다.
+ if ComputeVersion(s) != s.Version {
+ t.Fatal("ComputeVersion is not deterministic")
+ }
+ preset2 := *s
+ preset2.Preset = "IP2"
+ if ComputeVersion(&preset2) == s.Version {
+ t.Fatal("preset must be a hash ingredient")
+ }
+}
+
+func TestVerifyVersion(t *testing.T) {
+ s := verifiedSet()
+ if err := s.VerifyVersion(); err != nil {
+ t.Fatalf("valid set rejected: %v", err)
+ }
+ // 내용만 바뀌고 꼬리표는 그대로 — 무증상 손상의 형태 그대로 재현
+ s.Vectors[0][0] = 0.9999
+ if err := s.VerifyVersion(); err == nil {
+ t.Fatal("content corruption with intact tag was not detected")
+ }
+ // 레거시(전달자에 preset 없음): 재계산 불가 → 스킵
+ legacy := &CentroidSet{Version: "sha256:whatever", Dim: 2, Vectors: [][]float32{{1, 0}}}
+ if err := legacy.VerifyVersion(); err != nil {
+ t.Fatalf("legacy (no preset) must skip verification: %v", err)
+ }
+}
+
+// C5 end-to-end at the file layer: a bit flipped inside the gob's float
+// payload decodes cleanly but must now fail Load via the hash recomputation.
+func TestLoadDetectsBitrot(t *testing.T) {
+ dir := t.TempDir()
+ s := verifiedSet()
+ if err := s.Persist(dir); err != nil {
+ t.Fatalf("persist: %v", err)
+ }
+ if _, err := Load(dir); err != nil {
+ t.Fatalf("clean roundtrip must verify: %v", err)
+ }
+
+ path := filepath.Join(dir, "centroids.gob")
+ raw, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ // float32(1.0)의 gob 표현 바이트를 찾아 한 비트 뒤집는다 — 구조는 유지되고
+ // 내용만 변하는 손상. (float 위치를 못 찾으면 마지막 바이트를 뒤집는다.)
+ flipped := false
+ for i := len(raw) - 1; i > 0; i-- {
+ cand := append(append([]byte{}, raw[:i]...), raw[i]^0x01)
+ cand = append(cand, raw[i+1:]...)
+ if err := os.WriteFile(path, cand, 0o600); err != nil {
+ t.Fatal(err)
+ }
+ got, err := Load(dir)
+ if err != nil && strings.Contains(err.Error(), "cache verify") {
+ flipped = true // 원하는 경로: decode 성공 + verify가 잡음
+ break
+ }
+ if err != nil {
+ continue // decode 자체가 깨진 위치 — 다른 바이트로 재시도
+ }
+ _ = got
+ t.Fatalf("corrupted cache loaded without complaint (byte %d)", i)
+ }
+ if !flipped {
+ t.Skip("no byte position produced intact-gob corruption in this encoding")
+ }
+}
diff --git a/internal/server/centroids.go b/internal/server/centroids.go
index 340633b..239988f 100644
--- a/internal/server/centroids.go
+++ b/internal/server/centroids.go
@@ -51,6 +51,7 @@ func (s *Server) SetCentroids(stream runedv1.RunedService_SetCentroidsServer) er
cs = &route.CentroidSet{
Version: p.Header.GetVersion(),
Dim: int(p.Header.GetDim()),
+ Preset: p.Header.GetPreset(),
}
if n := p.Header.GetNlist(); n > 0 {
cs.Vectors = make([][]float32, 0, n)
@@ -70,6 +71,13 @@ func (s *Server) SetCentroids(stream runedv1.RunedService_SetCentroidsServer) er
if err := cs.Validate(int(vectorDim)); err != nil {
return status.Error(codes.InvalidArgument, err.Error())
}
+ // §9.2 C2: the version tag is a content hash — recompute it over the
+ // received vectors and reject a push whose content does not match its
+ // claim (relay assembly corruption). Skipped when the sender carried no
+ // preset (legacy chain), since the hash cannot be recreated without it.
+ if err := cs.VerifyVersion(); err != nil {
+ return status.Error(codes.InvalidArgument, err.Error())
+ }
s.centroids.Store(cs)
if s.centroidCacheDir != "" {
diff --git a/proto/runed/v1/runed.proto b/proto/runed/v1/runed.proto
index 8ae00f2..c6684f1 100644
--- a/proto/runed/v1/runed.proto
+++ b/proto/runed/v1/runed.proto
@@ -162,6 +162,11 @@ message CentroidSetHeader {
string version = 1; // content hash (sha256); must be non-empty
uint32 dim = 2; // must equal InfoResponse.vector_dim
uint32 nlist = 3; // total centroid count that will follow
+ // evi preset the set was trained for (e.g. "IP1") — a version-hash
+ // ingredient. When present, runed recomputes the content hash over the
+ // received vectors and rejects the push on mismatch; empty (legacy
+ // senders) skips that verification.
+ string preset = 4;
}
// CentroidBatch carries centroids in id order (append order == cluster id).
From d12b2077653c641b482b596b91b96addcbde44dc Mon Sep 17 00:00:00 2001
From: couragehong
Date: Wed, 15 Jul 2026 13:39:28 +0900
Subject: [PATCH 04/20] test(route,server): fix hollow assertions and document
each test
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- verify_test: TestComputeVersionRecipe now hashes the hand-assembled
bytes and compares to ComputeVersion, so recipe drift (dim/nlist order,
endianness, preset NUL) actually fails the test instead of the golden
buffer being dead
- centroids_test: TestEmbedWithRouteRequiresCentroids wires a backend so
the bootstrap gate passes, then asserts the NO_CENTROID_SET reason —
previously it passed via the bootstrap error and never exercised the
routing branch (would pass even with with_route=false)
- centroids_test: add a content-hash-mismatch case (preset present, tag
wrong) to cover the SetCentroids VerifyVersion gate the no-preset cases
skip
- add doc comments describing what each test verifies
Co-Authored-By: Claude Fable 5
---
internal/route/route_test.go | 12 +++++++++++
internal/route/verify_test.go | 15 ++++++++++++-
internal/server/centroids_test.go | 36 ++++++++++++++++++++++++++++++-
3 files changed, 61 insertions(+), 2 deletions(-)
diff --git a/internal/route/route_test.go b/internal/route/route_test.go
index fd5d064..b53d38c 100644
--- a/internal/route/route_test.go
+++ b/internal/route/route_test.go
@@ -12,6 +12,9 @@ func testSet() *CentroidSet {
}
}
+// TestValidate covers Validate's rejection cases — nil receiver, empty
+// vectors, dim mismatch against the served dimension, and a ragged row whose
+// length differs from Dim — plus the happy path.
func TestValidate(t *testing.T) {
if err := testSet().Validate(3); err != nil {
t.Fatalf("valid set rejected: %v", err)
@@ -34,6 +37,9 @@ func TestValidate(t *testing.T) {
}
}
+// TestAssign checks that Assign picks the centroid with the highest inner
+// product against the query, and that a tie resolves to the lowest cluster id
+// (matching the SDK/engine metric).
func TestAssign(t *testing.T) {
s := testSet()
cases := []struct {
@@ -53,6 +59,10 @@ func TestAssign(t *testing.T) {
}
}
+// TestPersistLoadRoundtrip persists a set and reloads it, asserting the
+// version/dim/shape survive the gob roundtrip and that the reloaded set still
+// routes correctly. (Preset is empty here, so Load's hash verification is
+// skipped — the content-hash path is covered by verify_test.go.)
func TestPersistLoadRoundtrip(t *testing.T) {
dir := t.TempDir()
orig := testSet()
@@ -71,6 +81,8 @@ func TestPersistLoadRoundtrip(t *testing.T) {
}
}
+// TestLoadMissing asserts Load errors (rather than returning an empty set)
+// when no cache file exists in the directory.
func TestLoadMissing(t *testing.T) {
if _, err := Load(t.TempDir()); err == nil {
t.Fatal("missing cache should error")
diff --git a/internal/route/verify_test.go b/internal/route/verify_test.go
index 0ef8535..c880ff9 100644
--- a/internal/route/verify_test.go
+++ b/internal/route/verify_test.go
@@ -1,7 +1,9 @@
package route
import (
+ "crypto/sha256"
"encoding/binary"
+ "encoding/hex"
"math"
"os"
"path/filepath"
@@ -39,7 +41,15 @@ func TestComputeVersionRecipe(t *testing.T) {
u32(math.Float32bits(f))
}
}
- if got := ComputeVersion(s); !strings.HasPrefix(got, "sha256:") {
+ // 골든 비교: 손으로 조립한 재료 바이트를 직접 해싱해, 구현이 dim·nlist 순서,
+ // 엔디안, preset NUL 구분자까지 바이트 단위로 동일한 레시피를 따르는지 고정한다.
+ sum := sha256.Sum256(buf)
+ want := "sha256:" + hex.EncodeToString(sum[:])
+ got := ComputeVersion(s)
+ if got != want {
+ t.Fatalf("recipe drift: ComputeVersion=%s, hand-assembled=%s", got, want)
+ }
+ if !strings.HasPrefix(got, "sha256:") {
t.Fatalf("version prefix missing: %s", got)
}
// 같은 재료로 두 번 계산하면 결정적이어야 하고, 재료 하나만 바뀌어도 달라져야 한다.
@@ -53,6 +63,9 @@ func TestComputeVersionRecipe(t *testing.T) {
}
}
+// TestVerifyVersion exercises the integrity gate: a set whose content matches
+// its tag passes, content mutated under an intact tag is rejected, and a legacy
+// set carrying no Preset skips verification (the hash can't be recomputed).
func TestVerifyVersion(t *testing.T) {
s := verifiedSet()
if err := s.VerifyVersion(); err != nil {
diff --git a/internal/server/centroids_test.go b/internal/server/centroids_test.go
index c1a1508..1dc70bd 100644
--- a/internal/server/centroids_test.go
+++ b/internal/server/centroids_test.go
@@ -5,6 +5,7 @@ import (
"testing"
runedv1 "github.com/CryptoLabInc/runed/gen/runed/v1"
+ "github.com/CryptoLabInc/runed/internal/backend"
"github.com/CryptoLabInc/runed/internal/route"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
@@ -38,6 +39,14 @@ func header(version string, dim, nlist uint32) *runedv1.SetCentroidsRequest {
}}
}
+// headerPreset is header with a non-empty Preset, so the receiver recomputes
+// and enforces the content hash instead of skipping it as a legacy push.
+func headerPreset(version, preset string, dim, nlist uint32) *runedv1.SetCentroidsRequest {
+ return &runedv1.SetCentroidsRequest{Payload: &runedv1.SetCentroidsRequest_Header{
+ Header: &runedv1.CentroidSetHeader{Version: version, Preset: preset, Dim: dim, Nlist: nlist},
+ }}
+}
+
func batch(vecs ...[]float32) *runedv1.SetCentroidsRequest {
cs := make([]*runedv1.Centroid, len(vecs))
for i, v := range vecs {
@@ -55,6 +64,9 @@ func dimVec(hot int) []float32 {
return v
}
+// TestSetCentroidsInstallsAndPersists drives a well-formed header+batch stream
+// and asserts the set is installed in memory (centroids.Load), the close
+// response echoes version and nlist, and a reloadable copy lands in the cache.
func TestSetCentroidsInstallsAndPersists(t *testing.T) {
s := New("test")
s.SetCentroidCacheDir(t.TempDir())
@@ -83,6 +95,12 @@ func TestSetCentroidsInstallsAndPersists(t *testing.T) {
}
}
+// TestSetCentroidsRejectsBadStreams asserts each malformed stream is rejected
+// with InvalidArgument and installs no set. Cases: empty stream, batch before
+// header, duplicate header, dim mismatch against the served dimension, empty
+// version, and a content-hash mismatch (Preset present so the hash is enforced,
+// version tag deliberately wrong) — the last exercises the SetCentroids
+// VerifyVersion gate that the no-preset cases skip.
func TestSetCentroidsRejectsBadStreams(t *testing.T) {
cases := []struct {
name string
@@ -99,6 +117,9 @@ func TestSetCentroidsRejectsBadStreams(t *testing.T) {
{"empty version", []*runedv1.SetCentroidsRequest{
header("", uint32(vectorDim), 1), batch(dimVec(0)),
}},
+ {"content hash mismatch", []*runedv1.SetCentroidsRequest{
+ headerPreset("sha256:deadbeef", "IP1", uint32(vectorDim), 1), batch(dimVec(0)),
+ }},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
@@ -114,15 +135,28 @@ func TestSetCentroidsRejectsBadStreams(t *testing.T) {
}
}
+// TestEmbedWithRouteRequiresCentroids checks the routing precondition in
+// isolation: with a backend wired (so the bootstrap gate passes) but no
+// centroid set, an Embed with_route fails FAILED_PRECONDITION carrying the
+// NO_CENTROID_SET reason. The no-centroid check runs before the forward pass,
+// so the unstarted backend is never invoked. Asserting the reason (not just the
+// code) is what pins this to the routing branch rather than the bootstrap one.
func TestEmbedWithRouteRequiresCentroids(t *testing.T) {
s := New("test")
- // No backend yet: bootstrapping error wins regardless of routing.
+ s.SetBackend(backend.NewLlamaBackend(backend.Config{}), "test")
+
_, err := s.Embed(t.Context(), &runedv1.EmbedRequest{Text: "hi", WithRoute: true})
if status.Code(err) != codes.FailedPrecondition {
t.Fatalf("want FailedPrecondition, got %v", err)
}
+ if got := reasonOf(err); got != ReasonNoCentroidSet {
+ t.Fatalf("reason = %q, want %q", got, ReasonNoCentroidSet)
+ }
}
+// TestLoadCentroidsValidates checks the boot-time restore path: LoadCentroids
+// rejects a set whose dim differs from the served dimension and accepts a
+// matching one, after which Info exposes the loaded centroid version.
func TestLoadCentroidsValidates(t *testing.T) {
s := New("test")
bad := &route.CentroidSet{Version: "v", Dim: 8, Vectors: [][]float32{make([]float32, 8)}}
From 3f5006cbd3d6cf9ecaee51a902d1d7b15bef66a2 Mon Sep 17 00:00:00 2001
From: couragehong
Date: Wed, 15 Jul 2026 14:49:21 +0900
Subject: [PATCH 05/20] fix(server): make SetCentroids install atomic; document
nlist as a hint
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- add installMu guarding the in-memory Store + disk Persist so two
concurrent pushes of different sets can't leave memory holding one
set while the cache holds another (a restart would then silently
serve the stale one); guards only the tail, not the Recv loop
- comment that header nlist is only a preallocation capacity hint —
the authoritative count is len(cs.Vectors) and the content hash
already pins the exact vectors, so header nlist isn't enforced
Co-Authored-By: Claude Fable 5
---
internal/server/centroids.go | 10 ++++++++++
internal/server/server.go | 5 +++++
2 files changed, 15 insertions(+)
diff --git a/internal/server/centroids.go b/internal/server/centroids.go
index 239988f..5ae085a 100644
--- a/internal/server/centroids.go
+++ b/internal/server/centroids.go
@@ -53,6 +53,12 @@ func (s *Server) SetCentroids(stream runedv1.RunedService_SetCentroidsServer) er
Dim: int(p.Header.GetDim()),
Preset: p.Header.GetPreset(),
}
+ // Header nlist is only a capacity hint for the preallocation; the
+ // authoritative count is len(cs.Vectors), built from the batches
+ // that actually arrive and reported back in the response. We don't
+ // enforce header nlist == received count — the content hash
+ // (VerifyVersion) already pins the exact vectors, so a mismatched
+ // header can't slip a wrong set through.
if n := p.Header.GetNlist(); n > 0 {
cs.Vectors = make([][]float32, 0, n)
}
@@ -79,6 +85,9 @@ func (s *Server) SetCentroids(stream runedv1.RunedService_SetCentroidsServer) er
return status.Error(codes.InvalidArgument, err.Error())
}
+ // Install memory + disk under installMu so a concurrent push commits both
+ // as one unit; without it the two stores can interleave and diverge.
+ s.installMu.Lock()
s.centroids.Store(cs)
if s.centroidCacheDir != "" {
if err := cs.Persist(s.centroidCacheDir); err != nil {
@@ -87,6 +96,7 @@ func (s *Server) SetCentroids(stream runedv1.RunedService_SetCentroidsServer) er
slog.Warn("centroids: persist failed (set is live in memory)", "err", err, "dir", s.centroidCacheDir)
}
}
+ s.installMu.Unlock()
slog.Info("centroids: set installed", "version", cs.Version, "nlist", len(cs.Vectors), "dim", cs.Dim)
return stream.SendAndClose(&runedv1.SetCentroidsResponse{
diff --git a/internal/server/server.go b/internal/server/server.go
index 8543d25..8a9a7e2 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -93,6 +93,11 @@ type Server struct {
// is persisted (empty = no persistence, used by tests).
centroids atomic.Pointer[route.CentroidSet]
centroidCacheDir string
+ // installMu makes a SetCentroids install atomic across the in-memory
+ // store and the disk persist, so two concurrent pushes can't leave memory
+ // holding one set while the cache on disk holds another (which a restart
+ // would then silently serve). It guards only the tail, not the Recv loop.
+ installMu sync.Mutex
// Embed + EmbedBatch counter. Surfaced via HealthResponse.total_requests.
requests atomic.Int64
From 85b65ad400a95e0fa9018312d77edc182677939c Mon Sep 17 00:00:00 2001
From: couragehong
Date: Thu, 16 Jul 2026 19:24:23 +0900
Subject: [PATCH 06/20] fix(backend): stop mistaking a saturated llama-server
for a dead one
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Under sustained embedding load (20 concurrent capture workers), EnsureStarted
entered a kill/restart loop: the single-slot llama-server starves its /health
endpoint while inference saturates it, the per-RPC 500ms probe missed, and one
missed probe was treated as death. Worse, the restart path called stopLocked
directly — skipping the inflightMu drain that Stop performs — so every queued
embed was severed with a connection EOF. The restarted child saturated again
immediately and the loop repeated every ~6s (observed: dozens of restarts over
3 minutes, 244 embeds cut, 240/1000 captures failed).
Three defenses, in escalation order:
1. Proof-of-life tracking: every completed embed (doJSON success) and every
successful health probe stamps lastAlive. A running child with a proof of
life within aliveGrace (15s) that misses the quick probe is busy, not dead
— EnsureStarted leaves it alone. Under saturation embeds complete
continuously, so this short-circuits almost every false alarm.
2. Drain-then-reprobe (restartIfDead): with no life signs, take the
inflightMu writer lock — which by construction waits for every in-flight
embed, so nothing is ever severed — then re-probe with a generous budget.
A merely-saturated child empties its queue during the drain and answers;
the restart is skipped. restartMu single-flights bursts of suspicious
RPCs. Lock order restartMu → inflightMu → lifecycleMu (Stop keeps its
inflightMu → lifecycleMu suffix).
3. Only a child that stays unresponsive with zero in-flight work is killed
and relaunched.
Serving() gets the same busy-vs-dead awareness so health dashboards stop
reporting DEGRADED during every heavy burst.
Regression tests (verdict_test.go, -race): a busy child with fresh life signs
is left alone; a child that recovers after the drain is not restarted and the
in-flight embed completes un-severed; a truly dead child is replaced.
Co-Authored-By: Claude Fable 5
---
internal/backend/embed.go | 4 +
internal/backend/llama.go | 140 ++++++++++++++++++++++++++++---
internal/backend/verdict_test.go | 131 +++++++++++++++++++++++++++++
3 files changed, 265 insertions(+), 10 deletions(-)
create mode 100644 internal/backend/verdict_test.go
diff --git a/internal/backend/embed.go b/internal/backend/embed.go
index 4fe3a8d..fe82f87 100644
--- a/internal/backend/embed.go
+++ b/internal/backend/embed.go
@@ -77,6 +77,10 @@ func (b *LlamaBackend) doJSON(ctx context.Context, path string, in any, out any)
if err := json.NewDecoder(resp.Body).Decode(out); err != nil {
return fmt.Errorf("decode: %w", err)
}
+ // A completed request is the strongest proof of life there is — it keeps
+ // EnsureStarted's health verdict from mistaking a saturated child (whose
+ // /health probes starve under inference load) for a dead one.
+ b.noteAlive()
return nil
}
diff --git a/internal/backend/llama.go b/internal/backend/llama.go
index 0caf57f..0d25d47 100644
--- a/internal/backend/llama.go
+++ b/internal/backend/llama.go
@@ -15,10 +15,28 @@ import (
"regexp"
"strconv"
"sync"
+ "sync/atomic"
"syscall"
"time"
)
+// Health-verdict tuning. A saturated single-slot llama-server can miss the
+// quick per-RPC probe (its HTTP loop is starved by inference) while being
+// perfectly alive — killing it then severs every queued embed and the
+// restarted server saturates again immediately, a restart loop observed in
+// production (dozens of kills over 3 minutes, 244 embeds cut with EOF).
+const (
+ // quickProbeTimeout is the cheap per-RPC /health probe. Misses under
+ // saturation are expected and are NOT treated as death on their own.
+ quickProbeTimeout = 500 * time.Millisecond
+ // verdictProbeTimeout is the generous probe used when actually deciding
+ // whether to restart.
+ verdictProbeTimeout = 2 * time.Second
+ // aliveGrace: if any embed completed or probe succeeded this recently,
+ // the child is alive by definition — a failed quick probe means busy.
+ aliveGrace = 15 * time.Second
+)
+
type Config struct {
BinaryPath string
ModelPath string
@@ -80,6 +98,26 @@ type LlamaBackend struct {
// short-lived RPC context, so the resurrected llama-server outlives
// the request that woke it.
daemonCtx context.Context
+
+ // restartMu single-flights the drain-and-verdict restart path so a burst
+ // of RPCs that all missed the quick probe elects one leader; followers
+ // find the child verified (or restarted) and return. Lock order:
+ // restartMu → inflightMu → lifecycleMu.
+ restartMu sync.Mutex
+ // lastAlive is the unix-nano time of the last proof of life: a successful
+ // /health probe or a completed embed HTTP call. It distinguishes a busy
+ // child (making progress, probes starved) from a dead one.
+ lastAlive atomic.Int64
+}
+
+// noteAlive records a proof of life. Called on successful health probes and
+// completed embed calls (see embed.go).
+func (b *LlamaBackend) noteAlive() { b.lastAlive.Store(time.Now().UnixNano()) }
+
+// recentlyAlive reports whether a proof of life landed within grace.
+func (b *LlamaBackend) recentlyAlive(grace time.Duration) bool {
+ ns := b.lastAlive.Load()
+ return ns != 0 && time.Since(time.Unix(0, ns)) < grace
}
func NewLlamaBackend(cfg Config) *LlamaBackend {
@@ -129,32 +167,98 @@ func (b *LlamaBackend) Start(ctx context.Context) error {
// entry: a healthy backend returns within milliseconds (just a quick
// /health probe under lifecycleMu).
//
+// A failed quick probe on a running child is NOT treated as death: a
+// saturated llama-server starves its HTTP loop and misses the probe while
+// still making progress. If a proof of life (completed embed or successful
+// probe) landed within aliveGrace the child is considered busy and left
+// alone; only a child with no life signs goes to restartIfDead, which drains
+// in-flight work and re-probes before killing anything.
+//
// Pre-condition: Start has been called at least once so daemonCtx is
// recorded. Returns an error if the daemonCtx has been cancelled
// (process shutdown in progress).
func (b *LlamaBackend) EnsureStarted() error {
b.lifecycleMu.Lock()
- defer b.lifecycleMu.Unlock()
if b.daemonCtx == nil {
+ b.lifecycleMu.Unlock()
return errors.New("backend not initialized; Start must be called once first")
}
- if err := b.daemonCtx.Err(); err != nil {
+ dctx := b.daemonCtx
+ if err := dctx.Err(); err != nil {
+ b.lifecycleMu.Unlock()
return fmt.Errorf("backend daemon context done: %w", err)
}
b.mu.Lock()
haveCmd := b.cmd != nil
b.mu.Unlock()
- if haveCmd {
- if b.IsHealthy(b.daemonCtx) {
- return nil
+
+ if !haveCmd { // intentional idle-suspend (or a crash) — plain cold start
+ slog.Info("backend: cold start (resuming after suspend)")
+ start := time.Now()
+ err := b.startLocked(dctx)
+ if err == nil {
+ slog.Info("backend: cold start complete",
+ "port", b.Port(),
+ "duration_ms", time.Since(start).Milliseconds())
}
- slog.Warn("backend: process alive but unhealthy, restarting")
+ b.lifecycleMu.Unlock()
+ return err
+ }
+
+ if b.probeHealthy(dctx, quickProbeTimeout) {
+ b.lifecycleMu.Unlock()
+ return nil
+ }
+ if b.recentlyAlive(aliveGrace) {
+ // Probe starved but work is completing — saturated, not dead.
+ b.lifecycleMu.Unlock()
+ return nil
+ }
+ b.lifecycleMu.Unlock()
+ // No proof of life within grace: escalate — but restartIfDead still
+ // drains and re-probes before it kills anything.
+ return b.restartIfDead(dctx)
+}
+
+// restartIfDead is the only path that may kill an unresponsive child. It
+// tells busy apart from dead by construction:
+//
+// 1. restartMu single-flights bursts of suspicious RPCs;
+// 2. a generous re-probe catches a child a predecessor already fixed;
+// 3. the inflightMu writer lock DRAINS every in-flight embed — a saturated
+// child empties its queue right here (and nothing gets severed with EOF,
+// which the old path did by skipping this lock);
+// 4. a post-drain re-probe: a child that answers now was busy — restart is
+// skipped. Only a child that stays unresponsive with zero load is dead.
+func (b *LlamaBackend) restartIfDead(dctx context.Context) error {
+ b.restartMu.Lock()
+ defer b.restartMu.Unlock()
+
+ if b.probeHealthy(dctx, verdictProbeTimeout) {
+ return nil // recovered (or a predecessor restarted it)
+ }
+
+ b.inflightMu.Lock()
+ defer b.inflightMu.Unlock()
+ b.lifecycleMu.Lock()
+ defer b.lifecycleMu.Unlock()
+
+ b.mu.Lock()
+ haveCmd := b.cmd != nil
+ b.mu.Unlock()
+ if haveCmd && b.probeHealthy(dctx, verdictProbeTimeout) {
+ slog.Info("backend: health recovered after draining in-flight work — busy, not dead; restart skipped")
+ return nil
+ }
+
+ if haveCmd {
+ slog.Warn("backend: unresponsive with no in-flight work, restarting")
_ = b.stopLocked(context.Background())
}
- slog.Info("backend: cold start (resuming after suspend)")
+ slog.Info("backend: cold start (restart after failed health verdict)")
start := time.Now()
- if err := b.startLocked(b.daemonCtx); err != nil {
+ if err := b.startLocked(dctx); err != nil {
return err
}
slog.Info("backend: cold start complete",
@@ -335,11 +439,17 @@ func (b *LlamaBackend) getCmd() *exec.Cmd {
}
func (b *LlamaBackend) IsHealthy(ctx context.Context) bool {
+ return b.probeHealthy(ctx, quickProbeTimeout)
+}
+
+// probeHealthy GETs /health with the given budget. A success is a proof of
+// life and refreshes lastAlive.
+func (b *LlamaBackend) probeHealthy(ctx context.Context, timeout time.Duration) bool {
port := b.Port()
if port == 0 {
return false
}
- ctx, cancel := context.WithTimeout(ctx, 500*time.Millisecond)
+ ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
url := fmt.Sprintf("http://%s:%d/health", b.cfg.Host, port)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
@@ -351,7 +461,11 @@ func (b *LlamaBackend) IsHealthy(ctx context.Context) bool {
return false
}
defer resp.Body.Close()
- return resp.StatusCode == 200
+ if resp.StatusCode != 200 {
+ return false
+ }
+ b.noteAlive()
+ return true
}
// Serving reports whether the backend can currently serve embeddings, and if
@@ -367,6 +481,12 @@ func (b *LlamaBackend) Serving(ctx context.Context) ServingState {
if b.IsHealthy(ctx) {
return ServingOK
}
+ if b.recentlyAlive(aliveGrace) {
+ // Probe starved by a saturated child that is still completing
+ // work — it IS serving, just busy. Reporting DEGRADED here made
+ // dashboards cry wolf during every heavy embed burst.
+ return ServingOK
+ }
return ServingDegraded
}
if st == childSuspended {
diff --git a/internal/backend/verdict_test.go b/internal/backend/verdict_test.go
new file mode 100644
index 0000000..ff7a130
--- /dev/null
+++ b/internal/backend/verdict_test.go
@@ -0,0 +1,131 @@
+//go:build !windows
+
+package backend
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+// These tests pin the busy-vs-dead health verdict. The production failure
+// they guard against: a saturated single-slot llama-server starves its
+// /health endpoint, the old EnsureStarted treated one missed 500ms probe as
+// death and killed the child WITHOUT draining in-flight embeds — severing
+// them with EOF — and the restarted child saturated again immediately,
+// looping every ~6s.
+
+// newVerdictBackend wires a fake llama-server and a placeholder child into a
+// backend whose daemonCtx is set, mirroring the state after a real Start.
+func newVerdictBackend(t *testing.T, handler http.Handler) (*LlamaBackend, *httptest.Server) {
+ t.Helper()
+ fake := httptest.NewServer(handler)
+ t.Cleanup(fake.Close)
+ host, port := splitHostPort(t, fake.URL)
+ b := NewLlamaBackend(Config{Host: host})
+ cmd := attachPlaceholderChild(t, b, port)
+ t.Cleanup(func() { cleanupPlaceholderChild(cmd) })
+ b.daemonCtx = context.Background()
+ return b, fake
+}
+
+// TestEnsureStartedBusyChildIsLeftAlone: the quick probe fails but an embed
+// completed moments ago — EnsureStarted must treat the child as busy and
+// return nil without restarting (same cmd, no cold start).
+func TestEnsureStartedBusyChildIsLeftAlone(t *testing.T) {
+ b, _ := newVerdictBackend(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusServiceUnavailable) // /health starved
+ }))
+ before := b.getCmd()
+
+ b.noteAlive() // an embed just completed
+ if err := b.EnsureStarted(); err != nil {
+ t.Fatalf("EnsureStarted on a busy child: %v", err)
+ }
+ if b.getCmd() != before {
+ t.Fatal("busy child was restarted despite a fresh proof of life")
+ }
+}
+
+// TestEnsureStartedDrainRecoverySkipsRestart: no recent proof of life, but
+// the child answers the generous verdict probe once its (simulated) queue
+// drains — restartIfDead must skip the restart and keep the same child.
+func TestEnsureStartedDrainRecoverySkipsRestart(t *testing.T) {
+ var busy atomic.Bool
+ busy.Store(true)
+ release := make(chan struct{})
+ b, _ := newVerdictBackend(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if strings.HasPrefix(r.URL.Path, "/health") {
+ if busy.Load() {
+ w.WriteHeader(http.StatusServiceUnavailable)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+ return
+ }
+ // the in-flight embed: blocks until released, then "queue drained"
+ <-release
+ busy.Store(false)
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"data":[{"embedding":[0.1]}]}`))
+ }))
+ before := b.getCmd()
+
+ embedDone := make(chan error, 1)
+ go func() {
+ _, err := b.Embed(context.Background(), "x", true)
+ embedDone <- err
+ }()
+ time.Sleep(100 * time.Millisecond) // let the embed take its RLock
+
+ ensureDone := make(chan error, 1)
+ go func() { ensureDone <- b.EnsureStarted() }()
+
+ // EnsureStarted must be draining (blocked on inflightMu), not killing:
+ select {
+ case err := <-ensureDone:
+ t.Fatalf("EnsureStarted returned (%v) without waiting for the in-flight embed", err)
+ case <-time.After(300 * time.Millisecond):
+ }
+
+ close(release) // queue drains; health flips to 200
+
+ if err := <-embedDone; err != nil {
+ t.Fatalf("in-flight embed was severed: %v", err)
+ }
+ select {
+ case err := <-ensureDone:
+ if err != nil {
+ t.Fatalf("EnsureStarted after drain recovery: %v", err)
+ }
+ case <-time.After(5 * time.Second):
+ t.Fatal("EnsureStarted never returned after drain")
+ }
+ if b.getCmd() != before {
+ t.Fatal("child was restarted even though it recovered after the drain")
+ }
+}
+
+// TestEnsureStartedDeadChildIsRestarted: no proof of life, health stays down
+// through the drain — the child must actually be replaced. The relaunch uses
+// a binary that exits immediately, so EnsureStarted surfaces the start error;
+// what matters is that the dead child was removed.
+func TestEnsureStartedDeadChildIsRestarted(t *testing.T) {
+ b, _ := newVerdictBackend(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusServiceUnavailable) // dead: never recovers
+ }))
+ b.cfg.BinaryPath = "/usr/bin/false"
+ before := b.getCmd()
+
+ err := b.EnsureStarted()
+ if err == nil {
+ t.Fatal("EnsureStarted should surface the failed relaunch")
+ }
+ if cur := b.getCmd(); cur == before {
+ t.Fatal("dead child was not replaced")
+ }
+}
From d5c7e4d86f5556777d07f76e11bc3dae23398081 Mon Sep 17 00:00:00 2001
From: couragehong
Date: Thu, 16 Jul 2026 19:40:41 +0900
Subject: [PATCH 07/20] fix(review): follower fast-path in restartIfDead;
deterministic drain test
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Review findings on the busy-vs-dead verdict:
1. restartIfDead did not re-check proof of life after acquiring restartMu.
Followers queued behind a leader (whose drain let embeds complete and
stamp lastAlive) would each run their own 2s reprobe and — under renewed
saturation — their own full drain cycle in series. Re-check recentlyAlive
first; the leader's drain or a restart's startup health poll has usually
stamped it, so followers exit immediately.
2. TestEnsureStartedDrainRecoverySkipsRestart synchronized on a 100ms sleep
to assume the in-flight embed held its RLock; on a slow runner the drain
could acquire instantly while /health still reported busy, restarting a
healthy child and flaking the test. Replace the sleep with the same
received-channel handshake inflight_test.go uses.
Co-Authored-By: Claude Fable 5
---
internal/backend/llama.go | 7 +++++++
internal/backend/verdict_test.go | 10 +++++++++-
2 files changed, 16 insertions(+), 1 deletion(-)
diff --git a/internal/backend/llama.go b/internal/backend/llama.go
index 0d25d47..abe44b2 100644
--- a/internal/backend/llama.go
+++ b/internal/backend/llama.go
@@ -235,6 +235,13 @@ func (b *LlamaBackend) restartIfDead(dctx context.Context) error {
b.restartMu.Lock()
defer b.restartMu.Unlock()
+ // Followers queued behind a leader re-check the cheap signals first: the
+ // leader's drain let embeds complete (stamping lastAlive) and a restart's
+ // startup health poll stamps it too — without this, every queued follower
+ // would run its own drain cycle in series.
+ if b.recentlyAlive(aliveGrace) {
+ return nil
+ }
if b.probeHealthy(dctx, verdictProbeTimeout) {
return nil // recovered (or a predecessor restarted it)
}
diff --git a/internal/backend/verdict_test.go b/internal/backend/verdict_test.go
index ff7a130..8000241 100644
--- a/internal/backend/verdict_test.go
+++ b/internal/backend/verdict_test.go
@@ -57,6 +57,7 @@ func TestEnsureStartedBusyChildIsLeftAlone(t *testing.T) {
func TestEnsureStartedDrainRecoverySkipsRestart(t *testing.T) {
var busy atomic.Bool
busy.Store(true)
+ received := make(chan struct{})
release := make(chan struct{})
b, _ := newVerdictBackend(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/health") {
@@ -68,6 +69,7 @@ func TestEnsureStartedDrainRecoverySkipsRestart(t *testing.T) {
return
}
// the in-flight embed: blocks until released, then "queue drained"
+ close(received)
<-release
busy.Store(false)
w.Header().Set("Content-Type", "application/json")
@@ -80,7 +82,13 @@ func TestEnsureStartedDrainRecoverySkipsRestart(t *testing.T) {
_, err := b.Embed(context.Background(), "x", true)
embedDone <- err
}()
- time.Sleep(100 * time.Millisecond) // let the embed take its RLock
+ // Handshake, not a sleep: the drain assertion below is only meaningful
+ // once the embed actually holds its RLock inside the fake server.
+ select {
+ case <-received:
+ case <-time.After(2 * time.Second):
+ t.Fatal("embed never reached the fake llama-server")
+ }
ensureDone := make(chan error, 1)
go func() { ensureDone <- b.EnsureStarted() }()
From d9a82b09ce92f4df8daad93edcb07fbc008a5fb4 Mon Sep 17 00:00:00 2001
From: couragehong
Date: Fri, 17 Jul 2026 00:20:03 +0900
Subject: [PATCH 08/20] fix(backend): bound the embed HTTP call so a hung
server can't wedge recovery
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
doJSON used http.DefaultClient (no timeout) with the caller's ctx, which
carries no deadline on the embed path (the server handler forwards the gRPC
ctx unchanged). A llama-server that accepts the TCP connection but never
responds — a hang, not a crash, since a crash resets the connection — would
block the call forever. That call holds inflightMu.RLock, so it would also
wedge restartIfDead's inflightMu.Lock() drain: the recovery path that exists
to replace a stuck server would itself hang on the stuck server.
Wrap each embed request in context.WithTimeout(ctx, embedRequestTimeout=120s)
(WithTimeout takes the earlier of the caller's deadline and the ceiling). The
ceiling is far above any legitimate embed, including the ~10s deep-queue waits
seen under heavy concurrency, so it only trips on a genuinely wedged backend;
the request then errors, releases the RLock, and the next EnsureStarted
restarts the child.
TestStuckServerDoesNotWedgeDrain: a fake server that never responds — the
embed returns on the timeout and a subsequent inflightMu.Lock drains, proving
the recovery path no longer hangs.
Co-Authored-By: Claude Fable 5
---
internal/backend/embed.go | 21 ++++++++
internal/backend/embedtimeout_test.go | 70 +++++++++++++++++++++++++++
2 files changed, 91 insertions(+)
create mode 100644 internal/backend/embedtimeout_test.go
diff --git a/internal/backend/embed.go b/internal/backend/embed.go
index fe82f87..19f538b 100644
--- a/internal/backend/embed.go
+++ b/internal/backend/embed.go
@@ -8,10 +8,24 @@ import (
"fmt"
"io"
"net/http"
+ "time"
)
const maxErrorBodyBytes = 4 << 10 // 4 KiB cap for reading an error body
+// embedRequestTimeout is an absolute ceiling on a single embed HTTP call.
+// http.DefaultClient has no timeout and the caller's ctx may carry none, so
+// without this a llama-server that accepts the TCP connection but never
+// responds (a hang, not a crash — a crash resets the connection) would block
+// the call forever. That call holds inflightMu.RLock, so a forever-blocked
+// embed would also wedge restartIfDead's inflightMu.Lock() — the recovery path
+// that exists precisely to replace a stuck server. The ceiling is far above
+// any legitimate embed, including deep-queue waits observed under heavy
+// concurrency (~10s), so it only ever trips on a genuinely wedged backend;
+// the request then fails, releasing the RLock, and the next EnsureStarted
+// restarts the child. A var (not const) so tests can shrink it.
+var embedRequestTimeout = 120 * time.Second
+
// ErrNotStarted is returned when Embed/EmbedBatch is invoked but the
// backend has no live llama-server (port == 0). Callers can recover by
// invoking EnsureStarted and retrying: the typical trigger is the idle
@@ -54,6 +68,13 @@ func (b *LlamaBackend) doJSON(ctx context.Context, path string, in any, out any)
return fmt.Errorf("marshal request: %w", err)
}
+ // Bound the call so a hung (not crashed) llama-server can't block it — and
+ // thus the inflightMu.RLock it holds — forever. WithTimeout fires at the
+ // earlier of the caller's deadline or our ceiling, so a caller with a
+ // tighter deadline still wins.
+ ctx, cancel := context.WithTimeout(ctx, embedRequestTimeout)
+ defer cancel()
+
req, err := http.NewRequestWithContext(ctx, "POST", url, bytes.NewReader(body))
if err != nil {
return err
diff --git a/internal/backend/embedtimeout_test.go b/internal/backend/embedtimeout_test.go
new file mode 100644
index 0000000..d74fd5a
--- /dev/null
+++ b/internal/backend/embedtimeout_test.go
@@ -0,0 +1,70 @@
+//go:build !windows
+
+package backend
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "testing"
+ "time"
+)
+
+// TestStuckServerDoesNotWedgeDrain reproduces the deadlock a hung (not crashed)
+// llama-server could cause: an in-flight Embed holds inflightMu.RLock while its
+// HTTP call blocks, and Stop() (same inflightMu.Lock the recovery path takes)
+// would then wait forever. With embedRequestTimeout bounding the call, the
+// embed fails, releases the RLock, and the drain completes.
+func TestStuckServerDoesNotWedgeDrain(t *testing.T) {
+ stop := make(chan struct{})
+ fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ select {
+ case <-r.Context().Done(): // client (embedRequestTimeout) cancelled
+ case <-stop: // test teardown
+ }
+ }))
+ defer fake.Close() // runs last — after the handler is unblocked
+ defer close(stop) // runs first — lets any stuck handler return
+
+ orig := embedRequestTimeout
+ embedRequestTimeout = 300 * time.Millisecond
+ defer func() { embedRequestTimeout = orig }()
+
+ host, port := splitHostPort(t, fake.URL)
+ b := NewLlamaBackend(Config{Host: host})
+ cmd := attachPlaceholderChild(t, b, port)
+ defer cleanupPlaceholderChild(cmd)
+
+ embedErr := make(chan error, 1)
+ start := time.Now()
+ go func() {
+ _, err := b.Embed(context.Background(), "hello", true) // no caller deadline
+ embedErr <- err
+ }()
+
+ // Must fail on its own timeout, not hang forever.
+ select {
+ case err := <-embedErr:
+ if err == nil {
+ t.Fatal("embed against a hung server returned nil; expected a timeout error")
+ }
+ if d := time.Since(start); d > 3*time.Second {
+ t.Fatalf("embed took %v; embedRequestTimeout not bounding the call", d)
+ }
+ case <-time.After(3 * time.Second):
+ t.Fatal("embed did not return — embedRequestTimeout is not bounding the HTTP call")
+ }
+
+ // The drain (inflightMu.Lock, as restartIfDead does) must now complete.
+ drained := make(chan struct{})
+ go func() {
+ b.inflightMu.Lock()
+ b.inflightMu.Unlock()
+ close(drained)
+ }()
+ select {
+ case <-drained:
+ case <-time.After(2 * time.Second):
+ t.Fatal("inflightMu never drained — a stuck embed still wedges the recovery path")
+ }
+}
From e836c571efc4cc81e27eceb3a6a7faa28dfa33b8 Mon Sep 17 00:00:00 2001
From: couragehong
Date: Fri, 17 Jul 2026 00:56:54 +0900
Subject: [PATCH 09/20] refactor(backend): stamp proof-of-life in IsHealthy,
not probeHealthy (review)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Per review: probeHealthy is now a pure "does /health answer 200?" query with
no side effect, and IsHealthy records proof of life on success. The direct
probeHealthy callers that only decide whether to restart (EnsureStarted's
quick probe, restartIfDead's verdict probe) no longer reset the liveness clock
as a side effect of asking.
Measured trade-off (documented on IsHealthy, TestQuickProbeStampEdge): dropping
the quick-probe stamp reopens one narrow edge — idle >aliveGrace, a slow first
embed, then a concurrent second request whose quick probe is starved — where
that second request's EnsureStarted escalates into restartIfDead and pays the
verdict probe (~1.7s vs ~0.5s before). It never false-restarts, never blocks on
the drain, and self-corrects on the next embed completion, so at most one
request in a rare window sees the extra latency — acceptable for the cleaner
separation.
Co-Authored-By: Claude Fable 5
---
internal/backend/llama.go | 29 +++++---
internal/backend/noteAliveedge_test.go | 92 ++++++++++++++++++++++++++
2 files changed, 113 insertions(+), 8 deletions(-)
create mode 100644 internal/backend/noteAliveedge_test.go
diff --git a/internal/backend/llama.go b/internal/backend/llama.go
index abe44b2..469f123 100644
--- a/internal/backend/llama.go
+++ b/internal/backend/llama.go
@@ -445,12 +445,29 @@ func (b *LlamaBackend) getCmd() *exec.Cmd {
return b.cmd
}
+// IsHealthy probes /health and stamps proof of life on success. noteAlive is
+// here, not in probeHealthy, so restart-decision probes (EnsureStarted quick,
+// restartIfDead verdict) don't reset the liveness clock as a side effect.
+//
+// Trade-off (TestQuickProbeStampEdge): without the quick-probe stamp, a rare
+// edge (idle → slow first embed → concurrent second request with a starved
+// quick probe) makes that one request pay the verdict probe (~1.7s vs ~0.5s).
+// No false restart, no drain block, self-corrects on the next embed. If it ever
+// matters, stamp in restartIfDead's verdict probe rather than undo the split.
func (b *LlamaBackend) IsHealthy(ctx context.Context) bool {
- return b.probeHealthy(ctx, quickProbeTimeout)
+ ok := b.probeHealthy(ctx, quickProbeTimeout)
+ if ok {
+ b.noteAlive()
+ }
+ return ok
}
-// probeHealthy GETs /health with the given budget. A success is a proof of
-// life and refreshes lastAlive.
+// probeHealthy GETs /health with the given budget and reports whether the
+// server answered 200. It does NOT record proof of life — callers that treat a
+// success as liveness (IsHealthy) stamp it themselves; the direct callers
+// (EnsureStarted's quick probe, restartIfDead's verdict probe) intentionally
+// do not, so a probe made only to decide whether to restart doesn't also reset
+// the idle/liveness clock.
func (b *LlamaBackend) probeHealthy(ctx context.Context, timeout time.Duration) bool {
port := b.Port()
if port == 0 {
@@ -468,11 +485,7 @@ func (b *LlamaBackend) probeHealthy(ctx context.Context, timeout time.Duration)
return false
}
defer resp.Body.Close()
- if resp.StatusCode != 200 {
- return false
- }
- b.noteAlive()
- return true
+ return resp.StatusCode == 200
}
// Serving reports whether the backend can currently serve embeddings, and if
diff --git a/internal/backend/noteAliveedge_test.go b/internal/backend/noteAliveedge_test.go
new file mode 100644
index 0000000..121069c
--- /dev/null
+++ b/internal/backend/noteAliveedge_test.go
@@ -0,0 +1,92 @@
+//go:build !windows
+
+package backend
+
+import (
+ "context"
+ "net/http"
+ "net/http/httptest"
+ "sync/atomic"
+ "testing"
+ "time"
+)
+
+// TestQuickProbeStampEdge measures the "idle → slow first embed → concurrent
+// second request" edge that the EnsureStarted quick-probe stamp covers. It
+// times the SECOND request's EnsureStarted while the first embed holds the
+// slot and /health is starved (quick probe misses). Current code stamps
+// lastAlive on the first request's quick probe, so the second short-circuits
+// fast; moving noteAlive to IsHealthy drops that stamp, so the second escalates
+// into restartIfDead (extra verdict-probe latency). The test asserts the second
+// request stays bounded either way (no false restart, self-correcting) and
+// prints the latency so the behavior difference is visible.
+func TestQuickProbeStampEdge(t *testing.T) {
+ var inflight atomic.Bool
+ embedStarted := make(chan struct{})
+ embedRelease := make(chan struct{})
+ var startedOnce atomic.Bool
+
+ mux := http.NewServeMux()
+ mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
+ if inflight.Load() {
+ // Starved under load: block past quickProbeTimeout (500ms) but
+ // recover within verdictProbeTimeout (2s).
+ select {
+ case <-time.After(1200 * time.Millisecond):
+ case <-r.Context().Done():
+ return
+ }
+ }
+ w.WriteHeader(http.StatusOK)
+ })
+ mux.HandleFunc("/v1/embeddings", func(w http.ResponseWriter, r *http.Request) {
+ inflight.Store(true)
+ if !startedOnce.Swap(true) {
+ close(embedStarted)
+ }
+ <-embedRelease
+ inflight.Store(false)
+ w.Header().Set("Content-Type", "application/json")
+ _, _ = w.Write([]byte(`{"data":[{"embedding":[0.1,0.2,0.3]}]}`))
+ })
+ fake := httptest.NewServer(mux)
+ defer fake.Close()
+ defer close(embedRelease)
+
+ host, port := splitHostPort(t, fake.URL)
+ b := NewLlamaBackend(Config{Host: host, BinaryPath: "/usr/bin/false"})
+ b.daemonCtx = context.Background()
+ cmd := attachPlaceholderChild(t, b, port)
+ defer cleanupPlaceholderChild(cmd)
+
+ // Request A: EnsureStarted (idle server → quick probe succeeds) then Embed
+ // (holds the slot). Mirrors server.go's Embed handler.
+ go func() {
+ if err := b.EnsureStarted(); err != nil {
+ t.Errorf("A EnsureStarted: %v", err)
+ return
+ }
+ _, _ = b.Embed(context.Background(), "A", true)
+ }()
+
+ select {
+ case <-embedStarted:
+ case <-time.After(3 * time.Second):
+ t.Fatal("embed A never reached the server")
+ }
+
+ // Request B: time its EnsureStarted while A holds the slot and /health is starved.
+ start := time.Now()
+ err := b.EnsureStarted()
+ elapsed := time.Since(start)
+ if err != nil {
+ t.Fatalf("B EnsureStarted returned error (unexpected restart/kill?): %v", err)
+ }
+ if b.getCmd() != cmd {
+ t.Fatal("B path restarted the child — false kill of a busy server")
+ }
+ t.Logf("B EnsureStarted latency while server busy: %v", elapsed.Round(time.Millisecond))
+ if elapsed > 5*time.Second {
+ t.Fatalf("B blocked too long (%v) — likely stuck on drain", elapsed)
+ }
+}
From 94d6db48d4ae8838f543b9398d4bc77ad0490035 Mon Sep 17 00:00:00 2001
From: couragehong
Date: Fri, 17 Jul 2026 18:19:32 +0900
Subject: [PATCH 10/20] renamed vault to console
---
gen/runed/v1/runed_grpc.pb.go | 4 ++--
internal/route/route.go | 2 +-
proto/runed/v1/runed.proto | 2 +-
3 files changed, 4 insertions(+), 4 deletions(-)
diff --git a/gen/runed/v1/runed_grpc.pb.go b/gen/runed/v1/runed_grpc.pb.go
index 4d2828e..d45af39 100644
--- a/gen/runed/v1/runed_grpc.pb.go
+++ b/gen/runed/v1/runed_grpc.pb.go
@@ -51,7 +51,7 @@ type RunedServiceClient interface {
Shutdown(ctx context.Context, in *ShutdownRequest, opts ...grpc.CallOption) (*ShutdownResponse, error)
// SetCentroids replaces the daemon's IVF centroid set (header frame, then
// id-ordered batches — the same wire shape runespace's GetCentroids
- // streams). rune-mcp relays the set here from the Vault so Embed can route
+ // streams). rune-mcp relays the set here from the Console so Embed can route
// inserts (with_route) without runed ever dialing the index engine. The
// set is persisted to the daemon cache and survives restarts.
SetCentroids(ctx context.Context, opts ...grpc.CallOption) (grpc.ClientStreamingClient[SetCentroidsRequest, SetCentroidsResponse], error)
@@ -152,7 +152,7 @@ type RunedServiceServer interface {
Shutdown(context.Context, *ShutdownRequest) (*ShutdownResponse, error)
// SetCentroids replaces the daemon's IVF centroid set (header frame, then
// id-ordered batches — the same wire shape runespace's GetCentroids
- // streams). rune-mcp relays the set here from the Vault so Embed can route
+ // streams). rune-mcp relays the set here from the Console so Embed can route
// inserts (with_route) without runed ever dialing the index engine. The
// set is persisted to the daemon cache and survives restarts.
SetCentroids(grpc.ClientStreamingServer[SetCentroidsRequest, SetCentroidsResponse]) error
diff --git a/internal/route/route.go b/internal/route/route.go
index 9efb8a6..7a2ac47 100644
--- a/internal/route/route.go
+++ b/internal/route/route.go
@@ -1,7 +1,7 @@
// Package route holds the daemon's IVF centroid set and the plaintext
// cluster assignment used to route inserts. runed never dials the index
// engine: the set arrives via the SetCentroids RPC (relayed
-// runespace → vault → rune-mcp → runed) and is cached on disk so a restart
+// runespace → console → rune-mcp → runed) and is cached on disk so a restart
// does not need a re-push. Assignment must agree byte-for-byte with the
// engine's insert routing: max inner product over l2-normalized vectors,
// ties to the lowest id (mirrors runespace-go-sdk clustering).
diff --git a/proto/runed/v1/runed.proto b/proto/runed/v1/runed.proto
index c6684f1..0bba79e 100644
--- a/proto/runed/v1/runed.proto
+++ b/proto/runed/v1/runed.proto
@@ -29,7 +29,7 @@ service RunedService {
// SetCentroids replaces the daemon's IVF centroid set (header frame, then
// id-ordered batches — the same wire shape runespace's GetCentroids
- // streams). rune-mcp relays the set here from the Vault so Embed can route
+ // streams). rune-mcp relays the set here from the Console so Embed can route
// inserts (with_route) without runed ever dialing the index engine. The
// set is persisted to the daemon cache and survives restarts.
rpc SetCentroids(stream SetCentroidsRequest) returns (SetCentroidsResponse);
From efa6a374c5d89ed401cb6e7bd2ed274530e848d7 Mon Sep 17 00:00:00 2001
From: couragehong
Date: Fri, 17 Jul 2026 19:09:12 +0900
Subject: [PATCH 11/20] chore: drop unused Python entries from .gitignore
No Python file has ever existed in this repo's history; the
scripts/.venv/ and __pycache__/ entries came along with the initial
public import.
Co-Authored-By: Claude Fable 5
---
.gitignore | 2 --
1 file changed, 2 deletions(-)
diff --git a/.gitignore b/.gitignore
index 9cdc293..f894508 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,9 +1,7 @@
-scripts/.venv/
*.gguf
/bin/
/dist/
.DS_Store
*.onnx
/models/
-__pycache__/
/third_party/
From 28978cde1987fd156a8f469eae015cb3cdde6287 Mon Sep 17 00:00:00 2001
From: couragehong
Date: Sun, 19 Jul 2026 22:20:46 +0900
Subject: [PATCH 12/20] fix(INST-7): bind the embedding socket at a short path
when RUNED_HOME is deep
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
A UNIX domain socket path must fit sockaddr_un.sun_path — 104 bytes on
macOS, 108 on Linux. When $RUNED_HOME sits under a deep directory (long
scratchpad HOMEs, sandboxes), $RUNED_HOME/embedding.sock exceeds that and
BOTH sides of the IPC failed: the daemon's bind died with the cryptic
"bind: invalid argument", and the client's connect() hit the same limit,
so fixing either side alone was useless.
Introduce ipc.ResolveSocketPath: paths under 104 bytes pass through
unchanged; a path at/over the limit maps to /tmp/runed-.sock, where
is the first 8 bytes of sha256(cleaned canonical path) in hex. The
alias is deterministic — daemon and client each resolve the same canonical
path and converge on the identical short alias with no handshake — and
collision-safe across users/homes because the hash covers the full path.
/tmp is joined literally (not os.TempDir(), which on macOS is itself a
long /var/folders/... path). The resolver is idempotent, so defensive
re-resolution at any bind/dial site is harmless.
Applied at every bind/dial surface:
- ipc.Listen resolves before binding (stale-socket cleanup, 0700 chmod,
and ownership tracking all operate on the alias).
- cmd/runed resolves sockPath up front so the reachability probe, stale
removal, and self-eviction watchdog match what Listen binds.
- client dials the resolved alias but keeps the canonical path for
spawn.EnsureDaemon, which derives the daemon's RUNED_HOME from it.
- spawn.probeDaemon resolves before stat/dial; the spawn.lock and log
paths stay under the canonical home.
Windows named pipes are unaffected; the resolver is pure and untagged, so
GOOS=windows compiles as before.
Co-Authored-By: Claude Opus 4.8
---
client/client.go | 22 ++++++--
cmd/runed/main.go | 7 ++-
internal/ipc/resolve.go | 40 ++++++++++++++
internal/ipc/resolve_test.go | 104 +++++++++++++++++++++++++++++++++++
internal/ipc/uds_unix.go | 6 ++
internal/spawn/spawn.go | 10 ++++
6 files changed, 182 insertions(+), 7 deletions(-)
create mode 100644 internal/ipc/resolve.go
create mode 100644 internal/ipc/resolve_test.go
diff --git a/client/client.go b/client/client.go
index 5652c8e..2dd2a6e 100644
--- a/client/client.go
+++ b/client/client.go
@@ -24,6 +24,7 @@ import (
"sync"
runedv1 "github.com/CryptoLabInc/runed/gen/runed/v1"
+ "github.com/CryptoLabInc/runed/internal/ipc"
"github.com/CryptoLabInc/runed/internal/spawn"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
@@ -40,10 +41,14 @@ type Client struct {
// happy path (non-Unavailable RPCs) is unlocked.
mu sync.Mutex
- conn *grpc.ClientConn
- grpc runedv1.RunedServiceClient
- socketPath string // captured at Connect time for retry/respawn (T9)
- noSpawn bool // captured to avoid retrying when caller opted out (T9)
+ conn *grpc.ClientConn
+ grpc runedv1.RunedServiceClient
+ // socketPath is the CANONICAL path captured at Connect time for
+ // retry/respawn (T9). Dial sites resolve it via ipc.ResolveSocketPath
+ // (INST-7); spawn.EnsureDaemon needs the canonical form because it
+ // derives the daemon's RUNED_HOME from the path's parent directory.
+ socketPath string
+ noSpawn bool // captured to avoid retrying when caller opted out (T9)
}
// Option configures Connect behavior.
@@ -115,9 +120,14 @@ func Connect(ctx context.Context, opts ...Option) (*Client, error) {
// dialer to receive the full "unix://..." URI as its addr, which then
// becomes "unix:///path" after the net.Dialer prepends no scheme — the
// socket file "unix:///path" of course does not exist.
+//
+// The canonical socketPath is resolved just-in-time for the dial (INST-7):
+// when it exceeds the sun_path limit the daemon binds a short deterministic
+// alias, and connect() on the canonical path would fail with EINVAL. The
+// Client keeps the canonical form so respawn derives the right RUNED_HOME.
func dialAndProbe(ctx context.Context, socketPath string) (*Client, error) {
conn, err := grpc.NewClient(
- "unix://"+socketPath,
+ "unix://"+ipc.ResolveSocketPath(socketPath),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
@@ -143,7 +153,7 @@ func dialAndProbe(ctx context.Context, socketPath string) (*Client, error) {
func (c *Client) reconnectLocked() error {
c.conn.Close()
conn, err := grpc.NewClient(
- "unix://"+c.socketPath,
+ "unix://"+ipc.ResolveSocketPath(c.socketPath), // INST-7: dial the alias
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
diff --git a/cmd/runed/main.go b/cmd/runed/main.go
index 60b5f43..6d47f60 100644
--- a/cmd/runed/main.go
+++ b/cmd/runed/main.go
@@ -92,7 +92,12 @@ func run() error {
}
logger.Info("paths resolved", "home", paths.Home)
- sockPath := filepath.Join(paths.Home, "embedding.sock")
+ // INST-7: when $RUNED_HOME is deep, the canonical path exceeds the
+ // platform's sun_path limit (104 bytes on macOS, 108 on Linux) and cannot
+ // be bound or dialed. Resolve it up front so the reachability probe, the
+ // stale-socket cleanup, the ownership watchdog, and the actual bind all
+ // operate on the same (possibly short-aliased) path the client dials.
+ sockPath := ipc.ResolveSocketPath(filepath.Join(paths.Home, "embedding.sock"))
// Early bail-out: if another daemon is already accepting connections on
// our socket, we'd just waste time on self-bootstrap before failing at
diff --git a/internal/ipc/resolve.go b/internal/ipc/resolve.go
new file mode 100644
index 0000000..6febe22
--- /dev/null
+++ b/internal/ipc/resolve.go
@@ -0,0 +1,40 @@
+package ipc
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "path/filepath"
+)
+
+// sunPathLimit is the conservative cross-platform capacity of
+// sockaddr_un.sun_path: 104 bytes on macOS, 108 on Linux. A path at or over
+// the macOS limit fails bind/connect with EINVAL on at least one supported
+// platform, so we treat >= 104 as unusable everywhere.
+const sunPathLimit = 104
+
+// ResolveSocketPath returns a bindable/dialable path for the canonical socket
+// path (INST-7). Short paths are returned unchanged; a path at/over the
+// platform's sun_path limit — e.g. $RUNED_HOME/embedding.sock under a deep
+// sandbox HOME — is mapped to a short, DETERMINISTIC alias derived from the
+// canonical one, so the daemon (bind) and the client (dial) converge on the
+// same path without any handshake.
+//
+// The alias is /tmp/runed-.sock where is the first 8 bytes of
+// sha256(canonical) in hex (~32 bytes total, well under the limit). The hash
+// covers the full canonical path — which includes the user's home — so
+// distinct RUNED_HOMEs and distinct users do not collide. /tmp is joined
+// literally rather than via os.TempDir(): on macOS the latter is itself a
+// long /var/folders/... path that can re-trip the very limit we're avoiding.
+// The input is filepath.Clean-ed before hashing so that differently spelled
+// forms of the same path (e.g. an uncleaned WithSocketPath value vs. the
+// daemon's filepath.Join of $RUNED_HOME) still hash to the same alias. The
+// function is idempotent — an already-resolved alias is short and comes back
+// unchanged — so callers may resolve defensively at every bind/dial site.
+func ResolveSocketPath(canonical string) string {
+ cleaned := filepath.Clean(canonical)
+ if len(cleaned) < sunPathLimit {
+ return cleaned
+ }
+ sum := sha256.Sum256([]byte(cleaned))
+ return "/tmp/runed-" + hex.EncodeToString(sum[:8]) + ".sock"
+}
diff --git a/internal/ipc/resolve_test.go b/internal/ipc/resolve_test.go
new file mode 100644
index 0000000..cea76df
--- /dev/null
+++ b/internal/ipc/resolve_test.go
@@ -0,0 +1,104 @@
+//go:build !windows
+
+package ipc
+
+import (
+ "net"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+)
+
+func TestResolveSocketPath_ShortPathUnchanged(t *testing.T) {
+ p := "/tmp/runed-test/embedding.sock"
+ if got := ResolveSocketPath(p); got != p {
+ t.Fatalf("short path changed: got %q want %q", got, p)
+ }
+}
+
+func TestResolveSocketPath_LongPathAliased(t *testing.T) {
+ long := "/" + strings.Repeat("d", 120) + "/embedding.sock"
+ if len(long) < sunPathLimit {
+ t.Fatalf("test setup: path not long enough (%d bytes)", len(long))
+ }
+ got := ResolveSocketPath(long)
+ if !strings.HasPrefix(got, "/tmp/runed-") || !strings.HasSuffix(got, ".sock") {
+ t.Fatalf("alias has wrong shape: %q", got)
+ }
+ if len(got) >= sunPathLimit {
+ t.Fatalf("alias too long (%d bytes): %q", len(got), got)
+ }
+ // Deterministic: the same canonical input yields the same alias — this is
+ // what lets the daemon and the client converge without a handshake.
+ if again := ResolveSocketPath(long); again != got {
+ t.Fatalf("not deterministic: %q vs %q", got, again)
+ }
+ // Distinct canonical inputs (different RUNED_HOMEs/users) must not collide.
+ other := "/" + strings.Repeat("e", 120) + "/embedding.sock"
+ if ResolveSocketPath(other) == got {
+ t.Fatalf("distinct canonical paths mapped to the same alias %q", got)
+ }
+ // Idempotent: resolving an already-resolved alias returns it unchanged, so
+ // defensive double-resolution at bind/dial sites is harmless.
+ if re := ResolveSocketPath(got); re != got {
+ t.Fatalf("alias not idempotent: %q -> %q", got, re)
+ }
+}
+
+func TestResolveSocketPath_UncleanedSpellingsConverge(t *testing.T) {
+ // The client may get an uncleaned path via WithSocketPath while the
+ // spawned daemon recomputes it with filepath.Join (which cleans); both
+ // spellings must hash to the same alias.
+ clean := "/" + strings.Repeat("d", 120) + "/embedding.sock"
+ unclean := "/" + strings.Repeat("d", 120) + "//./embedding.sock"
+ if ResolveSocketPath(clean) != ResolveSocketPath(unclean) {
+ t.Fatalf("cleaned and uncleaned spellings diverged: %q vs %q",
+ ResolveSocketPath(clean), ResolveSocketPath(unclean))
+ }
+}
+
+// TestListen_LongPathBindsAndDials is the INST-7 end-to-end regression: a
+// canonical socket path over the sun_path limit (deep $RUNED_HOME) must still
+// yield a listener the client can reach, via the deterministic short alias
+// both sides resolve independently.
+func TestListen_LongPathBindsAndDials(t *testing.T) {
+ base := shortTempDir(t)
+ deep := filepath.Join(base, strings.Repeat("d", 60), strings.Repeat("e", 60))
+ if err := os.MkdirAll(deep, 0o700); err != nil {
+ t.Skipf("cannot create deep dir in this sandbox: %v", err)
+ }
+ canonical := filepath.Join(deep, "embedding.sock")
+ if len(canonical) < sunPathLimit {
+ t.Fatalf("test setup: canonical path not long enough (%d bytes)", len(canonical))
+ }
+
+ // Daemon side: binding the canonical path used to fail with
+ // "bind: invalid argument"; Listen now binds the alias.
+ lis, err := Listen(canonical)
+ if err != nil {
+ t.Fatalf("Listen on long canonical path: %v", err)
+ }
+ defer lis.Close()
+
+ resolved := ResolveSocketPath(canonical)
+ if resolved == canonical {
+ t.Fatal("long canonical path was not aliased")
+ }
+ // Client side: dialing the independently resolved alias reaches the
+ // daemon's listener.
+ conn, err := net.Dial("unix", resolved)
+ if err != nil {
+ t.Fatalf("dial resolved alias %s: %v", resolved, err)
+ }
+ conn.Close()
+
+ // The /tmp alias keeps the socket owner-only.
+ info, err := os.Stat(resolved)
+ if err != nil {
+ t.Fatalf("stat alias: %v", err)
+ }
+ if info.Mode()&0o777 != 0o700 {
+ t.Fatalf("want mode 0700 on alias, got %o", info.Mode()&0o777)
+ }
+}
diff --git a/internal/ipc/uds_unix.go b/internal/ipc/uds_unix.go
index 699ca22..d8829ea 100644
--- a/internal/ipc/uds_unix.go
+++ b/internal/ipc/uds_unix.go
@@ -28,7 +28,13 @@ import (
// which can delete a healthy daemon's socket after $RUNED_HOME is recreated
// out from under a running daemon. StillOwned exposes the same identity check
// so the daemon can self-evict when its socket is taken over.
+//
+// A path at/over the sun_path limit is transparently remapped via
+// ResolveSocketPath before binding (INST-7) — binding the canonical path
+// would fail with a cryptic "bind: invalid argument". Clients resolve the
+// same canonical path before dialing, so both sides meet at the alias.
func Listen(path string) (Listener, error) {
+ path = ResolveSocketPath(path)
if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
return nil, fmt.Errorf("mkdir parent: %w", err)
}
diff --git a/internal/spawn/spawn.go b/internal/spawn/spawn.go
index 0c91374..c441cfb 100644
--- a/internal/spawn/spawn.go
+++ b/internal/spawn/spawn.go
@@ -12,6 +12,8 @@ import (
"path/filepath"
"syscall"
"time"
+
+ "github.com/CryptoLabInc/runed/internal/ipc"
)
// dialResult enumerates the meaningful outcomes of probing the daemon
@@ -81,7 +83,15 @@ func EnsureDaemon(ctx context.Context, socketPath string) error {
// We don't speak gRPC here — successful dial + immediate close is
// sufficient evidence that a listener exists. The actual Health RPC
// happens later when the caller's Connect runs.
+//
+// The canonical path is resolved via ipc.ResolveSocketPath before any
+// stat/dial (INST-7): when $RUNED_HOME is deep, the daemon binds a short
+// deterministic alias, and probing the canonical path would always report
+// the daemon absent. EnsureDaemon deliberately keeps the CANONICAL path for
+// everything else — the spawn.lock location, and the RUNED_HOME the daemon
+// is launched with — so only the socket itself moves to the alias.
func probeDaemon(ctx context.Context, socketPath string) (dialResult, error) {
+ socketPath = ipc.ResolveSocketPath(socketPath)
// Pre-flight stat: if the path exists but isn't a socket, classify as
// hostile before attempting the dial. Linux returns ECONNREFUSED for
// dialing a regular file (which our errors.Is below would otherwise
From 2594f69cf17c4d041aa1b3777d8379f16a816d04 Mon Sep 17 00:00:00 2001
From: couragehong
Date: Sun, 19 Jul 2026 22:38:58 +0900
Subject: [PATCH 13/20] fix(INST-7): isolate long-path socket aliases per user
---
client/client_test.go | 40 +++++++++++++++++
internal/ipc/resolve.go | 40 -----------------
internal/ipc/resolve_test.go | 23 +++++++++-
internal/ipc/resolve_unix.go | 79 +++++++++++++++++++++++++++++++++
internal/ipc/resolve_windows.go | 9 ++++
internal/ipc/uds_unix.go | 3 +-
internal/spawn/spawn_test.go | 22 +++++++++
7 files changed, 173 insertions(+), 43 deletions(-)
delete mode 100644 internal/ipc/resolve.go
create mode 100644 internal/ipc/resolve_unix.go
create mode 100644 internal/ipc/resolve_windows.go
diff --git a/client/client_test.go b/client/client_test.go
index 204d2dd..abb73f6 100644
--- a/client/client_test.go
+++ b/client/client_test.go
@@ -4,10 +4,23 @@ import (
"context"
"os"
"path/filepath"
+ "strings"
"testing"
"time"
+
+ runedv1 "github.com/CryptoLabInc/runed/gen/runed/v1"
+ "github.com/CryptoLabInc/runed/internal/ipc"
+ "google.golang.org/grpc"
)
+type longPathHealthServer struct {
+ runedv1.UnimplementedRunedServiceServer
+}
+
+func (longPathHealthServer) Health(context.Context, *runedv1.HealthRequest) (*runedv1.HealthResponse, error) {
+ return &runedv1.HealthResponse{Status: runedv1.HealthResponse_STATUS_OK}, nil
+}
+
// shortTempDir returns a per-test temp dir under /tmp. macOS's $TMPDIR and
// Go's t.TempDir() produce paths that can exceed the 104-byte sockaddr_un
// limit, causing bind EINVAL for unrelated reasons. /tmp keeps paths short.
@@ -80,3 +93,30 @@ func TestConnect_WithNoSpawn_FailsFastWhenNoDaemon(t *testing.T) {
t.Fatal("expected error with WithNoSpawn and no daemon, got nil")
}
}
+
+// TestConnect_LongSocketPath is the client-side half of INST-7. Listen and
+// Connect independently resolve the same over-limit canonical path, and the
+// real gRPC Health probe must cross that aliased socket successfully.
+func TestConnect_LongSocketPath(t *testing.T) {
+ home := filepath.Join(shortTempDir(t), strings.Repeat("d", 60), strings.Repeat("e", 60))
+ canonical := filepath.Join(home, "embedding.sock")
+ lis, err := ipc.Listen(canonical)
+ if err != nil {
+ t.Fatalf("listen long path: %v", err)
+ }
+ gs := grpc.NewServer()
+ runedv1.RegisterRunedServiceServer(gs, longPathHealthServer{})
+ go func() { _ = gs.Serve(lis) }()
+ t.Cleanup(func() {
+ gs.Stop()
+ _ = lis.Close()
+ })
+
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ c, err := Connect(ctx, WithSocketPath(canonical), WithNoSpawn())
+ if err != nil {
+ t.Fatalf("Connect on long canonical path: %v", err)
+ }
+ _ = c.Close()
+}
diff --git a/internal/ipc/resolve.go b/internal/ipc/resolve.go
deleted file mode 100644
index 6febe22..0000000
--- a/internal/ipc/resolve.go
+++ /dev/null
@@ -1,40 +0,0 @@
-package ipc
-
-import (
- "crypto/sha256"
- "encoding/hex"
- "path/filepath"
-)
-
-// sunPathLimit is the conservative cross-platform capacity of
-// sockaddr_un.sun_path: 104 bytes on macOS, 108 on Linux. A path at or over
-// the macOS limit fails bind/connect with EINVAL on at least one supported
-// platform, so we treat >= 104 as unusable everywhere.
-const sunPathLimit = 104
-
-// ResolveSocketPath returns a bindable/dialable path for the canonical socket
-// path (INST-7). Short paths are returned unchanged; a path at/over the
-// platform's sun_path limit — e.g. $RUNED_HOME/embedding.sock under a deep
-// sandbox HOME — is mapped to a short, DETERMINISTIC alias derived from the
-// canonical one, so the daemon (bind) and the client (dial) converge on the
-// same path without any handshake.
-//
-// The alias is /tmp/runed-.sock where is the first 8 bytes of
-// sha256(canonical) in hex (~32 bytes total, well under the limit). The hash
-// covers the full canonical path — which includes the user's home — so
-// distinct RUNED_HOMEs and distinct users do not collide. /tmp is joined
-// literally rather than via os.TempDir(): on macOS the latter is itself a
-// long /var/folders/... path that can re-trip the very limit we're avoiding.
-// The input is filepath.Clean-ed before hashing so that differently spelled
-// forms of the same path (e.g. an uncleaned WithSocketPath value vs. the
-// daemon's filepath.Join of $RUNED_HOME) still hash to the same alias. The
-// function is idempotent — an already-resolved alias is short and comes back
-// unchanged — so callers may resolve defensively at every bind/dial site.
-func ResolveSocketPath(canonical string) string {
- cleaned := filepath.Clean(canonical)
- if len(cleaned) < sunPathLimit {
- return cleaned
- }
- sum := sha256.Sum256([]byte(cleaned))
- return "/tmp/runed-" + hex.EncodeToString(sum[:8]) + ".sock"
-}
diff --git a/internal/ipc/resolve_test.go b/internal/ipc/resolve_test.go
index cea76df..2b71384 100644
--- a/internal/ipc/resolve_test.go
+++ b/internal/ipc/resolve_test.go
@@ -3,6 +3,7 @@
package ipc
import (
+ "fmt"
"net"
"os"
"path/filepath"
@@ -23,7 +24,8 @@ func TestResolveSocketPath_LongPathAliased(t *testing.T) {
t.Fatalf("test setup: path not long enough (%d bytes)", len(long))
}
got := ResolveSocketPath(long)
- if !strings.HasPrefix(got, "/tmp/runed-") || !strings.HasSuffix(got, ".sock") {
+ wantDir := filepath.Join("/tmp", fmt.Sprintf("runed-%d", os.Getuid()))
+ if filepath.Dir(got) != wantDir || !strings.HasPrefix(filepath.Base(got), "runed-") || !strings.HasSuffix(got, ".sock") {
t.Fatalf("alias has wrong shape: %q", got)
}
if len(got) >= sunPathLimit {
@@ -46,6 +48,18 @@ func TestResolveSocketPath_LongPathAliased(t *testing.T) {
}
}
+func TestResolveSocketPath_RelativeAndAbsoluteSpellingsConverge(t *testing.T) {
+ relative := filepath.Join(strings.Repeat("d", 60), strings.Repeat("e", 60), "embedding.sock")
+ absolute, err := filepath.Abs(relative)
+ if err != nil {
+ t.Fatalf("abs: %v", err)
+ }
+ if ResolveSocketPath(relative) != ResolveSocketPath(absolute) {
+ t.Fatalf("relative and absolute spellings diverged: %q vs %q",
+ ResolveSocketPath(relative), ResolveSocketPath(absolute))
+ }
+}
+
func TestResolveSocketPath_UncleanedSpellingsConverge(t *testing.T) {
// The client may get an uncleaned path via WithSocketPath while the
// spawned daemon recomputes it with filepath.Join (which cleans); both
@@ -101,4 +115,11 @@ func TestListen_LongPathBindsAndDials(t *testing.T) {
if info.Mode()&0o777 != 0o700 {
t.Fatalf("want mode 0700 on alias, got %o", info.Mode()&0o777)
}
+ parent, err := os.Stat(filepath.Dir(resolved))
+ if err != nil {
+ t.Fatalf("stat alias parent: %v", err)
+ }
+ if parent.Mode().Perm() != 0o700 {
+ t.Fatalf("want alias parent mode 0700, got %o", parent.Mode().Perm())
+ }
}
diff --git a/internal/ipc/resolve_unix.go b/internal/ipc/resolve_unix.go
new file mode 100644
index 0000000..785f2ee
--- /dev/null
+++ b/internal/ipc/resolve_unix.go
@@ -0,0 +1,79 @@
+//go:build !windows
+
+package ipc
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "fmt"
+ "os"
+ "path/filepath"
+ "syscall"
+)
+
+// sunPathLimit is the conservative cross-platform capacity of
+// sockaddr_un.sun_path: 104 bytes on macOS, 108 on Linux. A path at or over
+// the macOS limit fails bind/connect with EINVAL on at least one supported
+// platform, so we treat >= 104 as unusable everywhere.
+const sunPathLimit = 104
+
+// socketAliasDir is deliberately short and private to the current uid. Putting
+// the socket directly in world-writable /tmp would let another local user bind
+// the predictable alias first and impersonate or deny the daemon.
+func socketAliasDir() string {
+ return filepath.Join("/tmp", fmt.Sprintf("runed-%d", os.Getuid()))
+}
+
+// ResolveSocketPath returns a bindable/dialable path for the canonical socket
+// path (INST-7). Short absolute paths are returned unchanged; a path at/over the
+// sun_path limit is mapped to a deterministic alias under a short, per-user
+// runtime directory so daemon and client converge without a handshake.
+//
+// Inputs are made absolute before both the length check and hash. In particular,
+// spawn may receive a relative WithSocketPath while runed resolves RUNED_HOME to
+// an absolute path; hashing their original spellings would make the two sides
+// choose different aliases. The alias is idempotent because it is already an
+// absolute path shorter than sunPathLimit.
+func ResolveSocketPath(canonical string) string {
+ cleaned := filepath.Clean(canonical)
+ if absolute, err := filepath.Abs(cleaned); err == nil {
+ cleaned = absolute
+ }
+ if len(cleaned) < sunPathLimit {
+ return cleaned
+ }
+ sum := sha256.Sum256([]byte(cleaned))
+ return filepath.Join(socketAliasDir(), "runed-"+hex.EncodeToString(sum[:16])+".sock")
+}
+
+// ensureSocketParent creates the socket's parent and, for a short-path alias,
+// verifies that the shared-runtime entry is a real directory owned by this uid
+// with no group/other access. An attacker-created symlink or foreign-owned
+// directory fails closed instead of moving the unauthenticated local gRPC
+// endpoint into an untrusted namespace.
+func ensureSocketParent(path string) error {
+ dir := filepath.Dir(path)
+ if dir != socketAliasDir() {
+ return os.MkdirAll(dir, 0o700)
+ }
+ if err := os.Mkdir(dir, 0o700); err != nil && !os.IsExist(err) {
+ return err
+ }
+ info, err := os.Lstat(dir)
+ if err != nil {
+ return err
+ }
+ if info.Mode()&os.ModeSymlink != 0 || !info.IsDir() {
+ return fmt.Errorf("socket alias parent %s is not a real directory", dir)
+ }
+ stat, ok := info.Sys().(*syscall.Stat_t)
+ if !ok || uint64(stat.Uid) != uint64(os.Getuid()) {
+ return fmt.Errorf("socket alias parent %s is not owned by the current user", dir)
+ }
+ if info.Mode().Perm() != 0o700 {
+ if err := os.Chmod(dir, 0o700); err != nil {
+ return fmt.Errorf("secure socket alias parent %s: %w", dir, err)
+ }
+ }
+ return nil
+}
diff --git a/internal/ipc/resolve_windows.go b/internal/ipc/resolve_windows.go
new file mode 100644
index 0000000..09fc0c5
--- /dev/null
+++ b/internal/ipc/resolve_windows.go
@@ -0,0 +1,9 @@
+//go:build windows
+
+package ipc
+
+import "path/filepath"
+
+// Windows support uses named pipes rather than sockaddr_un, so the Unix
+// short-path aliasing rule does not apply.
+func ResolveSocketPath(path string) string { return filepath.Clean(path) }
diff --git a/internal/ipc/uds_unix.go b/internal/ipc/uds_unix.go
index d8829ea..9a2fd3e 100644
--- a/internal/ipc/uds_unix.go
+++ b/internal/ipc/uds_unix.go
@@ -10,7 +10,6 @@ import (
"fmt"
"net"
"os"
- "path/filepath"
"sync"
"syscall"
)
@@ -35,7 +34,7 @@ import (
// same canonical path before dialing, so both sides meet at the alias.
func Listen(path string) (Listener, error) {
path = ResolveSocketPath(path)
- if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil {
+ if err := ensureSocketParent(path); err != nil {
return nil, fmt.Errorf("mkdir parent: %w", err)
}
diff --git a/internal/spawn/spawn_test.go b/internal/spawn/spawn_test.go
index 8933af8..e6ea94f 100644
--- a/internal/spawn/spawn_test.go
+++ b/internal/spawn/spawn_test.go
@@ -10,6 +10,8 @@ import (
"strings"
"testing"
"time"
+
+ "github.com/CryptoLabInc/runed/internal/ipc"
)
// fakeRunedScript is a shell script that mimics what the real runed binary
@@ -52,6 +54,26 @@ func TestEnsureDaemon_AlreadyRunning(t *testing.T) {
}
}
+// A deep RUNED_HOME must still take the already-running fast path: both the
+// listener and spawn probe resolve the same short alias instead of attempting
+// to dial the over-limit canonical path.
+func TestEnsureDaemon_AlreadyRunningAtLongPath(t *testing.T) {
+ home := filepath.Join(shortTempDir(t), strings.Repeat("d", 60), strings.Repeat("e", 60))
+ canonical := filepath.Join(home, "embedding.sock")
+ lis, err := ipc.Listen(canonical)
+ if err != nil {
+ t.Fatalf("listen long path: %v", err)
+ }
+ defer lis.Close()
+
+ t.Setenv("RUNED_CONFIG", "/tmp/runed-no-such-file")
+ ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
+ defer cancel()
+ if err := EnsureDaemon(ctx, canonical); err != nil {
+ t.Fatalf("EnsureDaemon long path: %v", err)
+ }
+}
+
// bindFakeListener opens a UDS at path that simply accepts connections
// and immediately closes them. Returns a stop func to unbind. We use it
// as a stand-in for "a daemon is reachable".
From 2c0868e65a6bb7a50e479770956c73e3cb5287d9 Mon Sep 17 00:00:00 2001
From: couragehong
Date: Sun, 19 Jul 2026 22:50:43 +0900
Subject: [PATCH 14/20] docs: keep socket comments implementation-focused
---
client/client.go | 12 ++++++------
client/client_test.go | 5 ++---
cmd/runed/main.go | 2 +-
internal/ipc/resolve_test.go | 7 +++----
internal/ipc/resolve_unix.go | 2 +-
internal/ipc/uds_unix.go | 2 +-
internal/spawn/spawn.go | 2 +-
7 files changed, 15 insertions(+), 17 deletions(-)
diff --git a/client/client.go b/client/client.go
index 2dd2a6e..76f1754 100644
--- a/client/client.go
+++ b/client/client.go
@@ -43,12 +43,12 @@ type Client struct {
conn *grpc.ClientConn
grpc runedv1.RunedServiceClient
- // socketPath is the CANONICAL path captured at Connect time for
- // retry/respawn (T9). Dial sites resolve it via ipc.ResolveSocketPath
- // (INST-7); spawn.EnsureDaemon needs the canonical form because it
+ // socketPath is the canonical path captured at Connect time for
+ // retry and respawn. Dial sites resolve it via ipc.ResolveSocketPath;
+ // spawn.EnsureDaemon needs the canonical form because it
// derives the daemon's RUNED_HOME from the path's parent directory.
socketPath string
- noSpawn bool // captured to avoid retrying when caller opted out (T9)
+ noSpawn bool // captured to avoid retrying when the caller opted out
}
// Option configures Connect behavior.
@@ -121,7 +121,7 @@ func Connect(ctx context.Context, opts ...Option) (*Client, error) {
// becomes "unix:///path" after the net.Dialer prepends no scheme — the
// socket file "unix:///path" of course does not exist.
//
-// The canonical socketPath is resolved just-in-time for the dial (INST-7):
+// The canonical socketPath is resolved just-in-time for the dial:
// when it exceeds the sun_path limit the daemon binds a short deterministic
// alias, and connect() on the canonical path would fail with EINVAL. The
// Client keeps the canonical form so respawn derives the right RUNED_HOME.
@@ -153,7 +153,7 @@ func dialAndProbe(ctx context.Context, socketPath string) (*Client, error) {
func (c *Client) reconnectLocked() error {
c.conn.Close()
conn, err := grpc.NewClient(
- "unix://"+ipc.ResolveSocketPath(c.socketPath), // INST-7: dial the alias
+ "unix://"+ipc.ResolveSocketPath(c.socketPath),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
if err != nil {
diff --git a/client/client_test.go b/client/client_test.go
index abb73f6..bdff181 100644
--- a/client/client_test.go
+++ b/client/client_test.go
@@ -94,9 +94,8 @@ func TestConnect_WithNoSpawn_FailsFastWhenNoDaemon(t *testing.T) {
}
}
-// TestConnect_LongSocketPath is the client-side half of INST-7. Listen and
-// Connect independently resolve the same over-limit canonical path, and the
-// real gRPC Health probe must cross that aliased socket successfully.
+// TestConnect_LongSocketPath verifies that Listen and Connect independently
+// resolve the same over-limit canonical path and exchange a real Health RPC.
func TestConnect_LongSocketPath(t *testing.T) {
home := filepath.Join(shortTempDir(t), strings.Repeat("d", 60), strings.Repeat("e", 60))
canonical := filepath.Join(home, "embedding.sock")
diff --git a/cmd/runed/main.go b/cmd/runed/main.go
index 6d47f60..db456e4 100644
--- a/cmd/runed/main.go
+++ b/cmd/runed/main.go
@@ -92,7 +92,7 @@ func run() error {
}
logger.Info("paths resolved", "home", paths.Home)
- // INST-7: when $RUNED_HOME is deep, the canonical path exceeds the
+ // When $RUNED_HOME is deep, the canonical path can exceed the
// platform's sun_path limit (104 bytes on macOS, 108 on Linux) and cannot
// be bound or dialed. Resolve it up front so the reachability probe, the
// stale-socket cleanup, the ownership watchdog, and the actual bind all
diff --git a/internal/ipc/resolve_test.go b/internal/ipc/resolve_test.go
index 2b71384..4f49c2c 100644
--- a/internal/ipc/resolve_test.go
+++ b/internal/ipc/resolve_test.go
@@ -72,10 +72,9 @@ func TestResolveSocketPath_UncleanedSpellingsConverge(t *testing.T) {
}
}
-// TestListen_LongPathBindsAndDials is the INST-7 end-to-end regression: a
-// canonical socket path over the sun_path limit (deep $RUNED_HOME) must still
-// yield a listener the client can reach, via the deterministic short alias
-// both sides resolve independently.
+// TestListen_LongPathBindsAndDials verifies that a canonical socket path over
+// the sun_path limit yields a listener reachable through the deterministic
+// short alias.
func TestListen_LongPathBindsAndDials(t *testing.T) {
base := shortTempDir(t)
deep := filepath.Join(base, strings.Repeat("d", 60), strings.Repeat("e", 60))
diff --git a/internal/ipc/resolve_unix.go b/internal/ipc/resolve_unix.go
index 785f2ee..943e3fd 100644
--- a/internal/ipc/resolve_unix.go
+++ b/internal/ipc/resolve_unix.go
@@ -25,7 +25,7 @@ func socketAliasDir() string {
}
// ResolveSocketPath returns a bindable/dialable path for the canonical socket
-// path (INST-7). Short absolute paths are returned unchanged; a path at/over the
+// path. Short absolute paths are returned unchanged; a path at/over the
// sun_path limit is mapped to a deterministic alias under a short, per-user
// runtime directory so daemon and client converge without a handshake.
//
diff --git a/internal/ipc/uds_unix.go b/internal/ipc/uds_unix.go
index 9a2fd3e..552ffb7 100644
--- a/internal/ipc/uds_unix.go
+++ b/internal/ipc/uds_unix.go
@@ -29,7 +29,7 @@ import (
// so the daemon can self-evict when its socket is taken over.
//
// A path at/over the sun_path limit is transparently remapped via
-// ResolveSocketPath before binding (INST-7) — binding the canonical path
+// ResolveSocketPath before binding; binding the canonical path
// would fail with a cryptic "bind: invalid argument". Clients resolve the
// same canonical path before dialing, so both sides meet at the alias.
func Listen(path string) (Listener, error) {
diff --git a/internal/spawn/spawn.go b/internal/spawn/spawn.go
index c441cfb..9b3e163 100644
--- a/internal/spawn/spawn.go
+++ b/internal/spawn/spawn.go
@@ -85,7 +85,7 @@ func EnsureDaemon(ctx context.Context, socketPath string) error {
// happens later when the caller's Connect runs.
//
// The canonical path is resolved via ipc.ResolveSocketPath before any
-// stat/dial (INST-7): when $RUNED_HOME is deep, the daemon binds a short
+// stat/dial: when $RUNED_HOME is deep, the daemon binds a short
// deterministic alias, and probing the canonical path would always report
// the daemon absent. EnsureDaemon deliberately keeps the CANONICAL path for
// everything else — the spawn.lock location, and the RUNED_HOME the daemon
From fdd40e416856532698d0b1e2ea3aef3ccbeb875b Mon Sep 17 00:00:00 2001
From: couragehong
Date: Sun, 19 Jul 2026 22:33:36 +0900
Subject: [PATCH 15/20] fix(OPS-90): ship the license texts runed's
redistribution requires
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
runed redistributes llama-server (llama.cpp, MIT) and the Qwen3-Embedding
GGUF weights (Apache-2.0) via self-bootstrap, but carried no license text at
all — not even its own (README said "LICENSE file forthcoming"). MIT requires
the copyright + permission notice to accompany all copies, and Apache-2.0 §4
requires a copy of the license with redistribution, so this was a live
compliance gap on every machine that ran the bootstrap.
- LICENSE: runed's own MIT text (canonical wording; copyright line
"Copyright (c) 2026 CryptoLab, Inc." — entity/year subject to legal review).
- THIRD_PARTY_LICENSES/: byte-exact upstream texts, fetched from source —
llama.cpp LICENSE (ggml authors, MIT) and the canonical Apache License 2.0
(apache.org, sha256 cfc7749b…) for Qwen3-Embedding-0.6B, plus a factual
attribution index. The Qwen HF repos declare license:apache-2.0 and ship no
LICENSE/NOTICE of their own, so no NOTICE contents exist to reproduce
(§4(d) applies only when the work includes one) — none were invented.
- licenses.go embeds the texts into the daemon binary; bootstrap now installs
them to $RUNED_HOME/licenses/ on every EnsureAll (idempotent, non-fatal on
write failure), so the artifacts and their licenses always travel together.
- README's License section now points at the real files.
Out of scope (release-pipeline work): baking the texts into the re-hosted
llama-server tarball itself, and legal sign-off on the attribution wording.
Verified: go build/vet clean, all 9 packages pass, new tests prove the
installed copies are byte-identical to the embedded texts and self-heal on
rerun; upstream texts diff-verified against their sources at fetch time.
Co-Authored-By: Claude Opus 4.8
---
LICENSE | 21 ++
README.md | 18 +-
.../Qwen3-Embedding.Apache-2.0.LICENSE | 202 ++++++++++++++++++
THIRD_PARTY_LICENSES/README.md | 24 +++
THIRD_PARTY_LICENSES/llama.cpp.LICENSE | 21 ++
internal/bootstrap/install.go | 7 +
internal/bootstrap/licenses.go | 37 ++++
internal/bootstrap/licenses_test.go | 64 ++++++
licenses.go | 20 ++
9 files changed, 407 insertions(+), 7 deletions(-)
create mode 100644 LICENSE
create mode 100644 THIRD_PARTY_LICENSES/Qwen3-Embedding.Apache-2.0.LICENSE
create mode 100644 THIRD_PARTY_LICENSES/README.md
create mode 100644 THIRD_PARTY_LICENSES/llama.cpp.LICENSE
create mode 100644 internal/bootstrap/licenses.go
create mode 100644 internal/bootstrap/licenses_test.go
create mode 100644 licenses.go
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 0000000..61aa72f
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 CryptoLab, Inc.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.md b/README.md
index 6796a75..b47301b 100644
--- a/README.md
+++ b/README.md
@@ -162,10 +162,14 @@ Set the daemon's `RUNED_MODEL` env var to the desired GGUF path.
## License
-- `runed` Go code: MIT (LICENSE file forthcoming).
-- Qwen3-Embedding-0.6B: Apache 2.0.
-- llama.cpp: MIT.
-
-Redistribution of the bundled `llama-server` binary and GGUF model files is
-permitted under the respective upstream licenses; CryptoLab makes no
-independent claims on those artifacts.
+- `runed` Go code: MIT — see [`LICENSE`](LICENSE).
+- llama.cpp (`llama-server`): MIT — see
+ [`THIRD_PARTY_LICENSES/llama.cpp.LICENSE`](THIRD_PARTY_LICENSES/llama.cpp.LICENSE).
+- Qwen3-Embedding-0.6B (GGUF): Apache-2.0 — see
+ [`THIRD_PARTY_LICENSES/Qwen3-Embedding.Apache-2.0.LICENSE`](THIRD_PARTY_LICENSES/Qwen3-Embedding.Apache-2.0.LICENSE).
+
+The attribution index lives in
+[`THIRD_PARTY_LICENSES/README.md`](THIRD_PARTY_LICENSES/README.md). The
+self-bootstrap installs these texts to `$RUNED_HOME/licenses/` alongside the
+downloaded `llama-server` binary and GGUF weights, so every machine that
+receives the artifacts also receives the licenses that cover them.
diff --git a/THIRD_PARTY_LICENSES/Qwen3-Embedding.Apache-2.0.LICENSE b/THIRD_PARTY_LICENSES/Qwen3-Embedding.Apache-2.0.LICENSE
new file mode 100644
index 0000000..d645695
--- /dev/null
+++ b/THIRD_PARTY_LICENSES/Qwen3-Embedding.Apache-2.0.LICENSE
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/THIRD_PARTY_LICENSES/README.md b/THIRD_PARTY_LICENSES/README.md
new file mode 100644
index 0000000..7eadc1c
--- /dev/null
+++ b/THIRD_PARTY_LICENSES/README.md
@@ -0,0 +1,24 @@
+# Third-party licenses
+
+`runed` downloads and runs the following third-party components at first
+launch (self-bootstrap; see the manifest documentation in the top-level
+README). Their license texts are reproduced verbatim in this directory and
+are also installed to `$RUNED_HOME/licenses/` alongside the artifacts they
+cover, so every machine that receives the binaries receives the licenses.
+
+| Component | Role | License | Upstream |
+|---|---|---|---|
+| llama.cpp (`llama-server`) | Local inference server child process | MIT — [`llama.cpp.LICENSE`](llama.cpp.LICENSE) | |
+| Qwen3-Embedding-0.6B (GGUF) | Embedding model weights | Apache-2.0 — [`Qwen3-Embedding.Apache-2.0.LICENSE`](Qwen3-Embedding.Apache-2.0.LICENSE) | |
+
+Notes:
+
+- The llama.cpp license text is the upstream `LICENSE` file, unmodified
+ (`Copyright (c) 2023-2026 The ggml authors`).
+- The Qwen3-Embedding-0.6B repositories on Hugging Face declare
+ `license: apache-2.0` and ship no LICENSE or NOTICE file of their own, so
+ the canonical Apache License 2.0 text is reproduced here unmodified. No
+ NOTICE contents exist upstream to reproduce (Apache-2.0 §4(d) applies only
+ when the work includes a NOTICE file).
+- `runed` itself is licensed under the MIT License — see the top-level
+ [`LICENSE`](../LICENSE).
diff --git a/THIRD_PARTY_LICENSES/llama.cpp.LICENSE b/THIRD_PARTY_LICENSES/llama.cpp.LICENSE
new file mode 100644
index 0000000..e7dca55
--- /dev/null
+++ b/THIRD_PARTY_LICENSES/llama.cpp.LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2023-2026 The ggml authors
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/internal/bootstrap/install.go b/internal/bootstrap/install.go
index dcf0ce1..8062a9f 100644
--- a/internal/bootstrap/install.go
+++ b/internal/bootstrap/install.go
@@ -103,6 +103,13 @@ func EnsureAll(ctx context.Context, p *Paths, m *Manifest, logger *slog.Logger,
logger.Warn("audit: installed.json write failed", "err", auditErr)
}
+ // Ship the license texts with the artifacts they cover (OPS-90). A write
+ // failure is surfaced but does not brick the daemon — the artifacts above
+ // are already installed and the next bootstrap retries the copy.
+ if licErr := installLicenses(p); licErr != nil {
+ logger.Warn("licenses: install failed", "err", licErr)
+ }
+
return llamaBinPath, modelPath, variant, nil
}
diff --git a/internal/bootstrap/licenses.go b/internal/bootstrap/licenses.go
new file mode 100644
index 0000000..7fac604
--- /dev/null
+++ b/internal/bootstrap/licenses.go
@@ -0,0 +1,37 @@
+package bootstrap
+
+import (
+ "fmt"
+ "io/fs"
+ "os"
+ "path/filepath"
+
+ runed "github.com/CryptoLabInc/runed"
+)
+
+// installLicenses copies the embedded license texts (runed's own LICENSE plus
+// the third-party texts for llama-server and the Qwen3 GGUF model) into
+// $RUNED_HOME/licenses/, so the machine that just received the downloaded
+// artifacts also holds the licenses that cover them (OPS-90). Idempotent —
+// files are rewritten in place on every bootstrap, so license updates ship
+// with daemon updates.
+func installLicenses(p *Paths) error {
+ root := filepath.Join(p.Home, "licenses")
+ return fs.WalkDir(runed.LicenseFS, ".", func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ dst := filepath.Join(root, path)
+ if d.IsDir() {
+ return os.MkdirAll(dst, 0o755)
+ }
+ data, err := runed.LicenseFS.ReadFile(path)
+ if err != nil {
+ return fmt.Errorf("read embedded %s: %w", path, err)
+ }
+ if err := os.WriteFile(dst, data, 0o644); err != nil {
+ return fmt.Errorf("write %s: %w", dst, err)
+ }
+ return nil
+ })
+}
diff --git a/internal/bootstrap/licenses_test.go b/internal/bootstrap/licenses_test.go
new file mode 100644
index 0000000..4729035
--- /dev/null
+++ b/internal/bootstrap/licenses_test.go
@@ -0,0 +1,64 @@
+package bootstrap
+
+import (
+ "os"
+ "path/filepath"
+ "testing"
+
+ runed "github.com/CryptoLabInc/runed"
+)
+
+// installLicenses must land every embedded license text under
+// $RUNED_HOME/licenses/, byte-identical to the embedded copy (OPS-90).
+func TestInstallLicenses_WritesAllTextsVerbatim(t *testing.T) {
+ home := t.TempDir()
+ p := &Paths{Home: home}
+
+ if err := installLicenses(p); err != nil {
+ t.Fatalf("installLicenses: %v", err)
+ }
+
+ for _, path := range []string{
+ "LICENSE",
+ "THIRD_PARTY_LICENSES/README.md",
+ "THIRD_PARTY_LICENSES/llama.cpp.LICENSE",
+ "THIRD_PARTY_LICENSES/Qwen3-Embedding.Apache-2.0.LICENSE",
+ } {
+ want, err := runed.LicenseFS.ReadFile(path)
+ if err != nil {
+ t.Fatalf("embedded %s missing: %v", path, err)
+ }
+ got, err := os.ReadFile(filepath.Join(home, "licenses", path))
+ if err != nil {
+ t.Fatalf("installed %s missing: %v", path, err)
+ }
+ if string(got) != string(want) {
+ t.Errorf("%s: installed copy differs from embedded text", path)
+ }
+ }
+}
+
+// A second run must overwrite cleanly (bootstrap runs on every launch).
+func TestInstallLicenses_Idempotent(t *testing.T) {
+ home := t.TempDir()
+ p := &Paths{Home: home}
+ if err := installLicenses(p); err != nil {
+ t.Fatalf("first install: %v", err)
+ }
+ // Corrupt one file; the rerun must restore the embedded text.
+ target := filepath.Join(home, "licenses", "LICENSE")
+ if err := os.WriteFile(target, []byte("tampered"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := installLicenses(p); err != nil {
+ t.Fatalf("second install: %v", err)
+ }
+ got, err := os.ReadFile(target)
+ if err != nil {
+ t.Fatal(err)
+ }
+ want, _ := runed.LicenseFS.ReadFile("LICENSE")
+ if string(got) != string(want) {
+ t.Error("rerun did not restore the embedded LICENSE text")
+ }
+}
diff --git a/licenses.go b/licenses.go
new file mode 100644
index 0000000..b186297
--- /dev/null
+++ b/licenses.go
@@ -0,0 +1,20 @@
+// Package runed exposes the repository's license texts as an embedded
+// filesystem so the self-bootstrap can install them next to the third-party
+// artifacts it downloads (OPS-90). Redistribution of llama-server (MIT) and
+// the Qwen3 GGUF weights (Apache-2.0) requires shipping the license texts
+// with the copies — embedding them in the daemon binary guarantees every
+// machine that receives the artifacts also receives the licenses.
+package runed
+
+import "embed"
+
+// LicenseFS holds this repository's own LICENSE plus the third-party license
+// texts (with their attribution index). Paths inside the FS mirror the repo:
+//
+// LICENSE
+// THIRD_PARTY_LICENSES/README.md
+// THIRD_PARTY_LICENSES/llama.cpp.LICENSE
+// THIRD_PARTY_LICENSES/Qwen3-Embedding.Apache-2.0.LICENSE
+//
+//go:embed LICENSE THIRD_PARTY_LICENSES
+var LicenseFS embed.FS
From 60e038d3892ad9dfde7c21aa993cead0bb930427 Mon Sep 17 00:00:00 2001
From: couragehong
Date: Sun, 19 Jul 2026 22:48:54 +0900
Subject: [PATCH 16/20] fix(OPS-90): require notices in installs and release
archives
---
.github/workflows/release.yaml | 16 +++++++++
Makefile | 6 ++--
internal/bootstrap/install.go | 19 ++++++----
internal/bootstrap/install_test.go | 3 ++
internal/bootstrap/licenses.go | 2 +-
internal/bootstrap/licenses_test.go | 55 ++++++++++++++++++++++++++++-
licenses.go | 2 +-
7 files changed, 91 insertions(+), 12 deletions(-)
diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
index 7e8ae36..f0a7641 100644
--- a/.github/workflows/release.yaml
+++ b/.github/workflows/release.yaml
@@ -144,6 +144,22 @@ jobs:
VERSION: ${{ steps.meta.outputs.version }}
run: make release-tarball
+ - name: Verify license files in release tarball
+ shell: bash
+ run: |
+ set -euo pipefail
+ archive=$(find dist -maxdepth 1 -name '*.tar.gz' -print -quit)
+ test -n "${archive}"
+ tar -tzf "${archive}" > "${archive}.contents"
+ for path in \
+ LICENSE \
+ THIRD_PARTY_LICENSES/README.md \
+ THIRD_PARTY_LICENSES/llama.cpp.LICENSE \
+ THIRD_PARTY_LICENSES/Qwen3-Embedding.Apache-2.0.LICENSE
+ do
+ grep -Fxq "${path}" "${archive}.contents"
+ done
+
- uses: actions/upload-artifact@v4
with:
name: release-${{ matrix.goos }}-${{ matrix.goarch }}
diff --git a/Makefile b/Makefile
index ffba173..205d93f 100644
--- a/Makefile
+++ b/Makefile
@@ -84,12 +84,14 @@ test:
clean:
rm -rf bin/ gen/ dist/
-# Packages the Go binaries plus llama-server into a single tarball.
+# Packages the Go binaries, llama-server, and their license texts into a single tarball.
# Assumes `make build` and `make llama-server` have already populated bin/.
release-tarball:
mkdir -p dist
TARNAME=runed-$(VERSION)-$(OS_LABEL)-$(GOARCH).tar.gz; \
- tar -czf dist/$$TARNAME -C bin runed rundemo llama-server; \
+ tar -czf dist/$$TARNAME \
+ -C bin runed rundemo llama-server \
+ -C $(CURDIR) LICENSE THIRD_PARTY_LICENSES; \
cd dist && ( \
(command -v shasum >/dev/null 2>&1 && shasum -a 256 $$TARNAME > $$TARNAME.sha256) \
|| (command -v sha256sum >/dev/null 2>&1 && sha256sum $$TARNAME > $$TARNAME.sha256) \
diff --git a/internal/bootstrap/install.go b/internal/bootstrap/install.go
index 8062a9f..86712f4 100644
--- a/internal/bootstrap/install.go
+++ b/internal/bootstrap/install.go
@@ -81,6 +81,12 @@ func EnsureAll(ctx context.Context, p *Paths, m *Manifest, logger *slog.Logger,
return "", "", "", fmt.Errorf("install lock: %w", err)
}
defer lock.Release()
+ // Install the notices before any third-party artifact can be downloaded.
+ // Failing closed keeps every successful artifact installation accompanied
+ // by the license text that permits its redistribution.
+ if err := installLicenses(p); err != nil {
+ return "", "", "", fmt.Errorf("install licenses: %w", err)
+ }
llamaBinPath, llamaSpec, err := ensureLlamaServer(ctx, p, m, logger, reporter)
if err != nil {
@@ -103,13 +109,6 @@ func EnsureAll(ctx context.Context, p *Paths, m *Manifest, logger *slog.Logger,
logger.Warn("audit: installed.json write failed", "err", auditErr)
}
- // Ship the license texts with the artifacts they cover (OPS-90). A write
- // failure is surfaced but does not brick the daemon — the artifacts above
- // are already installed and the next bootstrap retries the copy.
- if licErr := installLicenses(p); licErr != nil {
- logger.Warn("licenses: install failed", "err", licErr)
- }
-
return llamaBinPath, modelPath, variant, nil
}
@@ -131,6 +130,9 @@ func EnsureLlamaServer(ctx context.Context, p *Paths, m *Manifest, logger *slog.
return "", fmt.Errorf("install lock: %w", err)
}
defer lock.Release()
+ if err := installLicenses(p); err != nil {
+ return "", fmt.Errorf("install licenses: %w", err)
+ }
llamaBinPath, llamaSpec, err := ensureLlamaServer(ctx, p, m, logger, reporter)
if err != nil {
@@ -173,6 +175,9 @@ func EnsureModel(ctx context.Context, p *Paths, m *Manifest, logger *slog.Logger
return "", "", fmt.Errorf("install lock: %w", err)
}
defer lock.Release()
+ if err := installLicenses(p); err != nil {
+ return "", "", fmt.Errorf("install licenses: %w", err)
+ }
modelPath, modelSpec, err := ensureModel(ctx, p, m, variant, logger, reporter)
if err != nil {
diff --git a/internal/bootstrap/install_test.go b/internal/bootstrap/install_test.go
index 29e22d3..c349085 100644
--- a/internal/bootstrap/install_test.go
+++ b/internal/bootstrap/install_test.go
@@ -226,6 +226,9 @@ func TestEnsureModel_NoLlamaServerEntryNeeded(t *testing.T) {
if gotVariant != variant {
t.Errorf("got variant %q, want %q", gotVariant, variant)
}
+ if _, err := os.Stat(filepath.Join(dir, "licenses", "THIRD_PARTY_LICENSES", "Qwen3-Embedding.Apache-2.0.LICENSE")); err != nil {
+ t.Fatalf("partial model bootstrap did not install license texts: %v", err)
+ }
}
// Stage tick fires on Ensure* entry so Health flips Phase even when
diff --git a/internal/bootstrap/licenses.go b/internal/bootstrap/licenses.go
index 7fac604..3fbb419 100644
--- a/internal/bootstrap/licenses.go
+++ b/internal/bootstrap/licenses.go
@@ -12,7 +12,7 @@ import (
// installLicenses copies the embedded license texts (runed's own LICENSE plus
// the third-party texts for llama-server and the Qwen3 GGUF model) into
// $RUNED_HOME/licenses/, so the machine that just received the downloaded
-// artifacts also holds the licenses that cover them (OPS-90). Idempotent —
+// artifacts also holds the licenses that cover them. Idempotent —
// files are rewritten in place on every bootstrap, so license updates ship
// with daemon updates.
func installLicenses(p *Paths) error {
diff --git a/internal/bootstrap/licenses_test.go b/internal/bootstrap/licenses_test.go
index 4729035..8263165 100644
--- a/internal/bootstrap/licenses_test.go
+++ b/internal/bootstrap/licenses_test.go
@@ -1,15 +1,17 @@
package bootstrap
import (
+ "log/slog"
"os"
"path/filepath"
+ "strings"
"testing"
runed "github.com/CryptoLabInc/runed"
)
// installLicenses must land every embedded license text under
-// $RUNED_HOME/licenses/, byte-identical to the embedded copy (OPS-90).
+// $RUNED_HOME/licenses/, byte-identical to the embedded copy.
func TestInstallLicenses_WritesAllTextsVerbatim(t *testing.T) {
home := t.TempDir()
p := &Paths{Home: home}
@@ -38,6 +40,57 @@ func TestInstallLicenses_WritesAllTextsVerbatim(t *testing.T) {
}
}
+// Every public install entry point must stop before installing an artifact when
+// the accompanying license texts cannot be written.
+func TestEnsureEntryPointsFailWhenLicensesCannotBeInstalled(t *testing.T) {
+ tests := []struct {
+ name string
+ run func(*Paths, *Manifest) error
+ }{
+ {
+ name: "all",
+ run: func(p *Paths, m *Manifest) error {
+ _, _, _, err := EnsureAll(t.Context(), p, m, slog.Default(), nil)
+ return err
+ },
+ },
+ {
+ name: "llama server",
+ run: func(p *Paths, m *Manifest) error {
+ _, err := EnsureLlamaServer(t.Context(), p, m, slog.Default(), nil)
+ return err
+ },
+ },
+ {
+ name: "model",
+ run: func(p *Paths, m *Manifest) error {
+ _, _, err := EnsureModel(t.Context(), p, m, slog.Default(), nil)
+ return err
+ },
+ },
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ home := t.TempDir()
+ t.Setenv(EnvHome, home)
+ p, err := Resolve()
+ if err != nil {
+ t.Fatalf("resolve paths: %v", err)
+ }
+ // A regular file blocks creation of the required licenses directory.
+ if err := os.WriteFile(filepath.Join(home, "licenses"), []byte("blocked"), 0o600); err != nil {
+ t.Fatalf("block license directory: %v", err)
+ }
+ m := &Manifest{DefaultModel: "test-model"}
+ err = tc.run(p, m)
+ if err == nil || !strings.Contains(err.Error(), "install licenses") {
+ t.Fatalf("error = %v, want install licenses failure", err)
+ }
+ })
+ }
+}
+
// A second run must overwrite cleanly (bootstrap runs on every launch).
func TestInstallLicenses_Idempotent(t *testing.T) {
home := t.TempDir()
diff --git a/licenses.go b/licenses.go
index b186297..9c66fa0 100644
--- a/licenses.go
+++ b/licenses.go
@@ -1,6 +1,6 @@
// Package runed exposes the repository's license texts as an embedded
// filesystem so the self-bootstrap can install them next to the third-party
-// artifacts it downloads (OPS-90). Redistribution of llama-server (MIT) and
+// artifacts it downloads. Redistribution of llama-server (MIT) and
// the Qwen3 GGUF weights (Apache-2.0) requires shipping the license texts
// with the copies — embedding them in the daemon binary guarantees every
// machine that receives the artifacts also receives the licenses.
From bdb35848cf19a4f67ca1ce73882c7e488d04fc56 Mon Sep 17 00:00:00 2001
From: couragehong
Date: Mon, 20 Jul 2026 01:56:32 +0900
Subject: [PATCH 17/20] =?UTF-8?q?fix(OPS-90):=20relicense=20runed=20itself?=
=?UTF-8?q?=20MIT=20=E2=86=92=20Apache-2.0,=20ship=20NOTICE=20everywhere?=
=?UTF-8?q?=20the=20LICENSE=20goes?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- LICENSE: byte-exact apache.org LICENSE-2.0.txt
(sha256 cfc7749b96f63bd31c3c42b5c471bf756814053e847c10f3eb003417bc523d30)
- NOTICE: Apache-2.0 §4(d) attribution (runed / Copyright 2026 CryptoLab, Inc.)
- NOTICE rides with the LICENSE at every redistribution point: go:embed
LicenseFS → $RUNED_HOME/licenses/ install, release tarball contents,
and the release workflow's tarball verification list
- README + THIRD_PARTY_LICENSES/README attribution index updated (third-party
texts unchanged: llama.cpp stays MIT, Qwen3 GGUF stays Apache-2.0)
Verified: go build ./... + full test suite (installLicenses now asserts the
NOTICE lands verbatim), LICENSE checksum matches the canonical text.
Co-Authored-By: Claude Opus 4.8
---
.github/workflows/release.yaml | 1 +
LICENSE | 223 +++++++++++++++++++++++++---
Makefile | 2 +-
NOTICE | 4 +
README.md | 2 +-
THIRD_PARTY_LICENSES/README.md | 5 +-
internal/bootstrap/licenses_test.go | 1 +
licenses.go | 9 +-
8 files changed, 219 insertions(+), 28 deletions(-)
create mode 100644 NOTICE
diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
index f0a7641..cb57366 100644
--- a/.github/workflows/release.yaml
+++ b/.github/workflows/release.yaml
@@ -153,6 +153,7 @@ jobs:
tar -tzf "${archive}" > "${archive}.contents"
for path in \
LICENSE \
+ NOTICE \
THIRD_PARTY_LICENSES/README.md \
THIRD_PARTY_LICENSES/llama.cpp.LICENSE \
THIRD_PARTY_LICENSES/Qwen3-Embedding.Apache-2.0.LICENSE
diff --git a/LICENSE b/LICENSE
index 61aa72f..d645695 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,21 +1,202 @@
-MIT License
-
-Copyright (c) 2026 CryptoLab, Inc.
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/Makefile b/Makefile
index 205d93f..fc1458e 100644
--- a/Makefile
+++ b/Makefile
@@ -91,7 +91,7 @@ release-tarball:
TARNAME=runed-$(VERSION)-$(OS_LABEL)-$(GOARCH).tar.gz; \
tar -czf dist/$$TARNAME \
-C bin runed rundemo llama-server \
- -C $(CURDIR) LICENSE THIRD_PARTY_LICENSES; \
+ -C $(CURDIR) LICENSE NOTICE THIRD_PARTY_LICENSES; \
cd dist && ( \
(command -v shasum >/dev/null 2>&1 && shasum -a 256 $$TARNAME > $$TARNAME.sha256) \
|| (command -v sha256sum >/dev/null 2>&1 && sha256sum $$TARNAME > $$TARNAME.sha256) \
diff --git a/NOTICE b/NOTICE
new file mode 100644
index 0000000..6704a63
--- /dev/null
+++ b/NOTICE
@@ -0,0 +1,4 @@
+runed
+Copyright 2026 CryptoLab, Inc.
+
+This product includes software developed at CryptoLab, Inc.
diff --git a/README.md b/README.md
index b47301b..95551b9 100644
--- a/README.md
+++ b/README.md
@@ -162,7 +162,7 @@ Set the daemon's `RUNED_MODEL` env var to the desired GGUF path.
## License
-- `runed` Go code: MIT — see [`LICENSE`](LICENSE).
+- `runed` Go code: Apache-2.0 — see [`LICENSE`](LICENSE) and [`NOTICE`](NOTICE).
- llama.cpp (`llama-server`): MIT — see
[`THIRD_PARTY_LICENSES/llama.cpp.LICENSE`](THIRD_PARTY_LICENSES/llama.cpp.LICENSE).
- Qwen3-Embedding-0.6B (GGUF): Apache-2.0 — see
diff --git a/THIRD_PARTY_LICENSES/README.md b/THIRD_PARTY_LICENSES/README.md
index 7eadc1c..076aef4 100644
--- a/THIRD_PARTY_LICENSES/README.md
+++ b/THIRD_PARTY_LICENSES/README.md
@@ -20,5 +20,6 @@ Notes:
the canonical Apache License 2.0 text is reproduced here unmodified. No
NOTICE contents exist upstream to reproduce (Apache-2.0 §4(d) applies only
when the work includes a NOTICE file).
-- `runed` itself is licensed under the MIT License — see the top-level
- [`LICENSE`](../LICENSE).
+- `runed` itself is licensed under the Apache License 2.0 — see the top-level
+ [`LICENSE`](../LICENSE) and [`NOTICE`](../NOTICE) (Apache-2.0 §4(d):
+ redistributions must include the NOTICE file).
diff --git a/internal/bootstrap/licenses_test.go b/internal/bootstrap/licenses_test.go
index 8263165..35e6ffa 100644
--- a/internal/bootstrap/licenses_test.go
+++ b/internal/bootstrap/licenses_test.go
@@ -22,6 +22,7 @@ func TestInstallLicenses_WritesAllTextsVerbatim(t *testing.T) {
for _, path := range []string{
"LICENSE",
+ "NOTICE",
"THIRD_PARTY_LICENSES/README.md",
"THIRD_PARTY_LICENSES/llama.cpp.LICENSE",
"THIRD_PARTY_LICENSES/Qwen3-Embedding.Apache-2.0.LICENSE",
diff --git a/licenses.go b/licenses.go
index 9c66fa0..d775a12 100644
--- a/licenses.go
+++ b/licenses.go
@@ -8,13 +8,16 @@ package runed
import "embed"
-// LicenseFS holds this repository's own LICENSE plus the third-party license
-// texts (with their attribution index). Paths inside the FS mirror the repo:
+// LicenseFS holds this repository's own LICENSE (Apache-2.0) and NOTICE plus
+// the third-party license texts (with their attribution index). Apache-2.0
+// §4(d) requires redistributions to carry the NOTICE file, so it travels with
+// the LICENSE everywhere. Paths inside the FS mirror the repo:
//
// LICENSE
+// NOTICE
// THIRD_PARTY_LICENSES/README.md
// THIRD_PARTY_LICENSES/llama.cpp.LICENSE
// THIRD_PARTY_LICENSES/Qwen3-Embedding.Apache-2.0.LICENSE
//
-//go:embed LICENSE THIRD_PARTY_LICENSES
+//go:embed LICENSE NOTICE THIRD_PARTY_LICENSES
var LicenseFS embed.FS
From d4a67fe3c61095b6cab28315957c2a68bc0864ea Mon Sep 17 00:00:00 2001
From: couragehong
Date: Mon, 20 Jul 2026 09:32:29 +0900
Subject: [PATCH 18/20] docs: refresh runed README
---
.github/assets/repo-badges.svg | 6 +
.github/assets/repo-hero.svg | 19 +++
README.md | 299 ++++++++++++++++-----------------
3 files changed, 171 insertions(+), 153 deletions(-)
create mode 100644 .github/assets/repo-badges.svg
create mode 100644 .github/assets/repo-hero.svg
diff --git a/.github/assets/repo-badges.svg b/.github/assets/repo-badges.svg
new file mode 100644
index 0000000..9ffa7da
--- /dev/null
+++ b/.github/assets/repo-badges.svg
@@ -0,0 +1,6 @@
+
diff --git a/.github/assets/repo-hero.svg b/.github/assets/repo-hero.svg
new file mode 100644
index 0000000..54f7493
--- /dev/null
+++ b/.github/assets/repo-hero.svg
@@ -0,0 +1,19 @@
+
diff --git a/README.md b/README.md
index 95551b9..9a82328 100644
--- a/README.md
+++ b/README.md
@@ -1,175 +1,168 @@
-# runed — Shared embedding daemon
+
-`runed` is a Go daemon that wraps [llama.cpp](https://github.com/ggml-org/llama.cpp)
-`llama-server` to serve Qwen3-Embedding-0.6B embeddings via gRPC over a UNIX
-domain socket. It is designed as a **shared singleton process per machine** so
-that multiple client sessions do not each load their own ~400 MB embedding
-model.
+
-`runed` is normally installed via the [`rune` CLI](https://github.com/CryptoLabInc/rune):
+`runed` is RUNE's shared local embedding daemon. It keeps one Qwen3 embedding model per machine and serves every agent session over a Unix domain socket, avoiding a separate model copy for every `rune-mcp` process.
-```
-rune install
+The daemon starts on demand, verifies every downloaded artifact by SHA-256, suspends the heavyweight `llama-server` child when idle, and brings it back transparently on the next embedding request.
+
+## Why a shared daemon
+
+```text
+agent session A ── rune-mcp A ─┐
+agent session B ── rune-mcp B ─┼── Unix socket ── runed ── llama-server
+agent session C ── rune-mcp C ─┘ └─ one Qwen3 model
```
-This places the binary at `~/.runed/bin/runed` with a manifest URL baked
-in at build time (via `-ldflags`). End users don't need to set any
-environment variable — the first launch downloads the `llama-server`
-release tarball and the embedding GGUF described by the manifest into
-`~/.runed/{bin/llama-cpp,models}/`. Subsequent launches reuse the
-installed artifacts as long as the manifest SHA-256s still match.
+- **One model per machine** instead of one model per agent session.
+- **Local text processing** so passages do not need to leave the user's device for embedding.
+- **Automatic startup** serialized across concurrent clients.
+- **Idle suspension** that releases model memory without removing the daemon socket.
+- **Verified bootstrap** for the `llama-server` archive and GGUF model.
+- **Single and batch RPCs** with L2-normalized vectors.
-For standalone testing of the daemon without `rune`, see
-[`scripts/dev_standalone.sh`](scripts/dev_standalone.sh).
+## Installation
-## Usage
+End users normally receive `runed` through the [RUNE](https://github.com/CryptoLabInc/rune) installer. The bootstrap layer places the binary under `~/.runed/bin/`, then `rune-mcp` starts it automatically when an embedding is first needed.
-`runed` is a passive gRPC daemon. It is normally spawned on demand by a
-client (e.g. `rune-mcp`, `rundemo`, or any program that imports the
-`client/` package) the first time an embedding request arrives:
+For standalone development:
-```
-client.Connect()
- socket dial fails
- → spawn.EnsureDaemon execs ~/.runed/bin/runed (detached)
- → paths.Resolve / EnsureDirs
- → socket probe (exit 0 if another daemon already listening)
- → self-bootstrap (manifest fetch → llama-server tarball + GGUF
- download on first boot, SHA cache check on subsequent boots)
- → backend.Start (llama-server child, port 0)
- → listen on ~/.runed/embedding.sock
- client reconnects → embed RPC → result
+```bash
+git clone https://github.com/CryptoLabInc/runed.git
+cd runed
+make build
+./scripts/dev_standalone.sh
```
-Direct foreground launch is supported for development and debugging:
+Linux and macOS are supported. The current client deliberately rejects Windows because the Plan A transport uses a Unix domain socket.
-```
-./bin/runed
-```
+## Go client
-After `RUNED_IDLE_TIMEOUT` (default 10m) of no RPC activity, the daemon
-stops its `llama-server` child to release the ~470MB+ of model weights
-from memory — but `runed` itself stays up with the gRPC socket still
-listening. The next `Embed`/`EmbedBatch` RPC transparently restarts the
-backend (cold-start latency paid only on that single request). The
-daemon process exits only on SIGINT/SIGTERM/SIGHUP or a Shutdown RPC.
+Programs can use the public [`client`](client/) package directly:
-## Configuration
+```go
+package main
-### Environment
+import (
+ "context"
+ "fmt"
+ "log"
-| Variable | Purpose | Default |
-|---|---|---|
-| `RUNED_HOME` | Data directory | `~/.runed` |
-| `RUNED_MANIFEST` | Manifest URL for self-bootstrap | `DefaultManifestURL` (build-time ldflags) |
-| `RUNED_LLAMA_SERVER` | Skip self-bootstrap; use this binary | (unset) |
-| `RUNED_MODEL` | Skip self-bootstrap; use this GGUF | (unset) |
-| `RUNED_MODEL_VARIANT` | Pick a non-default model from `manifest.models` | `manifest.default_model` |
-| `RUNED_CTX_SIZE` | `llama-server --ctx-size` (max input length in tokens) | 2048 |
-| `RUNED_IDLE_TIMEOUT` | After this much idle, stop the llama-server child to free model memory. `runed` itself stays up; the next Embed RPC resurrects the backend. `"0"` disables suspend | 10m |
-
-`RUNED_MANIFEST` should be **HTTPS** in production. HTTP is permitted
-(for private networks) but emits a warning at startup — a MITM that
-rewrites the manifest can also rewrite the per-artifact SHA256s, so
-artifact integrity collapses to "trust the manifest channel" alone.
-
-The `DefaultManifestURL` referenced in the table above is injected at
-build time via `-ldflags`; see [`CONTRIBUTING.md`](CONTRIBUTING.md#build-time-options)
-for the relevant `make build` flags.
-
-### Manifest format
-
-`runed` reads only a subset of the manifest; extra keys (used by the
-companion `rune` installer) are ignored:
-
-```json
-{
- "version": 1,
- "platforms": {
- "darwin-arm64": {
- "llama_server": {
- "url": "https://.../llama-mac-arm64.tar.gz",
- "sha256": "...",
- "size": 22000000,
- "extract": "tar.gz",
- "exec": "llama-server"
- }
- }
- },
- "models": {
- "qwen3-embedding-0.6b.q6_K": {
- "url": "https://huggingface.co/.../qwen3-embedding-0.6b-q6_k.gguf",
- "sha256": "...",
- "size": 472000000
- }
- },
- "default_model": "qwen3-embedding-0.6b.q6_K"
+ "github.com/CryptoLabInc/runed/client"
+)
+
+func main() {
+ ctx := context.Background()
+
+ c, err := client.Connect(ctx)
+ if err != nil {
+ log.Fatal(err)
+ }
+ defer c.Close()
+
+ vector, err := c.Embed(ctx, "Why did we cap retry backoff at five minutes?")
+ if err != nil {
+ log.Fatal(err)
+ }
+ fmt.Println(len(vector))
}
```
-- `extract`: `""` (raw binary placed at `LlamaDir/`) or `"tar.gz"`
- (extracted into `LlamaDir`, `exec` path resolved inside).
-- A sidecar marker `~/.runed/bin/llama-cpp/.llama_server.sha256` tracks
- the last-installed tarball hash so repeat boots don't re-extract.
-- Models are verified by hashing the on-disk file against
- `models[].sha256` — no marker file.
-
-### config.json (`~/.runed/config.json`, optional)
-
-This file is read by **two** components with overlapping schemas; leave
-any field you don't need unset. Unknown fields are ignored.
-
-| Field | Reader | Purpose |
-|---|---|---|
-| `version` | both | Schema version (currently `1`) |
-| `llama_server` | spawn | If set, skip daemon self-bootstrap and use this binary |
-| `model` | spawn | If set, skip daemon self-bootstrap and use this GGUF |
-| `runed_binary` | spawn | Path to the daemon binary (fallback to PATH / `DefaultRunedBinary`) |
-| `idle_timeout` | spawn | Propagated to the spawned daemon as `RUNED_IDLE_TIMEOUT` |
-| `model_variant` | bootstrap | Pick a manifest model variant (overrides `manifest.default_model`) |
-
-Example — minimal config telling the spawn layer to use a custom
-`runed_binary` but leaving artifact resolution to self-bootstrap:
-
-```json
-{
- "version": 1,
- "runed_binary": "/opt/runed/bin/runed",
- "idle_timeout": "1m"
-}
+`client.Connect` first probes the default socket. If no healthy daemon is present, it serializes startup, launches `runed`, waits for health, and reconnects. `client.WithNoSpawn()` is available for tests and explicitly managed deployments.
+
+## Runtime lifecycle
+
+On first boot, `runed` performs the following sequence:
+
+1. Resolve `RUNED_HOME` and the daemon socket.
+2. Fetch the release manifest over HTTPS.
+3. Download and verify the platform-specific `llama-server` archive.
+4. Download and verify the selected Qwen3 GGUF model.
+5. Launch `llama-server` on an ephemeral loopback port.
+6. Serve gRPC over `~/.runed/embedding.sock`.
+
+Subsequent boots reuse artifacts whose SHA-256 hashes still match the manifest.
+
+After `RUNED_IDLE_TIMEOUT` without an embedding RPC, only the `llama-server` child stops. `runed` stays reachable and restarts the backend for the next `Embed` or `EmbedBatch` call. Set the timeout to `0` to disable idle suspension.
+
+## Configuration
+
+| Variable | Purpose | Default |
+| --- | --- | --- |
+| `RUNED_HOME` | Runtime, model, log, and socket directory. | `~/.runed` |
+| `RUNED_MANIFEST` | Self-bootstrap manifest URL. | Build-time release URL |
+| `RUNED_LLAMA_SERVER` | Use an explicit `llama-server` binary and skip that download. | Unset |
+| `RUNED_MODEL` | Use an explicit GGUF model and skip that download. | Unset |
+| `RUNED_MODEL_VARIANT` | Select a named model from the manifest. | `default_model` |
+| `RUNED_CTX_SIZE` | Maximum input context passed to `llama-server`. | `2048` |
+| `RUNED_IDLE_TIMEOUT` | Suspend the model backend after this idle duration. | `10m` |
+
+`RUNED_MANIFEST` should use HTTPS in production. Artifact hashes protect downloads only after the manifest itself has been trusted.
+
+An optional `~/.runed/config.json` can specify `runed_binary`, `llama_server`, `model`, `model_variant`, and `idle_timeout`. Environment variables remain useful for one-off development overrides.
+
+## Release model
+
+The release workflow currently pins **Qwen3-Embedding-0.6B Q8_0**, approximately 610 MB, as the production manifest's default model. The f16 model remains the parity reference; smaller quantizations trade retrieval fidelity for disk and memory savings.
+
+| Variant | Approximate size | Mean cosine vs. f16 | Intended use |
+| --- | ---: | ---: | --- |
+| f16 | 1.1 GB | ≈ 0.99999 | Parity reference |
+| **Q8_0** | **610 MB** | **≈ 0.9993** | **Current release default** |
+| Q6_K | 472 MB | 0.994 | Smaller high-quality alternative |
+| Q5_K_M | 424 MB | 0.990 | Size-sensitive environments |
+| Q4_K_M | 378 MB | 0.971 | Development or secondary reranking |
+
+The selected manifest controls the actual filename, hash, and default. Treat this table as guidance, not a substitute for inspecting a custom manifest.
+
+## Development
+
+Go 1.26.2 or newer is required.
+
+```bash
+make build
+make test
+
+# Full integration coverage with local artifacts
+RUNED_TEST_LLAMA_SERVER=/path/to/llama-server \
+RUNED_TEST_GGUF=/path/to/Qwen3-Embedding-0.6B-Q8_0.gguf \
+go test -race ./...
```
-## Model variants
-
-The f16 GGUF is the parity reference — cosine similarity ≥ 0.9999 against
-sentence-transformers on all 8 fixture texts. Quantized variants trade parity
-for size/latency.
-
-| Variant | Size | Mean cosine | Verdict |
-|---|---|---|---|
-| f16 | 1.1 GB | ≈ 0.99999 | parity reference |
-| q8_0 | 610 MB | ≈ 0.9993 | high-fidelity alternative |
-| q6_K | 472 MB | 0.994 | **production default** |
-| q5_K_M | 424 MB | 0.990 | borderline; natural-language only |
-| q4_K_M | 378 MB | 0.971 | rerank-only or dev staging; not sole retrieval backbone |
-
-**Production default is q6_K** (`models/qwen3-embedding-0.6b.q6_K.gguf`) —
-23% smaller than q8_0 with mean cosine 0.994 against the f16 reference.
-f16 is reserved for parity verification against sentence-transformers.
-Set the daemon's `RUNED_MODEL` env var to the desired GGUF path.
-
-## License
-
-- `runed` Go code: Apache-2.0 — see [`LICENSE`](LICENSE) and [`NOTICE`](NOTICE).
-- llama.cpp (`llama-server`): MIT — see
- [`THIRD_PARTY_LICENSES/llama.cpp.LICENSE`](THIRD_PARTY_LICENSES/llama.cpp.LICENSE).
-- Qwen3-Embedding-0.6B (GGUF): Apache-2.0 — see
- [`THIRD_PARTY_LICENSES/Qwen3-Embedding.Apache-2.0.LICENSE`](THIRD_PARTY_LICENSES/Qwen3-Embedding.Apache-2.0.LICENSE).
-
-The attribution index lives in
-[`THIRD_PARTY_LICENSES/README.md`](THIRD_PARTY_LICENSES/README.md). The
-self-bootstrap installs these texts to `$RUNED_HOME/licenses/` alongside the
-downloaded `llama-server` binary and GGUF weights, so every machine that
-receives the artifacts also receives the licenses that cover them.
+See [CONTRIBUTING.md](CONTRIBUTING.md) for protobuf generation, build-time manifest injection, and model parity requirements.
+
+## Repository map
+
+| Path | Responsibility |
+| --- | --- |
+| [`cmd/runed/`](cmd/runed/) | Daemon entry point and process lifecycle. |
+| [`client/`](client/) | Auto-spawning Go client. |
+| [`internal/bootstrap/`](internal/bootstrap/) | Manifest, download, verification, extraction, and license installation. |
+| [`internal/backend/`](internal/backend/) | `llama-server` child process and embedding HTTP bridge. |
+| [`internal/ipc/`](internal/ipc/) | Local socket listener and path handling. |
+| [`internal/server/`](internal/server/) | gRPC health, embedding, centroid, and shutdown services. |
+| [`proto/runed/v1/`](proto/runed/v1/) | Public protobuf contract. |
+
+## License and third-party software
+
+- `runed`: [Apache License 2.0](LICENSE) with [NOTICE](NOTICE).
+- `llama.cpp` / `llama-server`: MIT; see [`THIRD_PARTY_LICENSES/llama.cpp.LICENSE`](THIRD_PARTY_LICENSES/llama.cpp.LICENSE).
+- Qwen3-Embedding-0.6B: Apache License 2.0; see [`THIRD_PARTY_LICENSES/Qwen3-Embedding.Apache-2.0.LICENSE`](THIRD_PARTY_LICENSES/Qwen3-Embedding.Apache-2.0.LICENSE).
+
+The bootstrap installs the applicable texts under `$RUNED_HOME/licenses/` beside the downloaded third-party artifacts. See [`THIRD_PARTY_LICENSES/README.md`](THIRD_PARTY_LICENSES/README.md) for the attribution index.
+
+