Repository navigation
hub accounts plan
Audience: the engineer or agent implementing this, in a fresh session, with no memory of the conversation that produced it. Everything needed is either here or named by file and line.
Goal: let a person sign up at spikeforge.net, sign in from the desktop app, get a bounded amount of hosted storage, publish an SNN they trained to the SpikeForge hub, and download someone else's — without weakening a single honesty guarantee the curated catalog already makes, and without putting a single user byte on the production host's full root disk.
Read first:
spikeforge_hub/CURATION.md
is the policy governing the curated catalog. This plan does not amend it and
does not add a single uploaded artifact to it. Uploads live in a separate
namespace with a separate policy (§6). Where this plan and CURATION.md
appear to disagree about the curated catalog, that document wins and this plan
is wrong.
Implementation status (2026-09-16). P1-P3 are built in
spikeforge-hub-api:
identity, per-account quotas, the two-phase upload, the community namespace and
its index. The identity primitives come from
capsize-auth. Four decisions
in this document were settled differently during implementation and the
sections below record what was actually chosen; §14 lists what is still open.
P4 (verification) and P5 (desktop sign-in endpoints) are not built.
As-shipped record (2026-10-13). This document first landed on the
automation/pipeline branch and was restored to main to close issue #85;
every hub_accounts_plan pointer in this repository and in
spikeforge-hub-api resolves here. P4 has since shipped on both sides — the
sandbox runner in this repository (scripts/hub_verify/,
.github/workflows/hub-verify.yml) and the dispatch plus signed-callback
endpoints in spikeforge-hub-api — with the exact reconciled contract pinned
in §7.3. P5's hub-side endpoints (loopback PKCE, device code, PATs, session
revocation) and the PAT-based CLI (login, whoami, push, pull,
quota) have shipped; the desktop-app keyring and sign-in UI live in
spikeforge-dashboard and are outside this record. Where a dated
"as-shipped"/"as built" note below disagrees with the original design text,
the note describes what the merged code does and the design text describes
what was first recommended.
Four measured facts about the current deployment shape the whole architecture. Each was checked on the host rather than assumed, and two of them rule out an implementation that would otherwise look obvious.
Measured on 188.245.220.130 (the shared box described in the operator's
notes) on 2026-09-16:
Filesystem Size Used Avail Use% Mounted on
/dev/sda1 38G 35G 848M 98% /
/dev/sdb 49G 14G 34G 29% /mnt/HC_Volume_106876357
Mem: 3 GB total, ~1 GB available
Docker: 12.41 GB images, 7.21 GB build cache, data root /var/lib/docker
Two storage pools with opposite situations, and the distinction drives most of this plan:
-
Root disk
/dev/sda1: 848 MB free of 38 GB (98% full). Shared with airunner/UwU Chat, audiovisualizer, fastsearch, floodit, the Capsize web platform, and WXRQ. Docker's data root is still here (docker info→/var/lib/docker), so every image build draws on these 848 MB. The deployment workflow already knows this is fragile — it has a step named "Report disk capacity without deleting other services' images" (.github/workflows/deploy-hetzner.yml). -
Hetzner Cloud Volume
/dev/sdb: 33 GB free of 49 GB, mounted at/mnt/HC_Volume_106876357(/etc/fstab,nofail,discard). It holds one thing today:wxrq/videoat 14 GB. This is real, network-attached, snapshottable capacity, and it is resizable upward without moving the data.
Consequences:
-
Persistent data — artifact bytes and the Postgres data directory — live
on the volume, never on the root disk. WXRQ already establishes the
convention: an absolute bind mount of
/mnt/HC_Volume_106876357/<project>/…into the container (/opt/wxrq/compose.yaml:95). Hub paths follow it as/mnt/HC_Volume_106876357/spikeforge/{artifacts,postgres}. - 33 GB of volume changes the storage recommendation. Self-hosting the artifact bytes is viable at this project's scale — at the observed 46 KB–414 KB per artifact (§1), 10 GB is tens of thousands of models. §5.2 recommends this over a third-party bucket for v1, behind an adapter so the decision stays reversible.
- The hub must cap its own total footprint (proposed 10 GB, §5.2), independently of per-user quotas. The volume's other tenant is a growing video library; a hub that fills the volume takes WXRQ down with it. This is the one shared-resource risk the design has to own explicitly.
-
Root-disk pressure is still a P0 blocker, because builds and container
layers land there regardless of where data goes. ~7.2 GB of reclaimable
Docker build cache is the immediate breathing room (
docker builder prune); relocating Docker's data root onto the volume is the durable fix. Adding a service to a root disk at 98% without doing one of the two will fail at build time. - No verification, training, or model loading for community content runs on this host — that is about RAM and untrusted code, not disk, and the volume does not change it. See §7.
landing/CNAME contains spikeforge.net, and the apex resolves to GitHub
Pages (185.199.108–111.153). The static landing page plus the rendered hub
page (scripts/build_hub_page.py → spikeforge.net/hub/) are published from
landing/. There is no server-side execution at the apex, so signup, login,
sessions, and cookies cannot be served from it as it stands. §3.4 gives the
two ways to satisfy "signup and login at spikeforge.net" and recommends one.
dash.spikeforge.net runs the FastAPI app in server/ behind one shared
secret:
-
server/auth.py— one process-wideSPIKEFORGE_DASHBOARD_TOKEN, compared withhmac.compare_digest. It is an operator gate, not an identity. Everyone who has it is the same principal. -
server/app.py:31—_sessions: Dict[int, Session], oneSessionper socket, but every session shares oneMODEL_DIR, oneHUB_CACHE_DIR, one filesystem, one process, and one 3 GB RAM budget (spikeforge/config.py:24-38). - Multi-user sessions and authentication are recorded as explicitly
deferred in
plans/operations_plan.md:205andplans/professional_roadmap.md:420,493.
So "log in" must not be read as "make the hosted dashboard multi-tenant." The hosted dashboard runs training on a 3 GB shared box; turning it into a per-user compute service is a different, much larger project with a real hosting bill. This plan gives the hosted dashboard hub identity and browse/download, and puts publishing where the user's models actually live — the desktop app and the CLI. §8.3 states the consequence honestly and §13 keeps per-user server-side storage as a later, optional phase.
spikeforge-dashboard's Electron main process spawns a packaged PyInstaller
backend on 127.0.0.1 and points it at a per-user data directory
(desktop/main.cjs, spawnBackend()):
SPIKEFORGE_DATA_DIR: path.join(app.getPath("userData"), "data")Training, encoding, NIR export, and the model store are all local, on the user's own hardware, with no account needed. This is the good news in the whole design: the desktop app needs an account for exactly one thing — talking to the hub. No session affinity, no server-side compute, no remote-execution security model. A hub token is all it needs.
| Piece | Where | What it gives us |
|---|---|---|
| Portable artifact format |
spikeforge/serving/bundle.py, bundle_manifest.py
|
.spkf — a zip holding manifest.json, weights.pt, encode_config.json, preprocessing.json, optional graph.nir.json, and SHA256SUMS. Build, verify, and load already exist. This is the upload unit; no new format is needed.
|
| Bundle HTTP route | server/bundle_routes.py |
GET /api/bundle/{name} builds a saved checkpoint into a .spkf and returns it. The upload flow reads bytes from here. |
| Catalog schema + validator |
spikeforge_hub/entry.py:112-141, spikeforge_hub/catalog.py
|
HubEntry with license, dataset_license, dataset_attribution, size_bytes, topology, test_accuracy, test_samples. load_catalog(path) validates and reports bad entries rather than dropping them. A remote community index reuses this verbatim. |
| Download + verify + cache |
spikeforge_hub/downloads.py, verify.py, cache.py
|
Child-process download worker with progress and cancel, sha256+size verification, cache layout, and cache.dir_bytes() / entry_bytes() size accounting. |
| Import funnel |
spikeforge_hub/import_model.py, inspect.py, compat.py, weight_map.py
|
inspect → compat verdict (exact/mappable/incompatible) → promote with drift check. This is what makes a community download useful rather than just a file. |
| Hub UI |
spikeforge-dashboard/src/components/HubPanel.tsx, HubEntryCard.tsx, HubCompatBadge.tsx, HubDownloadProgress.tsx, src/hubTypes.ts
|
Browse, download, compat badges, progress. Publishing is the new direction; browsing already has a home. |
| Hub WS protocol |
server/hub_handlers.py, hub_messages.py, hub_payloads.py
|
hub_list, hub_search, hub_download_state, hub_inspect, hub_import. Versioned via PROTOCOL_MAJOR (server/app.py:47-66). |
| Governance registry | spikeforge_hub/registry.py |
dev → staging → prod promotion, recorded approver, HMAC-signed entries, lineage. Local today; its vocabulary is what the hosted service's moderation states should mirror. |
| CI + deploy |
.github/workflows/deploy-hetzner.yml, docker-compose.hetzner.yml
|
rsync + docker compose up --build behind the shared uwu_shared Caddy network, .env from the SPIKEFORGE_ENV_FILE secret, health check. A second service follows the same pattern. |
And what does not exist: any database (no sqlite, sqlalchemy,
psycopg, or asyncpg anywhere in the tree), any notion of a user, any
upload path, any object storage, and any email sending.
Artifact sizes, measured — every entry in today's catalog:
| Artifact | Bytes |
|---|---|
mnist-conv-net.pt |
46,293 |
mnist-fc-small.pt |
109,117 |
mnist-recurrent-net.pt |
229,645 |
mnist-fc-legacy.pt / fashion- / kmnist-
|
~414,000 each |
Between 46 KB and 414 KB. This matters for §5: storage is not the scarce resource; egress and moderation attention are. A quota's job here is abuse control, not cost control.
Three planes, deliberately separated because they have different trust levels, different failure modes, and different hardware needs.
flowchart TB
subgraph clients["Clients"]
WEB["Browser: hub web app<br/>(account, browse, publish)"]
DESK["SpikeForge Desktop<br/>Electron + local PyInstaller backend"]
CLI["spikeforge-hub CLI<br/>(PAT auth)"]
end
subgraph control["Control plane — hub API (metadata only)"]
API["hub-api container<br/>FastAPI, behind shared Caddy<br/>hub.spikeforge.net"]
DB[("Postgres<br/>users, models, versions,<br/>quota ledger, tokens")]
end
subgraph data["Data plane — attached volume (never the root disk)"]
OBJ[("/mnt/HC_Volume_106876357/spikeforge<br/>artifacts/ + postgres/")]
CADDY["Caddy file_server<br/>content-addressed public paths"]
end
subgraph verify["Verification plane — ephemeral, untrusted-content sandbox"]
GHA["GitHub-hosted runner<br/>bundle.verify → inspect → compat<br/>→ NIR export + drift → energy"]
end
WEB -->|"cookie session"| API
DESK -->|"bearer token"| API
CLI -->|"PAT"| API
API --- DB
DB -.->|"data dir on volume"| OBJ
DESK -->|"streamed upload, capped"| API
WEB -->|"streamed upload, capped"| API
API -->|"write once, content-addressed"| OBJ
API -->|"302 to the artifact path"| CADDY
CADDY --- OBJ
DESK -->|"download bytes"| CADDY
API -->|"repository_dispatch"| GHA
GHA -->|"reads the one artifact"| CADDY
GHA -->|"signed report callback"| API
Why the hub API is a new service and not a few routes bolted onto
server/:
-
server/is single-tenant by design and documented as such (§0.3). Adding identity to it contradicts its own invariants. - The hub must stay up while the dashboard restarts, rebuilds, or OOMs. Publishing should not fail because someone's training job ate the box.
- Different dependency surface: the hub API needs no
torch. The dashboard image is a multi-GB PyTorch install; the hub API should be tens of MB. On a disk at 98%, that difference is the difference between shipping and not. - Different trust boundary: the hub API handles credentials and other
people's content. Keeping it out of the process that runs
torch.loadon user-selected files is basic hygiene.
Repository: a new capsize-games/spikeforge-hub-api repo, following
ARCH-0001's naming (plans/arch-0001-target-topology.md). It is a service,
not a distribution — nothing pip installs it. The client half ships inside
the existing spikeforge-hub distribution as spikeforge_hub/client.py (§8).
Note: repository creation and branch protection must be done by the operator;
this environment cannot administer the org's repos.
Decided: email with a password and a pluggable set of OAuth2 providers, so people have more than one way in. What follows was the original recommendation and the reasoning that overturned it; it is kept because the tradeoff is still the one to re-read before adding a provider.
The recommendation below was built on avoiding a password stack. That argument
collapsed when an existing one turned up: uwuchat-auth-core, extracted from
UwUChat, already had argon2id hashing with a constant-time dummy verify,
a zxcvbn strength policy, and reset tokens, all tested. Generalised and
published as
capsize-auth, it supplies
the password layer, a generic authorization-code flow driven from provider
descriptions, PKCE, and the token primitives.
So the cost of email and password is now only what it always really was:
a transactional email provider, for verification and reset delivery, which
capsize-auth deliberately does not do. That remains unprovisioned and is a
launch blocker for the password route — an account cannot publish until its
address is proven, and nothing can prove one until mail sends.
Provider links live in their own table rather than as a column per provider, which is what makes "any provider we are allowed to implement" configuration rather than a migration. Presets ship for GitHub, Google, GitLab, Discord, Twitch, Microsoft and ORCID; a deployment offers only those it registers credentials for, so a sign-in page renders exactly the buttons that work.
The original recommendation: GitHub OAuth as the only identity provider, with email/password deferred until there is evidence it is needed.
What this avoids, permanently or for now: password hashing and rotation
policy, credential-stuffing defence, password reset flows, an SMTP provider
and its deliverability problem, email-verification bounce handling, and the
bulk of bot signups. What it gives: a verified email, an avatar, a stable
account id, a username that seeds the hub namespace (@handle/model), and —
critically — a device flow we can lean on for the desktop app.
The audience fits: the people who publish SNN checkpoints have GitHub
accounts, and the project already lives there (issues, wiki, releases,
CONTRIBUTING.md).
The cost, stated plainly: someone without a GitHub account cannot sign up. If that is unacceptable, the fallback is email + password with argon2id, a transactional email provider, and the full reset/verification surface — a substantial addition with a recurring operational burden (see §14, Q1). A middle option is adding a second OAuth provider (Google, ORCID — ORCID is worth considering for an academic audience) rather than building passwords.
Whichever is chosen, the hub API is the authorization server for its own tokens. Identity is delegated; authorization is not. Clients never see a GitHub token.
-
GET /login→ redirect to the provider with PKCE andstate. -
GET /oauth/callback→ exchange code, upsertusersrow, create a session. - Session cookie:
__Host-sfh_session,HttpOnly,Secure,SameSite=Lax, path/, opaque 256-bit random id, server-side record with absolute expiry (30 days) and idle expiry (7 days). Not a JWT — sessions must be revocable on sign-out and on account deletion, and a stateful table is simpler than a revocation list. - CSRF:
SameSite=Laxplus a double-submit token on every state-changing form, because the hub web app and the API share an origin (§3.4).
Three token kinds, with distinct lifetimes:
| Kind | Format | Lifetime | Scope | Storage |
|---|---|---|---|---|
| Access token | opaque, sfh_at_…
|
1 hour | request-scoped | memory only |
| Refresh token | opaque, sfh_rt_…
|
90 days, rotating | mint access tokens | OS-encrypted at rest |
| Personal access token (PAT) | opaque, sfh_pat_…
|
user-chosen, revocable |
models:read, models:write
|
user's own secret store / CI secret |
Only a hash is stored server-side (SHA-256 for the high-entropy opaque tokens; these are not passwords, so a slow KDF buys nothing). The plaintext is shown once at creation. Refresh rotation detects theft: presenting a rotated refresh token invalidates the whole chain.
Desktop login flow — recommended: loopback PKCE (RFC 8252).
- Electron main reserves an ephemeral port on 127.0.0.1 (the codebase
already has
reservePort()indesktop/main_helpers.cjs) and starts a one-shot HTTP listener. - It opens the system browser — never an in-app
BrowserWindow, so the user authenticates on a URL bar they can inspect — athttps://hub.spikeforge.net/oauth/authorize?...&redirect_uri=http://127.0.0.1:<port>/callbackwith a PKCEcode_challengeand a randomstate. - The user signs in (or signs up — the same page) and approves.
- The browser is redirected to the loopback listener with
code; main exchanges it for an access + refresh token over TLS, then shuts the listener down. - Tokens go to disk via Electron's built-in
safeStorage.encryptString(no native dependency), inapp.getPath("userData"), mode0600.
Fallbacks, both needed:
-
Device authorization flow (RFC 8628) —
POST /oauth/device_codereturns a short user code; the app shows "go to hub.spikeforge.net/activate and enterWXYZ-1234" and polls. Required for the case where loopback fails: a remote/SSH session, a hardened Linux desktop with no browser handler, or a locked-down corporate machine. - Paste a PAT — always available, and the only option for CI and headless scripts.
Linux keyring caveat, stated because it will bite: safeStorage on Linux
needs an available secret service (gnome-libsecret/kwallet). The AppImage
target (electron-builder.yml) will run on machines without one. Behaviour
when safeStorage.isEncryptionAvailable() is false: do not silently write
a plaintext refresh token. Keep the session in memory for the run, tell the
user their sign-in will not persist, and offer a PAT as the persistent
alternative. A plaintext long-lived credential written without consent is the
kind of thing this project should not ship.
Sign-up from the desktop app needs no extra work under this design: step 2's page carries both, so the user never leaves the flow. The "signup goes to the website" fallback is what happens naturally if the browser hand-off fails.
| Host | Serves | Where |
|---|---|---|
spikeforge.net |
landing (17 locales) + static /hub/ catalog page |
GitHub Pages, unchanged |
hub.spikeforge.net |
hub web app and its API, same origin | new container, shared Caddy on the VPS |
cdn.spikeforge.net |
published artifact bytes | shared Caddy, served from the volume (§5.2) |
dash.spikeforge.net |
hosted dashboard | unchanged |
docs.spikeforge.net |
wiki redirect | unchanged |
Recommended: hub.spikeforge.net for the account UI and API on one
origin. The landing page gets a "Sign in" link. Single-origin keeps cookies
simple (__Host- prefix works, no cross-site CORS credentials, no CORS
preflight on every state-changing call), and it leaves the free-CDN static
landing page untouched on a host whose disk is full.
The alternative, if "at spikeforge.net" must be literal: put Cloudflare in
front of the apex and proxy /account/* and /api/* to the VPS while Pages
keeps the rest. It reads better in the URL bar and costs a second routing
layer, a second place a deploy can break, and CDN cache rules that must not
cache authenticated responses. Recommend hub. first; the apex path stays
available later without changing the API.
Postgres, in a container on the shared network, once disk is reclaimed (§0.1). SQLite+WAL would genuinely work at this scale and would cost no RAM — but quota reservation is a concurrent read-modify-write, the CI callback writes while the API serves, and migrating a live user table later is unpleasant. Postgres idles at tens of MB; take it now.
users
id uuid pk
handle citext unique -- namespace: @handle/model
email citext unique -- from the provider, for notices only
display_name text
provider text -- 'github'
provider_id text -- unique with provider
storage_limit bigint -- per-user override of the default
state text -- active | suspended | deleted
created_at, updated_at timestamptz
sessions id, user_id, created_at, last_seen_at, expires_at, ua, ip_hash
tokens id, user_id, kind, hash, name, scopes[], expires_at,
revoked_at, last_used_at, chain_id -- refresh rotation
models
id uuid pk
owner_id uuid -> users
slug text -- unique per owner; reserved list applies
visibility text -- public | private
license text -- SPDX id, entry.py's vocabulary
dataset text
dataset_license text
dataset_attribution text
summary, description text
state text -- active | hidden | takedown
created_at, updated_at
model_versions
id uuid pk
model_id uuid -> models
version text -- semver-ish, immutable once published
sha256 char(64) -- of the .spkf
size_bytes bigint
object_key text -- artifacts/{model_id}/{version}.spkf
state text -- reserved | uploaded | verifying
-- | published | rejected | deleted
manifest jsonb -- extracted from the bundle manifest
verification jsonb -- report from the sandbox (§7)
download_count bigint
published_at, created_at, expires_at
quota_ledger -- append-only; the balance is derived, never edited in place
id, user_id, model_version_id, delta_bytes, reason, created_at
-- reason: reserve | commit | release | delete | admin_adjust
audit_log id, actor_id, action, subject, detail jsonb, created_at
Two deliberate choices:
-
quota_ledgeris append-only. A single mutablebytes_usedcolumn drifts the first time a commit races a delete, and then a user is stuck unable to upload with no way to see why. A ledger can be recomputed from the stored artifacts and reconciled; a counter cannot be audited. -
As built, the ledger has no
reservereason. It records only bytes actually stored, and pending bytes are derived from thereservedmodel-version rows, which already carry a size and an expiry. That removes the compensatingreleaseentry at commit time, and it means an abandoned upload stops counting against an allowance the moment it expires rather than when a janitor next runs — so a crashed request cannot leave a permanent charge behind. The janitor then only reclaims disk, and correctness does not depend on it having run. -
model_versions.manifestis the bundle's own manifest, extracted server-side, never client-asserted. The client sends a checksum and a size; everything descriptive comes from bytes we verified (§7). A client that claims 98% accuracy does not get to have that rendered as a fact.
sequenceDiagram
participant C as Client
participant A as hub API
participant O as Volume (artifacts/)
participant V as Verify sandbox
C->>A: POST /v1/uploads {model, version, sha256, size, license, ...}
A->>A: quota check + reserve size_bytes (ledger)
A->>A: dedup: same sha256 already stored?
A->>A: volume headroom check (hub cap, free space)
A-->>C: {upload_id, put_url, expires_at} or 409 duplicate / 413
C->>A: PUT bytes (Content-Length pinned to the reservation)
A->>O: stream to a temp file, hashing as it goes; never buffer in RAM
A->>A: size or sha256 mismatch -> discard, release, 422
A->>O: fsync + atomic rename to the content-addressed path
C->>A: POST /v1/uploads/{id}/commit
A->>A: state = verifying; ledger reserve -> commit
A->>V: repository_dispatch
V->>A: GET artifact via a signed, single-object, read-only URL
V->>V: bundle.verify, inspect, compat, NIR export + drift, energy
V->>A: POST /internal/v1/verifications/{id} (signed)
A->>A: state = published | rejected; index invalidated
Details that matter:
-
The reservation happens before a single byte is accepted, and the
PUTis refused unlessContent-Lengthequals the reserved size. The API enforces the cap during the stream as well and aborts past it, becauseContent-Lengthis a client claim. Without both halves, "reserve 2 MB, upload 2 GB" is trivially exploitable. -
Bytes stream to disk, never into memory. On a box with ~1 GB available RAM (§0.1) a buffered 256 MiB upload is an OOM that takes the other services with it. Write to a temp file on the same filesystem as the destination, hash while streaming, then
fsyncand atomically rename. A partially written artifact must never be reachable under its final name. -
The hash is computed server-side and compared to the intent. The client's claimed
sha256is an assertion; the stored artifact's hash is the fact. A mismatch discards and releases. -
Reservations expire (15 minutes). A janitor releases expired reservations and deletes orphan temp files. Abandoned uploads must not permanently consume a user's quota — or the volume.
-
Content-addressed dedup. Identical
sha256across users stores one file; the ledger still charges each owner (simpler, and it keeps deletion from unsharing bytes under someone else). Reference-counted deletion. -
Versions are immutable. Republishing means a new version. This is what makes a pinned checksum meaningful — the same property
CURATION.mdalready relies on for reference entries. -
Downloads never pass through Python. The API authorizes, then hands off to Caddy to serve the file. The app process must not be in the path of a file transfer it has no reason to touch.
As built this is a 302 to a content-addressed path on
cdn.spikeforge.netthat Caddy serves straight off the volume, not anX-Accel-Redirect-style internal redirect: Caddy has no native equivalent. That is sufficient while every published artifact is public, and it is the specific thing that has to change before private models are possible (§14 Q6) — a signed path, or a Caddy module that can verify one.As shipped (2026-10-13), the verification sandbox (§7.3) fetches the same public content-addressed URL a published download redirects to — not a per-job signed URL. The object key is unguessable, so an unpublished version's bytes are no more exposed than a published one's.
Recommended for v1: the Hetzner Cloud Volume already on the box
(/mnt/HC_Volume_106876357/spikeforge/artifacts, §0.1), served by the shared
Caddy, with an S3-compatible adapter written but unused.
Why the volume wins at this project's scale:
- It exists and it is paid for. 33 GB free today, on a network-attached, snapshottable volume that can be grown without moving the data. At the observed 46 KB–414 KB per artifact (§1), a 10 GB hub cap is tens of thousands of models.
- No new vendor, no new credentials, no new failure mode. No bucket policy, no key rotation, no CDN cache-invalidation story, no second invoice. For a one-person project that operational surface is a real cost, and it is the cost that gets skipped and then bites.
- Server traffic is already bundled. Egress from the box is part of the server's included traffic allowance rather than a per-GB line item, which removes the one axis that made a third-party bucket attractive.
-
Backups are a volume snapshot plus
pg_dump(§11), not a second replication design.
What the volume does not give, stated plainly: no CDN edge (downloads are served from one machine in one region), no independent durability from the rest of the box's operations, and no ability to absorb a traffic spike without affecting the six other services behind the same Caddy. Those are the conditions that should trigger the move to object storage, and they are plausible if the hub succeeds.
So: write the storage layer as an adapter from day one. Two
implementations, one interface — put(key, stream, expected_sha256, size),
open(key), delete(key), stat(key), signed_url(key, ttl) — with
SPIKEFORGE_HUB_STORAGE=volume|s3. The two-phase reserve/commit protocol in
§5.1 is written so it works unchanged against a presigned-PUT backend: the
only difference is whether put_url points at our API or at a bucket. Moving
later is then a config change and a byte copy, not a redesign.
Layout on the volume:
/mnt/HC_Volume_106876357/spikeforge/
artifacts/{sha256[0:2]}/{sha256}.spkf # content-addressed, immutable
reports/{model_version_id}/verification.json
tmp/{upload_id} # same filesystem, atomic rename
postgres/ # the database data directory
Content-addressed paths (rather than {model}/{version}/) make dedup free and
make the file name self-verifying; the human-readable mapping lives in
model_versions.object_key. Sharding on the first two hex characters keeps any
one directory small.
Two hard limits the hub enforces on itself:
- A total hub footprint cap (proposed 10 GB), checked at upload intent and independent of per-user quotas. The volume's other tenant is WXRQ's growing video library (14 GB today); a hub that fills this volume takes WXRQ down with it. That is unacceptable and must be structurally impossible, not merely monitored.
- A free-space floor (refuse uploads below 5 GB free on the volume, regardless of the cap), so a wrong cap or a wxrq growth spurt still cannot produce a full filesystem.
Neither the artifacts nor the Postgres data directory may live on / — see
§0.1. Both belong on the volume via the absolute bind mount WXRQ already
established (/opt/wxrq/compose.yaml:95).
Today's artifacts are 46 KB–414 KB (§1). A conv/event model with more
parameters, or a .spkf carrying an uncompressed state dict, will be larger,
but this is a kilobyte-to-megabyte domain, not a gigabyte one.
Per-account quotas sit inside the hub-wide footprint cap of §5.2, and the two do different jobs: the per-account limit is fairness and abuse control, the hub-wide cap is what protects the volume's other tenant. Overcommitting the per-account limits is fine and expected — the hub-wide cap is the one that must never be exceeded.
Proposed defaults, per account:
| Limit | Default | Why |
|---|---|---|
| Total stored bytes | 1 GiB | Thousands of artifacts at observed sizes. Generous without being a free file host, and 10 accounts at the limit still fit inside the hub's own 10 GB cap (§5.2). |
| Single artifact | 256 MiB | Room for a much larger model than anything shipped; well under anything worth abusing. |
| Models | 50 | Namespace hygiene. |
| Versions per model | 20 | Keeps history without unbounded growth. |
| Uploads per day | 50 | Rate control, independent of bytes. |
| Total egress per account per day | 10 GiB | Not a billing control — server traffic is bundled (§5.2) — but a fairness control: downloads are served from the same box as six other services. |
storage_limit is a per-user column so a research group can be raised without
a deploy. Quota is enforced at intent (before bytes move) and reconciled
at commit. A 413 must say which limit was hit and what the current usage
is — a quota error that does not name the number is a support ticket.
As shipped (2026-10-13): the per-day egress ceiling in the table above
exists in this document only — the service keeps no egress counter
(downloads redirect to the CDN, so the API never sees the bytes) and
enforces nothing here. Every other number above is a default in
hub_api/config.py.
Reuse spikeforge_hub/cache.py's dir_bytes()/entry_bytes() vocabulary on
the client side so the desktop app can show local cache usage next to hosted
usage; they are different pools and the UI must not blur them.
The curated catalog's entire value is that every claim in it has been
checked (CURATION.md: "An invented repository id or an unnamed license is a
fabrication and must never be committed here"). Community uploads are, by
definition, claims nobody on this project has checked. Mixing them destroys
the guarantee that makes the hub worth having.
So:
| Curated | Community | |
|---|---|---|
| Ids |
reference/mnist-fc-small, nir/conv_net
|
@handle/model-slug |
source |
reference, bundled
|
community (new) |
| Ships in the wheel | yes, spikeforge_hub/models.json
|
never |
| Distribution | inside spikeforge-hub
|
hosted index, fetched and cached |
| Policy |
CURATION.md (unchanged) |
new COMMUNITY.md
|
| Accuracy numbers | reproduced by scripts/train_reference_models.py
|
uploader's claim, labelled as such |
| Trust label | curated |
machine-checked or unchecked
|
-
An upload is never promoted into
models.json. If the project wants a community model as a reference entry, that goes throughCURATION.md's process — retrained, re-measured, checksum-pinned by us. -
The
@prefix is what keeps the namespaces unambiguous in a single list, a URL, and a CLI argument. Reservereference,nir,snntorch,norse,lava,spikingjelly,spikeforge,admin,api,hub, and the existing curated prefixes as handles. -
machine-checkedis notverified. It means: the bundle's checksums matched, it loaded withweights_only=True, it produced a NIR graph, the drift check passed, and an energy report was generated (§7). It does not mean the accuracy claim was reproduced, the license was read by a human, or the model is any good. The badge copy must say so — the existing hub page already carries a "metadata-only" honesty notice and this is the same obligation.HubCompatBadge.tsxis the component that grows a trust variant;COPY_POLICY.mdgoverns the words on it. -
License is mandatory at upload and validated against the same vocabulary
as
HubEntry(entry.py:229— a concrete SPDX-style id or the explicitunverified-candidatemarker; free text rejected).dataset_licenseanddataset_attributionfollow the existing split: the weights' license is not the data's license (documentation/model-hub.md).
Offline clients reuse the existing loader. The hosted index is served as
GET /v1/index.json in the exact schema spikeforge_hub/catalog.py
already validates (version: 1, entries: [...]), with source: "community".
The client fetches it with ETag caching into HUB_CACHE_DIR, then calls
load_catalog() on the cached file — so a malformed hosted entry is
reported through issues() exactly like a malformed bundled one, and the
whole catalog still renders offline. No second validation path, no second
schema.
As shipped (2026-10-13): the label rule is
hub_api/catalog/trust.py::label_for — machine-checked if and only if the
version is published and its stored report has "passed": true, else
unchecked. Index entries also carry the service-measured sha256 digest,
so a pull checksum-verifies the download the way direct-URL downloads
already do.
Anyone can host .pt files. What SpikeForge can do that Hugging Face cannot
is tell you, before you download, whether an artifact exports to NIR,
survives a drift check against the reference interpreter, maps onto a known
topology, and what it costs in SOP/MAC/AC. Every one of those tools already
exists in this repo (spikeforge_hub/inspect.py, compat.py,
spikeforge_targets/energy/, the reference interpreter behind invariant 4 in
rules.md). Running them on upload turns a file host into the honest hub the
roadmap describes.
Pipeline on each new version:
-
bundle.verify—SHA256SUMS, manifest shape, required entries (bundle_manifest.REQUIRED_ENTRIES). -
torch.load(..., weights_only=True)— already howbundle.py:68-76loads weights. Non-negotiable for community content. -
inspect→ artifact kind and structure. -
compat→exact/mappable/incompatibleagainst shipped presets. - NIR export + drift check against the reference interpreter.
- Deployment report + energy estimate.
- Post a signed report; set
publishedorrejectedwith named reasons.
The report is attached to the listing and to verification.json in storage.
A rejection must name what failed, in the uploader's words — "weights are not
loadable" is actionable, "verification failed" is not.
Run it on GitHub-hosted ephemeral runners, triggered by
repository_dispatch.
- Not on the VPS: no disk, no RAM, and it would put
torch.loadon user-supplied bytes inside the box that holds the credentials for six other projects. -
Not on the existing self-hosted
spikeforge-cirunner. That runner is used bydeploy-hetzner.ymland holds the Hetzner deploy key. Executing untrusted content on a persistent self-hosted runner is how a self-hosted runner becomes an attacker's shell — it is the single most predictable way this design could be compromised. GitHub-hosted runners are fresh VMs, thrown away after each job, and have the CPU and disk this needs.
The runner gets a read-only, key-scoped, short-lived credential for just that one object, and an HMAC-signed callback token valid for one report. It never gets a credential that reaches any other artifact, or any write path.
Additional guards inside the sandbox: a decompressed-size cap before reading
zip members (bundle.py:193-200 reads members into memory — a zip bomb is
otherwise a runner OOM rather than a rejection), a wall-clock timeout, and no
network egress beyond the two endpoints it needs.
Shipped 2026-10-13 on both sides: the trigger and callback in
spikeforge-hub-api (hub_api/uploads/dispatch.py,
hub_api/routes/verifications.py, hub_api/schemas/verifications.py,
hub_api/uploads/verify.py) and the runner here
(scripts/hub_verify/, .github/workflows/hub-verify.yml). The two halves
were aligned after landing (spikeforge-hub-api 0632a5e); what follows is
the reconciled contract, and on any disagreement the code cited here wins.
Dispatch (hub API → GitHub). When a commit moves a version into verifying,
the API fires a repository_dispatch at the repo named by
verification_dispatch_repo (empty by default, which disables the trigger).
Event and payload:
-
event_type:hub-artifact-verify(verification_dispatch_event). -
client_payload:version_id(uuid string),sha256(hex),object_key,artifact_url(the public content-addressed download URL),callback_url(informational — the runner does not trust it; see below).
Runner. hub-verify.yml listens for hub-artifact-verify, runs on
ubuntu-latest (never self-hosted, 20-minute ceiling), and calls
python -m hub_verify.cli with version_id and artifact_url from the
payload. The callback base URL is the repository variable
SPIKEFORGE_HUB_API_BASE_URL, never the payload's callback_url — the
destination is configured, not dispatched. Checks run in
bundle → inspect → compat → drift → energy order and stop at the first
failure, whose message becomes the rejection reason.
Callback (runner → hub API).
POST /internal/v1/verifications/{version_id}:
- Header
X-Spikeforge-Hub-Signature: sha256=<hex>— HMAC-SHA256 over the exact request-body bytes, keyed with the sharedverification_secret(hub_verify/signing.py::sign_body, byte-identical tohub_api/auth/signatures.py::sign). - Body:
{"passed": bool, "reasons": [str, ...], ...}—reasonsnames each failing step in the uploader's words and is required non-empty whenpassedis false ("verification failed"alone is refused); every other field is carried through verbatim (extra="allow") and needs no hub-side change. The runner sendsversion_id,passed,reasons,checks(bundle/inspect/compat/drift/energy detail),summary,generated_at, andrunner(workflow + run URL). - Identity: the runner also sends top-level
framework,kind,topology, andinput_shape, measured from the bytes — never from uploader claims.frameworkis the runner's own assertion (spikeforge: it only verifies.spkfbundles, whose load enforces that format);kindis what the inspect step found;topologynames the spec the runner loaded, built, and drift-checked;input_shapeis the trailing probe dimensions of the exact tensor the drift check consumed (e.g.1x28x28,784). The four keys are always present and null whenever the run fails before the funnel completes. The hub side renders them verbatim into the index entry (hub_api/catalog/index.py::entry_for), so a machine-checked upload shows its real topology and kind. - Effect (
apply_report):passed→published(stampedpublished_at); failed →rejected; the report is stored verbatim inversion.verificationeither way. Only a version inverifyingcan receive a report.
The rule that resolves every ambiguity: metadata over HTTPS from the UI; bytes handled by whichever side already has them on disk.
-
Publishing from desktop: the renderer holds the access token (obtained
in main via §3.3, passed in over a
contextBridgechannel), calls the hub API for the upload intent, fetches the.spkffrom the local backend's existingGET /api/bundle/{name}, and streams it to theput_urlthe intent returned. The local Python backend never sees the hub token. -
Downloading: the bytes must land on disk where the import funnel can
reach them, so this goes through the Python side —
spikeforge_hub/downloads.py's child-process worker gains acommunitysource that resolves a signed URL and writes intoHUB_CACHE_DIR, then the existing inspect → compat → promote funnel runs unchanged. The token is passed per request and never persisted by the backend. -
CLI:
spikeforge_hub/client.py(new, in the existing distribution) wraps the API with PAT auth;spikeforge_hub/cli.pygrowslogin,whoami,push,pull, andquota.
The dashboard↔backend WebSocket protocol is versioned and schema-generated
(plans/arch-0001-protocol-contract.md; PROTOCOL_MAJOR gate at
server/app.py:47-66). New messages are additive — a MINOR bump, not MAJOR:
| Direction | Message | Purpose |
|---|---|---|
| → | hub_pull |
download a community version into the cache |
| ← | hub_pull_state |
progress/cancel, mirroring hub_download_state
|
| → | bundle_build |
build a .spkf for a saved model and report its path, size, sha256 |
| ← | bundle_built |
the above, so the renderer can upload it |
Note hub_list/hub_search need no change: the community index is loaded
by the same catalog.load_catalog() path (§6), so it appears in the existing
payloads with source: "community". hubTypes.ts grows the new source
value and the trust label; HubPanel.tsx grows a filter.
On dash.spikeforge.net every visitor shares one MODEL_DIR (§0.3). So:
- Sign-in and browse/download/import: yes. Hub identity is per-socket, the token lives in the page, nothing is written to a shared location that identifies a user.
- Publishing: not from the hosted dashboard in v1. The saved models there are a shared pile; letting anyone publish from it means letting anyone publish anyone else's checkpoint, and the quota would be charged to whoever clicked. The UI should say exactly that and point at the desktop app and the CLI — not hide the button with no explanation.
Making the hosted dashboard genuinely per-user means per-user
MODEL_DIR/HUB_CACHE_DIR (today module-level constants in
spikeforge/config.py:24-38), per-user process or job isolation, and a
compute budget on a 3 GB shared box. That is phase 5 at the earliest, with a
hosting decision behind it, and it should not gate any of the above.
The six reference/* checkpoints are the only trained weights this project
vouches for, and they reach the hosted index through the same push path as
any community upload — no separate first-party pipeline:
-
Train.
python scripts/train_reference_models.py --only <name> --publishtrains the row and publishes the checkpoint intospikeforge_hub/weights/with its catalog entry (checksum, dataset declarations, measuredtest_accuracy/test_samples). -
Push.
spikeforge-hub push reference/<name> --version <v>stages the packaged.pt, builds the.spkf, and uploads it. Licence, dataset declarations, and scores default from the catalog row; explicit flags override them. The bundle'sexpected_metricscarries the catalog-measured scores. -
Verify. The commit dispatches
hub-verify.yml(§7.3); the version moves topublishedwith themachine-checkedlabel, or torejectedwith named reasons. -
Pull round-trip.
spikeforge-hub pull @<handle>/<slug>re-downloads the bytes through the funnel; the pulled bundle's sha256 must match the pushed.spkf, andpullsurfaces the recordedexpected_metrics.
A staff-pushed reference renders as a community upload — unchecked until
the runner passes, machine-checked after. That is deliberate: there is no
third trust word. The project's vouching lives in the curated catalog row
the bytes were built from, not in a hub-side badge, and inventing a
curated label for hosted bytes would promise human review no step here
performs (see §6 and hub_api/catalog/trust.py).
# identity
GET /login -> provider redirect (PKCE)
GET /oauth/callback -> session cookie
POST /logout
GET /oauth/authorize -> desktop loopback PKCE
POST /oauth/token -> code | refresh_token grant
POST /oauth/device_code -> device flow start
GET /activate -> device-code entry page
GET /v1/me -> profile + quota {used, reserved, limit}
POST /v1/tokens -> mint a PAT (plaintext shown once)
DELETE /v1/tokens/{id}
# discovery (public, cacheable)
GET /v1/index.json -> catalog-schema snapshot, ETag
GET /v1/models?q=&framework=&task=&license=&sort=
GET /v1/models/{handle}/{slug}
GET /v1/models/{handle}/{slug}/versions/{version}
GET /v1/models/{handle}/{slug}/versions/{version}/download -> 302 signed
# publishing (authenticated)
POST /v1/uploads -> intent: quota check, returns put_url
PUT /v1/uploads/{id}/content -> streamed bytes, Content-Length pinned
POST /v1/uploads/{id}/commit
DELETE /v1/uploads/{id} -> release reservation
PATCH /v1/models/{handle}/{slug} -> description, visibility, license
DELETE /v1/models/{handle}/{slug}/versions/{version}
# internal
POST /internal/v1/verifications/{version_id} -> HMAC-signed CI callback
GET /health -> matches server/app.py
Conventions: Idempotency-Key on POST /v1/uploads so a retried intent does
not double-reserve; RFC 9457 problem details on errors; cursor pagination;
Retry-After on 429.
As built (2026-10-13, spikeforge-hub-api main), the route table differs
from the sketch above in the identity half and matches it in the
community/publishing half:
- Identity ships as JSON endpoints under
/v1/auth/*(register,login,logout,refresh,verify,verify/resend,password/reset,password/reset/confirm,sessions/revoke-all) plus external-provider brokering at/oauth/{provider}/start|callback; the hub's own loopback-PKCE grant isGET /oauth/authorize+POST /oauth/token, withPOST /oauth/device_codeandGET /activateas the device fallback (§3.3). -
GET /v1/mereturns the profile plus astorageblock (used_bytes,reserved_bytes,limit_bytes,available_bytes), alimitsblock (the §5.3 ceilings), andcan_publish.spikeforge-hub quotaprints thestorageblock;whoamiprints the whole document. - Discovery ships as sketched:
GET /v1/index.json(catalog schema,source: "community", the service-measuredsha256per entry),GET /v1/models,GET /v1/models/{handle}/{slug}, andGET /v1/models/{handle}/{slug}/versions/{version}/download→ 302 to the content-addressed public URL. There is no per-version detailGET, and noPATCH/DELETEmodel routes, in v1. - Publishing ships as sketched:
POST /v1/uploads(intent:name,version,sha256,size_bytes,license,dataset,dataset_license,dataset_attribution,summary),PUT /v1/uploads/{id}/content(pinnedContent-Length, enforced mid-stream),POST /v1/uploads/{id}/commit→{upload_id, state, published_at}.spikeforge-hub pushruns all three phases. - Callbacks ship at
POST /internal/v1/verifications/{version_id}(§7.3). - Errors are RFC 9457 problem details; the CLI surfaces the human
detail.
| Risk | Control |
|---|---|
Pickle RCE via uploaded .pt
|
weights_only=True everywhere (already the case at bundle.py:68-76); loading only ever in the ephemeral sandbox (§7.2); never in the API process |
| Zip bomb | decompressed-size cap before archive.read(); wall-clock timeout |
| Path traversal in bundles | already safe: bundle.py reads named members, never extractall — keep it that way, and add a test that asserts it |
| Quota bypass | reserve-before-transfer, pinned Content-Length and a cap enforced mid-stream, server-computed sha256, expiring reservations |
| Credential theft | short access tokens, rotating refresh with chain invalidation, hashed at rest, safeStorage on desktop, no token in any URL or log |
| Self-hosted runner compromise | community verification only on GitHub-hosted ephemeral runners (§7.2) |
| Namespace squatting / impersonation | reserved-handle list, curated prefixes reserved, handle changes leave a redirect but do not free the old name immediately |
| Enumeration / scraping | rate limits per token and per IP, cursor pagination, no sequential ids in public URLs (uuids) |
| Malicious model content | this is not solvable by scanning; it is solvable by attribution (@handle), takedown (§12), and the trust labels in §6 not overclaiming |
| Secrets in the deploy path | the new service's env goes in the same SPIKEFORGE_ENV_FILE-style secret as the dashboard's; no third-party storage keys exist while §5.2's volume backend is in use |
| Filling the volume and taking WXRQ down | a hub-wide footprint cap and a free-space floor checked at upload intent, not just monitoring (§5.2) |
| Upload OOM on a 3 GB box | stream to disk, never buffer; enforce the cap during the stream, not only from Content-Length (§5.1) |
-
New compose service alongside the dashboard on the
uwu_sharednetwork, publishing no host port, with a/healthcheck matching the existing pattern (docker-compose.hetzner.yml). Two absolute bind mounts onto the volume, following/opt/wxrq/compose.yaml:95:/mnt/HC_Volume_106876357/spikeforge/artifactsfor the hub API and/mnt/HC_Volume_106876357/spikeforge/postgresfor the database. Nothing persistent in a Docker named volume, because those live on the full root disk (§0.1). -
Caddy. A
hub.spikeforge.netsite block, plus the file-serving root for artifact downloads. Two mechanisms exist in this estate and they should not both grow: the canonical place is airunnerweb'sextensions/docker/Caddyfile, bind-mounted read-only intouwuchat-caddy-1; spikeforge's owndeploy/configure-docs.shinstead appends to the host-owned config in place with a validate-then-reload. Pick the airunnerweb Caddyfile for the new block unless there is a reason not to, and note the divergence either way so the next person does not have to rediscover it. -
Separate deploy workflow.
deploy-hetzner.ymlis path-filtered onclient/**,server/**,spikeforge/**; the hub API lives in its own repo with its own workflow so a hub deploy never rebuilds the multi-GB PyTorch image, and a dashboard deploy never restarts the hub. -
Backups. A scheduled
pg_dumpwritten off-box, plus Hetzner volume snapshots. The database is now the only piece of this system whose loss is unrecoverable — artifacts are content-addressed and re-uploadable, accounts and the quota ledger are not. - Disk watch, both pools. The root disk at 90% (at 98% a deploy fails at build time, and the failure is confusing), and the volume at 80% — the volume alert is WXRQ's early warning as much as the hub's. Report both in the deploy workflow's existing capacity step.
-
Docker data root. Relocating
/var/lib/dockeronto the volume is the durable fix for root-disk pressure and would benefit every service on the box, not just this one. It is a separate, disruptive operation (daemon stop, data copy,daemon.jsonchange) and is deliberately not part of this plan — but it is the reasondocker builder prunekeeps being needed. - Metrics. Uploads, rejections by reason, bytes stored against both the per-account and hub-wide caps, volume free space, egress, signups, and verification queue depth. Rejection reasons are the product feedback loop: a spike in one reason is usually a documentation bug, not user error.
Accepting user accounts and user content changes what this project owes people. All of this is a launch blocker, not a nice-to-have:
-
Privacy policy and terms of service at
hub.spikeforge.net, covering what is stored (email, handle, IP hashes, upload metadata), why, for how long, and the deletion path. The box and its volume are in the EU, and users will be too: GDPR applies — lawful basis, data-subject access, deletion, and a named contact. - Account deletion that actually deletes: sessions, tokens, private versions, and PII, with public published artifacts either transferred or removed — decided and documented before the first signup, not after the first request.
- Content policy and takedown process: a contact address, a response commitment, and the ability to pull an artifact immediately — which is why downloads are authorized per request rather than served from a world-readable directory (§5.1).
- License declaration is mandatory at upload (§6) — both so downloaders know what they may do and so the project is not redistributing unlicensed weights.
- One-person moderation is a real cost. Every upload is content this project hosts and is asked about. Keep v1 small deliberately: a manual approval gate for the first N accounts, or invite-only publishing, is a legitimate phase-2 choice and much easier to relax than to add.
Each phase is independently shippable and independently useful. No phase leaves the tree in a state where a half-built feature is exposed.
| Phase | Scope | Done when |
|---|---|---|
| P0 — clear the ground | Reclaim root-disk space so builds work; create /mnt/HC_Volume_106876357/spikeforge/{artifacts,postgres,tmp}; create the spikeforge-hub-api repo; decide identity provider (§14 Q1); draft ToS/privacy |
df -h / shows build headroom; the volume paths exist with the right ownership; the repo exists with CI and branch protection |
| P1 — identity, no uploads | Hub API skeleton, Postgres, provider OAuth, web sign-up/sign-in, /v1/me, PATs, hub.spikeforge.net behind Caddy |
A person signs up in a browser, sees an empty account with a stated quota, and mints a PAT |
| P2 — community read path |
models/model_versions schema, /v1/index.json in catalog schema, GET/download, source: "community" in HubEntry, client hub_pull, trust labels in the UI |
A hand-seeded community model is browsable in the dashboard, downloads, and imports through the existing funnel |
| P3 — upload with quota | Two-phase upload, quota ledger, reservation expiry, dedup, bundle_build message, publish UI in the dashboard, spikeforge-hub push
|
A user publishes a .spkf from the CLI and from the desktop app, sees usage move, and is refused at the limit with a message naming the limit |
| P4 — verification |
repository_dispatch sandbox job, signed callback, report rendering, machine-checked label, rejection reasons |
An artifact that fails drift is rejected with a named reason; one that passes shows its NIR/compat/energy report on the listing |
| P5 — desktop sign-in polish | Loopback PKCE, device-code fallback, safeStorage with the no-keyring path, sign-out, token revocation UI |
A user signs in from the AppImage and the Windows portable build, restarts, and is still signed in — or is told plainly why not |
| P6 — optional, gated | Per-user hosted dashboard storage; email/password identity; org/team namespaces | Only after a hosting decision and a demonstrated need |
Recommended first slice if this must be smaller: P0 + P1 + P2 with a hand-seeded index. It proves the hostname, the deploy, the identity flow, and the client's community namespace without accepting a single byte of user content — which means no quota, no moderation, and no legal surface yet. The riskiest part of this whole design is not the code; it is the obligations that start the moment uploads open.
-
Identity provider.Answered: email and password plus any OAuth2 provider, built oncapsize-auth(§3.1). What this leaves open is the thing it depends on: which transactional email provider, since nothing can verify an address until mail sends, and an unverified account cannot publish. -
Storage backend.Answered: the attached volume, behind a storage protocol shaped so a presigned-upload backend slots in without touching the two-phase flow (§5.2). Only the volume implementation exists; the S3 one is deliberately unwritten rather than written and untested. - The hub's own footprint cap — is 10 GB of the volume's 33 GB the right share to claim, given WXRQ's video library is the growing neighbour? And are the §5.3 per-account numbers right on top of it?
-
Publishing gate for v1 — open to anyone who signs in, or invite-only / manually approved while moderation is one person (§12)?Answered (2026-10-13):openfrom launch (publishing_gateinhub_api/config.py) — any verified account may publish. -
Is
hub.spikeforge.netacceptable for sign-in, or must it bespikeforge.net/accountbehind a proxy (§3.4)? -
Private models — in scope for v1, or public-only? Public-only is meaningfully simpler (everything is cacheable, and there is no authorization matrix on reads).Answered (2026-10-13): public-only for v1 — downloads are 302s to public content-addressed URLs, and the download route states private models are out of scope.
- Hosted training or inference for users. The desktop app is the compute story, and it already works (§0.4).
- Making
dash.spikeforge.netmulti-tenant (P6 at the earliest, §8.3). - Paid tiers, billing, teams, or organizations.
- Model cards as a rich authored surface — v1 renders the manifest we verified plus a description field.
- Any change to
spikeforge_hub/models.json,CURATION.md, or the reference checkpoints. The curated catalog is untouched by all of this (§6).
- Home
- Architecture
- Backend Execution
- Benchmarks
- Dashboard
- Development
- Encoding Goldens
- Event Datasets
- Event Runtime And Energy
- Features
- Implications And Boundaries
- Interop Foldins
- Interpreter Spine
- Introspection
- Model Deployment
- Model Hub
- Notes
- Operational Maturity
- Production Workflows
- Project Layout
- Quickstart
- Requirements
- Sequence Primitives
- Streaming Timeseries
- Targets And Interop
- Usage
- Arch 0001 Adr Repo Topology
- Arch 0001 Core Boundary
- Arch 0001 Decision Metrics
- Arch 0001 Migration Plan
- Arch 0001 Packaging Versioning
- Arch 0001 Protocol Contract
- Arch 0001 Risk Register
- Arch 0001 Target Topology
- Backend Execution Plan
- C Package Contract
- Client Diff Classification
- Ecosystem Listings
- Ecosystem Roadmap
- Event Runtime Plan
- Hub Accounts Plan
- Hub Expansion Plan
- Plans
- Integration Plan Historical
- Interop Foldins Plan
- Interpreter Spine Plan
- Memory System Research
- Model Hub Plan
- Nir C Runtime Profile
- Nir Unmappable Decisions
- Operations Plan
- Production Toolkit Plan
- Production Use Cases
- Professional Roadmap
- Repo Topology Plan
- Sequence Primitives Plan
- Snn Interpreter Provenance
- Use Case Audio Keyword Spotting
- Use Case Biosignal Medical Monitoring
- Use Case Computational Neuroscience
- Use Case Edge Power Budgets
- Use Case Event Camera Vision
- Use Case Intrusion Anomaly Detection
- Use Case Low Latency Sensor Stream
- Use Case Rl Control Robotics
- Use Case Spiking Transformers
- Use Case Streaming Timeseries