This is the pre-ORM compatibility baseline at Scriptorium revision
4ec5c88d9e4ae27041599ad8293a28130b56fbf2, inspected on 2026-09-20. It records
actual source behavior, including distinctions omitted by the older architecture
summary. It is an input to implementation and acceptance testing, not a claim
that the requested ORM or migration lifecycle has shipped.
The complete application include path is
main → site_ext → render → auth → db → util; render also includes the pinned
Scribe root. Preserve this tree, wrapper signatures, and the raw database handle
passed to site_ext_boot and site_ext_dispatch. Scribe stays upstream-owned.
app/db.wfl owns seven application tables and one explicit index. Most data
access is there, but auth.session_start directly queries SQLite for seven-day
expiry, and render.site_context directly queries SQLite for the year.
main.wfl has no direct SQL. Extensions may own other tables, indexes, triggers,
and SQL through the same connection; core schema inspection must not reject,
rewrite, drop, or adopt those objects.
The baseline creates these statements with IF NOT EXISTS. No table has foreign
keys, cascade behavior, enum checks, custom collation, generated columns, or a
STRICT/WITHOUT ROWID declaration. role and status values shown in UI docs
are application conventions, not database constraints.
CREATE TABLE users (id INTEGER PRIMARY KEY AUTOINCREMENT, username TEXT UNIQUE NOT NULL, password_hash TEXT NOT NULL, role TEXT NOT NULL DEFAULT 'author', created_at TEXT DEFAULT (datetime('now')));
CREATE TABLE sessions (id TEXT PRIMARY KEY, user_id INTEGER NOT NULL, created_at TEXT DEFAULT (datetime('now')), expires_at TEXT NOT NULL, csrf_token TEXT NOT NULL DEFAULT '');
CREATE TABLE posts (id INTEGER PRIMARY KEY AUTOINCREMENT, slug TEXT UNIQUE NOT NULL, title TEXT NOT NULL, body_markdown TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'draft', author_id INTEGER, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')));
CREATE TABLE pages (id INTEGER PRIMARY KEY AUTOINCREMENT, slug TEXT UNIQUE NOT NULL, title TEXT NOT NULL, body_markdown TEXT NOT NULL DEFAULT '', status TEXT NOT NULL DEFAULT 'draft', author_id INTEGER, created_at TEXT DEFAULT (datetime('now')), updated_at TEXT DEFAULT (datetime('now')));
CREATE TABLE settings (skey TEXT PRIMARY KEY, svalue TEXT NOT NULL DEFAULT '');
CREATE TABLE media (id INTEGER PRIMARY KEY AUTOINCREMENT, filename TEXT UNIQUE NOT NULL, original_name TEXT NOT NULL DEFAULT '', content_type TEXT NOT NULL DEFAULT '', size INTEGER NOT NULL DEFAULT 0, uploader_id INTEGER, created_at TEXT DEFAULT (datetime('now')));
CREATE TABLE login_attempts (id INTEGER PRIMARY KEY AUTOINCREMENT, ip TEXT NOT NULL, attempted_at TEXT DEFAULT (datetime('now')));
CREATE INDEX idx_login_attempts_ip_time ON login_attempts (ip, attempted_at);Five integer primary keys use AUTOINCREMENT: users, posts, pages, media, and
login_attempts. A rebuild must preserve sqlite_sequence, including a high-water
mark above the current maximum row ID. The implicit unique indexes for username,
slugs, filename, and text primary keys are significant even though the app names
only the rate-limit index.
All timestamp columns and the author/uploader references are nullable. The text
primary keys have no explicit NOT NULL clause. Do not strengthen those legacy
constraints during adoption. Existing orphans and nulls are valid legacy state;
deleting a user does not delete content, media, sessions, or attempts.
The older supported sessions definition is identical except it lacks
csrf_token. The old upgrader checks
pragma_table_info('sessions') and adds
csrf_token TEXT NOT NULL DEFAULT '' only when missing. Existing session IDs,
users, timestamps, and expirations are untouched. Its tests also construct a
database containing only this old sessions table; all remaining tables are
then created by db_migrate.
- Single-row wrappers return a row map or WFL
nothingwhen no row matches. Collection wrappers return a list, including an empty list. Counts return the numericnvalue ofcount(*); no count wrapper returns the SQL row map. - SQL NULL maps to
nothing; empty text remains empty text.first_rowtests list length, not row truthiness. Defaults apply when INSERT omits a field; they must not silently replace explicit NULL or empty text. - User, session, post, page, media, and attempt mutation wrappers return the
native
executeresult, not an inserted ID or an ORM record. Current WFL source defines it as a map withaffected_rowsandlast_insert_id. Keep that wrapper contract even if the reusable ORM offers record-returning methods. setting_getreturns its fallback only when the key has no row. An existing empty setting is returned unchanged.setting_set,setting_default,install_mark_done, and successfuldb_migratereturnyes.field_ortreats missing keys andnothingas absent, but preserves empty text,no, and zero.map_hasalso treats a presentnothingas absent; it is therefore unsuitable for ORM missing-versus-NULL validation.- Query projections below are contracts. In particular, list rows intentionally omit password hashes and several content fields; changing every result to a full model record would change those contracts.
All predicates containing caller values use bound parameters. Quoted constants
such as published, time expressions, and sort keys are static source SQL.
No wrapper interpolates caller text into an identifier or SQL fragment.
| Action | Behavior | Application callers |
|---|---|---|
setting_get(conn, key, fallback) |
Select svalue by skey; fallback only for no row |
site_context, home pagination, installer form, settings form, install_is_done |
setting_set(conn, key, value) |
INSERT … ON CONFLICT(skey) DO UPDATE SET svalue = ?; returns yes |
Settings save, installation title/tagline, installed flag |
setting_default(conn, key, value) |
INSERT OR IGNORE; existing value wins |
Boot defaults |
install_is_done(conn) |
True only for setting installed exactly yes |
Dispatch gate, installer POST guard, boot backfill and startup message |
install_mark_done(conn) |
Upsert installed=yes |
Installation and boot backfill |
install_apply(conn, title, tagline, username, hash) |
Create admin, then set title/tagline/installed; catches user-create failure and returns no | Installer POST |
The old install_apply is not atomic. Its promised compatibility is that an
initial username failure leaves settings untouched; an error after user creation
can leave a partially installed site, whose next boot locks the installer because
a user exists. The new transaction implementation should make installation
atomic while retaining successful results and the duplicate-username no result.
Do not weaken the boot backfill for pre-wizard installations.
| Action | Result or mutation | Application callers |
|---|---|---|
user_count(conn) |
Numeric count of all users | Boot backfill, dashboard |
user_create(conn, username, hash, role) |
Insert three named values; DB generates ID/time | Installer, admin user create |
user_by_username(conn, username) |
id, username, password_hash, role, created_at |
Login verification, installer session creation |
user_by_id(conn, id) |
Same projection | Admin edit/update |
user_list(conn) |
id, username, role, created_at; ORDER BY id ASC |
Admin users list |
user_set_role(conn, id, role) |
Update only role by ID | Admin user update |
user_set_password(conn, id, hash) |
Update only hash by ID | Admin user update when password nonempty |
user_delete(conn, id) |
Delete only user row by ID | Admin delete after self-delete guard |
Passwords are already hashes when passed to persistence; never hash them again
during adoption or mapping. role and password_hash need explicit trusted
assignment paths, while the generic ORM must reject unchecked mass assignment.
Username update is not offered by the existing wrapper. User deletion leaves
references intact; surviving sessions cease resolving because their user join
no longer matches.
| Action | Result or predicate | Application callers |
|---|---|---|
session_create(conn, sid, user_id, expires_at, csrf) |
Insert supplied session ID/user/expiry/token; SQLite generates created time | auth.session_start |
session_user(conn, sid) |
Inner join users; u.id AS id, u.username AS username, u.role AS role, s.csrf_token AS csrf_token; s.id=? AND expires_at > datetime('now') |
current_user, request dispatch |
session_delete(conn, sid) |
Delete only matching session | Logout |
session_purge_expired(conn) |
Delete where expiry <= datetime('now') |
Boot |
login_attempt_record(conn, ip) |
Insert IP; SQLite generates ID/time | Invalid credentials and invalid/failed installer inputs |
login_attempts_recent(conn, ip) |
Count matching IP with attempted_at > datetime('now', '-15 minutes') |
Login and installer POST |
login_attempts_clear(conn, ip) |
Delete all attempts for that IP | Successful login/install |
login_attempts_purge(conn) |
Delete attempts <= datetime('now', '-15 minutes') |
Boot |
session_start generates a 32-byte random hex ID and a separate CSRF token,
obtains expiry with SELECT datetime('now', '+7 days') AS exp, then calls
session_create. Preserve SQLite UTC text values and strict time boundaries.
Legacy sessions gaining an empty CSRF token remain identifiable; csrf_ok
rejects an empty token and does not invent one during lookup or upgrade.
The HTTP limiter refuses requests once the recent count is at least 10, before credentials or CSRF are checked. Login CSRF failure does not add an attempt; bad credentials do. Installer field-validation and apply failures add attempts; successful authentication clears only the requesting IP.
post_create inserts slug, title, Markdown body, status, and author ID.
post_update updates slug/title/body/status and sets updated_at=datetime('now')
for one ID; it preserves author and creation time. post_delete deletes one ID.
Their callers are the corresponding create/update/delete HTTP handlers, which
enforce CSRF and author/admin ownership outside the wrapper.
| Read action | Exact projection | Filter and order |
|---|---|---|
post_by_id |
id, slug, title, body_markdown, status, author_id, created_at, updated_at, author_name |
ID; left join username as author_name |
post_by_slug |
Same | Slug only, including drafts; left join username |
post_list_published |
id, slug, title, body_markdown, status, created_at, author_name |
Published; created_at DESC, id DESC; bound LIMIT/OFFSET |
post_list_all |
id, slug, title, status, author_id, updated_at, author_name |
updated_at DESC, id DESC |
post_list_by_author |
Same list projection | author_id; updated_at DESC, id DESC |
post_count_published |
Numeric count | Published only |
posts_total |
Numeric count | All rows |
The author join is LEFT JOIN in every post read, so null and dangling authors
retain the post with author_name=nothing. The public post handler separately
rejects drafts; moving its filter into post_by_slug would change that public
wrapper. Public home uses published count/list and posts_per_page, falling
back to 5 if parsed value is below 1. Admin dashboard/list calls all/by-author
according to role. Edit/update/delete first call post_by_id for existence and
ownership. Duplicate slug creation is caught by the handler; other database
constraints remain authoritative.
Page create/update/delete mirror posts, including updated time and preserving author/created time. There is no user join in any page wrapper.
| Read action | Exact projection | Filter and order |
|---|---|---|
page_by_id |
id, slug, title, body_markdown, status, author_id, updated_at |
ID |
page_by_slug |
Same | Slug AND published |
page_list_all |
id, slug, title, status, updated_at |
title ASC |
page_list_by_author |
Same list projection | author_id; title ASC |
page_list_published |
id, slug, title |
Published; title ASC |
pages_total |
Numeric count | All rows |
Page ordering has no explicit secondary key. Do not silently promise or impose
an ID tie-breaker as a compatibility assumption. Public rendering uses the slug
lookup; site_context builds navigation with the published list; admin list
chooses all/by-author; edit/update/delete use ID lookup and ownership checks.
media_create inserts filename, original_name, content_type, size, uploader_id;
SQLite creates ID/time. media_by_id returns
id, filename, original_name, content_type, size, uploader_id, created_at or
nothing. media_list_all returns those fields plus left-joined username as
uploader_name, ordered created_at DESC, id DESC. media_delete removes only
the row by ID. Null/dangling uploaders do not remove a media list row.
The upload handler writes bytes first, then inserts metadata; an insertion
failure attempts to delete the just-written file. Delete checks uploader/admin
ownership, deletes the row, then attempts file deletion. ORM migration must not
introduce cascades or rewrite filenames. Preserve /assets/uploads/ URLs and
the storage path chosen at boot. Database transactions alone cannot make a
filesystem write/delete atomic.
Current boot performs these operations in order:
- Read optional
.wflcfgfrom process CWD.config_value_fromtrims lines, ignores whole-line#comments, uses the first exact key, and retains inline#text. Missing config reads as empty text. - Resolve HTTP port through
config_port_from: numeric integer 1–65535 or fallback 8080. Resolvedata_dir; create it if nonempty and absent. Defaults arescriptorium.dbandstatic/uploads; configured paths are<data_dir>/scriptorium.dband<data_dir>/uploads. - Configure the public theme and issue existing missing/refused-theme notices.
- Open
sqlite://plus resolved database path and rundb_migrate. - Insert only absent settings: site_title=Scriptorium, site_tagline=Words, well kept., posts_per_page=5.
- If any user exists and installed is not exactly yes, upsert installed=yes.
- Call
site_ext_boot(db)once with the open database handle. - Purge expired sessions and old attempts; create uploads directory if absent.
- Listen on the resolved port; print matching startup/installer URLs; dispatch requests using the same open connection until shutdown.
Administrative migration commands must resolve the same path and visibly name it. Read-only status/plan must not accidentally initialize an absent database. Startup must stop before serving if migration/adoption fails. It may perform supported forward upgrades; it must never roll back destructively. Preserve the extension's place after successful schema/default/backfill and before listening.
At request dispatch, assets are served before the installation lock. An
uninstalled site sends every other route to /install; an installed site's
late installer GET or POST redirects without applying setup. Public extension
dispatch precedes stock public routes, returns yes only after responding, and
otherwise falls through. Its signature is
site_ext_dispatch(db, req, req_method, req_path, req_body, user).
These concrete requirements follow from the inventory:
- Validate the core table structure, defaults, uniqueness, primary keys, and owned index before recording any baseline. Recognize fresh, exact current, and supported pre-CSRF schemas explicitly. Recognize safe partial legacy creation only when each present managed object matches its historical definition. Stop on incompatible or ambiguous objects without a successful ledger row. Preserve every extension-owned object and row.
- Use immutable migration-specific definitions, independent of current model metadata. Adoption is an inspected history event, never permission to claim that unexecuted arbitrary migrations ran. Preserve passwords, IDs, nullable values, defaults, sessions, CSRF tokens, timestamps, and sequence state.
- Keep existing wrappers as projection/return adapters over one reusable ORM. Model metadata can describe logical relationships without adding physical foreign keys to legacy tables. Explicit relationship loading must preserve nullable/dangling links and avoid one-query-per-row collection loading.
- Preserve existing native connection ownership for callers and extensions. ORM-owned connections need explicit close semantics; borrowed handles must not be closed unexpectedly. Transaction scope must use verified native WFL behavior and documented nested semantics, never catch-and-report-success.
- Parameterize every value, validate metadata identifiers and sort directions, distinguish missing/null/empty/default/not-found, reject unknown fields, and require explicit authority for sensitive assignment and unfiltered bulk mutation. Keep counts and generated SQL inspectable without parameter values.
- Keep the seven-day expiry, fifteen-minute attempt window, setting upsert, SQLite timestamp defaults/updates, and year lookup as explicit, named uses of a parameterized SQL escape hatch if the generic query API cannot express them naturally. Exceptions must not become a second persistence layer.
- Characterize these exact wrapper projections, ordering, empty results, return maps, duplicate errors, null/orphan relationships, installation lock, and SQL time boundaries before refactoring. Exercise file-backed adoption, restart, and HTTP callers afterward; existing memory tests alone are not recovery evidence.
The WFL foundations were read in full from
G:\repos\wfl\Docs\wfl-foundation.md. The supplied task attachment includes the
same No-Unlearning Invariant: “For every feature, the beginner form and the expert
form must be the same form, or connected by a smooth path with nothing to
unlearn.” No separate foundations attachment was present in the supplied
references, so there is no second full text to compare. The implementation must
use explicit versioned migrations in the first executable example and extend
the same model/record/query vocabulary through filters, relationships,
transactions, and schema evolution. Avoid opaque encoded metadata strings,
disposable automatic synchronization, and silently swallowed errors. Readable
WFL actions, strict validation, actionable safe diagnostics, native standard
library integration, and bounded/query-visible relationship loading make the
foundations measurable acceptance conditions.
docs/ARCHITECTURE.mdconstraint 2 says main includes render directly; actual main includes site_ext. Its diagram already shows the correct tree.- Constraint 4 says csrf ALTER is guarded by try; source now inspects schema and propagates ALTER errors. Its no-transactions claim must be rechecked against the required current nightly, not copied as a runtime limitation.
- The database-layer description and README layout say all queries live in db.wfl; auth expiry and render year queries are counterexamples.
- Architecture's theme extension recipe mentions an active-theme setting;
current implementation selects
theme/theme_rootfrom.wflcfg. - Architecture and README prose say more than 10 failures; actual HTTP threshold is at least 10 recent failures.
main.wflheader andauth.sid_from_cookiecomment claim request values only resolve in the main loop, although architecture already records that limitation as lifted and media upload reads request values in an action.- Existing docs correctly describe no general migration lifecycle at the baseline. README, CLAUDE, SECURITY, testing, CONTRIBUTING, hygiene policy, and workflow commands must describe actual shipped WFL migration/testing behavior when integration is complete.