Status: canonical. Last updated 2026-09-04. Each ADR records the decision, the rationale, the alternatives considered, and the outcome. The rationale paragraphs are authoritative; the surrounding background and source links have been pruned.
access(args, op, **kwargs) Policy Modulesbbsengine6.message Packagestartup.main as the Canonical Bootstrap Entry Pointzoid6 Owner Role for SECURITY DEFINER Helpersbbsengine6.passwordnotify Subsystem Deletionbbsengine6 is organised as four layers:
database.py + py/src/bbsengine6/sql/.session/, member/, bank/, channel/,
message/, auth/, services/, invite.py, pgrole.py,
password.py, password_cipher/, blurb.py, folder.py,
util.py, bottombar.py, menu_next/, editor.py,
screen.py.io/, menu.py, listbox.py, form.py,
editor.py / ed/, input.py + the PHP web layer
(engine/, php/, smarty/, skin/, js/).module.py + every registered module.Separation of concerns lets each layer be tested without dragging
in the next. The data layer is swappable behind database.getpool;
business logic is shared by terminal and web clients; presentation
layers can be added without touching the layer below. The module
system overlays all three so a new feature can compose anything it
needs.
The concrete layering — which packages sit in which layer — is
documented in architecture.md.
Layered architecture. See architecture.md.
bbsengine6.module is the runtime-loadable plugin loader. Every
registered module exposes init, access, buildargs, main,
discovered via importlib.import_module. Per-op authorization is
delegated to a package-specific access(args, op, **kwargs) (see
Decision 10).
Plugins add features without touching the core. init runs once
per load; access is the policy hook; buildargs parses CLI flags;
main runs the feature. The loader wraps everything in
runcallback so exceptions surface as a clean False/traceback
rather than a process crash.
The MenuOption registry in bbsengine6.menu_next is the modern
way for game submodules to register options against a shared
menu — see the consumer pattern in casino/SPEC.md §3 and the
description in ./module.md.
module.run + the four-function contract. New modules can be
added without modifying the loader.
The primary user interface is the terminal. The Python TUI is
rich (colors, widgets, keyboard navigation); the web layer
(engine/, php/, smarty/, skin/, js/) is a secondary
read/write surface over the same database.
bbsengine.org is a bulletin board system; the BBS heritage is text-first. Terminal clients work over SSH, render instantly, and don't require a browser or JavaScript. The web layer reuses the same database, so the two clients stay in lock-step without a unifying web framework.
Terminal-first, web-secondary. The web layer is documented in
../../SPEC.md.
Python owns the terminal, the business logic, and the WebSocket
daemon (bed.py + net/). PHP owns the web request handlers.
JavaScript owns client-side interactivity in the browser.
Each language is chosen for what it does best. Python handles system complexity and the TUI. PHP is mature web hosting and matches the existing site deployment. JavaScript is the browser standard. Splitting along these lines avoids either dragging a web framework into the terminal or rebuilding the TUI for the web.
Multi-language stack with the boundaries in
architecture.md §3.
PostgreSQL 12+ is the primary database. The schema lives at
py/src/bbsengine6/sql/. JSONB, ltree, UUID-ossp, and roles are
load-bearing features.
PostgreSQL gives ACID, JSONB for flexible per-row state (flags,
attrs, __session.data), ltree for the folder hierarchy
(py/src/bbsengine6/sql/ltree.sql), UUID-ossp for session
identifiers, and a real role system that lets the engine run
each request under a member-scoped role. The five
SECURITY DEFINER helpers (manage_schema_priv,
manage_database_priv, manage_role_privs,
manage_secondary_role, get_role_privs) are owned by the
dedicated unprivileged zoid6 role — see
Decision 13.
PostgreSQL. See ../../SPEC.md for the
schema inventory.
The PHP web layer and the Python engine read/write the same
PostgreSQL database. They do not call each other over the wire
for ordinary request handling; integration points (real-time
push, login auditing) go through bed.
The two layers stay independent — neither has to know about the
other's runtime. Both can be deployed standalone. Real-time
push (Phase 11) and login flow (post-2026-08-23) share bed as
the broker: PHP authenticates locally with
bbsengine6\\password\\libpassword, Python authenticates
locally with bbsengine6.password, and bed's AuthService
issues the bearer tokens used by the WS layer.
Independent layers, shared database. Bed is the integration broker for real-time and authentication.
The package dependency graph is a strict DAG. Lower layers never
import upward. module.py is the cross-layer loader; loaded
modules may not import back into module.py. util.py has no
upward imports; it's a shared leaf.
Cycles break Python's import system, make tests brittle, and hide what depends on what. A DAG lets every layer be reasoned about and replaced in isolation.
importlib caching. Rejected:
illusion of safety; refactoring becomes terrifying.No cycles. Enforced by convention and code review; see
dependencies.md for the matrix.
The TUI uses ANSI colors (16, 256, 24-bit RGB), interactive
widgets (menu, listbox, form, editor), and keyboard
navigation. The widget set lives in bbsengine6.io and the
top-level menu.py / listbox.py / form.py / editor.py /
ed/ modules.
Rich UI is faster and more pleasant than line-mode prompts; the
BBS audience expects it; modern terminals support it natively;
the implementation cost is contained inside io/.
Rich terminal UI. Phase 4 hardening (see
../../ROBUSTNESS_REVIEW.md)
pinned DSR-based input waits, _input_dirty, the filter kwarg,
listbox math, and bottombar padding.
When changing a primary key value (e.g. member moniker),
database.update runs an explicit cascade in this order:
updatepk=True.ON UPDATE CASCADE handles any remaining dependents.ON UPDATE CASCADE fires after the parent row changes, so the
parent UPDATE alone would violate the FK. Pre-emptively rewriting
dependents first lets the cascade succeed. The single transaction
keeps the system consistent under any failure.
CASCADE alone. Rejected: the FK violation fires
before CASCADE runs.SET CONSTRAINTS ALL DEFERRED. Rejected: complex
transaction management, easy to leave constraints disabled
on failure.Explicit cascade ordering. See member.lib.update for the
implementation pattern.
access(args, op, **kwargs) Policy ModulesAuthorization for a domain operation lives in a package-local
access(args, op, **kwargs) function. The wire-protocol handler
in bed decodes tokens / parses envelopes before calling
access; access only inspects decoded state and the domain
arguments.
The wire envelope (HMAC, expiry, instance match) is bound to the
transport; the policy is bound to the domain. Mixing the two
couples every policy module to bed's HMAC scheme and forces
test fixtures to mint valid tokens. Decoupling means auth.access
can be unit-tested with plain dicts; bed/api/auth.py owns the
HMAC plumbing.
| Module | Ops |
|---|---|
bbsengine6.auth.access |
login, reconnect, refresh, revoke. |
bbsengine6.bank.access |
transfer, deposit, withdraw, read. |
bbsengine6.message.access |
subscribe, unsubscribe, list_pending. |
bbsengine6.module.check |
op="run" at module-load time. |
The shared shape is documented in auth-bank.md.
access table. Rejected: turns every
authorization decision into a SQL lookup, hides per-op
semantics.Per-op access() per package. Bed owns the wire envelope; the
package owns the policy.
bbsengine6.message Packagebbsengine6.message is split into four layers — Service,
DAL, State, Domain — mirroring casino's four-layer
architecture (see casino/SPEC.md §3).
| Layer | Module(s) |
|---|---|
| Service | bbsengine6.message.service, bbsengine6.message.lib |
| DAL | bbsengine6.message.dal.messages, …recipients, …groups, …blocking, …ratelimit, …types, …_pool |
| State | bbsengine6.message.cache |
| Domain | bbsengine6.message.templates, bbsengine6.message.access (in __init__.py) |
The DAL never imports psycopg directly; all DB plumbing goes
through bbsengine6.database. Async DAL is not yet provided —
the current implementation is fully sync.
Before the split, bbsengine6.message.lib was 1850 lines
mixing policy, I/O, rendering, and DB plumbing. Per-table DAL
modules give the service layer a clean composition surface;
the cache module is intentionally not under dal/ because
it has no DB I/O. Templates and access sit at the package root
because they're not DAL.
message.py. Rejected: untestable,
no layering, no per-table ownership.Layered package, documented in
../../SPEC.md and the
message subsystem's own docs.
startup.main as the Canonical Bootstrap Entry PointDB bring-up runs through bbsengine6.startup.main, which loops
("stage_zero", "stage_one", "engine", "bank") via
startup.lib.runmodule → console.lib.runmodule(package="bbsengine6.startup")
→ module.run(...). The thin console-script shim is
py/src/bbsengine6/bed.py.
bbsengine6.backend owns the actual check*, stage_zero,
stage_one, engine, bank modules. console/ and
startup/ carry four-line shims that re-export
init, access, buildargs, main from the canonical home.
console/ previously held admin UI; startup/ held engine-init
plumbing. The two directories had drifted into byte-identical
duplicates with a broken four-stage orchestrator. Folding the
init plumbing into backend/ makes the canonical home
unambiguous; the thin shims preserve existing import paths.
console.lib.runmodule gained a package= kwarg (default
"bbsengine6.console") so the same dispatcher serves both
call sites. At the end of a successful boot,
startup.main._maybe_subscribe_to_bed opens a BedConnection
and subscribes to bed's message pushes (failure is non-fatal —
io.getch falls back to DB polling).
console.check* import paths.Canonical home at bbsengine6.backend/. The change list is in
the Phase 0 / backend-refactor commit history (the
TODO_BACKEND.md working notes were retired as part of the
2026-09-04 doc consolidation).
zoid6 Owner Role for SECURITY DEFINER HelpersThe five privilege-management helpers — manage_schema_priv,
manage_database_priv, manage_role_privs,
manage_secondary_role, get_role_privs — are SECURITY DEFINER functions owned by a dedicated unprivileged PostgreSQL
role zoid6
(NOSUPERUSER NOCREATEDB NOCREATEROLE NOLOGIN INHERIT).
backend.checkzoid6role creates the role; backend.checkzoid6owner
runs ALTER FUNCTION ... OWNER TO zoid6 against the five
helpers if ownership has drifted. The engine schema is
AUTHORIZATION zoid6 so manage_schema_priv can GRANT USAGE.
database.verify_function_owner checks the five helpers against
a hard-coded allow-list ("zoid6", "postgres"). The postgres
entry is a one-release transition aid; it will be dropped in
the next release (see ../../TODO_zoid6_role.md).
The bootstrap principal (typically a login superuser like jam
or opencode) should not own runtime helpers. A NOSUPERUSER
owner is the smallest possible privilege that can still run
ALTER FUNCTION ... OWNER TO on the five helpers and GRANT USAGE on engine. The allow-list converts a
non-deterministic ownership tuple into a fixed two-element
list.
Because manage_schema_priv is NOSUPERUSER-owned, every
schema.sql it grants on must itself be owned by zoid6. The
engine schema is handled in-repo via backend.checkengine;
other BBS submodules ship a <module>.startup.check<module>
mirror invoked from the submodule's startup main between
extension install and the schema.sql import. casino.startup.checkcasino
is the canonical example.
postgres owner. Rejected: makes the
allow-list non-deterministic; environment drift = silent
outage.Dedicated zoid6 owner role. See ../../SPEC.md
for the full pattern.
bbsengine6.passwordbbsengine6.password is the single source of truth for bcrypt
hashing on the Python side (mirrors PHP's
bbsengine6\\password namespace). The legacy
bbsengine6.password_cipher package keeps the bbsengine6.password
namespace free for bcrypt; bbsengine6.password_cipher is the
AES-256-GCM reversible encryption strategy for IMAP/SMTP secrets.
bbsengine6.member.checkpassword verifies locally
(crypt(plaintext, stored) with a passlib bcrypt fallback) and
rewrites legacy $1$ MD5-crypt hashes to fresh $2b$06$ on the
first successful login. PHP bbsengine6\\password\\libpassword
mirrors the same cost factor and emits $2y$. The
chk_member_password_bcrypt CHECK constraint
(^\$2[abxy]$, length 60) accepts both prefixes.
Before the change, the two sides were asymmetric: Python produced
new hashes locally (bbsengine6.util._BCRYPT_ROUNDS = 6) but
still round-tripped verify through PostgreSQL crypt(). PHP
round-tripped both directions. Round-tripping verify makes every
login hit PG twice and turns a pgcrypto upgrade into a
potential auth outage. After the rewrite, both sides produce and
verify locally; PG only stores and audits.
Single source of truth on each side. See
../../CHANGELOG.md entries "php: local
bcrypt hashing, no PostgreSQL crypt() round-trip" and
"py: member.checkpassword — local verify + opportunistic rehash".
notify Subsystem DeletionThe notify messaging subsystem was deleted in 2026 (commit
a689c89). Only three functions survive — member.moniker_exists,
member.group_exists, member.get_group_members — now in
py/src/bbsengine6/member/lib.py. The historical changelog
(CHANGELOG_NOTIFY_MESSAGING.md) was deleted as part of the
2026-09-04 doc consolidation; the surviving functions are
documented in ./member.md §"Moniker and group
validation (notify-era, retained)".
The replacement is the layered bbsengine6.message package
documented in Decision 11.
notify mixed wire-protocol, policy, and DB I/O in one place
and did not survive the Phase 0-5 hardening (see
../../ROBUSTNESS_REVIEW.md Phase 0.4
"Stale root tests that couldn't pass"). The notify → message
migration produced a layered package with a clean DAL,
replaced every call site, and pinned regression tests at every
layer.
notify, fix the tests. Rejected: the architectural
problems (mixing layers, no DAL boundary) remain.notify in place. Rejected: rename-without-restructure
hides the layering change.Deleted; replaced by bbsengine6.message. The BBSENGINE6_NOTIFYD_*.md
specs are HISTORICAL — kept for archaeology, do not link to them
from new docs (see ../../SPEC.md).
| # | Decision | Choice |
|---|---|---|
| 1 | Architecture | Layered (data / business / presentation / module system) |
| 2 | Extensibility | Module / plugin system |
| 3 | Primary interface | Terminal (web secondary) |
| 4 | Languages | Python + PHP + JavaScript |
| 5 | Database | PostgreSQL 12+ |
| 6 | Web layer | Separate, shared database, bed as integration broker |
| 7 | Dependencies | No cycles |
| 8 | TUI | Rich widgets + keyboard navigation |
| 9 | PK changes | Explicit cascade ordering |
| 10 | Authorization | Per-op access(args, op, **kwargs) per package |
| 11 | Message package | Layered Service / DAL / State / Domain |
| 12 | Bootstrap entry | bbsengine6.startup.main |
| 13 | SECURITY DEFINER ownership | Dedicated zoid6 role |
| 14 | Password hashing | Local verify + opportunistic rehash on each side |
| 15 | notify subsystem |
Deleted; replaced by bbsengine6.message |
Architectural Decision Records for bbsengine6.