Version. 0.1 Date. 2026-07-18 Author. Claude Code on DUNIN7-M4. Status. PROPOSED, NOT APPLIED. Surfaced for Operator approval per the Step 6 halt — this modifies the auth layer.
Three levels — founder, DUNIN7 representative, outside customer — expressed as a family of Depends-injectable dependencies over a single resolver function that GRANTHA can later answer. The storage question ("where does representative live on an identity?") is answered by mirroring the mechanism the founder gate already uses, which needs no migration and no change to Stele.
Two things in here are genuine questions rather than recommendations, and both are flagged in section 5. The larger one: there is no existing route that a representative should be able to reach and an outside customer should not. The sales generator, which is that route, is Stage 2. So the middle level can be built, but proving it on an existing route means removing access someone has today.
require_founder — deps.py:599. Depends-injectable, platform-scoped. Resolves the principal (so unauthenticated callers are already 401'd upstream by get_current_principal), reads the founder UUID via get_platform_founder_person_id, and 403s on mismatch including when unset — an un-bootstrapped deployment fails closed. Returns the Principal, so a gated route keeps its person parameter unchanged. Six route usages, all in admin_grants.py.
require_operator_designation — deps.py:444. A plain async function taking a membership positionally, engagement-scoped, checking OPERATOR in membership.designations against the fixed set operator / contributor / domain_expert (persons/memberships.py:38-40). Not injectable.
The mismatch is real and not reconcilable. These answer different questions. require_founder asks "who is this person on the platform?"; require_operator_designation asks "what is this person's role in this engagement?" A person is a DUNIN7 representative across the whole platform, not per engagement. The three-level model therefore belongs alongside require_founder, not layered onto designations — and it will not share a call shape with the engagement-scoped mechanism. The build brief anticipated this; the design leans into it rather than forcing uniformity.
Recommendation: mirror the founder mechanism. A system_config key holding the representative person UUIDs, read through the same encrypting store:
platform_founder_person_id (exists today)
dunin7_representative_person_ids (new — JSON list of UUIDs)
Why this and not the alternatives:
system_config; putting representatives beside it keeps one answer to "where does platform authority live" rather than two.principals is Stele's table, and Stele is a published Apache-2.0 package. A per-identity column there would turn an internal authorization decision into a public schema change.
Alternative considered — a column on host_account (the engine-owned identity home, models.py:269). This is the better answer eventually: properly per-identity, queryable, and it scales past a handful of people. It costs a migration and it is more machinery than three names justify today.
> NAMED STAGE 3 REVISIT (Operator ruling, 2026-07-18). Representative storage moves from system_config to a host_account column when representatives become administered rather than declared. Stage 3 provisions Aldous and Warwick and is that point. This is a scheduled revisit, not a vague intention: the system_config form is knowingly the interim answer, and the trigger for changing it is the arrival of provisioning, not a growth threshold. Recorded in authority/levels.py's module docstring as well, so the code carries its own expiry note.
Alternative rejected — a platform-level engagement whose memberships encode representatives. It would reuse the designation vocabulary, but it encodes a platform fact as an engagement fact. That is the conflation this design is trying to avoid.
New module loomworks/authority/levels.py, beside founder.py:
class PlatformLevel(IntEnum):
OUTSIDE_CUSTOMER = 1
DUNIN7_REPRESENTATIVE = 2
FOUNDER = 3
Ordered deliberately, so "admits its level and refuses the levels below" is >= rather than a set of special cases.
The seam — one function, the thing GRANTHA later answers:
async def resolve_platform_level(person_id: UUID, *, db: AsyncSession) -> PlatformLevel
Founder wins over representative if somehow both. Anyone authenticated and otherwise unlisted is OUTSIDE_CUSTOMER. Fails closed in the same sense require_founder does: an unreadable or absent configuration yields the lowest level, never a higher one.
The gates, in deps.py beside require_founder:
async def get_platform_level(...) -> PlatformLevel # injectable, for routes that branch
async def require_founder(...) # EXISTING — unchanged
async def require_dunin7_representative(...) # level >= DUNIN7_REPRESENTATIVE
async def require_authenticated_customer(...) # level >= OUTSIDE_CUSTOMER
require_founder is deliberately left as-is rather than reimplemented in terms of the new resolver. It is live on six routes, it already fails closed, and rewriting a working founder gate to prove a point about symmetry is risk without benefit. The new resolver reads the same founder config, so there is one source of truth even though there are two code paths to it.
On require_authenticated_customer — an honest note. Today this is thin: get_current_principal already 401s unauthenticated callers, so the level check adds no refusal that authentication did not already perform. Its value is that the level becomes explicit at the route and GRANTHA gets a seam there. It should not be oversold as new enforcement.
Following the engine's existing discipline: all three levels admitted at their own gate; each gate refusing every level below it; and the unauthenticated case refused at each gate (401 from principal resolution, not 403 — the distinction is worth asserting so a regression that silently converts one to the other is caught).
The build brief asks that at least one existing protected route be gated per level as the proof. Founder is already satisfied (admin_grants.py). Outside-customer is satisfiable. Representative is not, because the route that distinguishes a representative from a customer is the sales generator, and that is Stage 2.
Gating some other existing route at representative level would mean removing access that outside customers have today — a live behavior change made solely to satisfy a proof requirement. That seems clearly wrong, so it is not proposed.
Three ways forward:
GET /me/platform-level returns the caller's own level (customer-gated), plus a representative-gated companion route that exists to be refused. Non-destructive, immediately testable, and /me/platform-level is independently useful to the frontend when it needs to show or hide the tools menu.
> FRONTEND CONSUMER (Operator ruling, 2026-07-18). GET /me/platform-level is the endpoint the Operator Layer consumes for tools-menu visibility. Without it the frontend would have to either hardcode identities or discover standing by calling a gated route and interpreting a 403 — deciding what to render from an error response. The endpoint reports only the caller's own standing; there is no route to ask about another identity.
Recommendation: (a). It satisfies the brief's intent without inventing authority over an existing surface, and the frontend will want that endpoint regardless.
Raised during Step 2 and carried here as design input, not as a pre-judged change.
GET /engagements returns HTTP 200 with an empty list to an unauthenticated caller rather than 401. It is scoped by session, so no data leaks — the list is simply empty. But it means "unauthenticated request to a protected surface is refused" (section 9, item 4) cannot be demonstrated on that route.
The argument for leaving it: an empty list is a truthful answer to "what engagements can you see" when the answer is none, and the recorded engagement-list scoping decision (F2-a) deliberately chose session scoping.
The argument for changing it: a caller cannot distinguish "you have no engagements" from "you are not signed in," which is a confusing failure mode for the frontend and hides expired-session bugs behind an empty state. A 401 is unambiguous and lets the client redirect to sign-in.
This is a product decision about the shape of the API, not a security hole in either form. No change proposed here. If the Operator wants the 401 behavior, it should be scoped as its own change with the frontend's empty-state handling considered alongside it, not folded into the authorization build.
For section 9's verification, a genuinely gated route (one of the six founder-gated admin_grants routes) demonstrates the refusal cleanly.
DUNIN7 — Done In Seven LLC — Miami, Florida Loomworks — dunin7.com perimeter — Stage 1 three-level authorization design — v0_1 — 2026-07-18