Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 17 additions & 3 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,11 +154,25 @@ bytes and invalid signatures.
Domain tag: `sohocloud/listing/v0`

Order: `NodeID` (string), `Class` (string), `Printers` (repeated: `Kind` string,
`Model` string), `Capacity.VCPUs` (int64), `Capacity.MemMB` (int64),
`Capacity.DiskMB` (int64), `Capacity.PrintQPS` (int64), `OptIn.Compute` (bool),
`OptIn.Print` (bool), `IssuedAt` (time), `Seq` (uint64).
`Model` string), `GPUs` (repeated: `API` string, `Model` string, `VRAMMB`
int64), `Capacity.VCPUs` (int64), `Capacity.MemMB` (int64),
`Capacity.DiskMB` (int64), `Capacity.StorageCommitMB` (int64),
`Capacity.PrintQPS` (int64), `OptIn.Compute` (bool), `OptIn.Print` (bool),
`OptIn.Storage` (bool), `IssuedAt` (time), `Seq` (uint64).

`Class` ∈ {`micro`, `standard`, `server`}. `Kind` ∈ {`traditional`, `threed`}.
`API` ∈ {`vulkan`, `nnapi`, `cuda`, `metal`}.

`Capacity.DiskMB` is scratch space available to a running job;
`Capacity.StorageCommitMB` is long-lived storage the node commits to hold for
the network (shard hosting). The two are deliberately distinct so a node can
offer either without the other. How stored data is encrypted, sharded, and
audited is a frontend/agent concern outside this protocol — coordination only,
the wire never carries stored content.

Advertising a GPU is the opt-in for GPU work: a listing with no `GPUs`
receives none, and a node withdraws a GPU by omitting it from its next
listing.

`Seq` is strictly monotonic per node. A coordinator MUST reject a listing whose
`Seq` does not strictly exceed the last one seen for that node (replay/rollback
Expand Down
2 changes: 1 addition & 1 deletion employment/employment.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import (
// advisory routing hints, not enforcement — opt-out enforcement is local to the
// node (see Decline / listing.WorkloadOptIn).
type JobSpec struct {
Workload string // "compute" | "print"
Workload string // "compute" | "print" | "storage"
Image string
Args []string
PrinterKind string // set for print workloads; empty otherwise
Expand Down
55 changes: 47 additions & 8 deletions listing/listing.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// Package listing defines a node's signed advertisement of what compute and
// physical-print work it will accept. A listing is self-issued and node-signed:
// no coordinator speaks for a node's capabilities.
// Package listing defines a node's signed advertisement of what compute,
// storage, and physical-print work it will accept. A listing is self-issued
// and node-signed: no coordinator speaks for a node's capabilities.
package listing

import (
Expand Down Expand Up @@ -37,12 +37,41 @@ type PrinterCapability struct {
Model string
}

// Capacity is a point-in-time resource snapshot the node advertises.
// GPUAPI identifies the compute API through which a GPU is offered. The
// protocol enumerates APIs so a coordinator can avoid offering GPU work a
// node cannot run; it does NOT define scheduling policy (SPEC §5.2).
type GPUAPI string

const (
GPUVulkan GPUAPI = "vulkan" // cross-platform, incl. Android / Android TV
GPUNNAPI GPUAPI = "nnapi" // Android Neural Networks API
GPUCUDA GPUAPI = "cuda" // NVIDIA desktop/server
GPUMetal GPUAPI = "metal" // Apple silicon
)

// GPUCapability describes one GPU the node offers. Advertising a GPU is the
// opt-in: a listing with no GPUs receives no GPU work, and a node withdraws
// a GPU by omitting it from its next listing. Like everything in a listing
// this is advisory — local enforcement still governs (SPEC §5.1).
type GPUCapability struct {
API GPUAPI
Model string
VRAMMB int
}

// Capacity is a point-in-time resource snapshot the node advertises. DiskMB
// is scratch space available to a running job; StorageCommitMB is long-lived
// storage the node commits to hold for the network (shard hosting). The two
// are deliberately distinct so a node can offer either without the other.
// How stored data is encrypted, sharded, and audited is a frontend/agent
// concern outside this protocol: coordination only — the wire never carries
// stored content.
type Capacity struct {
VCPUs int
MemMB int
DiskMB int
PrintQPS int // print jobs the node will queue; 0 if none
VCPUs int
MemMB int
DiskMB int
StorageCommitMB int
PrintQPS int // print jobs the node will queue; 0 if none
}

// WorkloadOptIn is ADVISORY. The coordinator is NOT a security boundary for
Expand All @@ -53,13 +82,15 @@ type Capacity struct {
type WorkloadOptIn struct {
Compute bool
Print bool
Storage bool
}

// CapabilityListing is a node's signed capability advertisement.
type CapabilityListing struct {
NodeID identity.NodeID
Class ComputeClass
Printers []PrinterCapability
GPUs []GPUCapability
Capacity Capacity
OptIn WorkloadOptIn
IssuedAt time.Time
Expand All @@ -80,12 +111,20 @@ func (l CapabilityListing) CanonicalBytes() []byte {
b.String(string(p.Kind))
b.String(p.Model)
}
b.Count(len(l.GPUs))
for _, g := range l.GPUs {
b.String(string(g.API))
b.String(g.Model)
b.Int64(int64(g.VRAMMB))
}
b.Int64(int64(l.Capacity.VCPUs))
b.Int64(int64(l.Capacity.MemMB))
b.Int64(int64(l.Capacity.DiskMB))
b.Int64(int64(l.Capacity.StorageCommitMB))
b.Int64(int64(l.Capacity.PrintQPS))
b.Bool(l.OptIn.Compute)
b.Bool(l.OptIn.Print)
b.Bool(l.OptIn.Storage)
b.Time(l.IssuedAt)
b.Uint64(l.Seq)
return b.Sum()
Expand Down
31 changes: 29 additions & 2 deletions listing/listing_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,9 @@ func TestListingRoundTrip(t *testing.T) {
NodeID: "node-1",
Class: ClassStandard,
Printers: []PrinterCapability{{Kind: Printer3D, Model: "Prusa MK4"}},
Capacity: Capacity{VCPUs: 4, MemMB: 8192, DiskMB: 100000},
OptIn: WorkloadOptIn{Compute: true, Print: true},
GPUs: []GPUCapability{{API: GPUVulkan, Model: "Adreno 750", VRAMMB: 8192}},
Capacity: Capacity{VCPUs: 4, MemMB: 8192, DiskMB: 100000, StorageCommitMB: 500000},
OptIn: WorkloadOptIn{Compute: true, Print: true, Storage: true},
IssuedAt: time.Unix(1_700_000_000, 0),
Seq: 7,
}
Expand All @@ -26,6 +27,32 @@ func TestListingRoundTrip(t *testing.T) {
}
}

func TestGPUTamperDetected(t *testing.T) {
pub, priv, _ := ed25519.GenerateKey(nil)
l := CapabilityListing{
NodeID: "node-1",
Class: ClassMicro,
GPUs: []GPUCapability{{API: GPUNNAPI, Model: "Tensor G4", VRAMMB: 4096}},
IssuedAt: time.Unix(1, 0),
Seq: 1,
}
l.Sign(priv)
l.GPUs[0].VRAMMB = 24576 // inflate the advertised GPU after signing
if l.Verify(pub) {
t.Fatal("tampered GPU capability verified")
}
}

func TestStorageCommitTamperDetected(t *testing.T) {
pub, priv, _ := ed25519.GenerateKey(nil)
l := CapabilityListing{NodeID: "node-1", Class: ClassMicro, Seq: 1, IssuedAt: time.Unix(1, 0)}
l.Sign(priv)
l.Capacity.StorageCommitMB = 1_000_000 // claim storage after signing
if l.Verify(pub) {
t.Fatal("tampered storage commitment verified")
}
}

func TestListingTamperDetected(t *testing.T) {
pub, priv, _ := ed25519.GenerateKey(nil)
l := CapabilityListing{NodeID: "node-1", Class: ClassMicro, Seq: 1, IssuedAt: time.Unix(1, 0)}
Expand Down
8 changes: 8 additions & 0 deletions testdata/reproduce_spec.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,13 +100,21 @@ def canon_capability_listing(f) -> bytes:
for p in printers:
out += enc_string(p["Kind"])
out += enc_string(p["Model"])
gpus = f.get("GPUs", [])
out += uvarint(len(gpus)) # repeated: count then elements inline
for g in gpus:
out += enc_string(g["API"])
out += enc_string(g["Model"])
out += enc_int64(g["VRAMMB"])
cap = f["Capacity"]
out += enc_int64(cap["VCPUs"])
out += enc_int64(cap["MemMB"])
out += enc_int64(cap["DiskMB"])
out += enc_int64(cap["StorageCommitMB"])
out += enc_int64(cap["PrintQPS"])
out += enc_bool(f["OptIn"]["Compute"])
out += enc_bool(f["OptIn"]["Print"])
out += enc_bool(f["OptIn"]["Storage"])
out += enc_time(f["_IssuedAtNanos"])
out += enc_uint64(f["Seq"])
return bytes(out)
Expand Down
20 changes: 17 additions & 3 deletions testdata/vectors.json
Original file line number Diff line number Diff line change
Expand Up @@ -149,14 +149,28 @@
"DiskMB": 2000000,
"MemMB": 65536,
"PrintQPS": 3,
"StorageCommitMB": 500000,
"VCPUs": 16
},
"Class": "server",
"GPUs": [
{
"API": "cuda",
"Model": "RTX 4090",
"VRAMMB": 24576
},
{
"API": "vulkan",
"Model": "Adreno 750",
"VRAMMB": 8192
}
],
"IssuedAt": "unixnano 1700000000000000123",
"NodeID": "node-alpha",
"OptIn": {
"Compute": true,
"Print": false
"Print": false,
"Storage": true
},
"Printers": [
{
Expand All @@ -170,8 +184,8 @@
],
"Seq": 42
},
"canonical_bytes_hex": "14736f686f636c6f75642f6c697374696e672f76300a6e6f64652d616c70686106736572766572020b747261646974696f6e616c0b4850204c617365724a657406746872656564095072757361204d4b340000000000000010000000000001000000000000001e84800000000000000003010017979cfe362a007b000000000000002a",
"signature_hex": "880fb9b2cb3ecc07ad4eaebf931e2ccc6583d06c8be1b8eb10b823feafb24533afbc68d8778f9e434bc4b5e11291a08673cf61efddeedb57ef3c67d1e8409c03"
"canonical_bytes_hex": "14736f686f636c6f75642f6c697374696e672f76300a6e6f64652d616c70686106736572766572020b747261646974696f6e616c0b4850204c617365724a657406746872656564095072757361204d4b3402046375646108525458203430393000000000000060000676756c6b616e0a416472656e6f2037353000000000000020000000000000000010000000000001000000000000001e8480000000000007a120000000000000000301000117979cfe362a007b000000000000002a",
"signature_hex": "996279bd351b85d29164a572fdb9b7e2a43c740c4f1fcfe3588de98da8e751ca66c1ffc8d71500930265cdc16fc2c1aecd7076fcefae2a9d5d5e62274c718207"
},
{
"name": "Heartbeat",
Expand Down
20 changes: 15 additions & 5 deletions vectors/vectors_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -218,7 +218,8 @@ func genMessages(t *testing.T) []messageCase {
t.Helper()
var out []messageCase

// 1. CapabilityListing — node-signed. Repeated Printers with >=2 elements.
// 1. CapabilityListing — node-signed. Repeated Printers and GPUs with >=2
// elements each so the count-prefixed repeat encoding is exercised.
{
seed := seedFill(0x11)
priv := ed25519.NewKeyFromSeed(seed)
Expand All @@ -230,8 +231,12 @@ func genMessages(t *testing.T) []messageCase {
{Kind: listing.PrinterTraditional, Model: "HP LaserJet"},
{Kind: listing.Printer3D, Model: "Prusa MK4"},
},
Capacity: listing.Capacity{VCPUs: 16, MemMB: 65536, DiskMB: 2_000_000, PrintQPS: 3},
OptIn: listing.WorkloadOptIn{Compute: true, Print: false},
GPUs: []listing.GPUCapability{
{API: listing.GPUCUDA, Model: "RTX 4090", VRAMMB: 24576},
{API: listing.GPUVulkan, Model: "Adreno 750", VRAMMB: 8192},
},
Capacity: listing.Capacity{VCPUs: 16, MemMB: 65536, DiskMB: 2_000_000, StorageCommitMB: 500_000, PrintQPS: 3},
OptIn: listing.WorkloadOptIn{Compute: true, Print: false, Storage: true},
IssuedAt: tAt(fixedNanoA),
Seq: 42,
}
Expand All @@ -249,11 +254,16 @@ func genMessages(t *testing.T) []messageCase {
{"Kind": string(l.Printers[0].Kind), "Model": l.Printers[0].Model},
{"Kind": string(l.Printers[1].Kind), "Model": l.Printers[1].Model},
},
"GPUs": []map[string]any{
{"API": string(l.GPUs[0].API), "Model": l.GPUs[0].Model, "VRAMMB": l.GPUs[0].VRAMMB},
{"API": string(l.GPUs[1].API), "Model": l.GPUs[1].Model, "VRAMMB": l.GPUs[1].VRAMMB},
},
"Capacity": map[string]int{
"VCPUs": l.Capacity.VCPUs, "MemMB": l.Capacity.MemMB,
"DiskMB": l.Capacity.DiskMB, "PrintQPS": l.Capacity.PrintQPS,
"DiskMB": l.Capacity.DiskMB, "StorageCommitMB": l.Capacity.StorageCommitMB,
"PrintQPS": l.Capacity.PrintQPS,
},
"OptIn": map[string]bool{"Compute": l.OptIn.Compute, "Print": l.OptIn.Print},
"OptIn": map[string]bool{"Compute": l.OptIn.Compute, "Print": l.OptIn.Print, "Storage": l.OptIn.Storage},
"IssuedAt": "unixnano " + i64toa(fixedNanoA),
"Seq": l.Seq,
}),
Expand Down
Loading