DUNIN7 · LOOMWORKS · RECORD
record.dunin7.com
Status Current
Path inspection-briefs/loomworks-ova-reach-enforcement-step-0-report-v0_1.md

Loomworks — OVA reach enforcement — Step 0 inspection report — v0.1

Version. 0.1 Date. 2026-07-28 Status. Verification report. Answers loomworks-ova-reach-enforcement-step-0-inspection-brief-v0_1 (which executes the scoping handoff's Section 6 step 2). Author. Claude Code on DUNIN7-M4. Operator: Marvin Percival.

Engine repo. /Users/dunin7/loomworks-engine Branch. main SHA read. 1aac8154ca440b831f5ba2bd0fc02d6af890614d HEAD commit. 1aac815Archive CR-2026-155 v0.2 alongside executed v0.1 — 2026-07-28 16:46:59 -0400

Drift note. No drift. git rev-list --count 1aac815..HEAD = 0. HEAD is the same commit the prior venture-diligence inspection read, so this report's findings and that report's V1 findings describe one identical tree and compose without reconciliation. Working tree clean apart from M uv.lock (dependency lockfile, no source drift).

Read-only session. Nothing committed, nothing pushed, no engine file modified.


Summary of verdicts

| # | Verification | Verdict | |---|---|---| | V1 | Declared access-mode seam in schema | BREAKS — nothing access-shaped exists; visibility is a false friend | | V2 | Full read-surface enumeration | Descriptive — 142 engagement-scoped routes, 63 reads; no bypass found | | V3 | Held-contribution machinery | BREAKSheld is a drafting state, not an admission gate | | V4 | Phase 45 OVA seam as shipped | PARTIALLY HOLDS — two delegation/read-scoped seams, no general authorizer | | V5 | Attribution-membership coupling | HOLDS — attribution is person-anchored, snapshot-denormalized | | V6 | Scope machinery above the engagement | Descriptive — engagement is the only Memory scope |

The one-line synthesis. The good news is structural: attribution does not couple to membership (V5), and every engagement-scoped read already funnels through one membership predicate (V2) — so a reach check has exactly one place to land and will not corrupt provenance. The bad news is that three things the scoping note may have assumed exist do not: the declared access mode (V1), admit-by-trusted-core semantics (V3), and a general authorizer seam (V4).


V1 — The declared seam: access mode in schema and code

Verdict: BREAKS. No access-mode element exists. Seed v0.12's "the access mode is declared and the seam is in place" is not backed by schema at HEAD. The scoping note must add the declaration as well as the enforcement.

Searches run

Across src/ and migrations/: access_mode, acl_, access_control, reach_, restricted, visibility.

What visibility actually is

Schema. migrations/versions/0030_phase_14_engagement_visibility.py:26-35 adds it:


op.add_column(
    "engagements",
    sa.Column("visibility", sa.Text(), nullable=False,
              server_default=sa.text("'private'")),
)

The migration's own docstring (:1-10) is decisive about intent:

> "Adds a NOT NULL TEXT column with default 'private'. Schema-only — no behavior in Phase 14. Future phases will introduce 'discoverable' and the associated discovery surface. … No CHECK constraint at the database layer — application logic validates allowed values. Today: {'private'}. Future: {'private', 'discoverable'}."

ORM mapping. src/loomworks/persons/models.py:60-62 — plain Mapped[str], no enum, no Literal.

Observed value domain (from writers, since no constraint exists):

Every read of the column in application code (exhaustive — two sites, same predicate):

Both are labelled "Phase 41 (CR-2026-055 §11.1): exclude personal-visibility" (:69, :135). That is the complete set: the column is read only to keep a person's own personal engagement out of their cross-engagement dashboard rollups.

Read

visibility is a false friend, and a well-documented one — the Phase 14 migration says "no behavior" in its own docstring, no CHECK constraint or application enum ever landed, and its only two readers use it to partition personal-from-project for a dashboard rollup, never to decide whether a principal may read anything. It is not the seam, it is not an unenforced access mode, and widening it would be building a new mechanism on a column whose established meaning is "which rollups is this engagement eligible for." The scoping note should treat the declaration as unbuilt: there is no access-mode column, no ACL table, no settings flag, and no reach-shaped schema element anywhere at 1aac815.


V2 — Full read-surface enumeration

Descriptive. Method: parsed every @router.<method>("...") decorator across all router modules (src/loomworks/api/routers/.py and src/loomworks/orchestration/routers/.py), extracted each handler's Depends(...) set, and classified read (GET) vs write (non-GET).

Totals. 420 decorated routes engine-wide; 142 are engagement-scoped (path contains /engagements/{), of which 63 are reads (GET) and 79 are writes.

Engagement-scoped routes by resolver dependency

| Resolver dependency | Reads | Writes | Total | |---|---:|---:|---:| | get_contributing_contributor | 39 | 37 | 76 | | get_current_principal | 6 | 20 | 26 | | get_resolved_actor | 13 | 6 | 19 | | get_current_contributor | 4 | 5 | 9 | | get_committing_resolved_actor | 0 | 3 | 3 | | get_committing_contributor | 0 | 3 | 3 | | get_current_user | 1 | 2 | 3 | | get_current_principal + get_resolved_actor | 0 | 1 | 1 | | (no standard resolver) | 0 | 2 | 2 | | Total | 63 | 79 | 142 |

The dominant read dependency is get_contributing_contributor (39 of 63 reads, 62%) — which the prior report established is a no-narrowing pass-through (deps.py:1208-1224).

The two routes taking no standard resolver — both writes, both gated

Both take Depends(assert_engagement_creator) instead (deps.py:518-548), a Phase 25 creator-match gate: it reads engagements.created_by_person_id and requires the calling session to match, 404 on absent engagement, 403 on mismatch, 401 when a session is required but absent. Neither is a read, so neither is a read-surface leak. One caveat worth carrying: the gate leaves legacy rows with NULL created_by_person_id open, deferring to "the existing contributor middleware" (deps.py:526-530) — see the middleware finding below.

/me and /operator traversals

All /me/* GET routes take a resolver: get_current_principal (dashboard ×3, candidate-engagements, credits, current-engagement, security ×2, settings, spend, memberships), get_current_user (GET /me), or require_authenticated_customer (me_platform_level.py:78, :96). None is engagement-scoped by path; the three dashboard routes reach engagement content and bound it in SQL via the person_engagements CTE (dashboard.py:68-74, :134-140, :224-230) joining memberships on person_id — the same membership predicate, expressed as a join rather than a dependency.

There is no /operator/* route in the engine at HEAD; the Phase 40 orchestration surface mounts at bare paths (/dashboard, /inbox, /library, /projects/{project_id}/...).

The orchestration surface — a third enforcement pattern

The orchestration routers take only get_current_principal (identity-only, not engagement-scoped) and then call an in-body helper:

src/loomworks/orchestration/helpers.py:45-63


async def verify_project_membership(*, person, project_id, db) -> None:
    membership = await get_membership_for_person_on_engagement(
        person_id=person.id, engagement_id=project_id, db=db,
    )
    if membership is None:
        raise OperatorProjectNotFoundError(project_id)

Call sites: narrative.py:45, composition.py:134, conversation_history.py:92, downstream.py:46, library.py:185, converse.py:696, router.py:2275. library.py:10 states it plainly — "verify_project_membership is the SOLE auth gate (CR §12.2 / O3)."

There is no auth middleware

src/loomworks/api/app.py registers exactly three middlewares: RequestIdMiddleware (:401, logging), CORS (:430), and SlowAPIMiddleware (:449, rate limiting). No authentication or authorization middleware exists. The assert_engagement_creator docstring's reference to "the existing contributor middleware" (deps.py:530) is stale prose — enforcement is entirely per-route via Depends.

Is there a chokepoint?

No single code chokepoint — but a single semantic one. Three enforcement patterns exist (resolver dependency; in-body verify_project_membership; SQL join on memberships in the dashboard CTEs), and all three converge on one predicate: does an active membership row exist for this person on this engagement? Every call to get_membership_for_person_on_engagement in the engine — 11 sites: deps.py:430, :891, :1160; orchestration/helpers.py:59; orchestration/router.py:803, :1143, :1356, :2301; orchestration/routers/inbox.py:278; engagement/commit_orchestration.py:72; engagement/redirect.py:354 — asks that question and only that question.

I found no engagement-scoped read that bypasses the membership predicate. That is the load-bearing good news for reach enforcement: the guarantee has one semantic seam to defend, not 63. The corresponding risk is that the seam is three code paths, not one, so a reach check added at the resolver layer alone would silently miss the seven orchestration routes and the three dashboard CTEs — and the orchestration surface is precisely where a partner-facing read (/library, /inbox, conversation history) lives.


V3 — Held-contribution machinery

Verdict: BREAKS. held is a drafting state, not an admission gate. Admit-by-trusted-core semantics do not exist in any form, de-facto or otherwise.

The state machine

src/loomworks/engagement/assertions.py — five states across six transition functions:

| Function | Line | Transition | |---|---:|---| | add_assertion | :157 | (none)held (:216) | | commit_assertion | :230 | heldcommitted (:351); requires held (:290-292) | | revise_assertion | :407 | any non-retractedheld (:458) | | retract_assertion | :485 | committedretracted (:538) | | discard_assertion | :560 | helddiscarded |

AS_CURRENT_STATES = ("held", "committed") at :68.

Who may create a held assertion

POST /engagements/{engagement_id}/contributions (api/routers/contributions.py:97-115) takes Depends(get_resolved_actor) — the base resolver, so membership alone. No other creation entry exists that bypasses membership.

Who may admit (heldcommitted)

Two layers, and neither is a trusted-core check:

HTTP layer. api/routers/assertions.py:322Depends(get_committing_resolved_actor), which requires operator or contributor designation (deps.py:1182-1197).

Data layer. engagement/assertions.py:283-292 — the complete set of checks:


if actor.kind in NON_HUMAN_ACTOR_KINDS:
    raise AgentCannotCommitError(...)
current = await _load_current_assertion(...)
if current.state != "held":
    raise AssertionNotHeldError(...)

That is actor-kind and state only. There is no comparison between the committing actor and the contributing actor, no designation check at the data layer, and no notion of a distinct admitting party.

discard_assertion mirrors commit's authority (assertions.py:352-360, Depends(get_committing_resolved_actor)).

"Trusted core" as a concept

Absent. Grep for trusted.core, trusted_core, admit_by, admitted across src/ returns no hits in an authorization sense — only unrelated uses ("admitted content types" at files.py:15/:81, the boundary record at engagement/boundary.py:256, channel prose at channels.py:75).

Why this breaks the shape

A single designation — contributor — grants both the ability to create a held assertion and the ability to commit it. The same person, in one session, can draft and admit their own contribution with no second party involved. held therefore functions as "drafted, not yet confirmed by its own author" — a save-before-submit state — not as "contributed by an outsider, awaiting admission by the trusted core."

The route description at assertions.py:349 says "a contribution can never discard itself," which is an actor-kind guarantee (an agent cannot discard), not a separation-of-duty guarantee between persons.

Read

The seed's contribution axis — trusted core writes directly, outside contributions held until admitted — has no engine implementation; what exists is a self-service draft/confirm cycle that happens to reuse the word "held." Delivering admit-by-trusted-core needs three things that do not exist: a way to designate a contributor as outside-the-core (the additive three-designation model has no such shape — prior report V1), a separation-of-duty check on commit (the data layer compares nothing), and a contribution path for a principal who cannot read — which is V1's blocked dependency. This is a larger gap than the reach question alone, and the scoping note should not assume the held machinery gives it a head start.


V4 — The Phase 45 OVA seam as shipped

Verdict: PARTIALLY HOLDS. Two OVA-shaped seams exist, both real and both stable-by-design, but neither is a general authorizer. Reach would extend a contract rather than route through one.

Seam 1 — verify_companion_authorization (the Phase 45 delegation seam)

Module. src/loomworks/delegation/authorization.py — module docstring :1-25 names it "OVA seam" outright (CR-2026-059 v0.1 §D2).

Interface (:99-106):


async def verify_companion_authorization(
    *, person_id: UUID, capability: str, engagement_id: UUID,
    db: AsyncSession, action_context: Optional[dict] = None,
) -> AuthorizationResult

Return contract (:59-70): AuthorizationResult with authorized: bool, delegation_ref, approval_mode: Literal["per_action","pre_authorized","denied"], reason: str. The docstring commits to stability — "any future implementation must populate exactly these four fields."

Current implementation. A delegation-assertion lookup in the person's personal memory (:107-120), with opt-in constraint matching.

Call sites. orchestration/router.py:1823, :1976 (live), notifications/service.py:685-698 (dispatch-conditional). Referenced as the pre-dispatch step at delegation/action_dispatch.py:79, :107 and credit/grant_decision_dispatcher.py:687.

Why it is not the reach seam. The question it answers is fixed by its signature: is the Companion authorized to perform this capability for this person? The subject is always the Companion acting as an agent of a person who already has access. It has no parameter for "which principal is trying to read" and no concept of a reader distinct from the delegator. Reach asks the inverse question — may this principal read this scope at all?

Seam 2 — _alpha_authorizer_stub (the closer analogue)

Module. src/loomworks/credit/cross_engagement_memory.py:65-85.


def _alpha_authorizer_stub(
    *, requesting_engagement_id: UUID, requesting_person_id: UUID,
    target_engagement_id: UUID,
) -> bool:
    """OVA-stub-style alpha-scope authorization. ...
    the alpha stub answers ``True`` for every request.
    ... When OVA proper deploys, this function swaps to a real
    Authorizer call without callers changing."""
    _ = (requesting_engagement_id, requesting_person_id, target_engagement_id)
    return True

This is shaped like a reach check — (requesting principal, requesting scope, target scope) → bool, gating a cross-engagement read. Its single call site is read_credit_management_assertions (:120-125), and the deny path is already written for: "when the OVA Authorizer denies, callers see an empty list."

Why it is not yet the reach seam. It is scoped to one hardcoded target (CREDIT_MANAGEMENT_ENGAGEMENT_ID), has exactly one caller, returns unconditional True, and is module-private (_-prefixed) inside the credit package. It is a contract rehearsal, not a general gate — which is precisely how its docstring describes itself.

Read

There is no general authorizer interface that a reach check can be dropped behind, so the scoping note cannot land reach as pure implementation work under an existing contract. But it is meaningfully better than starting from nothing: seam 2 is the right shape and the wrong scope, and the honest framing is to generalize _alpha_authorizer_stub — lift it out of credit/, drop the hardcoded target, give it real call sites at the three enforcement patterns V2 identified — rather than to invent an interface or build a bespoke ACL. Doing that keeps the eventual OVA substitution to one function body, which is what both stubs were written to protect.


V5 — Attribution-membership coupling

Verdict: HOLDS. Attribution is person/principal-anchored. Membership absence does not corrupt provenance, and no attribution FK targets memberships.

The attribution shape

Attribution is an embedded value object, not a foreign key. memory/base.py:66-118 defines ActorRef with four kinds (contributor, agent, person, companion), carrying id, kind, and display_name (:118, "populated for person-auth ActorRefs"). Provenance.wasAttributedTo: ActorRef (:146) is serialized into the event payload (:52).

Construction: stele/actor.py:25-37


def actor_from_person(person: Principal) -> ActorRef:
    return ActorRef(kind="person", id=person.id, display_name=person.display_name)

The id is the principal id. Membership is not consulted, not stored, and not referenced.

FK graph

memory_events (memory/events.py) declares exactly one foreign key: engagement_id → engagements.id (:92). There is no FK to persons, principals, or memberships from the event log.

Engine-wide, the only FK targeting memberships is membership_designations.membership_id (persons/models.py:212, migration 0029_phase_14_person_layer.py:157) — the designation join rows, which are part of the membership record itself, not attribution.

Display and provenance resolution

display_name is snapshotted into the payload at write time (base.py:118), so rendering a contributor's name requires no join to memberships, persons, or principals. I found no code path that resolves contributor display or provenance via membership.

Non-member attribution feasibility

Concretely: nothing breaks. An assertion attributed to a person holding no membership row would carry a complete, well-formed ActorRef (kind, principal id, display-name snapshot) inside an event whose only FK is to the engagement. There is no FK violation to trigger — attribution never touches the memberships table. There is no join to return empty — display reads the denormalized snapshot. Removing a membership after the fact leaves every prior attribution intact and fully renderable, since the payload is append-only and self-contained. The one thing that changes is access, not attribution: the person immediately fails the V2 membership predicate at all three enforcement patterns and can no longer read what they wrote. That is exactly the asymmetry the handoff's second candidate resolution ("a participation shape that is not membership") needs, and the substrate already permits it — the obstacle is entirely on the authorization side, not the provenance side.


V6 — Scope machinery above the engagement

Descriptive. Engagement is the only Memory scope in schema. Two scope-shaped structures exist above or beside it; neither scopes Memory.

Enumerated scope-shaped schema

organizationspersons/models.py:83-100 (migration 0079_stepup_organization_structure.py). Columns: id, name, presence_proof_window_seconds (default 900), always_tap_on_commit (default FALSE), created_at. Its docstring is explicit about purpose: "An Organization — the home of the freshness-window policy"; today "there is exactly one Organization, 'DUNIN7' (the platform organization)." This is a step-up-authentication policy container, not a Memory scope.

organization_membershipspersons/models.py:114-133, a person↔organization join with a uniqueness constraint on (person_id, organization_id) (:117).

Critically: engagements carries no organization_id. Organizations attach to persons, never to engagements. There is no engagement→organization edge, so an organization cannot scope Memory even in principle at HEAD.

workspacesgrouping/models.py:56-73. Person-owned (owner_person_id, DB-only FK to principals.id), named, archivable. engagement_workspace:76-99, a junction with composite PK (workspace_id, engagement_id) enforcing at-most-one-workspace-per-engagement.

Explicitly, as the brief asks: workspaces are NOT a Memory scope. They are a person-owned grouping of engagements for navigation and filtering. No memory_events row references a workspace; the junction's only edges are to workspaces and engagements; and a workspace is owned by one person rather than shared, so it cannot express "who may read." Its sole read-surface use is the GET /engagements?workspace_id= filter established in the prior report's V4.

Domains — no domains table, no domain_id column, no domain model. The domain_expert designation (memberships.py:40) is a flag whose backing table is explicitly deferred (memberships.py:10-11: "Phase 14 places only the flag; declaration agent and domain table are deferred — CR §S4").

Aggregating-scope / memory-space-extensibility directions — nothing from either reached schema. memory/base.py:111 carries a forward-note ("still grow into a nested object later if OVA needs it") but no structure.

Searches run

__tablename__ filtered for organization/domain/scope/workspace/tenant across src/ — the four tables above are the complete set. organization_id, domain_id, parent_engagement against the engagement models and all migrations — no engagement-level hits.

Read

Engagement is the only Memory scope, so engagement-only-first enforcement is not a compromise — it is the only enforcement currently expressible, and building it forecloses nothing the seed's N-scope model needs later, provided the check takes a scope identifier rather than an engagement id in its signature. The real cost of scope-general enforcement is not the authorizer, it is the missing substrate above it: the seed's N-scope reach model needs an engagement→scope edge that does not exist in any form (organizations attach to persons, workspaces are person-owned navigation, domains are unbuilt). Deferring that is honest; hardcoding engagement_id into the authorizer's parameter list is what would create the rework.


Cross-cutting note for the scoping note

Three findings compose into one observation the scoping note may want to carry.

V5 establishes that a person can hold attribution without membership, cleanly. V2 establishes that membership is the single semantic predicate governing every engagement-scoped read. Together these mean the handoff's second candidate resolution — a participation shape that is not membership — is substrate-feasible today: the write side already supports it, and the read side has exactly one predicate to teach about the new shape.

What blocks it is not the substrate but the three code paths that predicate is expressed through (resolver dependency, verify_project_membership, dashboard CTE), plus the absent declaration (V1) and the absent authorizer generality (V4). None of those is a redesign; all four are enumerable work with known sites, listed above.


DUNIN7 — Done In Seven LLC — Miami, Florida Loomworks — OVA reach enforcement — Step 0 inspection report — v0.1 — 2026-07-28 Engine main @ 1aac8154ca440b831f5ba2bd0fc02d6af890614d — no drift from 1aac815