require_founder + /admin/* gate — v0.1
CR number. CR-2026-101
Version. 0.1
Date. 2026-06-12
Repo (code). loomworks-engine (+ a verified no-op repoint check in loomworks Operator Layer — Step 4)
Repo (this document). loomworks-record, change-requests/
Anchor. origin/main @ 158d4bc (engine HEAD at drafting; §3 facts re-verified here).
Predecessor. cr-2026-098-engine-api-boundary-closure-v0_3.md (deferred "A3" — the founder-identity decision — to "a focused follow-up CR." This is that follow-up.)
Companion deliverable. standing-notes/loomworks-standing-note-platform-founder-authority-v0_1 (records that a platform-authority principal now exists; cross-referenced in §7).
Settled rulings. R1–R4, Operator-confirmed 2026-06-12 (founder-authority CR-drafting handoff v0.1). Built to as settled; not re-decided here.
> CR-number note. The handoff said "next after 098." 099 and 100 are already assigned (CR-2026-099 Companion orientation & discovery; CR-2026-100 first-class personal recall — both landed and tagged), so the next free number is 101. The change-requests/ directory holds only cr-2026-060 and cr-2026-098 as numbered files, which is why a directory-only check misleads; a full repo grep gives 100 as the current max.
The Loomworks engine has no platform-level founder identity. The three /admin/* credit routes — POST /admin/grants, GET /admin/grants, POST /admin/provisioning — are reachable by any authenticated person, a known, accepted, temporary exposure that CR-2026-098 v0.3 deferred as "A3." This session settled the authority model (rulings R1–R4 below). This CR builds it: it stores the founder identity as a singleton, builds one shared require_founder dependency, and **applies it to the three /admin/* routes** — closing the exposure.
What changes: a system_config row names the founder; a new require_founder dependency 403s any caller who is not the founder; the three admin routes gain that gate. What does not change: the founder's own use of the admin-grants UI (their session passes); the routes' behaviour for the founder; everything outside /admin/*.
Scope out (explicit): operator-mediated account recovery (seed mechanism 3) is not in this CR. It is the next arc, built on the require_founder dependency this CR creates (R4). No audit ledger for routine admin routes (R2). No account_status gating (self-service by design). No PersonRow role column, no multi-founder, no grantable authority (R1 singleton).
This CR rests on the 2026-06-12 rulings R1–R4; it does not reopen them.
CR-2026-098 v0.3 removed dead /admin/ surface but deliberately deferred the founder-identity decision, recording: "the founder/platform-authority decision for /admin/grants and /admin/provisioning … Until this lands, grants and provisioning remain reachable by any authenticated person — a known, accepted, temporary exposure, recorded here."* It named the three open sub-questions: who the founder is and where stored; door-vs-ledger; a single shared authorization dependency.
The live Operator Layer admin-grants UI made the exposure real rather than hypothetical (a built browser consumer of POST/GET /admin/grants). The Companion functional spec v0.3 §10.3 then recorded operator-mediated recovery as "committed, deferred … gated on a prerequisite the Engine has deliberately not yet built — a platform-founder authority principal." This CR is the settled resolution of that prerequisite. It answers all three sub-questions (R1: who/where; R2: door; R3: one dependency) for the /admin/* scope.
158d4bc)
Re-confirmed against current HEAD at drafting (Step-0 was also @ 158d4bc; HEAD unchanged, line numbers current):
PersonRow (src/loomworks/persons/models.py:65–155) has no role/founder/is_admin/authority column. The only operator guard is require_operator_designation (src/loomworks/api/deps.py:449) — engagement-membership-scoped (if OPERATOR not in membership.designations: 403). No platform-wide founder/operator dependency exists anywhere (sole (require|get|assert)_*(operator|founder|admin|authority|platform) function in api/)./admin/* routes live in src/loomworks/api/routers/admin_grants.py (APIRouter(tags=["admin","credit"]), no prefix; sole admin router, included at app.py:697): POST /admin/grants → admin_grants_route (handler def :211, decorator :199); GET /admin/grants → admin_grants_list_route (:454, decorator :444); POST /admin/provisioning → admin_provisioning_route (:638**, decorator :626). All three gate on get_current_person (deps.py:564) only — any authenticated person. The router docstring (lines ~11–13) records the exposure as "pending the founder-authority follow-up."system_config (src/loomworks/system_config/models.py, SystemConfigRow, table system_config): key/value — config_key (String(128), unique uq_system_config_config_key), encrypted_value (Text, Fernet ciphertext keyed by LOOMWORKS_SECRET_KEY), label (nullable). Store (system_config/store.py): get_system_config(config_key, db) returns the decrypted plaintext value (callers parse it, e.g. int(raw)); set_system_config(...) upserts (key lowercased, value encrypted on write); delete_system_config(...). A platform_founder_person_id key fits with no migration.Organizations.presence_proof_window_seconds (persons/models.py:168–172; its write path is itself unbuilt), not account_status. account_status is a bare column; its only mutation sites are self-service (credit/lifecycle.py:131/171/240 via /me/suspend · /me/reactivate · /me/delete) and the automatic lifecycle evaluator. So /admin/* is the only un-gated platform-authority gap. Do not gate account_status in this CR — those mutations are self-service by design.Nothing material differs from Step-0. If, at implementation HEAD, any of the above has moved, halt and surface before building (drafting-handoff-encodes-stated-state).
system_config. Stored as one row, key platform_founder_person_id, value the existing founder person's UUID. No PersonRow authority column; no migration (the table is key/value). The value column is Fernet-encrypted (built for secrets); a founder UUID stored there is encrypted for no security reason — this cosmetic mismatch is accepted: no plaintext column, no migration to avoid it./admin/*. Authority is verified at the door: a session-checked dependency resolves the current person and 403s if they are not the founder, before the handler runs. No audit-ledger row for routine admin routes** — they write their own domain records; a second audit row is redundant. (The ledger is reserved for operator-mediated recovery, a separate later CR.)require_founder dependency. Exactly one dependency, mirroring require_operator_designation. Every founder-gated route uses it. Adding a future founder-gated route is "add the dependency," never "re-implement the check."/admin/* only.** Founder principal (R1) + require_founder (R3) + apply to the three /admin/* routes (R2). Operator-mediated recovery is a separate later CR built on this dependency — out of scope here.A first-class way to store and read the founder, and an explicit way to set the first one.
src/loomworks/authority/founder.py (a named home for founder-authority concepts, so the later recovery CR extends it rather than scattering checks):PLATFORM_FOUNDER_CONFIG_KEY = "platform_founder_person_id".async def get_platform_founder_person_id(db) -> UUID | None — reads get_system_config(PLATFORM_FOUNDER_CONFIG_KEY, db); returns UUID(raw) if a row exists, else None. (Reader, no side effects.)async def set_platform_founder(*, person_id: UUID, db) -> None — calls set_system_config(config_key=PLATFORM_FOUNDER_CONFIG_KEY, value=str(person_id), db=db) (encryption handled by the store). Used by the bootstrap path only.
(Alternative considered and set aside: folding the reader into the system_config package. A named authority/ module was chosen because the standing note frames platform-founder authority as a distinct authority kind above engagement scope; a discoverable home serves the recovery CR that builds on it.)
scripts/bootstrap_platform_founder.py <person_uuid> — which opens a DB session and calls set_platform_founder(person_id=<uuid>, db=...). It is idempotent (upsert on the unique config_key). It is run once, by the operator, at the same trust level as direct DB + LOOMWORKS_SECRET_KEY access — i.e. whoever can run it could already write the row by hand; the script just does it correctly through the encrypting store. No HTTP surface, no founder-gated route, is created for it.require_founder going live. Otherwise there is a window after require_founder ships and before the founder row exists where get_platform_founder_person_id returns None and all three /admin/* routes fail closed (403) for everyone, including the operator — correct fail-closed behaviour, but a self-inflicted lockout if the ordering is missed. The deploy must bootstrap the founder and ship the gate together.
(Migration alternative considered and set aside: a Fernet-encrypted value is key-dependent, so an Alembic data-migration would have to either hardcode/recompute the ciphertext or perform app-level encryption inside the migration — clumsy and brittle. A management script using the existing set_system_config encrypt path is the clean bootstrap. Recorded so the choice is not silent.)
require_founder dependency (one seam, in deps.py)
In src/loomworks/api/deps.py, mirroring require_operator_designation (:449) in register:
async def require_founder(
person: Person = Depends(get_current_person),
db: AsyncSession = Depends(get_db_session),
) -> Person:
"""Refuse with 403 when the authenticated caller is not the platform founder.
Resolves the current person (so unauthenticated callers are already 401'd
by get_current_person), reads the stored founder UUID, and 403s on mismatch.
Returns the person so a gated route can keep using it directly."""
founder_id = await get_platform_founder_person_id(db)
if founder_id is None or person.id != founder_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Founder authority required for this action.",
)
return person
One definition. The 403 detail matches the existing guard's plain register ("Operator designation required for this action." → "Founder authority required for this action."). When no founder is set (founder_id is None), the routes fail closed (403) — a deliberate safe default: an un-bootstrapped deployment exposes nothing rather than everything. (This is a behaviour change from today's open state; it is the closure itself.)
/admin/* routes (two-pass; existing code)
In admin_grants.py, change each of the three handlers' person dependency from Depends(get_current_person) to Depends(require_founder) (the dependency returns the Person, so the handler body keeps its person parameter unchanged — minimal diff, no body rewrite):
admin_grants_route (:211), admin_grants_list_route (:454), admin_provisioning_route (:638).
Update the router docstring (lines ~11–13): remove the "gated by get_current_person only — any authenticated person — pending the founder-authority follow-up" exposure note; replace with the now-built founder gate (e.g. "gated by require_founder: the three /admin/* credit routes are founder-only; non-founders receive 403"). Two-pass: inspect-and-confirm the exact current docstring + dependency lines at implementation HEAD before editing.
loomworks) — verified finding, NOT assumed
Finding (investigated, not assumed): the OL admin-grants UI does not break when the gate lands; no OL code change is required. Evidence at loomworks main c17539a:
src/lib/api/admin-grants.ts already maps 403 → {kind:"unauthorized"} — postAdminGrant (lines 70–71: if (err.status === 401 || err.status === 403) return { kind: "unauthorized" }) and the list client (line 151, same). So a 403 from the new gate is already a handled result, not an unhandled throw.src/app/admin/grants/page.tsx renders unauthorized as a clean error card (handleSubmit :55–58 → ADMIN_GRANTS.errorUnauthorizedTitle = "You don't have permission to issue grants."); GrantList.tsx renders the same on its load (:84, :113). No crash, no blank state./admin/grants exists anywhere in src/components/nav/ (reached by direct URL only), so in practice the operator (the founder) is who navigates there.api() does not redirect on 403 (only on 401), so there is no redirect-loop risk from the gate.
Net: for the founder's session the routes pass — no behavioural change. For a non-founder reaching /admin/grants by URL, the engine now 403s → the existing permission-error card renders correctly (the copy "You don't have permission to issue grants." reads correctly for the new meaning). Step 4 is therefore a verification-only step: confirm the founder bootstrap (Step 1) sets the founder to the operator's person so their session passes, and confirm no OL change is needed. One optional, non-blocking polish is noted but not done in this CR: GrantList's reuse of the form-centric errorUnauthorizedTitle is acceptable but could later get list-specific copy — out of scope here.
require_founder tests (tests/test_require_founder.py or alongside the auth tests — CC confirms the home): founder session passes; a non-founder authenticated session → 403; an unauthenticated request → 401 (unchanged — get_current_person rejects first); the no-founder-set case → 403 (fail-closed)./admin/* route tests** (the existing POST/GET /admin/grants + POST /admin/provisioning tests — e.g. test_phase_47_credit_endpoints.py; CC confirms exact files): bootstrap a founder (set_platform_founder / a system_config row) and run them under a founder session so they still pass; add an assertion that a non-founder session is refused (403) on each of the three./admin/* routes write their own domain records; a second audit row is redundant. The ledger is reserved for operator-mediated recovery.require_founder. See §8.account_status gating — self-service by design (§3 correction).PersonRow role column, no multi-founder, no grantable authority — R1 singleton; founder is one stored UUID, set once at bootstrap.
Operator-mediated recovery (seed mechanism 3) is the next arc — its own scoping, built on the require_founder dependency this CR creates. It adds: the ledger (account_recovery_granted event following the write_*_event pattern, audit/events.py), the person-level credential-reset mechanics, and the out-of-band verification boundary. It is the highest-stakes consumer of this gate and was deferred to exactly it. Not this CR's work. The companion standing note (below) records the gate as the unblock condition so both threads — CR-2026-098 v0.3 and Companion spec v0.3 §10.3 — close.
This CR is paired with standing-notes/loomworks-standing-note-platform-founder-authority-v0_1 (HTML + Markdown), which records that a platform-authority principal — a new authority kind above engagement scope — now exists in the engine. Drafted for Operator review; no code, no commit, no push until the CR text is approved.
DUNIN7 — Done In Seven LLC — Miami, Florida
Change Request — Platform-founder authority principal + require_founder + /admin/* gate — CR-2026-101 — v0.1 — 2026-06-12