Architecture, security, and limitations
Rostrum is a single Go application organized around one validated event workspace. Pages, actions, public JSON, live updates, and exports all project from that same canonical state.
System view
flowchart TB
subgraph People["Browser personas"]
Attendee["Attendee"]
Speaker["Speaker · signed link"]
Reviewer["Reviewer · signed link"]
Organizer["Organizer / chair / observer"]
end
subgraph App["Rostrum · one Go process"]
Pages["GoSX server-rendered pages"]
Actions["Typed server actions & HTTP handlers"]
Policy["Arbiter policy adapter"]
Domain["Domain state & validation"]
Live["WebSocket activity hub"]
Outbox["Durable communications runner"]
Public["Published-data serializer"]
end
subgraph Persistence["Operator-owned persistence"]
Store["JSON · SQLite WAL · Postgres"]
Uploads["Private upload directory"]
Audit["Independent hash-chained JSONL ledger"]
Backups["Import backups & archives"]
end
subgraph Optional["Optional external edges"]
Mail["Resend or SMTP"]
Accelevents["One-way Accelevents publishing"]
Airtable["One-way Airtable projection"]
end
People --> Pages
Pages --> Actions
Actions --> Policy
Actions --> Domain
Domain --> Store
Actions --> Uploads
Actions --> Audit
Store --> Public
Public --> Pages
Actions --> Live
Live --> Organizer
Outbox --> Store
Outbox --> Mail
Store --> Accelevents
Store --> Airtable
Store --> Backups
There is no separate front-end application or worker deployment. The GoSX runtime provides managed navigation and a small set of generated islands; the repository contains no hand-written browser JavaScript. A process-local loop wakes the durable communications outbox, but due time, leases, retry state, idempotency, cancellation, and suppression remain in canonical state.
Request and decision flow
sequenceDiagram
actor Person
participant Route as GoSX route / handler
participant Guard as Session, role, CSRF, limits
participant Rules as Arbiter policy
participant State as Validated state store
participant Ledger as Independent audit ledger
participant Live as WebSocket hub
Person->>Route: request or managed action
Route->>Guard: authenticate and authorize
Guard-->>Route: scoped actor
Route->>Rules: evaluate governed decision
Rules-->>Route: outcome, rule, readable trace
Route->>State: validate and commit next aggregate
State-->>Route: committed
Route->>Ledger: append and fsync audit event
Route->>Live: broadcast safe refresh signal
Route-->>Person: HTML, action result, or redirect
The state commit and independent ledger append are intentionally separate. If the state commits and the ledger filesystem then fails, Rostrum returns an error that says the workspace commit already succeeded. This is an operational incident to reconcile, not an atomic transaction across two stores.
Source structure
| Concern | Location | Responsibility |
|---|---|---|
| Pages and server actions | app/ |
File routes, .gsx components, loaders, managed mutations |
| HTTP assembly | main.go |
Middleware, API, mounted downloads/uploads, identity, process startup |
| Domain | internal/domain/ |
Aggregate types, fresh/empty state, invariants, review and conflict helpers |
| Presentation | internal/present/ |
Safe view models for pages |
| Storage | internal/store/ |
JSON, SQLite, Postgres, read-only and audit decorators |
| Policy | rules/ |
CFP routing, form visibility, review governance, schedule conflicts |
| Identity | internal/identity/ |
Organizer magic links, OAuth, passkeys, setup |
| Signed access | internal/token/ |
Purpose-separated speaker and reviewer tokens |
| Public boundary | internal/publicapi/ |
Published schedule and speaker serialization |
| Communications | internal/communications/, internal/mail/ |
Durable runner and provider transports |
| Recovery | internal/archive/ |
Checksummed workspace and approved-upload artifacts |
Persistence model
Rostrum exposes one state-store contract with three canonical backends:
- JSON (default): validates a cloned next state, writes a temporary file, and atomically replaces the workspace file. It is the zero-configuration path.
- SQLite: persists the same aggregate contract with WAL enabled. It is a practical single-host option.
- Postgres: persists the same contract through
DATABASE_URL. The exact managed endpoint still needs operator backup, restore, and restart testing.
Uploads remain private files outside the aggregate. Their state references are
validated against the upload root. AUDIT_LOG_PATH is deliberately separate
from DATA_PATH; BACKUP_DIR stores exact pre-import backups.
Rostrum should run as one application replica for JSON and SQLite. A Postgres deployment should still remain single-replica until an operator has explicitly validated shared identity, live-update, rate-limit, and outbox behavior for a multi-process topology.
Identity and authorization boundaries
| Surface | Required authority |
|---|---|
/public/*, public CFP, /api/v1/* |
Anonymous, subject to publication and rate-limit rules |
/organizer/* |
organizer, chair, or observer; APP_MODE=preview is a special anonymous read-only posture |
| Sensitive organizer exports | organizer or chair; never observer |
| Final governed decision override | chair, with rationale and audit context |
/review/{token} |
Valid signed reviewer token on each request |
/portal/{speaker} and private files |
Matching signed token or bound speaker session; organizer session where explicitly allowed |
/calendar/{speaker}.ics |
Matching signed token, bound speaker session, or organizer-facing session |
/setup |
One-time process token, only while no organizer exists |
Organizer sessions are signed and encrypted by GoSX using SESSION_SECRET.
Production startup rejects a default/short secret, a non-HTTPS PUBLIC_URL,
and in-memory persistence. Non-local public URLs also require a strong session
secret even if APP_ENV was misconfigured.
Public-data contract
internal/publicapi constructs public responses from an allow-list. It emits
event metadata, published sessions, and speakers attached to those sessions.
The public whole-event calendar is built from that same published-session
boundary. Neither path serializes speaker email, proposal answers, review
records, draft sessions, upload paths, principals, audit events, or provider
configuration.
Public pages may be framed so organizers can embed the agenda. Other routes
send a frame-ancestors 'none' content-security policy. The global security
layer also supplies content-type protection, a permissions policy, referrer
policy, and HSTS on HTTPS.
Mutation defenses
- Managed forms use CSRF protection and server-side validation.
- Public form and magic-link requests have session/IP rate limits.
- Non-upload bodies are capped at 1 MiB; uploads use a 12 MiB request envelope and a 10 MiB file limit.
- Upload extensions and MIME types are allow-listed, filenames are sanitized, and stored paths are rechecked beneath the private upload root.
- Spreadsheet exports neutralize formula prefixes.
- Preview mode rejects unsafe methods and sensitive paths at middleware, then wraps the store in a second read-only barrier.
- Preview startup verifies the configured workspace template against its
required SHA-256 pin and refuses credentials or unsafe persistence
configuration. It also rejects an email-like value anywhere in the complete
workspace unless its domain is
example.com,example.net,example.org, or one of their subdomains.
Current limitations
- One instance represents one organization’s event workspace. There is no multi-tenant SaaS account plane.
- The anonymous hosted preview is intentionally non-mutating. Safe navigation, filtering, and persona inspection remain interactive, and the public CFP renders a client-only submit walkthrough so judges can type, save a draft, and submit a proposal without creating a request, email, or workspace record. The organizer agenda also offers a client-only drag rehearsal: cards can be moved between open cells and blocked room drops explain the conflict without creating an action request or changing workspace state. Fresh live mode is the evaluation path for real mutations.
- Provider unit/contract tests do not prove real email, Accelevents, or Airtable delivery. Operators must run the provider acceptance steps in the self-hosting manual with the exact credentials and endpoint they select.
- Rate-limit counters and the WebSocket hub are process-local.
- Full-archive upload recovery is a stopped-process procedure. Structured workspace JSON import is validated online, but archive extraction is not.
- The independent ledger cannot participate in the canonical store’s atomic commit; its post-commit failure has an explicit incident path.
- The public API is a small read-only v1 contract, not an OpenAPI-described mutation API.
- Deployment manifests provide a secure single-instance baseline, not a turnkey managed service or an availability SLA.
Verification layers
| Layer | Command | Proves |
|---|---|---|
| Static and unit | make check |
Formatting, GoSX formatting, Arbiter validation, vet, tests, race tests |
| Evaluation-preview contract | make smoke |
Generated fictional fixture, anonymous organizer/persona/public/embed/API/calendar surfaces, no-index headers, and mutation refusal |
| Bundle contract | make size-budget |
Production build and committed route/runtime size ceilings |
| Remote preview | make smoke SMOKE_URL=https://… SMOKE_EXPECTED_VERSION=<release> |
The same full read-only example contract and exact immutable release; not external-provider acceptance |
| Operator acceptance | Launch runbook | Exact image, deployment, credentials, storage, recovery, and approval evidence |