Audience: core contributors, architects, and long-term maintainers. ADRs are dense by design. If you are landing on the repo for the first time, start with README.md and SPEC.md; come here when you want to understand why a particular choice was made.
Architectural Decision Records for VOIDcore. The why behind every non-obvious choice in this repo. Read this when you're tempted to second- guess something — and update it when you change your mind.
Format: each decision is a section with Context, Decision, and Consequences. Lightweight ADR. Date stamps so future-you knows when a decision was made and what was known at the time.
Date: 2026-04-28
Context: The original spec draft (v1.0) recommended Bun + TypeScript on the basis that the project is small and the TS ecosystem is "current." Sysop has 20 years of Java distributed-systems experience and is already running Aletheia on Spring Boot in the same homelab. Stack consistency matters more than novelty for a solo project.
Decision: Backend is Java 21+ on Spring Boot 3.3+. JDK 21 LTS is the floor; JDK 25 if available on the deploy box.
Consequences:
- Stack consistency with Aletheia. One JVM tuning regime, one set of monitoring tools, one familiar deployment story.
- Sealed interfaces + records give the WS protocol proper algebraic data types. Switch-pattern-match enforces totality at compile time — catch "I added a new message variant and forgot a handler" at compile, not in production.
- Bigger memory footprint than Bun (~300MB resident vs ~80MB), but the homelab box has memory.
- Slower cold start (~5s vs ~50ms), irrelevant for a long-running service.
- Larger Docker image (~250MB JRE base vs ~80MB Alpine + Bun), worth it.
- Loses the "all one language" appeal of Bun + TS. Acceptable.
Rejected alternatives:
- Bun + TypeScript: novelty bias, not sysop's strength
- Node + TypeScript: same downsides as Bun, fewer upsides
- Kotlin + Ktor: would be fine, but Spring's ecosystem is deeper and matches Aletheia
- Go: nice for WS servers but Java has the edge in protocol-typing ergonomics with sealed types
Date: 2026-04-28
Context: Spring Boot 3.x supports both blocking-with-virtual-threads (via Spring Web) and reactive (Spring WebFlux). The BBS has a few hundred concurrent WS connections at most; backpressure isn't really a concern.
Decision: Virtual threads. spring.threads.virtual.enabled=true.
WebSocket handlers are blocking, one virtual thread per connection.
Consequences:
- Linear, readable handler code. No
Mono.flatMap(...)chains. - Cheap per-connection threads — millions are theoretically possible, hundreds is a non-issue.
- Familiar debugging: stack traces make sense.
- Lose the explicit backpressure semantics of
Flux<T>. For chat and presence broadcasts, that's a non-issue at this scale.
Rejected alternatives:
- WebFlux: overkill for board scale, fights the natural request/response shape of the BBS protocol.
- Platform threads + thread pool: works, but virtual threads are the right modern answer and free.
Date: 2026-04-28
Context: Spring offers three layers: WebSocketHandler (raw),
STOMP-over-WebSocket (pub/sub framing on top of WS), and reactive
WebFlux WS. STOMP gives topic dispatch for free
(/topic/chat, /topic/presence).
Decision: Plain WebSocketHandler with a custom JSON envelope. The
sysop owns the wire format. ~50 lines of SessionRegistry + topic
dispatcher.
Consequences:
- Full control of the on-the-wire format. Readable in browser devtools.
- No STOMP frame overhead.
- No client-side STOMP library dependency.
- Custom dispatcher needs writing — small, one-time cost.
- Sealed-type protocol fits naturally; would have to wedge STOMP frames around it otherwise.
Rejected alternatives:
- STOMP: convenient but hides the protocol, dilutes the "every-byte-on-the-wire-is-mine" feel of the BBS.
- Reactive WS: see ADR-002.
Date: 2026-04-28
Context: Spec v1.0 proposed SQLite. SQLite is operationally simpler (one file, no daemon) and adequate for board scale. But: out-of-band access (sysop CLI, scripts, future metrics scraper) wants a second process touching the DB; SQLite's locking story makes that fiddly. PostgreSQL adds one container's worth of complexity and removes a class of future operational pain.
Decision: PostgreSQL 17 in a sibling container, app DB owned by a
dedicated voidcore_app role. Schema managed by Flyway.
Consequences:
- Real backup tooling: pgBackRest, WAL archiving, point-in-time recovery
- Multi-process access: sysop CLI, monitoring, ad-hoc psql, all happy
CITEXT,JSONB, realTIMESTAMPTZ, eventualtsvectorfor search — all native, all free- One extra container running. Negligible RAM hit.
- Slightly more involved backup story than copying a file. Mitigated by pgBackRest sidecar.
- Postgres major-version upgrades require pg_upgrade. Acceptable.
Rejected alternatives:
- SQLite: simpler now, painful later
- H2 / Derby: embedded, Java-native, but ecosystem and tooling weaker than Postgres
- Managed Postgres (Neon, Supabase): defeats the purpose of homelab hosting
Date: 2026-04-28 Status: Superseded by ADR-005a
Context: Spring Data JPA is the conventional choice. It generates queries from method names, manages identity, handles dirty checking. It also: hides what the database is doing, surprises you with N+1 queries, and adds entity-management ceremony that doesn't fit the record-as-domain-object style.
Decision: Plain SQL via JDBI 3 (or JdbcTemplate if even that is
overkill). Domain objects are Java records. Repos return records.
Consequences:
- Queries are explicit. No N+1 surprises.
- Records map naturally to result rows.
- More verbose than
findByHandleAndIsBannedFalse(...)-style derived queries. Acceptable for a project of this size. - Loses change-tracking; updates are explicit
UPDATEstatements. Fine.
Rejected alternatives:
- Spring Data JPA: too much magic, fights records
- jOOQ: type-safe SQL DSL, but the codegen step adds friction; revisit if query complexity grows
- MyBatis: works, but JDBI is leaner
Date: 2026-04-28 Status: Accepted (supersedes ADR-005)
Context: ADR-005 chose JDBI on the grounds that "queries are explicit" and the codegen step in jOOQ is friction. On reflection, this under-weighted a real concern: defence-in-depth at the API-to-DB boundary, especially when AI agents author significant portions of the code.
JDBI relies on developers using bind parameters everywhere. The failure
mode is one query somewhere written with string concatenation —
"WHERE handle = '" + handle + "'" — and you have an injection. The
DB driver can't catch this; by the time the query reaches it, the
string has already been assembled. A code review can catch it; a busy
dev or a future AI agent might not.
jOOQ moves the safety boundary up: the DSL doesn't expose a string-
concat path. You can construct unsafe queries only by explicitly
calling DSL.sql(...), which is grep-able and reviewable. This is
structurally better for a multi-actor codebase.
The codegen step is real friction (one-time setup, runs against a live schema or a Flyway-driven schema dump in CI), but in exchange you get:
- Compile-time validation of every column name and type against the actual schema
- Schema drift becomes a compile error, not a runtime exception
- Type-safe Record / POJO mapping out of the box
- Bind parameters by default, raw SQL only on explicit opt-in
- AI agents are more likely to write safe code in a DSL where unsafe code requires ceremony
Decision: jOOQ. Codegen runs as part of the Gradle build, taking
the Flyway-applied schema as input via Testcontainers (a throwaway
Postgres instance applies migrations and jOOQ inspects the live
schema). Domain objects remain records; repos use jOOQ's DSLContext
internally and return records to the rest of the app.
Consequences:
- Defence-in-depth: harder to write injection-vulnerable code by accident or AI omission
- Compile-time schema validation: rename a column without updating the query, build fails, fast feedback
- Codegen step adds ~10s to clean builds, ~0s to incremental builds
- Build now requires Docker (Testcontainers) for the codegen step. Acceptable since the dev env already has Docker.
- jOOQ's free Open Source Edition supports Postgres natively; no licence cost.
- Slightly larger learning curve for contributors. Mitigated by the DSL being readable to anyone who knows SQL.
Rejected alternatives (revised list):
- JDBI: see ADR-005, primary issue is the injection-by-omission risk
- Spring Data JPA: too much magic, fights records, still has its own injection risks via JPQL string-building
- MyBatis: XML mappers are a step backwards
- Plain
JdbcTemplate: same risk profile as JDBI, even less ergonomic
Date: 2026-04-28
Context: Need versioned schema migrations. Two main contenders: Flyway and Liquibase.
Decision: Flyway. SQL files in src/main/resources/db/migration/.
Consequences:
- Plain SQL files, readable in any editor, diffable in git.
- First-class Spring Boot integration: runs on startup automatically.
- Enforced versioning prevents accidental schema drift.
- Liquibase's XML/YAML changelogs are more portable but less readable; the project doesn't need DB portability.
Rejected alternatives:
- Liquibase: more abstraction than needed
- Hand-rolled init scripts: don't track applied state, eventually break
Date: 2026-04-28
Context: The frontend is a terminal renderer. It does not need component lifecycles, virtual DOM, or state management libraries. The spec describes a small set of imperative screens. A framework would fight the mental model.
Decision: Plain TypeScript bundled with esbuild (or Bun build, both
fine). One bundle, served statically by Spring Boot from
src/main/resources/static/. Built by Gradle as part of bootJar.
Consequences:
- No framework upgrades to chase
- Tiny bundle (~30KB after minification, mostly the terminal renderer)
- Imperative code reads naturally for a screen-by-screen flow
- No JSX → no React mental model overhead
- Loses the ecosystem of React component libraries — irrelevant, this UI deliberately doesn't use components
Rejected alternatives:
- React: overkill, fights the imperative screen flow
- Vue: same as React
- Svelte: closest to a fit, but still more machinery than needed
- Lit: tempting for the templating, but the terminal API handles rendering natively
Date: 2026-04-28
Context: Two ways to organise the TS frontend: (a) same Gradle
project, frontend builds via Gradle plugin and lands in
src/main/resources/static/; (b) separate frontend/ directory with
its own package.json, built independently, copied in by Dockerfile.
Decision: Option (a). Frontend lives at
app/src/main/frontend/. A Gradle task invokes the bundler during
processResources and outputs to static/. Single build command:
./gradlew bootJar.
Consequences:
- One repo, one build, one image
- Gradle dependency tracking covers both Java and TS sources
- Less moving parts in CI / Docker
- Slightly unusual layout (TS source in a Java module)
- Coupling between front and back at build level — fine for solo development
Rejected alternatives:
- Separate frontend project: cleaner separation, more setup, multi-stage Docker build instead of single-stage
- Pre-built bundle committed to the repo: smallest build complexity, but binary-ish artifact in git
Date: 2026-04-28
Context: TLS termination must happen somewhere. Options: (a) a Caddy container in the BBS compose stack, (b) a Caddy on the BBS VM host, (c) the existing DMZ Caddy on a separate host.
Decision: Option (c). The DMZ Caddy (managed by a separate Ansible
repo) is the single TLS-termination point. The BBS VM exposes plain
HTTP on 127.0.0.1:8080 and is only reachable through the DMZ Caddy.
Consequences:
- Single source of truth for cert renewal across the homelab
- BBS stack stays small — no Caddy container to manage
- Internal hop from DMZ Caddy to BBS VM is plain HTTP over a trusted private network
- If the DMZ Caddy is compromised, every internal service is exposed. Mitigated by treating the DMZ host as security-critical.
- Adds the DMZ Caddy as a deployment dependency: BBS can't be served publicly without DMZ Caddy config also being updated.
Rejected alternatives:
- In-stack Caddy with its own ACME: double TLS termination, two cert renewals, more moving parts
- Direct exposure: makes the BBS stack handle TLS, ACME, security headers; not its job
Date: 2026-04-28
Context: Sysop has no current backup strategy. Need something that
covers (a) accidental DROP / bad migration, (b) hardware failure, (c)
total loss of the homelab. Tooling options range from pg_dump | gzip | cron to enterprise-grade backup platforms.
Decision:
- Layer 1 — pgBackRest sidecar in the same compose stack. Weekly fulls, daily incrementals, continuous WAL archiving, local volume. Provides PITR.
- Layer 2 — restic sidecar (optional, behind compose profile) shipping the pgbackrest repo offsite to B2/S3/SFTP, encrypted.
- Layer 3 — pg_dump logical dumps documented but not in compose.
Consequences:
- Strong recovery guarantees: PITR within retention window, off-site protection against home loss
- Operational complexity higher than
pg_dumpalone — but the runbook indocs/RESTORE-RUNBOOK.mdmakes the procedure explicit - Restic password is a single point of catastrophic failure if lost. Mitigated by repeated warnings in docs and by storing the password in a password manager and on paper.
- Adds one or two containers to the stack. Acceptable.
Rejected alternatives:
pg_dumponly: easy to set up, no PITR, no offsite protection by default, restore from a 24h-old dump means up to 24h data loss- Barman: similar to pgBackRest but slightly heavier for a single-DB setup
- Managed backup service: defeats the homelab premise
Date: 2026-04-28
Context: Single VM, single sysop, single instance. Kubernetes would be absurd. Bare-metal install (systemd unit for Postgres + systemd unit for Spring Boot JAR) is also viable but requires more setup per host.
Decision: Docker Compose. The deployment artifact is a directory
with docker-compose.yml, config files, and a Makefile. Drop on a VM,
make up.
Consequences:
- Reproducible, version-controlled deployment
- Trivial to spin up a second instance (e.g. for restore verification)
- Volume management is explicit and inspectable
- Adds Docker as a dependency on the host; harmless on Linux VMs
- Slightly heavier resource overhead than systemd + native binaries
- Image rebuilds for app changes — acceptable, fast on the dev box
Rejected alternatives:
- Kubernetes: laughably overkill
- Nomad: less overkill, still overkill
- systemd units only: works but per-host setup is more involved
Date: 2026-04-28
Context: First boot needs a sysop user. Options: (a) Flyway
migration that creates the user with a hardcoded password (terrible),
(b) Flyway migration that reads env vars (Flyway can't do that easily),
(c) Spring ApplicationRunner that creates the sysop on first boot if
none exists.
Decision: Option (c). The app reads VOIDCORE_SYSOP_HANDLE and
VOIDCORE_SYSOP_INITIAL_PASSWORD env vars on startup. If no user with
is_sysop=true exists, create one. After first login, the sysop
changes their password and the env vars become inert.
Consequences:
- Bootstrap is idempotent
- Flyway migrations stay deterministic (no env coupling)
- Initial password is in
.envuntil the sysop changes it — sysop responsibility to rotate - If the sysop user is deleted, the next boot recreates one. Probably desirable but flag this in docs.
Rejected alternatives:
- Hardcoded sysop password: never
- Flyway placeholder substitution: works, couples Flyway to env, awkward
- Manual sysop creation via SQL: easy to forget on a fresh deploy
Date: 2026-04-28
Context: Real BBSes show "you are caller #N" on the login banner. The number is part of the vibe.
Decision: A counters table with a caller_count row, seeded at
1337. Incremented per successful login. Displayed on the post-login
banner.
Consequences:
- Authentic BBS feel
- Persists across restarts — number only ever goes up
- Survives DB restore — counter restores with everything else
- Starting at 1337 is a small wink, not a deception
Rejected alternatives:
- Starting at 1: too on-the-nose ("only one previous user")
- Starting at a huge number: dishonest about scale
- No counter: loses the vibe
Date: 2026-04-28
Context: Real FidoNet addresses had structure: Zone:Net/Node. The
banner shows one as flavour. Number is currently arbitrary.
Decision: 23:495/0 for now, with sysop free to change if a more
meaningful number presents itself. The number is purely cosmetic; no
actual FidoNet protocol is implemented.
Consequences:
- Sets the BBS-vibe expectation
- Costs nothing
- Open to change via a config var in a future revision
- A small group of nerds will appreciate it; everyone else won't notice
Date: 2026-04-28
Context: Multi-node would require: Redis or Postgres-LISTEN/NOTIFY for the presence registry, sticky sessions or shared session store, broadcast fan-out across nodes. The board's expected scale doesn't need any of that.
Decision: Single instance. No clustering. Presence registry is an
in-process ConcurrentHashMap. Broadcasts iterate the local session
list.
Consequences:
- Massive simplification of the design
- Single point of failure: app crash drops everyone
- Mitigated by Docker
restart: unless-stoppedand a healthcheck — recovery is seconds, not minutes - Vertical scaling only: if board grows past a few thousand concurrents, revisit
Rejected alternatives:
- Multi-node: premature optimisation
- Active-passive failover: complexity not worth it for a hobby board
Date: 2026-04-28 Status: Accepted
Context: The earlier spec (v1.1) described a thick TypeScript client
that held screen-flow state, knew about menus and chat and files, and
communicated with the server via a domain message protocol (~30 message
types: bulletins.list, chat.send, mb.post.create, etc). This made
the client large (~2000 lines), tightly coupled to the server's domain,
and difficult to extend with new screens or doors without coordinated
changes on both sides.
Pressure-testing during design revealed several concerns:
- Latency on slow connections. International users with 200ms RTT would feel every menu transition. Thick client did keystroke-level responsiveness but every domain action still round-tripped.
- Doors as a future feature. A LORD-alike or label-sim door would need its own frontend code in the thick-client model — every door shipped client-side. Not viable for a one-developer project.
- Drift between client and server screen state. Two state machines for "what screen is the user on" inevitably drift. Bug class.
Decision: v1 ships a smart-terminal client. The architecture:
- Server holds all application logic and screen state. Each connected user has a server-side state machine driving what they see.
- Protocol is render frames + input events, not domain messages. Server sends region-targeted content updates and input mode declarations; client paints them and reports keystrokes / line submissions.
- Client knows: regions (named display areas with fixed v1 layout), input modes (none / keystroke / line / password), local echo for line and password modes, version-aware reconnect, side effects (sound, title, url, clipboard).
- Client does NOT know: what a menu is, what chat is, what a file is, any application concept. It's a runtime, not an app.
- Client retains its painted state across WS reconnects. On reconnect, region versions are exchanged; if all match, the server confirms sync without resending content. This makes flaky-connection reconnects (mobile, train tunnels) invisible to the user.
- A
current_screenJSONB column onsessionstracks where each user is in the BBS. On reconnect or server restart, users land back in their current area instead of the connect sequence. - Deep links work via URL fragment intents (
#nfo/track-name). The intent flows through the front door (connect sequence + login) and is honored after auth, preserving the brand moment while supporting shareable URLs.
Consequences:
- Client bundle drops from ~2000 lines to ~1500-2000 lines but the kind of code changes — terminal mechanics instead of application flow.
- Server protocol surface drops from ~30 client message types to ~8. Total protocol surface is similar but shaped around rendering, not domain.
- Adding new screens is a server-only change. Doors become trivial to add.
- Latency on slow connections is masked by client-side keystroke echo in the status row, plus local echo for line/password input. The BBS-correct "menu transition takes a moment" feel is preserved without typing feeling laggy.
- Server restart loses in-progress typing but preserves user position
via
current_screen. Acceptable trade. - Train-tunnel-flakiness: most reconnects send no content (sync match), so the user perceives no disruption.
Rejected alternatives:
- Thick client (v1.1 design). Each screen on the client; ~2000
lines of
screens/*.ts. Rejected because doors don't scale, drift is a real bug class, and the protocol becomes a sprawl of domain messages. - Pure thin "dumb rasteriser." Initial sketch had the client doing nothing but painting cells from the server. Rejected because 200ms-per-keystroke felt broken on real connections, and the design didn't survive the train-tunnel stress test.
- Animation primitives in the protocol. Initial smart-terminal
draft included
animate.typewriter, etc. Rejected because the client would need an extending vocabulary of primitives, growing the versioning surface. Streaming frames during animations is fine at BBS scale.
Forward compatibility: the protocol envelope reserves seq and
mac fields for v2 (ADR-018). The screen.define message type accepts
arbitrary layout descriptions even though v1 servers only emit the
default layout (ADR-017). v1 clients must tolerate but ignore these
forward-looking elements.
Date: 2026-04-28 Status: Accepted (forward-looking; recorded to preserve v1 protocol headroom)
Context: v1's smart-terminal architecture (ADR-016) ships with a single fixed layout and a single region type (text-cell). This is right-sized for the BBS-as-website-feature scope of v1.
Discussion during design surfaced a more ambitious framing: the BBS is the first application on a terminal-aesthetic application runtime that could host other text-mode applications (polls, wikis, code paste viewers, image galleries with text-mode rendering, music player widgets). Each application defines its own screen layouts and content; the client interprets layout descriptions as data rather than knowing them as code.
This is a categorically different product — not "the BBS plus features" but "a runtime, with the BBS as flagship app." It's explicitly not in v1 scope. But the v1 protocol must not foreclose it.
Decision: v2's defining capability is a dynamic UI runtime:
- Server emits layout definitions via
screen.define. Each definition describes named regions with positions, sizes, scrolling rules, cacheability, TTL. - Client implements multiple region types (text-cell, list-with- selection, form, progress, marquee, image). Each region type has its own protocol for content updates and user interactions.
- Screens are cacheable by ID + version. Clients cache static layouts and content, refresh on TTL or explicit invalidation.
- Pre-fetching: server can hint that likely-next screens be cached speculatively, removing menu-transition latency entirely for navigation between cached screens.
- Doors leverage the runtime: a door is just a server-side state machine that emits screen definitions for a custom layout.
v1 protocol headroom required to support v2 without breaking changes:
- The
screen.definemessage must exist in v1 even though v1 servers only emit one layout. v1 clients must tolerate definitions of layouts they don't fully implement (text-cell rendering of a layout still works even if the layout has unfamiliar region positions). - Layout descriptions must include cacheability hints (
cacheable,ttl_seconds,id,version) that v1 servers set to reasonable defaults and v1 clients ignore. - Region types must be a closed extensible set, with
text-cellas the default and v1's only implemented type. v2 region types are added with explicit feature flags so v1 clients can refuse unknown types rather than crash.
Consequences:
- v1's protocol contains forward-compatibility scaffolding that v1 doesn't exercise. This is a deliberate cost; cheaper now than a breaking protocol change later.
- A future contributor (or future-me) reading the v1 codebase will see unused fields. This ADR exists specifically to prevent "simplifying" them out and breaking v2.
- The v1/v2 boundary is a major uplift, not a feature addition. Re-shaping the BBS from "fan board" to "runtime + flagship app" is the v2 deliverable. Don't sneak v2 features into v1 — finish v1 first.
Forward-looking nature: ADRs typically record decisions for things being built. This one records a decision for something not being built, in order to constrain v1's design. It should be revisited when v2 work begins; if v2 thinking has evolved, supersede this ADR rather than silently changing the v1 protocol.
Date: 2026-04-28
Status: Accepted (forward-looking) — parked 2026-08-01, see ADR-035.
Its purpose was to preserve v1 protocol headroom, and it succeeded: the
seq / mac envelope fields exist and are reserved, so this design can
be implemented at any time without a protocol break. It is not scheduled
against any milestone. The cost (canonical serialisation, a key
lifecycle that must survive ADR-033's detached sessions and token-based
reconnect, plus the dev/prod operational surface described below) is not
currently justified by the gain over TLS for a small self-hosted board.
Revisit if the threat model changes.
Context: v1 ships with TLS at the DMZ Caddy plus Argon2id-hashed passwords plus opaque session tokens. This is appropriate for v1's threat model (small self-hosted board, sysop-trusted DMZ Caddy, no PII).
For v2's "external product" framing, the threat model expands:
- The system might be deployed by people other than the original sysop on infrastructure the maintainer doesn't control.
- A compromised session token (logged accidentally, exfiltrated via XSS, captured from a stolen browser profile) currently grants full account access until expiry.
- Replay attacks are not currently prevented — a captured WS frame could be re-sent.
- The sysop wants to be able to share debug captures of WS traffic without exposing live credentials.
These aren't v1 problems. They become real if v2 is positioned as something deployable by third parties.
Decision: v2 adds a forward-secure session security layer on top of v1's existing authentication:
- At successful auth, client and server perform an X25519 key exchange. Each side generates an ephemeral key pair, exchanges public components over the (TLS-protected) WS, derives a shared secret. Neither side alone can predict the resulting key.
- The shared secret derives a session key via HKDF-SHA256.
- The session key never appears on the wire after derivation. Client holds it in JS memory only — not localStorage. Server holds it in per-connection memory.
- Every subsequent WS message carries
seq(monotonic counter per direction) andmac(HMAC-SHA256 ofseq || message_id || body_hashkeyed by the session key). - Server enforces strict monotonic seq, rejects out-of-order or
replayed messages with
PROTOCOL_VIOLATION. - The persistent
session_token(in localStorage) becomes a permission to initiate a new key exchange on resume — not direct authentication. Compromising the token doesn't grant immediate message-forging capability; the attacker must perform a fresh key exchange, which is rate-limited and logged. - Forward secrecy: ephemeral keys are discarded after each session. Compromising a session key doesn't expose past traffic.
Operational concerns and mitigations:
The MAC layer makes hand-debugging WS traffic harder — captured frames can't be hand-edited and replayed. Three mitigations:
- Build-time flavor for dev vs prod. Dev builds compile with MAC enforcement disabled. Production builds always have it. Production binary literally contains no code path that disables MAC. Dev and prod use different Gradle profiles producing different artifacts.
- Loopback-only runtime override (defence in depth). Even in dev
builds, a runtime flag to disable MAC only takes effect for
connections from
127.0.0.1/::1. Production deploys never see loopback connections through the DMZ Caddy. - Verifier debugging tool. Alongside the MAC implementation, ship a small CLI/web tool that takes a session secret and verifies or computes MACs for a captured frame. Available to the sysop via a privileged API endpoint. This eliminates the temptation to disable security to debug — there's a better path.
- Loud warnings whenever MAC is off. Dev startup banner shouts
*** MAC LAYER DISABLED ***. Every WS message gets a WARN log line. The user-facing connect screen shows a warning that the session is insecure. Cost of running with MAC off becomes visible noise.
Consequences:
- Higher security ceiling appropriate for an externally-deployed product.
- Forward secrecy: even compromised long-term session tokens don't retroactively expose past sessions.
- Replay protection by construction: each
seqis one-shot. - Defence in depth: localStorage compromise no longer grants direct session forgery.
- More protocol surface (the
seqandmacenvelope fields, key exchange messages). - Build complexity: two Gradle profiles, two artifacts, CI rule that prod artifacts come from prod profile only.
- v1 protocol must reserve
seqandmacenvelope fields so v2 can enable them without breaking changes. v1 clients sendseq: 0andmac: null; v1 servers ignore both.
Rejected alternatives:
- Bearer token only (v1 status quo). Fine for v1 scope; not adequate for an externally-deployed product.
- Client-only random number generation (Nintendo CIC analogue). Original sketch. Rejected because security-through-obscurity is an anti-pattern. Same architectural shape (rolling secrets, replay prevention) achieved properly via X25519 + HMAC + counter.
- Single global runtime flag to disable MAC. A security mechanism that can be globally disabled with one flag is a security mechanism that will eventually be globally disabled by accident. Build-time flavor + loopback-only + loud warnings is structurally honest.
- Binary protocol for performance. Rejected explicitly: human- readable JSON is a hard requirement for debuggability. The MAC fields are added strings; they don't break readability.
Forward-looking nature: like ADR-017, this records a decision for
something not yet built, to preserve v1 protocol headroom. The v1
client and server must accept the seq and mac envelope fields as
optional, ignore their values, and not "simplify" them out of the
protocol.
Date: 2026-04-28 Status: Note
The v2-direction ADRs (ADR-016, ADR-017, ADR-018) all evolved through deliberate stress-testing during design rather than first-sketch acceptance. Each went through "seems right" → "what about X scenario?" → "ok the design needs Y" → "what about Z?" → "ok the design needs W" before landing in a form that survives operational reality.
Examples:
- ADR-016: thick client → thin client → "but train tunnels" → smart terminal with named regions and version-aware reconnect.
- ADR-017: fixed layouts → "but the runtime ambition" → dynamic layouts as data, with v1 reserving the protocol surface.
- ADR-018: "rolling random numbers" → "that's security through obscurity" → X25519 + HMAC → "but debugging" → MAC verifier tool + build flavors + loopback-only + loud warnings.
When implementing v2, follow the same discipline. Don't accept the first sketch of any of these designs. Stress-test against:
- 200ms RTT international users
- Mobile users on flaky train/tunnel connections
- Server restarts mid-session
- Long-lived sessions across browser hibernation
- Multiple tabs from the same user
- Concurrent sysop and user activity
- Sysop break-in mid-input
- Operational deployment mistakes (wrong build, wrong env var, wrong config file)
- Debug captures shared with non-sysops
- Compromised localStorage / browser extensions
- Adversarial new-user registrations (bots, low-effort abuse)
If the design as written doesn't have an answer for each of these, revise before implementing. First-sketch designs are starting points, not contracts.
Date: 2026-04-29 Status: Accepted
Context: v1 of SPEC.md treated doors as "Phase 5 stub menu entry." The actual design needs to land before #46/#47 to keep the protocol forward-compatible with the eventual DOS-game wrapper. Two questions need pinning:
- Who owns the screen during a door session — door or BBS?
- One WebSocket per playing user, or one WS per door (multiplexed)?
Classic BBS doors (LORD, TW2002) ran as DOS executables connected to the user via a virtual COM port. Modern stacks (Synchronet, Mystic) preserve that model: door owns the byte stream, BBS is a pipe. The upside is compatibility with thirty years of DOS doors. The downside is the BBS loses control — global notifications, mention popups, reserved keystrokes can't reach the user mid-door.
Decision:
Two interaction modes specced in voidcore-door-v1:
- Normal mode (v1). Cooperative. BBS grants the door a viewport
(a region inside
region-main, i.e. the current main app render surface); BBS minimises banner chrome to reclaim space, and still owns notifications, status, reserved keystrokes (Esc,Ctrl+], theme cycle), and session lifecycle. User input is filtered through the BBS — door sees only keys not consumed by reserved bindings. - Deferred mode (specced, not implemented). Sovereign. Door owns
the whole screen. BBS retains exactly two privileges: a force-exit
keystroke (
Ctrl+]) and the right to overlay an urgent notification (suspending the door viasuspend{}/resume{}messages).
v1 BBS rejects attach{mode: "deferred"} with MODE_NOT_SUPPORTED.
The wire surface is reserved; v2 flips the gate.
Transport: one WebSocket per door, multiplexed across all attached
users. Every message carries a session_id. A door is one service
serving N users, not N processes serving one user each. Multiplayer
becomes natural; single-player is multiplex-with-N=1, no special case.
The BBS validates every door message, enforces "door can only address
sessions it currently has attached," substitutes server-side identity
for scope: "user" storage operations, and silently drops messages
addressed to unknown sessions.
In-process Java doors and networked sidecar doors use the same protocol — only the transport differs. In-process variant is a SDK that serialises the same messages via direct method calls.
Consequences:
- Protocol stays simple and uniform: one schema, two transports.
- BBS can ship Normal mode + global notification overlay even during a door session. Mentions, sysop broadcasts, theme cycle all keep working — better UX than classic BBS doors.
- Multiplexed transport means one door's crash affects all its players. Acceptable: same as a single-process classical door, with the same restart semantics.
- Door must be idempotent for messages on already-detached sessions ("if unknown session_id, drop"). One-line SDK helper.
- Reattach-after-restart is part of the protocol —
attach.reason = "reconnect"triggers the door to rebuild state from KV. - Forward-compatible: when v2 ships Deferred mode, no protocol
bump needed; manifest opts in via
modes_supported: [normal, deferred]and BBS stops rejecting. - DOS-shim (v3+) will sit at the same protocol layer — DOSBox subprocess, ANSI/CP437 → row payload translation, runs in Deferred mode. No new contract for the BBS.
- Trust model: doors are partially trusted (operator-installed), not user-adversarial. In-process doors are trusted code. Networked doors are isolated by process boundary.
Rejected alternatives:
- Telnet/COM-port emulation only. Would maximise DOS-door compatibility on day one, but loses BBS control of global UX. The Normal-mode design retains compatibility (via Deferred + DOS-shim in v3+) without abandoning the BBS's modern feel.
- Per-user WebSocket. Each playing user opens its own WS to the door. Forces the door to coordinate state across N processes/ goroutines — the multiplayer case is just a fancy multiplex anyway, so cut to the chase.
- Doors run only in-process. Simplest but locks out other languages and the eventual DOS shim. Spec-once, transport-twice costs almost nothing.
- No reserved keystrokes. Lets doors take the whole keyboard. A misbehaving door becomes unkillable from the user's seat. Hard no.
Date: 2026-04-29 Status: Accepted
Context: Doors need persistent state. Three plausible models:
- Door brings own DB. Each door author wires JDBC, manages migrations, picks a schema. Maximum flexibility, maximum per-door operational burden.
- Door shares the BBS database. Door inserts into a
door_statetable directly via the same DataSource. Easy but couples doors to BBS schema; in-process doors get database access; networked doors don't, asymmetric. - BBS exposes a KV API over the door protocol. Door sends
storage.get/storage.put/storage.del/storage.scan; BBS handles persistence, scoping, backups.
Decision: Option 3 — namespaced KV store provided by the BBS through the door protocol. One Postgres table backs it:
CREATE TABLE door_state (
door_id TEXT NOT NULL,
scope TEXT NOT NULL CHECK (scope IN ('user', 'shared', 'global')),
scope_key TEXT NOT NULL,
key TEXT NOT NULL,
value JSONB NOT NULL,
version BIGINT NOT NULL DEFAULT 1,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (door_id, scope, scope_key, key)
);Three scopes:
user— per-player save state. BBS injectsscope_key = user_idfrom the currentsession_id; door cannot pass arbitrary user_ids.shared— cross-user state within one door (multiplayer game state).global— leaderboards, MOTD, "last winner" — visible to anyone using this door.
Compare-and-swap is exposed via expected_version. Last-write-wins is
the default. scan{prefix} returns paged entries for iteration.
KV is opt-in. Manifest declares storage_used: bbs-kv | own | none. Doors that want their own database can have one (asymmetric
in-process vs networked: in-process doors share the BBS DataSource
under their own table; networked doors connect to their own Postgres).
The KV is offered as a courtesy.
Consequences:
- Door authors who don't want to think about persistence don't have
to. Two methods (
get,put) cover 80% of game-state needs. - Backups are uniform:
door_staterolls into the same pgBackRest stanza as everything else (ADR-010). Operators get door state recovery for free. - Restart semantics are simple: door process dies, KV survives; reattach replays state from KV.
- Cross-user coordination has a clean primitive (
sharedscope) rather than every door inventing its own sync mechanism. - Networked doors still get BBS-grade persistence without holding database credentials, simplifying ops and reducing blast radius.
- Cost: one INSERT/UPDATE per put, one SELECT per get. At BBS scale (a few users, a few games) this is noise. Not a write-heavy load.
- Quota / abuse: advisory limits in v1 (1MB/value, 10MB per door×user-scope). Enforce only if anything pathological emerges.
- Schema is simple enough that an evil door can't break it — JSONB values are opaque to Postgres validation, but the surrounding columns are constrained.
Rejected alternatives:
- Door brings own DB. Too much per-door burden. The bar to authoring a door becomes "set up a Postgres" rather than "write game logic." Inhibits the "write a small thing in an afternoon" pattern that makes doors fun.
- Shared BBS database with raw access. Fine for in-process Java doors but breaks the symmetry the protocol-mediated design buys us. Networked doors would either get database creds (bad) or be second-class (also bad).
- Redis / external KV. Adds an operational dependency for what is, at homelab scale, a few hundred KV pairs across all doors. Postgres handles it fine.
- Filesystem (per-door directory). Loses transactional guarantees, loses CAS, and doesn't roll into the existing backup story.
Date: 2026-04-29 Status: Accepted (forward decision — implementation tracked as a later ticket, see ROADMAP.md)
Context: Sysop chores (banning users, posting bulletins, adding
NFO releases, broadcasting messages, tailing the audit log) are all
doable through the browser today, but a command-line client would
be useful for: scripting bulk operations (e.g. find releases/*.zip | xargs voidcore file add), running ops from a terminal without
opening a browser, and piping audit tail --json into operator
tooling.
The naive design is a REST API beside the WebSocket. That's wrong for this codebase — it would duplicate the entire sysop capability surface (auth, rate limiting, role checks, the JSONB audit log, the sealed-types totality enforcement) into a parallel endpoint set, and now there are two paths to keep in sync forever. Worse, REST gives no security advantage: any URL exposed to "the sysop's CLI" is also a URL an attacker hits if they grab the token. WS already has all the protections.
Decision: The admin CLI is a Spring Shell application that
speaks voidcore-node-v1 over WebSocket — the same protocol the
browser uses. No new server endpoints. No new auth path. No REST.
Architectural shape:
- The CLI imports the existing sealed
ClientMessage/ServerMessagerecords as a Gradle dependency. Protocol drift is impossible by construction — Java's exhaustive switch on sealed types fails to compile if either side adds a message variant the other doesn't handle. - Auth flow mirrors the browser: first run prompts for handle +
password (or reads
VOIDCORE_HANDLE/VOIDCORE_PASSWORDenv vars for unattended scripts), receives a session token viaauth.ok, stores it at~/.voidcore/session.jsonmode 0600. Subsequent runsauth.resumeand slide the TTL. - Every CLI action is dispatched through the same WS handler,
hits the same sysop-role gate, lands the same
sysop_actionsaudit row. Avoidcore broadcast "..."is indistinguishable in the audit log from a sysop pressing keys in the browser. - The CLI does not replace the Makefile. Container/infra
operations (
make backup-full,make logs,make psql,make secrets) need shell access to the VM and stay in the Makefile. The CLI is for BBS-level operations that go through the protocol. - Repository layout: multi-project Gradle build with a shared
protocol/module holding the sealed types, anapp/module for the BBS server, and acli/module for the Spring Shell client. Forces protocol stability — protocol changes are a single-module diff that both server and CLI consume.
Consequences:
- One auth surface, one rate limit, one audit log. Forever.
- Adding a new sysop command means: (1) new variant in the
sealed protocol type, (2) handler in the dispatcher, (3) new
command in
cli/. The compiler tells you if you skipped any of those. - Scriptable ops:
find releases/*.zip | xargs -L1 voidcore file addis the natural shape. JSON output (--json) for piping intojq. - Token theft from
~/.voidcore/session.jsonhas the same blast radius as token theft fromlocalStoragein the browser — both are sysop-level. File mode 0600 is the same posture as for.env. - The CLI runs anywhere the public BBS URL is reachable
(CDN → reverse proxy → app), so a sysop can do ops from a
laptop on cellular without VPN. Optional
--endpointflag for direct LAN access (e.g.ws://198.51.100.6:8080/ws) when on-prem. - New module to build, version, and deploy. Real work — not blocking anything else, queued post-doors.
Rejected alternatives:
- REST API beside the WS. Doubles the auth surface, doubles the audit-log plumbing, doubles the rate-limit configuration. No security gain — REST endpoints are at least as exposed as WS, and our threat model already accounts for token theft. Net negative.
- gRPC admin API. Same as REST but with binary framing. Adds Protobuf as a build dependency for what is, at homelab scale, a few dozen RPCs. Not worth the toolchain weight.
- SSH into the VM and run commands directly against Postgres. Bypasses the audit log, which is the whole point of having one. Acceptable for emergency ops only.
- A separate "admin" sub-protocol on a different WS endpoint. Solves nothing — same code path, different URL. Moves complexity without removing it.
- Wait for v2's dynamic UI runtime and have the CLI render the remote screens. Cute but inverts the relationship. The CLI needs to script the BBS, not be a thin terminal in front of it.
Date: 2026-04-30
Status: Accepted (positioning decision; implementation tracked
as the v1.5 information-substrate milestone, see ROADMAP.md and
SPEC-documents.md)
Context: Classic BBSes were file-exchange systems with social
features around them. Files were the substance; chat and message
bases were the connective tissue. VOIDcore inherits the social
machinery (chat, mentions, NetMail, threads, profiles) but does
not host files. The current "file area" (#27, extended in #81)
is really a curated catalog of releases pointing at external
hosts — useful in a community-catalog context but not a "files" substrate
in the BBS sense.
This leaves the question: if not files, then what is the substance
of this BBS? The answer that fits the system's actual ambition is
information: how-tos, glossary entries, articles, link
collections, release notes, personal notes. The classic BBS
information lineage isn't files — it's the FidoNet info echoes,
sysop FAQs, ASCII reference docs, the early Usenet
comp.lang.c-style reference threads. Modern web has wikis, Notion,
Obsidian, Logseq, Roam — all building on the same insight, all
mostly absent from BBS-shaped systems.
The natural next move is to add documents as a feature. That is the wrong move at the wrong level — it preserves the assumption that "files" or "pages" are still the substrate, with information as a subordinate concern. The right move is to invert: information is the substrate; everything else is connective tissue.
A second framing question follows: how is information organised? Three plausible answers all carry assumptions worth rejecting:
- Filesystem-style paths. Each doc lives at exactly one path. Forces "where do I file this" decisions, makes cross-cutting topics homeless, structurally rigid.
- Wiki-style flat namespace. Loses the BBS-keystroke aesthetic; orphan docs without explicit links go dark.
- User-spaces as primitive containers. Forces a structure on what is fundamentally just metadata: a "personal space" is just the predicate "documents authored by user X." Containerising it duplicates the metadata.
The right model is set-theoretic: documents are elements in one global pool, each carrying metadata. Navigation is the act of intersecting sets defined by that metadata. "Paths" become a projection of facet selections onto a breadcrumb display — they are not the canonical structure.
Decision:
The substance of VOIDcore is documents, with the system positioned as an information system that is also social, not a social system with documents bolted on.
Documents are first-class typed primitives:
- One global pool. No
spacestable, nopathcolumn, no parent/child hierarchy. Adocumentstable with rich metadata. - Kinds.
howto,article,link,glossary,release,note. Kind drives the renderer and the expectedfrontmattershape; doesn't gate storage. - Tags. Free-form lowercase array; multiple per doc; trivially added without schema changes.
- Author + optional editors. Authors own; editors can write. Multi-user editing is metadata, not a separate "shared space."
- Visibility.
public|private. Private = author and editors only. No spaces; no per-folder ACLs. - Body. Markdown-flavoured, rendered through the existing
text-cell region renderer; URLs already linkify (
#94-adjacent work). - Frontmatter. JSONB; kind-specific structured fields.
Navigation is faceted:
- The user enters an "info" surface and is shown both recent documents and the available facets (kind, tag, author, time).
- Selecting a facet narrows the visible set.
- The breadcrumb (e.g.
INFO/kind=howto/tag=samples) is a display of the current intersection; identical sets can be reached by selecting the same facets in different orders. - At each level, only facets that meaningfully narrow the current set are suggested.
Existing v1 primitives are recast as facet views or document kinds, not preserved as separate features:
| Was | Becomes |
|---|---|
File area (#27 / #81) |
kind=release documents; V5 metadata moves into frontmatter |
Bulletins (#26) |
kind=article documents with frontmatter.pinned=true; main menu surfaces them via a ?kind=article&pinned=true facet view |
User profiles (#84) |
The default landing for ?author=X facet view; not a separate screen primitive |
Full-text search (#90) |
A facet (textual) on the document pool, not a separate surface |
Moderation queue (#92) |
A facet on documents.status (pending / published); sysop sees ?status=pending |
Conversation primitives stay separate and are explicitly not-documents — their value is ephemerality and addressing, not curation:
- Chat (
#33) — ephemeral, room-scoped - Threads / posts (
#36–#39) — sequential conversation - NetMail (
#34) — addressed, private archival - One-liners (
#32) — ephemeral wall - Mentions (
#35) — annotations across surfaces; extends to documents
This separation prevents the substrate from becoming a kitchen sink. Conversation stays itself; information has its own primitive.
Semantic retrieval (vector search, document-as-agent deliberation) is not built into VOIDcore; it is deferred to optional integration with the Anchor service (see ADR-024).
Consequences:
- Schema is smaller than a folders-and-spaces design. One documents table, one document_editors table, one document_links table for backlinks. No spaces, no paths.
- Several existing primitives collapse into facet views, which is a net code reduction long-term — the file area, bulletins, and the proposed user-profile screen all become document filters with kind-specific renderers.
- No "where do I file this" friction. The act of authoring a document is choosing kind + tags, not choosing a folder.
- Faceted navigation has a learning curve that folders don't. Mitigated by (a) a default canonical "first descent" view that most users will never go past, and (b) the keystroke-menu aesthetic — every level renders as a small numbered menu, the native BBS idiom; users feel they're descending folders even though they aren't.
- Cross-references stay valuable even with set-membership-as-
primary-organisation. The
document_linkstable lets us show backlinks ("3 documents reference this one") and lets the in-doc linkifier turn~handle/slugreferences into clickable links — wiki value without committing to wiki structure. - Information remains the substance, social remains the connective tissue. The BBS doesn't pretend to be a website navigator (paths-as-content) or a CMS (containers-as-content).
- Forward-compatible with Anchor (ADR-024). The substrate is designed to be ingested into a richer semantic-retrieval service later without schema-coupling.
- Forward-compatible with v2's dynamic UI runtime. Per-kind
renderers in v1 are pre-canned by the server; in v2 they could
emit
screen.definepayloads from the same pool.
Rejected alternatives:
- Filesystem-style paths as canonical storage. Forces a single parent for every doc; cross-cutting topics homeless; "where do I file this" anxiety; structurally rigid against tag-based reality.
- Pure wiki, flat namespace, no faceting. Loses the BBS keystroke aesthetic; orphan docs without explicit links go dark; community needs link discipline that doesn't scale.
- Spaces as primitive containers. Forces a structure on metadata
that doesn't need it; "user space" is just
?author=X; containerising duplicates the metadata. Worse, it makes shared-space ACL rules a separate concern from per-doc editor rules — two systems doing one job. - Documents as a feature alongside files / threads / chat. Keeps the substrate question unresolved. Either documents matter and should be the substrate, or they don't and shouldn't be added.
- Build semantic retrieval as part of the substrate. Duplicates Anchor; turns VOIDcore into an LLM service which it isn't; couples the canonical store to the retrieval engine. See ADR-024.
- Skip information, stay social. Acceptable; just records that every other BBS made this same call. The point of writing this ADR is to say "this is the structural change that takes VOIDcore from social-board-with-extras to information-system- that-is-social."
Date: 2026-04-30 Status: Accepted (forward decision — implementation deferred until Anchor itself ships and the v1 documents substrate from ADR-023 is in production)
Context: ADR-023 establishes documents as the substrate of VOIDcore. Documents need retrieval. Three retrieval surfaces matter:
- Lexical. Bag-of-words over document body + title + tags.
Postgres
tsvector+ GIN; built-in, exact, deterministic. - Semantic. Vector embeddings + ANN. Catches "industrial kick compression" → "sidechain ducking with parallel processing" when the words don't overlap. Requires an embedding model pipeline.
- Deliberated. "Talk to a document": pose a question, get a first-person response grounded in the document's full argument, with visible reasoning. The proposer/critic/synthesiser pattern; transparency is the trust mechanism, not decoration.
Lexical we can build today (#90's scope; folds naturally into
the documents substrate). Semantic and deliberated surfaces are
where the work compounds: you need an embedding service, an ANN
index, claim-bearing summarisation, multi-agent prompting,
streaming output, and you need to keep all of it consistent with
the canonical document store.
A separate project — Anchor (the Anchor companion repo,
v0 spec dated 2026-04-26, by the same author) — is being designed
to do exactly this work. Anchor's contribution is not "RAG with
vectors": it is opinionated retrieval that is opinionated about
its consumer. Hierarchical claim-bearing summarisation
(document → chapters → sections → paragraphs → chunks); two
retrieval shapes (/validate for LLM-to-LLM consumers; /ask for
human consumers via three-agent deliberation); document-as-agent
as the primary human interaction model.
The same author building the same retrieval layer twice — once in VOIDcore, once in Anchor — is wasteful and produces two codebases that will diverge. The clean move is to integrate.
Decision:
VOIDcore does not build semantic retrieval, document-as- agent deliberation, or any of the embedding/summarisation pipeline. These capabilities arrive via optional integration with Anchor when Anchor ships.
The integration shape is loose-coupled HTTP:
- VOIDcore owns canonical document storage (
documents,document_editors,document_links, etc., per ADR-023). - VOIDcore owns lexical search (Postgres
tsvector+ GIN). - Anchor runs as a sibling service. It owns derived state: its own schema (chapters / sections / paragraphs / chunks / embeddings) and its own LM Studio + pgvector deployment.
- An
AnchorClient(OkHttp + Jackson) calls Anchor'sPOST /ingest,POST /retrieve,POST /documents/{id}/ask. - Each VOIDcore document carries a nullable
anchor_document_id(UUID) — populated when Anchor ingest succeeds; null otherwise. - Behaviour is feature-flagged via
voidcore.anchor.enabledinapplication.yml. Defaultfalse.
Three integration points:
- On document save (when enabled). Post the body + frontmatter
to Anchor's
/ingest. Store the returned UUID ondocuments.anchor_document_id. Async; document is searchable lexically the instant it's saved, semantically once Anchor's pipeline finishes. - On document search (when enabled). Run lexical
(
tsvector) and semantic (AnchorClient.retrieve) channels in parallel. Fuse via reciprocal rank fusion. Return the merged ranked list. When Anchor is disabled or unreachable, lexical only — graceful degradation, no error to the user. - On
/ask(when enabled). New keystroke[?]on a document viewer prompts for a question. VOIDcore calls Anchor's/ask, subscribes to Anchor's SSE stream, and emits proposer → critic → synthesiser as liveregion.updatepayloads to the client over the existing WS protocol. Deliberation transcript visible in the terminal-aesthetic frame. When disabled, the keystroke isn't offered.
The AnchorClient is the only point of contact. VOIDcore
schema is unaware of Anchor's internals; Anchor is unaware of
VOIDcore's. Both can evolve independently.
Consequences:
- VOIDcore schema stays simple. No
embedding vector(768)column, no embedding worker pool, no Ollama sidecar to operate unless / until Anchor ships. - Two services to operate when enabled. Anchor runs alongside the BBS in the same compose stack (or remote). Docker Compose surface grows by one service plus Anchor's Postgres / pgvector; the BBS's own Postgres stays single-purpose.
- Graceful degradation. Anchor down or disabled → lexical
search still works;
[?]askletter is hidden; users see no errors, just fewer features. [?]askbecomes a real BBS feature. Talking to a document inside the terminal — proposer streams in, critic appears, synthesiser revises — is a primitive nothing else in the BBS ecosystem (or modern web) offers in this aesthetic. The deliberation transcript renders naturally as a sequence of region updates.- Both projects stay focused. VOIDcore doesn't grow LLM primitives it shouldn't own. Anchor doesn't grow social / conversation primitives it shouldn't own.
- Forward path stays open. If Anchor's API stabilises and a third party builds an alternative implementation, VOIDcore picks it up by configuring a different endpoint — no schema change.
Rejected alternatives:
- Build semantic retrieval inside VOIDcore. Duplicates Anchor; turns VOIDcore into an LLM service that owns model hosting, embedding pipelines, and prompt engineering. Wrong product, wrong scope.
- Bolt on LangChain / Haystack / Spring AI. External dependency churn, generic retrieval that doesn't get document-as-agent for free, replaces a coordinated two-project boundary with uncoordinated framework dependence.
- Skip semantic entirely. Lexical search alone is a 1990s experience. VOIDcore deliberately avoids nostalgia-only features when they undermine the product; leaving semantic off would be authenticity by accident.
- Couple to Anchor's schema directly. Bidirectional schema dependency; both projects break together; loses the clean service boundary that lets either side evolve.
- Mandate Anchor at deploy time. Forces operators to run two services. Defeats the "deploy as one Docker Compose stack" posture; wrong default for a personal BBS.
Date: 2026-04-29 Status: Accepted (forward decision — implementation tracked as a later ticket, see ROADMAP.md)
Context: The BBS lineage has a notable architectural gap: the classic era federated only the store-and-forward messaging (FidoNet echomail, netmail). Realtime chat never federated. DDIAL was an island; later multinode chat BBSes were islands. IRC eventually became a federated realtime protocol but evolved separately from the BBS lineage and never came back to bridge the gap.
Modern open-source BBSes haven't closed this. Synchronet bridges to IRC, but IRC's federation model is loose by 2020s standards (server- trust model, no end-to-end privacy, fragile cross-network identity). ENiGMA½ has no equivalent. There is no current BBS that participates in modern federated realtime chat.
Matrix is the obvious modern target. It is, in essence, what FidoNet
would be if redesigned for realtime in 2014: federated, room-based,
opaque to any single homeserver operator, well-specified Application
Service API for protocol bridges, store-and-forward semantics for
offline users, and (when wanted) end-to-end encryption. The
matrix-appservice-irc bridge is the canonical reference for
"translate a room-based realtime chat protocol ↔ Matrix room"; no
such bridge exists for any BBS because no BBS exposes its chat in a
bridgeable way.
VOIDcore is well-positioned because chat is already protocol-
mediated, not bytestream. Chat broadcasts are typed events
(ChatMessage records with stable user_id, server-side timestamp,
mention syntax, room_id field) carried over the WebSocket. Bridging
to Matrix is a sidecar problem, not a refactor.
Decision: Federation of the BBS chatroom (and netmail in the same breath) is via a Matrix Application Service bridge. Implementation is v2 work; this ADR locks in the v1 protocol shape that keeps the door open and records the architecture so it doesn't need to be re-derived later.
The bridge is a separate sidecar process:
[BBS user] [Matrix federation]
│ │
▼ ▼
BBS WS ──► [bridge sidecar] ◄── Matrix Application Service
│ │ │
▼ ▼ ▼
chat netmail [homeserver: conduit/dendrite]
Concretely:
- Bridge connects to the BBS as a special "bridge" sysop session
(uses the existing
voidcore-node-v1protocol — no new server endpoint, ADR-021 pattern). Subscribes to chat broadcasts and netmail delivery events. - Bridge runs as a Matrix Application Service against a homeserver. Either operator-run (Conduit is the lightest option — single Rust binary, sqlite, ~50MB RAM) or against a third-party homeserver via a registered AppService with namespace claim.
- BBS chatroom (currently hardcoded
#general) maps 1:1 to a Matrix room (e.g.#general:bbs.example.com). The protocol carriesroom_idper message so v1 doesn't lock us into a single room. - BBS user posts → bridge republishes to Matrix room as
@bbs_sysop:bbs.example.com. Matrix federation distributes to every joined homeserver. - Matrix user posts → bridge injects into BBS chat broadcast as
<matrix:alice@example.org> ...so terminal clients can visually distinguish federated participants without the BBS UI needing to know about Matrix. - Mentions cross the bridge:
@SYSOPtyped in Matrix pings the BBS user;@matrix:alicetyped in BBS pings the Matrix user. Identity table maps stable user_id ↔ MXID. - Netmail maps cleanly to Matrix DMs. A BBS user sending netmail to a federated handle creates / reuses a 1:1 Matrix room.
v1 protocol shape preserved for v2 viability (these decisions already align — recording them so they don't drift):
| Need | v1 status |
|---|---|
| Chat events not bytestream | accepted — typed ChatMessage |
| Stable user_id distinct from handle | accepted — user_id carried separately |
| Server-side timestamps on every chat msg | accepted — posted_at set server-side |
Per-message room_id field |
accepted — already in protocol shape, hardcoded #general for v1 |
| Mention syntax with stable regex | accepted — @[A-Za-z0-9_\-.]{3,16} |
| Per-user mention notifications | accepted — #35 |
| Audit log of every cross-protocol message | will piggyback on sysop_actions's pattern |
Consequences:
- First-mover position. No BBS has shipped Matrix bridging.
VOIDcore in v2 would be the first where a user on Element
Web can join
#generaland chat with terminal-client BBS users, with mentions / DMs / scrollback all working cross-protocol. - Aesthetic preserved. Federated users appear in the BBS as
another set of handles with a
matrix:prefix marker. No modern- web-feature leaks into the terminal UI. - Operational dependency. Needs a Matrix homeserver. Conduit is the cheapest (single Rust binary, sqlite-backed, suitable for a homelab — adds maybe 50MB RAM and one container's worth of compose surface). Or the operator can register the bridge against an existing third-party homeserver they already use.
- Identity sprawl. Every BBS user effectively gets a Matrix MXID. Moderation now has a Matrix dimension — banned BBS user is also banned from the Matrix room. Sysop tools extend with cross-protocol semantics; ADR for that lands when v2 work begins.
- E2EE friction. Cross-protocol bridges and Matrix end-to-end
encryption play poorly together. Standard practice (mautrix,
appservice-irc) is to terminate encryption at the bridge boundary
and re-encrypt to the other protocol. Document the trust boundary
honestly; users joining
#generalfrom Matrix need to know the bridge sees plaintext. - Spec engineering. Matrix Application Service is well-specified
but bridges are one of the gnarlier corners of the ecosystem.
Realistic estimate is two to three weeks of focused work for an
implementer who knows the Matrix surface — not a weekend project.
References: matrix.org's
appservice-irc, themautrix-*family. Our half is small because chat is already typed events. - Federation gain. Community members on Matrix can talk to people on the BBS terminal client without either side installing the other's software. That is the actual product win, not the technical novelty.
Rejected alternatives:
- IRC bridge. Lower hanging fruit (Synchronet does it), but IRC's federation model is dated. Cross-network identity is unstable; scrollback / DMs / mentions don't translate cleanly. The same bridge effort is better spent on Matrix, which gets you IRC bridging for free via Matrix's own IRC bridge.
- XMPP bridge. XMPP federates but the user base has moved on; building bridge infrastructure for a network most contemporary users aren't on is poor leverage. Could be a v3 add-on if real demand materialises.
- ActivityPub bridge. ActivityPub federates social feeds, not realtime chat. Wrong protocol shape.
- Build a homegrown federation protocol for VOIDcore. The worst option. Solves a problem someone solved better and now every interlocutor needs custom client support.
- Skip federation, stay an island. Acceptable; just records that every other BBS made this same call. The point of writing this ADR now is to keep the option live without v1 commitments.
Date: 2026-04-30 Status: Accepted (refactor milestone; lands as the v1.4 internal-architecture work — between v1 base and v1.5 information substrate)
Context: ScreenRouter.java is currently a 2700+ line God
Object holding four distinct concerns:
- Routing — given a session phase, dispatch input to the right handler. (This is what the class is named for.)
- Per-screen state machines —
LOGIN_HANDLE → LOGIN_PASSWORD → MENU; sysop file edit menu → field prompt → back to menu; etc. - Per-screen layout — banner rows, frames, body content,
prompts, all hand-coded as Java calls to
Frames.update(). - Per-screen theming — colour names hardcoded inline (
"bright_yellow","grey"); border characters+/-/|baked in.
The class works but every change touches it; reading any one screen
requires scrolling through every other; concerns leak across screens
(one screen's fix breaks another). Worse, it sets a pattern:
forthcoming work — doors (#46-49, ADR-019), the documents
substrate (ADR-023, SPEC-documents.md) — would compound the
problem if it were written in the existing shape. Each new feature
becomes another section in the God Object.
The user specifically called out two desired structural changes:
- Separation of concerns. Each screen owns its logic; the router just routes; layout is data not code; theming is data not code.
- Skinning without recompile — but as an in-between step, not full template-loadable layout. Theme as data (colours, borders, glyphs); layout in compiled code.
Decision:
Refactor in three orderable steps, each independently useful, all landing before the documents substrate (ADR-023) work begins:
Extract a Screen interface. Each existing screen becomes its own
@Component-scanned class implementing it:
public interface Screen {
Phase phase();
String name();
LayoutTree onEnter(BbsContext ctx);
Transition onKey(BbsContext ctx, String key);
Transition onLine(BbsContext ctx, String text);
Transition onCancel(BbsContext ctx);
}ScreenRouter becomes a slim dispatcher:
@Component
public class ScreenRouter {
private final Map<Phase, Screen> screens;
public ScreenRouter(List<Screen> all) {
this.screens = all.stream().collect(toMap(Screen::phase, identity()));
}
public void onKeystroke(VoidCoreSession s, String key) { ... }
}Estimated impact: ScreenRouter shrinks from ~2700 lines to ~150; ~30 small Screen implementations, each 50–200 lines, each testable in isolation, each with one responsibility. No external behaviour change — same protocol, same UX, same database. Pure code-organisation win.
Each screen returns a LayoutTree from onEnter() instead of
calling Frames.update() directly. LayoutTree is a small data
hierarchy: Header / Spacer / MetaRow / FrameBox / Body / Footer / Prompt nodes carrying text and abstract style hints
(primary / accent / muted / alert), not concrete colours.
A LayoutRenderer walks the tree, applies the active theme, and
emits Frames.update calls.
Themes are expanded from "CSS variables only" (#41) to
structured YAML carrying:
- Palette: abstract style names → CGA colour names
- Border characters:
horizontal,vertical, four corners - Header glyphs:
header_left,header_right,bullet,prompt
Sysop drops a new theme YAML in themes/, redeploy (no
hot-reload), users see it in the theme picker. Layout stays in
Java; only theme is data.
Document Screen as a public extension point. A third-party JAR
on the classpath providing a @Component class WeatherScreen implements Screen gets auto-registered on the next deploy. No
runtime plugin loader, no hot-deploy, no scripting interpreter.
The boring-correct extension surface.
Sidecar plugins via the door protocol (ADR-019) remain available for non-JVM extensions; this complements rather than replaces.
Explicit non-decisions, recorded so future work doesn't drift:
- No template engine. No Mustache / Handlebars / Jinja / Thymeleaf for layout. Layout stays in compiled Java.
- No hot-reload. Theme change requires a redeploy.
- No scripted screens. No embedded JS / Lua / Python interpreter (rejecting the ENiGMA mozjs model).
- No grand plugin framework. Spring component-scan + the door protocol are the two extension points; a third "plugin runtime" is unnecessary at our scale.
LayoutTree is structurally identical to v2's screen.define
payload (ADR-017). v1.4 builds the tree in-memory and renders it
server-side; v2's protocol upgrade is "expose the tree on the wire
and let the client render." The schema is the same; only the wire
boundary moves. v1.4 makes the v2 protocol upgrade trivial without
committing to it.
Consequences:
- ScreenRouter shrinks dramatically; reading one screen no longer requires scrolling past 29 others; per-screen tests become practical.
- Forthcoming work (doors, documents) lands in the new pattern from day one — no need to refactor twice.
- Skinning surface meaningfully larger than current
#41themes: borders, glyphs, palette — all swappable per theme without touching screen code. - Adding a new screen is now a single small file plus a
Phaseenum entry — much lower cognitive load. LayoutTreebecomes the invariant around which the doors and documents work fits. Doors emitLayoutTreefor their viewport; document kind-renderers emitLayoutTreefor their viewer. Single rendering pipeline.- The v1 → v2 transition (when v2 begins) becomes "expose the rendering layer on the wire," not "rewrite every screen."
- Schema doesn't change. Database doesn't change. Protocol doesn't change. This is purely an internal refactor with extension surface added.
Cost:
- 30-ish Screen implementations need writing (one per existing screen). Each one mostly mechanical extraction. Estimated 1-2 weeks of focused work.
- One LayoutTree + LayoutRenderer + theme-loader pass. ~500-800 LOC new, similar deletion from ScreenRouter.
- Test coverage gain: per-screen tests become possible for the
first time. (Currently testing one screen requires constructing
a session and walking through
ScreenRouter's switch; with per-screen classes, you instantiate the screen and call its methods directly.)
Rejected alternatives:
- Keep ScreenRouter; live with the God Object. Compounds badly the moment doors and documents land. Every new feature becomes a section in a class no one wants to touch.
- Full template engine + hot reload of layout files. Buys hot- reload of layout, which is more flexibility than a personal BBS needs; introduces a templating engine, error reporting for malformed templates, and a watcher process. The user explicitly asked for the in-between step — theme as data, layout in code.
- Scripted screens (ENiGMA model — embedded JS). Way too much surface. Embedded interpreter, sandboxing, security review, two- language codebase. Power users get more than they need; sysop ops surface explodes.
- Refactor incrementally screen-by-screen as new work touches them. Tempting but produces a codebase where some screens use the new pattern and some don't, mixed for an indefinite period. A clean cut now, before doors and documents land, costs less.
- Move all layout to v2's protocol now (skip v1.4, jump straight to v2 dynamic UI). Conflates two decisions. v1 needs the refactor regardless of v2; coupling them means v1 can't ship without v2. Cleanly separating gives us the architecture win immediately and the protocol win when v2 is justified.
Date: 2026-05-01
Status: Accepted (lands as part of v1.4 PR-B; full spec in
SPEC-screen-navigation.md)
Context: During v1.4 PR-A's screen extraction, every cross-
screen transition went through a bespoke legacyShow* bridge on
ScreenRouter — one bridge per (caller, callee) edge in the
navigation graph. After PR-A landed there were ~30 of these
bridges. Each one had to know exactly which paint method to call
on the destination, and "back" navigation was ad-hoc per screen
(some screens called legacyShowMainMenu, some restored a previous
list from SessionState, some did neither and invented their own
re-paint).
The problems this caused:
- Each Screen had to know about its peers. A new screen meant adding a new bridge for every screen that wants to navigate to it. Quadratic in the worst case.
- "Back" was inconsistent. Some screens went back to where the user came from; some went to a hardcoded destination (e.g. main menu) regardless of how the user arrived.
- Deep flows accumulated state in session fields. A user
navigating MENU → BASES_LIST → THREADS_LIST → THREAD_VIEW →
COMPOSE_POST_BODY left a trail of
selectedBaseId,selectedThreadId, etc. — implicit state that screens read to reconstruct what the user was doing. Hard to follow, easy to leave stale. - ScreenRouter couldn't shrink without first solving the navigation problem; the bridges were the rendering router even though they shouldn't have been.
Decision: Replace the bridge-mesh with a per-session stack of phases. Top of stack is the active screen. Three operations:
push(Phase)— push and dispatch the new top'sonEnterpop()— pop the top, the screen beneath becomes active and itsonEnterre-firesreplaceTop(Phase)— swap the top without firingonEnter, used only by pre-auth linear flows (login / register)
A Navigator interface exposes these operations. ScreenRouter
implements it. BbsContext provides them as convenience helpers
(ctx.push(Phase.X), ctx.pop()).
Two structural decisions follow from this:
- Root-guard for
popon an empty stack. When the user pops the last screen (typically the main menu), the natural reading is "I'm done." v1.4 firesonAuthLogoutautomatically. (Other behaviours — confirm-and-logout, unkillable root — are deferred until there's a second use case.) - Pre-auth screens stay outside the stack. Login / register
are a linear state machine where "going back" doesn't mean
popping; they use
replaceTopto advance step-to-step. After auth succeeds, the stack is seeded withMENUand normal navigation begins. From the user's perspective, the main menu has no "back" — pressing back logs them out (the root-guard).
Consequences:
- Cross-screen navigation collapses from ~30 named bridges to a
uniform
ctx.push(Phase.X). ScreenRouter shrinks proportionally as bridges are deleted. - Each Screen owns its content; navigation is the router's problem. Screens never need to know which other screens exist.
- "Back" becomes a single primitive. Every screen's
[Q]keystroke or Esc handler isctx.pop(). Behaviour is uniform across the BBS. - Deep flows are tractable: the stack is the call graph; no more reverse-engineering "where did I come from" by reading session fields.
- Session-level state fields (
selectedSysopId,bulletinsCache, etc.) stay — they're per-screen data, not navigation history. The stack is navigation; session fields are payload. - The pre-auth exception is small and contained. Login and register were always linear; treating them as such avoids forcing them into a model that doesn't fit.
- v1.4 forgets the stack on WS disconnect. The persisted
current_screenJSONB stores a single resume target, not the whole stack. Acceptable for v1; v2 could persist the stack if there's demand.
Rejected alternatives:
- Keep the bridge mesh. Compounds badly with every new screen. Not a real option once the screen count exceeds ~10.
- Tree-of-screens with parent links. Each screen declares its parent at registration. Works for static hierarchies but breaks the moment a screen has multiple legitimate "previous" screens (e.g. SYSOP_RELEASE_EDIT is reachable from both SYSOP_RELEASES list and from any field-edit screen on pop). Stacks handle this naturally; static parent links don't.
- Push with typed arguments.
ctx.push(Phase.BULLETINS_VIEW, bulletinId). Type-safe but introduces a generic type per phase or a heterogeneous map. v1.4 punts: state goes through session fields, same as today. v1.6 can reconsider with concrete evidence of the friction. - Persistent stack across reconnects. Serialise the stack into
sessions.current_screen. Possible but raises questions about schema versioning, partial-state serialisation, and replay hazards. v1.4 trades persistence for simplicity. - Stack-everywhere including pre-auth. Forces login / register to fit a model they don't naturally inhabit. Either you let users press back from the password prompt to re-login as someone else (security smell) or you special-case anyway. The pre-auth exception is the honest separation.
Date: 2026-05-01
Status: Accepted (lands as part of v1.4 PR-B; spec section in
SPEC-screen-navigation.md §13 — "Messaging")
Context: Several screens broadcast updates across sessions: when a user posts a oneliner, every other user currently on the oneliners wall should see the new entry without manually refreshing. Same for chat messages, file-area download counts, new threads / replies, presence join/leave, and mention notifications.
The current implementation is imperative pub/sub: each writer walks the global session map, filters by current phase, and pushes a re-painted frame to each peer:
for (var entry : phases.entrySet()) {
if (entry.getValue().phase != Phase.ONELINERS) continue;
sendOnelinersFrame(peer);
}There are six of these loops across ScreenRouter, each
hand-written, each duplicating roughly the same iteration shape.
They cement a coupling that the v1.4 refactor is otherwise
trying to remove: Screens shouldn't iterate other sessions, and
ScreenRouter shouldn't know how each screen wants to be repainted.
The key observation that simplifies the design: every one of
these broadcast targets is DB-backed. Oneliners live in the
oneliners table, chat in chat_messages, files in files,
threads / posts in their own tables. The broadcasts aren't
delivering data — they're saying "the data you're looking at
changed; re-read it." The DB is the source of truth; the
broadcast is a refresh-trigger.
Decision: Cross-session change notification uses a
topic-based, payload-less, in-process pub/sub bus. Topics are
strings (e.g. "oneliners", "chat:#general", "thread:42").
Subscribers are sessions, attached automatically when their active
screen declares interest in a topic. Notification is a single
bus.notify(topic) call — no payload, no replay, no durability
guarantees.
Lifecycle integration with the Navigator stack:
- A Screen declares its topics via
Screen.topics(BbsContext) → List<String>(default: empty). Navigator.push(phase)→ after dispatchingonEnter, the router subscribes the session toscreen.topics(ctx).Navigator.pop()→ the router unsubscribes the leaving screen's topics before re-firing the new top'sonEnter.onDisconnect(session)→bus.unsubscribeAll(session).
Notification handling:
- A Screen's
onEvent(BbsContext, String topic) → Transitiondefaults to "re-fireonEnter" — i.e. re-paint with fresh DB read. This matches what the existing imperative broadcasts do (sendOnelinersFramealways re-readsoneliners.recent(40)). - A Screen wanting finer-grained behaviour (e.g. chat appending
one new line instead of re-painting fifty) overrides
onEventto handle the topic locally.
Writer-side:
// In OnelinersScreen.onLine, after insert:
ctx.publish("oneliners");
// Done. No iteration. No filtering. No broadcastOnelinerWall.The ctx.publish(topic) helper is a thin wrapper over
bus.notify(topic).
Implementation: in-process Map<String, Set<VoidCoreSession>>.
Subscriptions are weak in the sense that VoidCoreSession.isOpen()
is checked at delivery time and stale entries are cleaned up. No
external broker, no async semantics, no persistence. Estimated
~80 LOC.
Targeted notifications (mentions, sysop broadcasts) are NOT
in v1.4: every current cross-session message in the BBS is a
topic invalidation (something on screen X changed). Mentions
(@handle popup) and sysop broadcasts (force-push to every
authenticated session) are targeted notifications — different
semantic. v1.4 keeps them in their existing form
(notifyMentions in ScreenRouter, sysop broadcast via session
walk); the bus only handles topic invalidation. When targeted
notifications graduate (and they will, because they share
infrastructure with mentions), the path is per-user topics
(user:{id} subscribed-to on login). Recorded so the design
isn't relitigated.
Consequences:
- The six imperative
broadcast*loops in ScreenRouter delete. Each writer publishes to a topic; the bus does fan-out. Estimated ScreenRouter shrinkage: ~120 lines. - Screens own their primary action again. A Screen submits the new oneliner, then publishes; cross-session machinery is the bus's problem.
- No payloads = no missed-event bugs. A delivery failure means one user's view is one repaint stale; the next event (or the next time they enter the screen) makes it fresh. Idempotent by construction.
- The Navigator stack model and the bus model interlock cleanly: push subscribes, pop unsubscribes, both happen at the same hook point. The "what is this session listening to" answer is "whatever its current top of stack subscribed to."
- Forward-compatible with payloaded events: when a Screen profiles slow under repeated full re-reads, it adds a payload type for that one topic — backward-incompatible only with itself, not the rest of the bus.
- Forward-compatible with multi-node deployment: same interface, swap the in-process implementation for a Redis / NATS / similar broker. Defer until multi-node deployment is actually a concern.
Rejected alternatives:
- Keep the imperative broadcast loops. Doesn't scale (every new broadcast adds another hand-written loop), keeps the cross-session knowledge in screens / router that shouldn't have it, and is exactly the pattern the rest of v1.4 is moving away from.
- Payloaded events from day one. Solves a problem v1.4 doesn't have and adds API surface (typed event classes, payload schemas, replay considerations) before there's evidence it's needed. Adds complexity that idempotent invalidation doesn't.
- Spring
ApplicationEventPublisheras the bus. Tempting (free, framework-native) but couples the BBS's domain semantics to Spring's event model — every event becomes anApplicationEventsubclass. Our own thin interface makes it trivial to swap implementations later (Redis / NATS) and keeps domain types out of the framework. - Reactive streams (Project Reactor / RxJava). Massive
overkill for ~5 broadcast topics with at most a few hundred
subscribers each. The
Map<String, Set<Session>>is the right size for the problem. - Bake targeted notifications into v1.4 too. Mentions and
sysop broadcasts use a different semantic (per-user delivery,
not per-topic); shoehorning them into the same model now
forces design choices we don't have to make yet. Recorded as
a forward path (per-user topics,
user:{id}subscriptions) so the conversation has a starting point when it happens.
Date: 2026-05-01 Status: Accepted
Context: The v1.4 refactor is splitting ScreenRouter into
Screens, Navigator, and BbsServices incrementally. Some screens have
moved; some haven't. To let the migration proceed in small steps
without breaking behaviour, a handful of legacyX bridges live on
ScreenRouter (e.g. legacyShowUserList, legacyHandleOnelinerSubmit,
legacyPersistCurrentScreen — most of which have already been
removed) and screens reach them via (ScreenRouter) ctx.router().
The bridges have a clear lifecycle: each one disappears when its caller-side Screen is fully extracted. They are tracked tech debt with a known path to zero.
The risk is the opposite direction: someone (human or agent) hits friction during a new piece of work and reaches for a new bridge to get unstuck. Every new bridge is another point of coupling that has to be torn out later, and it almost always means one of three real architectural mismatches has gone unaddressed:
- The screen is trying to hold state that doesn't belong to it (typically data caches — see ADR-029).
- A helper is being added at the wrong layer (it doesn't need
router state, so it belongs in
BbsServices). - A cross-session broadcast is being written imperatively instead of through the bus (see ADR-027).
Decision: No new legacyX methods on ScreenRouter, no new
casts through ctx.legacyRouter(), and no new (ScreenRouter) ctx.router() casts in Screen implementations. Existing bridges are
allowed; they're tracked, with a path to deletion. New ones aren't.
When the temptation arises, the agent / engineer:
- Stops, does not add the bridge.
- Identifies which of the three mismatches above is in play.
- Either moves the helper properly (
BbsServices), introduces the missing abstraction (View layer for caches, bus topic for broadcasts), or — if none of those fit — writes up the architectural gap as a new ADR before proceeding.
The discipline is recorded both as this ADR and as a top-level rule
in AGENTS.md, so any agent reading the project at session start
sees it.
Consequences:
- The set of
legacyXmethods is monotonically decreasing. Counting them is a crude but effective health metric for the v1.4 migration. - Pressure to add a bridge becomes pressure to identify and fix the underlying architectural gap. Friction is a signal, not a thing to route around.
- Reviews have a clear, mechanical reject criterion: a PR that
introduces a new
legacy*symbol or a newctx.router()cast is rejected unless accompanied by an ADR explaining the unavoidable gap.
Rejected alternatives:
- Allow new bridges, just promise to clean them up later. Doesn't work — that's the contract that produced the 3000-line router in the first place. The whole point of the discipline is to break the "just one more" pattern.
- Encode the rule as a static analysis check (ArchUnit, etc.). Plausible but premature; the rule is enforceable by review at current scale, and the friction itself is the signal we want agents to feel rather than have automated away. Worth adding later if the codebase grows past what review covers.
Date: 2026-05-01 Status: Accepted (depends on ADR-027 message bus being live)
Context: ScreenRouter.SessionState holds three slots:
pendingHandle, bulletinsCache: List<Bulletin>,
filesCache: List<FileRecord>. Two are misnamed.
The caches are the list currently rendered to the user, used so
that when the user types [5] the router knows which Bulletin /
FileRecord they meant. They exist for two reasons that are now
both addressable elsewhere:
- Index-to-entity resolution at keystroke time — solved by the screen calling the data layer at keystroke time, not by stashing a snapshot at paint time, provided the data layer is fresh.
- Avoid re-querying the DB on every paint — solved by a cache that lives next to the data, not next to the session.
With the message bus (ADR-027) in place, the freshness guarantee
becomes structural: the data layer subscribes to its topic and
drops the cache on notify. The screen never holds a list and
never has to think about cache invalidation.
pendingHandle is a different thing entirely. It's mid-flow form
state during the linear pre-auth login / register sequence. It
doesn't belong on the router any more than the data caches do — it
belongs to the Screen that's actively walking the user through the
flow.
Decision:
-
Introduce a View layer between repository and Screen. A
BulletinView(and similarlyFileViewetc.) is a Spring singleton that:- Wraps the corresponding
Repository(which stays a stateless jOOQ adapter). - Holds an in-memory cached list, populated lazily on first read.
- Subscribes to its topic on the message bus and drops the
cache on
notify. - Exposes the read API the screen actually wants
(
list(),byId(id),page(n)).
- Wraps the corresponding
-
Screens never hold list snapshots. A screen calls
ctx.bulletins().list()on every paint and on every selection. The View answers from cache; the cache is fresh because the bus said so (or because nothing has invalidated it). -
Index-to-entity resolution becomes index-into-current-list. When the user types
[5], the screen doesctx.bulletins().list().get(4). Because the bus would have re-painted on any change since the user last saw the list, the list-as-of-paint and list-as-of-keystroke are the same view. -
pendingHandlemoves to its owning Screen. The pre-authLoginHandleScreen/RegisterHandleScreencarries the typed handle in its own per-session state (or via aTransitionpayload into the next step). It comes offSessionState. -
The
broadcastXloops in ScreenRouter delete in tandem. When a writer mutates bulletins, it callsctx.publish("bulletins"); the View invalidates; every screen currently subscribed re-paints via the defaultonEventbehaviour. The peer-cache poking (broadcastFileListUpdatewritingphases.put(peer, ...)for every other session) goes away — peer caches don't exist any more, only the singletonFileViewcache does, and it's already stale-on-notify.
After all four moves, SessionState either disappears or shrinks
to just phase — and phase is already duplicated by the
navigation stack top, making it deletable too.
Consequences:
- Screens get smaller and more obviously stateless about data — they hold UI state (selected index, scroll position) and nothing else.
- The "what's in the cache for this session" question stops being asked; the cache is global, not per-session.
- ScreenRouter loses the
bulletinsCache/filesCache/pendingHandleaccessors and theSessionStaterecord. More of the router's reason-to-exist evaporates. - Each new data-backed screen brings its own View (one Spring bean, ~30 LOC). The pattern is duplicated but each instance is trivial.
- Tests for screens stop having to construct fake
SessionStatewith stub caches. They build aBbsContextwhose Views are test doubles.
Lifecycle of the migration:
- ADR-027's message bus must land first. Without it, a View layer would have to either re-query on every read (defeating the cache) or invent its own freshness mechanism (re-creating the bus).
- Build the read-side View layer first; migrate
BulletinsListScreenandReleasesListScreento use it; delete the matching cache slot fromSessionStateand the matchinglegacy*Cacheaccessors from ScreenRouter. - Move
pendingHandleinto the relevant pre-auth Screen as part of that screen's PR-B extraction. - When all three slots are gone,
SessionStateitself goes.
Rejected alternatives:
- Cache lives on the repository. Conflates layers — the repository stays a pure DB adapter; cache + bus listening is a separate concern. The View layer keeps the repository's mock surface tiny in tests.
- Cache stays per-session, just gets invalidated by the bus. Every session pays for the same query independently when the data is global. The View singleton is one query for everyone.
- Pass the snapshot through the
Transitionchain so the screen doesn't need a cache at all. Works for the next-keystroke case but doesn't survive the user wandering around (push another screen, pop back, expect the list to be fresh). The View approach handles both cases the same way. - Keep
pendingHandleon the router because login / register are special. They're linear pre-auth flows, fine — but linear doesn't mean "router state." A screen that owns the flow can own its mid-flow data without help.
Date: 2026-05-01 Status: Forward decision (deferred; tracked smell, not v1.4)
Context: PR-B step 13b extracts {@code MentionService} from {@code ScreenRouter}. Same-room suppression (don't pop a notify on a peer who's already viewing the screen the mention was posted on) needs per-session phase information. Today that lives on {@code ScreenRouter}; {@code Navigator.currentPhase} is the read.
The Spring bean graph that resulted has a cycle:
ScreenRouter
↓ takes List<Screen>
Screen impls (e.g. OnelinersScreen)
↓ take BbsServices
BbsServices
↓ takes MentionService
MentionService
↓ takes Navigator
Navigator == ScreenRouter ← back to the top
Without intervention Spring can't pick a construction order. The
fix shipped in step 13b is @Lazy Navigator on the MentionService
constructor parameter — Spring injects a proxy that resolves to the
real ScreenRouter on first method call (well after wiring is
done). Functional, idiomatic, and works.
It is also a smell. @Lazy is the "I have a circular dependency I
don't want to fix structurally" annotation. Every reader who sees
it has to reconstruct why the cycle exists; the next refactor
that touches MentionService or Navigator risks tripping over it.
Decision (forward): Extract a NavigationState bean that owns
the per-session navigation stack and provides phase queries. The
shape:
NavigationState (bean, leaf)
- per-session Deque<Phase> stacks
- currentPhase(session)
- push(session, phase)
- pop(session) → Optional<Phase> (returned phase = leaving)
- replaceTop(session, phase)
- clear(session)
ScreenRouter implements Navigator
- delegates stack ops to NavigationState
- layers onEnter/onEvent dispatch + bus subscription on top
MentionService takes NavigationState (not Navigator)
- reads currentPhase only; no mutation
- no cycle: NavigationState has no deps on Screens / ScreenRouter
After the extraction the @Lazy comes off and the dep graph is
acyclic.
When: Not v1.4. The right moment is after enough of ScreenRouter's
non-navigation responsibilities have moved out (rendering — done;
caches — ADR-029; submit handlers — partly done) that the navigation
core is small enough to extract cleanly. Extracting now would touch
push / pop / replaceTop / Navigator / ScreenRouter all at once with
the rest of the migration in flight — not worth the conflict
surface.
Trigger: when (a) SessionState deletes per ADR-029 step 4, or
(b) the next time MentionService (or any other service) needs
Navigator for read-only state queries — that's two callers and
the smell stops being theoretical.
Consequences (when done):
@Lazyannotation goes away. One fewer Spring incantation a reader has to understand.MentionServiceand any future service-layer bean depending on navigation state has a clean dep (a leaf bean), not a 3000-line god object behind a proxy.- Tests for
MentionServiceget easier — the test stub forNavigationStateis trivial (aMap<String, Phase>); stubbingNavigatorrequires a fuller fake. - One more bean in the graph; one less ad-hoc proxy. Net win.
Rejected alternatives:
- Keep
@Lazyindefinitely. Works but accumulates as more services need read-only navigation queries; each adds another@Lazyand another opportunity to misread the wiring. - Make
Navigatora smaller interface. The cycle isn't about surface area —MentionServiceonly callscurrentPhasealready. The problem is thatNavigator's impl (ScreenRouter) has the fat dep graph. SplittingNavigatorintoNavigatorRead+NavigatorWritedoesn't break the cycle because the same bean implements both. - Have
MentionServiceskip same-room suppression. Removes the cycle but ships a behaviour regression — peers see two notifications (the inline line + the popup) instead of one. Not worth it.
Date: 2026-05-01 Status: Accepted (V1.5 server-side abstraction); V2 wire-format extension is a forward decision
Context: Every region the server paints today uses fixed row-indexed positioning: the server emits {@code Frames.row(N, …)} with an integer row number, the client renders that row at that position. Works perfectly for tabular lists (users, files, threads), fixed banners, and prompts. Doesn't work well for long-form text:
- NFO body in
ReleaseViewScreenlives in a 64-col fixed-width box, manually truncated with an ellipsis if a line overflows. Real NFOs are wider than that and the server doesn't even know the client's actual canvas width. - Bulletin body in
BulletinViewScreen— same shape, manual line- by-line emission, no wrapping. - Thread post bodies in
ThreadViewScreen— same. - Netmail body in
NetmailReadScreen— same.
These are all "blob of text the server has, client should display." Browsers can render a lot more than a fixed terminal grid; the wire format we have is the bottleneck.
The smart-terminal contract from SPEC §6 ("application logic on server, client renders frames") is already a display-server architecture in the X11 / RDP / VNC tradition. The fix is to admit that frames carry more than rows.
Decision: Add a per-region rendering mode. Each region ({@code banner}, {@code main}, {@code prompt}, {@code notify}) declares its mode per paint:
FIXED— the existing row-indexed grid. What every region uses today. Stays the default forever.FLOW— a small layout-element tree the server hands the rendering layer. The renderer turns the tree into something the client can paint.
Two phases of delivery:
V1.5 — server-side abstraction, no wire change.
Screens build a {@code Layout} (a sealed type, either {@code Fixed} or {@code Flow}). A server-side renderer walks {@code Flow} trees and emits the existing {@code Frames.row(…)} wire format at a server-known canvas width (80 cols by default, overridable per screen). Client unchanged. The win for V1.5: NFO, bulletin, post, and netmail bodies stop being awkwardly fixed-grid; the server does the wrapping, but at least it does it from a layout tree instead of hand-built rows. The abstraction is internal to the server.
V2 — wire-format extension, real responsive rendering.
The protocol envelope grows a {@code frame.flow} variant carrying the {@code Element} tree directly. Client-side renderer measures against the actual browser canvas, lays out, and paints. Real responsive reflow on resize. The same screens that opted into {@code FLOW} mode in V1.5 immediately benefit; nothing else changes. This is V2's protocol work, recorded here so the abstraction shape settles in V1.5 and the protocol extension is purely additive.
The vocabulary — small on purpose. Five elements plus two decorators covers everything the current "blob of text" cases want to express without becoming X or Swing.
Element ::= VStack(children: List<Element>, gap: int = 0)
| Text(content: String, style: String) // single line, no wrap
| Para(content: String, style: String) // wraps to canvas
| Rule // horizontal divider
| Spacer(rows: int = 1) // blank line(s)
| Padded(child: Element, leftCols: int) // indent decorator
| Styled(child: Element, style: String) // colour-override decorator
Containers are vertical only — no HStack, no Grid. Existing
tabular surfaces (user list, file list, threads list) already work
in FIXED mode and will stay there. {@code FLOW} is for stacked-
text content. Adding HStack later is additive if a screen
genuinely needs it.
Styles are existing string handles into the theme CSS ({@code phosphor / amber / cga / modern}). No new styling — the win is layout, not appearance.
The renderer — V1.5:
The walk is straight-line code, no constraint solver, no measurement passes:
- {@code VStack(children, gap)} → walk children in order; emit {@code gap} blank rows between any two children.
- {@code Text(s, style)} → one {@code Row} with the styled span. Truncated at canvas width (no wrap; that's {@code Para}'s job).
- {@code Para(s, style)} → split on hard newlines; for each resulting paragraph, word-wrap to canvas width; emit one {@code Row} per visual line. Single-newline collapses to a soft wrap; double-newline preserves a paragraph break.
- {@code Rule} → one {@code Row} with
repeat("-", canvas). - {@code Spacer(n)} → {@code n} blank rows.
- {@code Padded(child, n)} → render child against a canvas narrowed by {@code n}; prefix every emitted {@code Row} with {@code n} spaces.
- {@code Styled(child, style)} → render child; rewrite the style attribute on every emitted span to {@code style} (the innermost {@code Styled} wins).
That's it. ~150 lines of renderer plus the type definitions.
Per-region mode declaration:
Frames carry a mode field. FIXED is the default; omitting the
field is FIXED. A screen sending a FLOW paint emits:
{"region":"main","mode":"flow","seq":N,"rows":[...]}In V1.5 the rows are the server-rendered output of the {@code
Flow} tree at the assumed canvas width. In V2 the same envelope
gains an alternative payload (tree instead of rows) carrying
the {@code Element} tree directly; the client picks based on
which field is present. Backward-compatible with V1.5 clients —
they ignore unknown variants.
Consequences:
- All four "blob of text" screens (NFO, bulletin body, post body, netmail body) get cleaner code immediately. The screen returns a {@code Flow(VStack(Para(body), …))} instead of hand-emitting rows.
- Tabular and prompt screens stay on
FIXED. No mass migration required; opt-in per region per paint. - Renderer lives next to the screen layer (e.g. {@code io.aeyer.voidcore.ws.flow.layout}). It's a leaf — no dependencies on screens, repositories, or session state. Trivial to test (build a tree, assert the emitted rows).
- V2 protocol extension is now scoped: add the
treepayload toframe.flow; client-side renderer is new but contained. - Editing (
TextArea, caret/selection state, IME) is not unlocked by this. That's a separate ADR for V2 with cursor-pos events on the wire. - Themes work unchanged. {@code FLOW} elements use the same style string handles {@code FIXED} rows do.
Rejected alternatives:
- Bigger vocabulary (HStack, Grid, Box, Border, etc.) up-front. YAGNI for v1.5; can add later additively. The 7-element kit covers the actual pain points.
- Replace
FIXEDentirely withFLOW. Tabular lists and the prompt region are correctly fixed-position. Forcing them through a flow renderer adds work without value. - Build a real layout engine in V1.5 (constraint solver, real measurement passes). Massive over-engineering for the actual use cases. The straight-line renderer above is enough.
- Skip V1.5, ship straight to V2 with the wire format. Loses the internal-cleanup win and forces the protocol design before the abstraction has been exercised. Server-side first means V2's protocol can be designed against working code, not theoretical shapes.
- Per-screen mode instead of per-region. The banner is always
fixed; the prompt is always fixed; only
mainactually varies. Per-region is the correct granularity. - Editing primitives (TextArea, etc.) in this ADR. Different semantic — cursor state, input events, IME, selection. Belongs in its own ADR alongside the V2 protocol work.
Date: 2026-05-01 Status: Forward decision (V2)
Context: V1 has four hardcoded regions in the wire protocol — {@code banner}, {@code main}, {@code prompt}, {@code notify}. The client has fixed CSS layout for those names. Every screen targets the same set; no screen can declare "I want a {@code header}, no banner, and a {@code sidebar}." The region vocabulary is part of the server's protocol contract, not part of any individual screen's design.
This was the right call for V1 — minimal protocol surface, every screen renders consistently, the client is dumb about layout.
ADR-031 introduces per-region rendering modes ({@code FIXED}, {@code FLOW}) but keeps the region set itself fixed. That's correct for V1.5 (server-side abstraction only; wire format unchanged). When V2 lands the {@code tree} payload (per ADR-031 §5), a related question opens up: should the region set itself be part of what a screen declares?
Decision (forward): Yes. In V2, regions become screen-owned vocabulary, not server-protocol vocabulary. A screen declares:
- Which named regions it has (could be just {@code main}; could be {@code header} + {@code main} + {@code footer} + {@code sidebar}; could be anything the screen wants).
- The relative arrangement of those regions (probably a top-level layout tree using the same {@code Element} vocabulary from ADR-031, scaled up to handle 2D placement — additive, not a new vocabulary).
- The mode of each region ({@code FIXED} or {@code FLOW}).
The client renders whatever the screen declares. The four V1 names
become conventional — most screens still have a {@code main}
because most screens have a primary content area — but they're not
reserved. A Goodbye screen that wants a single full-canvas
banner with no input region can declare that. A future
SysopDashboard that wants a left sidebar and a main pane can
declare that.
Consequences (when V2 lands this):
- The wire envelope's {@code region} field becomes a screen-defined string, not an enum-like reserved word.
- The client side renderer no longer hardcodes positions for banner/main/prompt/notify. It reads the screen's region declaration on push and lays out accordingly.
- Existing V1.5 screens declaring the canonical four-region layout keep working unchanged through the V2 transition — V2 treats the V1 region names as a default convention if a screen doesn't declare its own layout.
- This pairs cleanly with ADR-031: ADR-031 defines what's inside a region ({@code Flow} tree of elements); ADR-032 defines what the regions themselves are. Same vocabulary scaled up one level — a screen's top-level declaration is a tree of regions, each of which has a mode and (if FLOW) its own internal tree.
What stays the same:
- The
notifyregion (transient toasts) probably stays a server- reserved name even in V2 — it's a cross-screen capability that any screen should be able to fire-and-forget. Recorded here so the conversation has a starting point; final call is V2-time. - {@code BbsContext.send(ServerMessage)} stays the screen's outbox — what changes is the shape of frames, not the send mechanism.
- Themes, styles, frame sequence numbers — unchanged.
Why forward, not now:
V1.5 ships ADR-031 server-side only (no protocol change). Adding
screen-owned regions on top of that requires the same V2 protocol
extension (tree payload variant) that ADR-031 §5 already plans.
There is no incremental V1.x value in delivering screen-owned
regions without the wire change — every existing V1 screen is
happy with the four canonical regions, and the {@code notify}
region is the only one that's genuinely cross-screen.
So this ADR exists purely to establish the V2 region model before any V2 protocol design starts, so the wire format can be designed for it from day 1 instead of bolted on later.
Rejected alternatives:
- Keep the four regions hardcoded forever. Works fine for the v1 BBS aesthetic but blocks anything that wants a different shape (alternate dashboards, sysop tools with sidebars, screens that want no banner). Bakes a UX assumption into the wire format that should be a screen concern.
- Let screens declare regions in V1.5 (server-side-only). The client still has CSS for the four named regions; declaring custom regions server-side that the client can't position doesn't help anyone. Server-side region declarations need a client-side renderer to mean anything — same gating constraint as ADR-031's {@code tree} payload. Both wait for V2.
- One reserved region (
notify) plus everything else screen- owned. Probably the right end-state, but the boundary between reserved and screen-owned is a V2 design decision, not a V1.5 one. Recorded as a starting point, not pinned.
Date: 2026-05-01 (revised 2026-05-02) Status: Accepted; phase 0 (actor substrate) and phase 1 (session-by-token refactor) landed in this PR.
Context: The current VoidCoreSession is keyed by WebSocketSession.getId()
(the underlying WS connection id). It is created in
afterConnectionEstablished and destroyed in afterConnectionClosed. All
per-user state — userId, handle, currentBulletinId, currentReleaseId,
currentDocumentId, registerDraft, netmailDraft, navigation stack via
NavigationState — lives on this object and dies with the WS connection.
The auth-side Session is separate: a row in the sessions table keyed by an
opaque token (per SPEC §5). It survives WS disconnects; the client persists
the token in localStorage and replays it via auth.resume on every reconnect.
When auth.resume succeeds, the server reconstructs all the per-user
working state from scratch: re-attach the user, re-fetch
current_screen from the DB, push the corresponding Phase, refire
onEnter so the screen repaints. Every reconnect pays the cost of re-doing
this.
This conflation of "BBS session" with "WebSocket connection" caused a cascade of small bugs the v1.5 debugging cycle uncovered:
- Pong-arrival race vs heartbeat-close.
HeartbeatSchedulerdecides to close a session for missed pongs; in the same window a pong is sitting in Tomcat's frame buffer. The close fires; the pong arrives 50ms later and hitshandlePongMessagewithsession-known=falsebecauseafterConnectionClosedalready calledregistry.remove. Hysteresis on the close mitigates the symptom; the underlying issue is that the VoidCoreSession's lifetime is tied to the wrong thing. - "Ghost WS" inbound messages. Same shape — keystrokes arrive on a WS whose VoidCoreSession was just removed. The earlier "force-close stale WS" fix was a bandaid that added log noise; the right answer is for the VoidCoreSession to outlive the WS so a brief reconnect window doesn't invalidate the session.
- Reconnect cost. Every transient WS drop runs the full post-auth
pipeline (read
current_screen, decode JSON, push the right Phase, fireonEnter, re-subscribe to bus topics). For a 30-second wifi blip, all that machinery runs again. With session-by-token, the in-memory state is still there; reconnect is just "re-attach the WS handle". - Concurrent multi-tab semantics. A user with two tabs has two VoidCoreSessions, each fully independent. Some operations (mention delivery, presence broadcasts) iterate sessions and may double-deliver to the same user. That's not a bug per se but it's awkward; if VoidCoreSession is keyed by user, multi-tab can be modelled as "one session, multiple WS attachments" which is closer to the user's mental model.
The root insight from the user's debugging: the BBS session is durable; the WebSocket is transient. The current code treats them as the same thing.
Decision:
Refactor VoidCoreSession to be keyed by the auth session token (or its
hash; or a server-allocated session-uuid that maps 1:1 to the auth token).
WebSocket connections attach and detach against an existing VoidCoreSession
instead of creating a new one each time. The auth flow becomes the
allocator: auth.login and auth.register create a new VoidCoreSession;
auth.resume returns the existing VoidCoreSession for that token.
First successful auth (login | register):
SessionRegistry.create(token, user) → VoidCoreSession
bind WS.getId() ↔ token in a side map for fast lookup
afterConnectionEstablished registers the binding,
afterConnectionClosed removes the binding (NOT the VoidCoreSession).
auth.resume on reconnect:
SessionRegistry.byToken(token) → existing VoidCoreSession (in-memory state intact)
bind the new WS.getId() to the same VoidCoreSession
NO need to re-restore current_screen — it's still in the VoidCoreSession's
NavigationState
Logout / session expiry / GC after N minutes WS-less:
SessionRegistry.destroy(token) → drops the VoidCoreSession
SessionRegistry's public surface gains:
getByToken(token)— primary lookupgetByConnection(wsId)— fast path during message dispatch- A timer that GCs VoidCoreSessions whose WS has been detached for > N minutes (configurable; default 5 minutes seems right — survives a tunnel hop or laptop sleep but reaps actual abandonment).
BbsWebSocketHandler.handleTextMessage looks up by wsId; for auth.*
messages it goes through getByToken. For all other messages, the WS must
already be bound to an VoidCoreSession (via prior auth or resume) — if it
isn't, drop the message and wait for the client's auth.resume.
AuthFinaliser and the resume path are the natural homes for the
allocator/lookup. The auth pipeline becomes:
// auth.login / auth.register
VoidCoreSession s = sessionRegistry.create(newAuthSession.token(), user);
sessionRegistry.bindWs(s, underlyingId);
// ... fire onEnter, return ResumeOk / AuthOk
// auth.resume
VoidCoreSession s = sessionRegistry.byToken(req.token()).orElse(null);
if (s == null) { /* token expired or first connect with stale token */
sendErr("AUTH_REQUIRED");
return;
}
sessionRegistry.bindWs(s, underlyingId);
// already-painted state is in s.navState; just confirm with the clientConsequences:
- Pong-arrival race disappears. A pong arriving 50ms after the
heartbeat close-decision still finds the VoidCoreSession (it's not removed
on WS close, only the WS binding is).
resetPingCounterworks on the still-attached counter; the next tick's increment-and-check uses the up-to-date value. Hysteresis becomes less critical (still useful as defence-in-depth). - "Ghost WS" path goes away. Inbound messages on a WS whose binding was removed look up the VoidCoreSession by token (carried in the envelope for auth messages, by previous binding for keystrokes). For keystrokes on a WS with no current binding, drop and wait for resume. No force-close needed.
- Reconnect is cheap. No re-running the post-auth pipeline. The
VoidCoreSession is already in the right state.
auth.resumebecomes a binding update + a "you're at screen X, here's the current paint" refresh — version-aware so unchanged regions don't even repaint. - Multi-tab is naturally modelled. One VoidCoreSession per logged-in user-token, multiple WS bindings allowed. Deliveries (mention popups, presence banners) target the VoidCoreSession; the registry decides which bound WS actually receives the frame. Future enhancement: deliver to the most-recently-active WS only.
- GC must be designed. VoidCoreSessions can outlive their WS, but they
shouldn't outlive the user's actual session. A periodic sweep reaps
VoidCoreSessions whose
auth.sessionrow has expired or whose last WS detached > N minutes ago without rebinding. This is a small bit of infrastructure but it's clean — the auth-session-row's TTL already exists as an authoritative "this session is dead" signal. - The
current_screenJSONB column is still useful, but its role shifts: from "everything we need to restore the screen on reconnect" to "fallback if the VoidCoreSession was GC'd before the user came back". It becomes the slow-path resume; the fast-path is "VoidCoreSession still in memory, just rebind". - Test surface increases. Need tests for: token-based lookup, GC
timing, multi-WS-per-VoidCoreSession, the "VoidCoreSession was GC'd, what
happens on auth.resume" path (falls back to current
applyPostAuthbehaviour, restoring fromcurrent_screen). - Backwards-compatible during migration. Both lookup paths
(by-WS-id and by-token) can coexist; new code uses by-token, legacy
paths can be migrated one screen at a time. The
Phasefield on VoidCoreSession'sNavigationStateis already there; the only change to consumer code is "where you previously got VoidCoreSession from registry.get(wsId), now you can also get it from registry.byToken".
Rejected alternatives:
- Keep WS-keyed VoidCoreSession; add a longer GC grace period. Treats the symptoms (the pong race, the ghost-WS) without addressing the conceptual mismatch. Adding hysteresis everywhere is a slippery slope; better to fix the lifecycle once.
- Move only the navigation stack to a token-keyed cache, leaving the rest of VoidCoreSession WS-keyed. Half-fix. The mention drafts, file selection ids, register draft, netmail draft are all per-user state that should survive a transient disconnect. Splitting VoidCoreSession is more code than just changing the key.
- Replace VoidCoreSession with the auth Session row entirely. The auth Session is DB-backed, designed for cold persistence (token, user_id, expiry, current_screen). Putting hot per-call state (drafts, navigation stack, message bus subscriptions) in the DB is wrong: high write rate, sync overhead, doesn't fit the data. The right answer is two layers — DB session for durability, in-memory VoidCoreSession for the working state, both keyed by the same token.
- Treat each WS as fully independent (current state). Forces the reconnect path to re-restore everything from scratch every time; creates the race conditions the v1.5 cycle hit; double-counts multi-tab users for delivery semantics. Status quo, kept until this ADR's implementation lands.
Sequencing:
This refactor is significant but bounded. It lands as a dedicated PR after the v1.5 documents-substrate milestone closes (PRs 4–8). Doing it earlier would conflict with the editor work in progress; doing it later would push us into v2 territory where ADR-018's seq/mac adoption may further reshape sessions. Right after v1.5 is the window.
Estimated scope: ~one week. Touches SessionRegistry,
BbsWebSocketHandler, VoidCoreSession lifecycle, the auth path
(AuthFinaliser, ScreenRouter.onAuthResume), plus a GC scheduler
sibling to HeartbeatScheduler. Existing screens don't change — they
already operate on VoidCoreSession opaquely.
Implementation note (added 2026-05-02):
The implementation vehicle is the actor model, landed alongside this ADR
in the same PR. Before token-keying the lifetime, the synchronisation
primitive itself had to be cleaned up — the original WS-keyed
VoidCoreSession mixed connection state, send-side concurrency, and per-user
working state behind a volatile field soup plus a sendLock. Two
production bugs traced to that conflation (bus-loop recursion in screen
push; pong-vs-text-frame race in heartbeat) made the substrate work the
prerequisite, not a parallel concern.
Concretely:
- Phase 0 — actor substrate (in this PR, lower commits):
VoidCoreSessionbecomes an interface; concrete state lives inVoidCoreSession; aSessionActorowns aBlockingQueue<Msg>and a virtual thread; aSessionProxyimplements the interface and routes every call throughactor.submit. Bus listeners enqueue async on the recipient's actor (enqueueAsync) so a synchronous publish never crosses a state boundary. Re-entrantsubmit(worker-thread fast-path) prevents the obvious actor-self-call deadlock. Lifetime still WS-keyed. - Phase 1 — token-keyed lifetime (in this PR, upper commits): the
ADR-033 decision proper.
VoidCoreSession.idbecomes a stable field captured at construction (no longerunderlying.getId());underlyingbecomes volatile + hasswapUnderlying(WebSocketSession);SessionRegistrygainsbyToken/byCurrentWsIdindices,rekeyOnAuth(actorId, token), a detached pool with TTL sweeper, andattachExistingByToken(token, ws)for the reconnect path.AuthFinalisercallsrekeyOnAuthafter login / register / resume.HeartbeatSchedulercloses the WS only — the actor lives until TTL or explicit logout. - Phase 2 — durable in-flight state (deferred): out of scope. Add when a real "in-flight compose survives a server restart" requirement emerges.
The actor was the missing primitive that made phase 1 simple. The
session-by-token refactor on top of volatile fields + sendLock would
have been threadable but ugly; on top of one-queue-one-thread it's just
"index the actor differently."
Date: 2026-08-01 Status: Accepted (documents shipped behaviour — written after the fact)
Context: DoorRuntimeService needs to call a language model — the
bundled Cityline MUD uses one for generated prose. The engine had no
sanctioned way to reach one, and ADR-024 (Anchor) deliberately scoped
retrieval to an optional external addon without saying anything about
generation. Without a decision recorded, the risk was per-door ad hoc
HTTP clients with their own timeouts, retries, and key handling.
Decision: One core gateway service (io.aeyer.voidcore.llm) that
speaks the OpenAI-compatible chat-completions shape — blocking and
SSE-streaming — against an endpoint the operator supplies. It is:
- Off by default.
voidcore.llm.enabled=false;isEnabled()additionally requires a non-blankbase-urlandchat-model, so a half-configured instance reports disabled rather than failing at call time. - Provider-agnostic.
base-url+chat-model+api-keyis the whole contract. A local Ollama or llama.cpp server, a hosted API, or nothing at all — VOIDcore doesn't care and doesn't bundle a default. - Consumed only by doors today.
DoorRuntimeServiceis the sole caller; core BBS screens do not generate text. - Bounded. Separate blocking (60s) and streaming (90s) timeouts on
separate clients, capped retries with backoff, and a dedicated
single-thread worker pool (
voidcore.workers.llm.pool-size) so a slow provider can't starve session actors.
Consequences:
- Deployments that never set
VOIDCORE_LLM_BASE_URLbehave exactly as before; the door falls back onLlmGatewayExceptionrather than breaking. "No AI in my BBS" stays a supported, default configuration. - The API key lives in operator env, never the database, and never reaches a door — doors ask the gateway, they don't hold credentials.
- Prompt content leaves the deployment when enabled. That is an operator choice made per-instance; the engine takes no position on which provider deserves it, which is why there is no default endpoint.
- Any future core consumer (a
[?]askon documents, per ADR-024) reuses this gateway rather than adding a second HTTP path.
Rejected alternatives:
- Per-door HTTP clients. Every door reimplements timeouts, retries, and key handling, and every door needs the API key. Worse security, worse failure behaviour.
- Bundling a provider SDK. Pins the engine to a vendor and adds a dependency that most deployments never use. The OpenAI wire shape is the de-facto interop format; plain HTTP against it costs nothing.
- Routing generation through Anchor (ADR-024). Anchor is a retrieval addon with a different lifecycle and may not be deployed. Wrong coupling, and it would make doors depend on a service that has nothing to do with them.
- On by default with a hosted default endpoint. Silently sends community content off-box. Non-starter for a self-hosted project.
Date: 2026-08-01 Status: Accepted
Context: ROADMAP.md files six unrelated things under "v2": the
dynamic UI runtime, cacheable content, forward-secure sessions, doors as
first-class apps, the admin CLI, the Matrix bridge, and possible
multi-tenancy. They share nothing but the label. The practical cost is
that the dynamic UI work — the only part with momentum, and the part
that decides whether the product stays bound to a character grid — is
hostage to a milestone that also contains a Matrix bridge.
The label is also wrong on its own terms. ADR-021 and ADR-022 both say
plainly that the CLI and the bridge speak voidcore-node-v1 with no
new server endpoints. They were never protocol work; they are clients.
Decision: v2 means the wire contract, and nothing else — the
voidcore-node-v1 → voidcore-node-v2 bump.
The boundary test: does it change what goes over the wire between BBS and client? Yes → v2. Only consumes the existing wire → not v2; it is an application and ships on its own schedule against v1.
v2 is, specifically, the layout and region vocabulary. That is the whole milestone:
- The
Elementtree as the general region content model. Partly shipped already, additively, asRegionUpdate.tree— see ADR-031. - New element kinds:
list(with selection events),progress,marquee,image. - Screen-owned regions —
regionbecomes a screen-declared string rather than the reserved four names (ADR-032). screen.definecarrying a real layout description instead of always emittinglayout: "default".
Reserved-but-inert fields stay inert. Four fields already sit on the
wire as forward-compatibility placeholders: envelope seq and mac,
and screen.define's cacheable / ttl_seconds. The v1 reservation
held for all of them — Envelope declares seq/mac, the client emits
seq: 0, mac: null, the server stamps them outbound and ignores them
inbound, and ScreenDefine carries the cache hints with
caching-disabled defaults.
They stay placeholders. Because the shape is already reserved, enabling any of them later is behaviour inside the existing contract — no field changes, no envelope break, no dependency on the v2 bump. That is what the reservation was for, and it means none of this work needs to be scheduled against a milestone at all.
On mac specifically, the cost/benefit does not justify it. TLS already
covers transport, so per-message HMAC buys defence against a compromised
reverse proxy plus forward secrecy for captured traffic — real, but
modest for a self-hosted board of a few hundred users. Against that:
canonical-serialisation hazards on both ends, a key lifecycle that must
now survive ADR-033's detached sessions and token-based reconnect (a
memory-only key dies on page reload while the session lives, so resume
would have to re-run the exchange against an existing token — which
leaves the token as the real credential), and ADR-018's whole
operational surface: build flavours, loopback override, a verifier tool,
warning banners. Parked, not planned. ADR-018 remains on file as the
design if the threat model ever changes. seq enforcement is far
cheaper and could ship in v1.x if reconnect debugging ever wants it, but
is likewise not scheduled.
Out of scope — applications on the protocol:
- Admin CLI (ADR-021), Matrix bridge (ADR-022) — WS clients of the existing protocol.
- Multi-tenancy — deployment and data-model shape, no wire impact.
- Anchor semantic retrieval (ADR-024) — server-side HTTP integration.
- The extension/scripting runtime — in-process and server-side.
Separate track: voidcore-door-v1 is its own wire contract. Deferred
mode and the DOS shim are door-protocol work and version independently
of the node protocol.
Explicitly not gated on v2:
viewport.resize. The message already exists in v1 and is validated; the server simply drops it (RoutingMessageDispatcher→notImplemented). Honouring it is behaviour, not wire change, and ships in v1.x.- Editor input ownership and non-destructive repaint — pure client work, and the prerequisite for resize being safe (a repaint arriving while an editor is live currently resets its mode).
Consequences:
- v2 becomes finishable. One concept, one negotiation point, one bump.
- The dynamic UI stops waiting on unrelated ambition. Most of what makes the UI less terminal-bound — resize, the editor fixes, migrating screens onto the tree path — turns out not to be v2 at all.
- The protocol version bump happens once and carries only the layout vocabulary. Nothing else has to be ready for it, because everything else the roadmap once bundled here either rides the existing wire or is already reserved on it.
- The CLI / Matrix / multi-tenancy sections stay in ROADMAP, relabelled as independent tracks rather than v2 content.
Rejected alternatives:
- Keep v2 as "everything ambitious." Guarantees nothing ships. That is the observed outcome so far.
- Bundle
seq/macinto the v2 bump "while the envelope is open," reasoning that an envelope can only be broken cheaply once. Tempting, and wrong: the fields are already on the wire, reserved and ignored, so enabling them never requires a break. Bundling them would have imported ADR-018's cost — canonicalisation, key lifecycle against detached sessions, the whole dev/prod operational surface — into a milestone that is otherwise a vocabulary extension. - Version each message type independently. No single negotiation point; the client/server capability matrix becomes combinatorial.
ADR-036 — Editor input ownership: a hidden focusable input, not a global keydown listener
Date: 2026-08-01 Status: Accepted
Context: SPEC §6.11 states the contract — server decides, client renders. The modal editor is the first widget that owns authoritative client-side state: the buffer only exists in the browser. Everything awkward about the editor follows from that, and the input path is where it hurts first.
Three different input strategies currently coexist in the client:
| Layer | Strategy |
|---|---|
input.ts (status line) |
A real <input>, kept off-screen but focusable |
text-field.ts |
A focusable div (tabIndex), keydown on the node |
editor.ts |
No focusable element; document.addEventListener("keydown") |
The editor is the outlier, and the consequences are concrete:
- Two uncoordinated global
documentkeydown listeners —input.tsandeditor.ts. Nothing connects them. Arbitration is implicit:input.tsbails when its own mode isnone, which holds only because editor-bearing screens sendinput.prompt {mode: "none"}(seeWizardFormApp.defaultInputPrompt). A screen that forgets gets every keystroke handled twice. The diagnosticconsole.debuginhandleKeyDown, and its comment about telling "the event never arrived" apart from "mode=none", is a fossil of chasing exactly this. - No IME support. There is no
compositionstarthandling anywhere in the frontend, and a keydown-driven buffer fundamentally cannot do composition. - No paste path in the editor;
input.tshas one, for the status line only. - Nothing for assistive technology to focus.
- Mobile. The on-screen keyboard requires a focused input — which is
precisely why
input.tsuses one (#42).
Decision: each editor widget owns a hidden but focusable input
element that holds focus while the editor is active, and reads keys
from it instead of from a document-level listener. Visible rendering
stays fully custom (paintEditor), so the terminal aesthetic is
untouched.
This is the Ace / CodeMirror 5 / Monaco split — native input handling,
custom painting — and it is already the pattern input.ts uses in this
codebase.
Consequences:
- Focus becomes the arbiter. While the editor's input is focused,
keys go to the editor. Correctness no longer depends on the server
having sent
mode: "none"; that becomes an optimisation rather than a requirement. - Mobile keyboard, IME composition, paste, and screen-reader focus all become reachable. (Composition still needs explicit handling — the input makes it possible, which today it is not.)
- The modal layer (
modes.ts,motions.ts,edits.ts) is unchanged. NORMAL-mode keys are intercepted before reaching the buffer, INSERT lets them through — the same shape as vim keymaps in CodeMirror/Ace. text-field.tsshould converge on the same pattern. It is already focus-based, so that change is smaller.- This does not by itself fix repaint destruction. See below.
Sequenced follow-up (separate change): paintEditor does
mountNode.replaceChildren(...) on every repaint, and renderTree
replaces a whole region's children on every tree update. That teardown
is what forces the module-global activeEditor singleton, the
findEditorId pre-scan in main.ts, and the 15-second
editor.snapshot timer. Making the editor's paint reconcile rather
than replace is the second half of this work, and it is what makes
viewport.resize safe by construction instead of by a special-case
guard. Kept separate so each lands and is verifiable on its own.
Known latent bug to cover with a test when this lands: widget ids
are generic ("body" in both ComposePostScreen and DocumentScreen,
"step" for every step of WizardFormApp), and activeEditor is keyed
on id alone. The reuse path preserves the live buffer and discards the
incoming content. No reachable path triggers it today — every core
wizard has exactly one MULTI_LINE step, and intervening screens force a
teardown — but a wizard with two consecutive long-text steps, or an
extension emitting an editor via GraalJsHostBridge, would.
Rejected alternatives:
contenteditable+ a mutation-observing view (CodeMirror 6 / ProseMirror). Higher ceiling, substantially more machinery, and in a modal editor most native editing behaviour is something you suppress rather than use.- Keep the global listener; add a repaint guard only. Cheapest path to
shipping
viewport.resize, but leaves mobile, IME, paste and accessibility broken, and keeps correctness dependent on every editor-bearing screen remembering to sendmode: "none". - Route all keys through
input.tsand forward to the editor. Makes the BBS input layer know about widget internals — the exact inversion the widget model exists to avoid.
Use ADR-NNN sequential numbering. Include date, context, decision, consequences, rejected alternatives. Keep each entry short — a paragraph per section is plenty. If a decision is later overturned, don't delete the ADR; mark it superseded and add a new ADR that supersedes it.
## ADR-NNN — Title
**Date:** YYYY-MM-DD
**Status:** Accepted | Superseded by ADR-MMM | Deprecated
**Context:** ...
**Decision:** ...
**Consequences:** ...
**Rejected alternatives:** ...