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. 1aac815 — Archive 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.
| # | 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 | BREAKS — held 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).
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.
Across src/ and migrations/: access_mode, acl_, access_control, reach_, restricted, visibility.
access_mode — zero hits.acl_ — zero hits.access_control — zero hits.reach_ — zero hits.restricted — hits only in unrelated senses: seed-amendment gating (engagement/considerations.py:115), seed-cadence Operator-only creation (engagement/cadences.py:81, :204, :335), and the closure-set restriction (engagement/closure_restrictions.py:87). None is an access-mode element.visibility — the only structural hit. Analysed below.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):
'private' — the server default (migration 0030:33).'automatic' — the Loomworks engagement, written by migrations/versions/0034_phase_15_loomworks_engagement_induction.py:259-262. This is the Phase 15 value the brief flagged.'personal' — personal engagements, written by migrations/versions/0058_phase_41_personal_engagement_induction.py:320; the widening is documented as a DB-layer no-op at 0057_phase_41_schema_additions.py:16-24.'discoverable' — named in the Phase 14 docstring as future; never written.Every read of the column in application code (exhaustive — two sites, same predicate):
src/loomworks/api/routers/dashboard.py:76 — AND e.visibility != 'personal'src/loomworks/api/routers/dashboard.py:142 — AND e.visibility != 'personal'
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.
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.
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.
| 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).
POST /engagements/{engagement_id}/seed — api/routers/engagements.py:313, handler amend_candidate_seed_routePOST /engagements/{engagement_id}/seed/induct — api/routers/engagements.py:368, handler induct_engagement_seed
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 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)."
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.
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.
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.
src/loomworks/engagement/assertions.py — five states across six transition functions:
| Function | Line | Transition |
|---|---:|---|
| add_assertion | :157 | (none) → held (:216) |
| commit_assertion | :230 | held → committed (:351); requires held (:290-292) |
| revise_assertion | :407 | any non-retracted → held (:458) |
| retract_assertion | :485 | committed → retracted (:538) |
| discard_assertion | :560 | held → discarded |
AS_CURRENT_STATES = ("held", "committed") at :68.
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.
held → committed)Two layers, and neither is a trusted-core check:
HTTP layer. api/routers/assertions.py:322 — Depends(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)).
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).
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.
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.
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.
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?
_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.
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.
Verdict: HOLDS. Attribution is person/principal-anchored. Membership absence does not corrupt provenance, and no attribution FK targets memberships.
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.
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_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.
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.
Descriptive. Engagement is the only Memory scope in schema. Two scope-shaped structures exist above or beside it; neither scopes Memory.
organizations — persons/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_memberships — persons/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.
workspaces — grouping/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.
__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.
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.
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