Metadata-Version: 2.4
Name: wexa
Version: 0.1.0
Summary: Python client for Wexa Fabric
License: Proprietary
Project-URL: Homepage, https://fabric.wexa.ai
Requires-Python: >=3.9
Description-Content-Type: text/markdown

# wexa

Python client for the Wexa Fabric api-gateway. One module, stdlib only, no
dependencies to conflict with yours.

Every call goes through the gateway's ten-stage governed lifecycle, so the same
things happen to a graph query and to an agent run: the credential is checked,
the grant is checked, quota is counted, arguments are validated, policy runs,
a human approves if the data is sensitive, then it executes and is recorded.
When something refuses, the error names the stage that refused it —
`S2:resolve-scope`, `S5:policy`, `S8:execute` — so you know whether to fix your
token, your payload, or your retry loop.

```bash
pip install wexa          # once published
pip install -e .          # from this repo today
```

Requires Python 3.9+.

---

## 60-second quickstart

**1. Get a key.** In the console: **Simple mode → Generate API key**. Keys are
project-scoped and shown once.

**2. Put it in the environment.**

```bash
export WEXA_WORKSPACE=https://fabric.wexa.ai
export WEXA_API_KEY=fab_sk_...
```

**3. Make a call.**

```python
from wexa import Fabric

fabric = Fabric()          # reads WEXA_WORKSPACE / WEXA_API_KEY
print(fabric.whoami())
```

```
{'user_id': 'usr_311', 'role': 'DEVELOPER', 'org_id': 'org_7f2a',
 'dept_id': '', 'project_id': 'prj_1c94',
 'grants': ['fabric:query.read', 'fabric:docs.read']}
```

That is the whole setup. The client discovers its endpoints from
`/v1/connection-info`, so the workspace URL is the only address you supply. You
can pass credentials inline instead, which is handy in a notebook:

```python
fabric = Fabric(workspace="https://fabric.wexa.ai", api_key="fab_sk_...")
```

**A client is bound to one project.** Scope is fixed into the key when it is
minted and never widens. To work across projects, make one client per key.

### What to do next

```bash
WEXA_WORKSPACE=... WEXA_API_KEY=... python3 examples/status.py
```

`status.py` prints your role, which tools your grants actually cover, how close
you are to the rate limit, and any approvals waiting on you. It is the fastest
answer to "why did that call fail".

---

## Authentication: what exactly do you send?

One HTTP header, on every call:

```
Authorization: Bearer <your credential>
```

That is all the client ever sends. `Fabric(api_key=...)` puts whatever string
you give it behind `Bearer `, so the parameter name is narrower than the truth —
**any bearer credential the gateway accepts works there.** The gateway names its
two options itself, at the unauthenticated `/v1/connection-info` endpoint:

```json
"auth": "OAuth 2.1 (PKCE) or Bearer API key (fab_sk_…)"
```

| Credential | Looks like | Where it comes from | Use it for |
|---|---|---|---|
| **API key** | `fab_sk_…` | Console → Simple mode → Generate API key | scripts, backends, CI — anything long-running |
| **Access token** | a JWT, three dot-separated blobs | the OAuth 2.1 (PKCE) flow, or your identity provider | apps acting on behalf of a signed-in person |

Both go in the same header and behave identically once issued. Start with an API
key; it needs no flow.

### A credential is a key card, not a password

It does not just prove who you are. It carries **where you may go** and **what you
may do**, stamped in at the moment it was minted:

- **Scope** — one org, one department, one project. Printed on the card.
- **Grants** — the specific doors it opens, like `fabric:query.read`.

Two consequences that surprise people:

1. **Scope never widens.** A client is bound to one project for its whole life.
   To reach a second project, mint a second credential and build a second
   client. There is no "switch project" call, by design.
2. **Being authenticated is not being authorized.** A perfectly valid credential
   still gets refused if it lacks the grant for the tool you called.

### The failure you will actually hit

Missing grants are the most common auth error, and the message says exactly
what is missing:

```
ForbiddenError: S2:resolve-scope — token missing required grant "fabric:ontology.write"
```

`S2` is the scope stage, so you know the credential was accepted (S1 passed) and
the *permission* is what's wrong. Fix the key, not the payload.

### Which grant does each call need?

Six grants cover every tool in this client:

| Grant | Opens |
|---|---|
| `fabric:query.read` | `query_context`, `search_code`, `fetch_code`, `connector_read` |
| `fabric:ontology.write` | `save_context`, `create_ontology` |
| `fabric:docs.read` | `docs` |
| `fabric:orchestrate.read` | `list_skills`, `get_process_flow`, `get_execution`, `knowledge_base_retrieve` |
| `fabric:orchestrate.write` | `create_process_flow`, `update_process_flow`, `create_agent` |
| `fabric:agent.run` | `run_agent`, `run_process_flow` |

Reads and writes are separate grants, and *running* something is separate from
*authoring* it — a credential can be allowed to run a process flow without being
allowed to change it.

Three other grants exist (`fabric:catalog.read`, `fabric:catalog.write`,
`fabric:codesync.write`) but belong to endpoint families this client does not
wrap. You will not need them here.

To see your own, ask:

```python
print(fabric.whoami()["grants"])
# ['fabric:query.read', 'fabric:ontology.write']
```

---

## Your first real call

Reads go through `query_context`, which runs read-only Cypher. One rule: the
query must constrain itself to your project, or the gateway's Cypher guard
rejects it before it reaches the graph.

```python
rows = fabric.query_context(
    query="MATCH (c:Customer) WHERE c.project_id = $project_id RETURN c LIMIT 50",
)
```

`$project_id` is bound for you from the token. Do **not** pass `project_id`
yourself — see [Arguments the server owns](#arguments-the-server-owns).

Needs `fabric:query.read`.

---

## What goes in a payload?

Every call is `fabric.<tool>(**keyword_arguments)`. There is no envelope to build
and no request object to construct — the keywords *are* the payload.

```python
fabric.query_context(query="MATCH (n) WHERE n.project_id = $project_id RETURN n")
#                    └──────────── this is the whole payload ────────────┘
```

### You supply the intent; the server supplies the identity

Some arguments are **server-owned**. They describe *who is calling*, and the
gateway fills them in from your credential. Sending them yourself is refused —
not ignored — because a payload that could name its own caller is a payload that
could impersonate one.

| You always send | The server always fills in |
|---|---|
| `query`, `nodes`, `relationships`, `goal`, `topic` … | `project_id`, `organization_id`, `executed_by` |

```python
# refused before it leaves your process
fabric.query_context(query="…", project_id="prj_other")

# correct — reference it as a bound parameter instead
fabric.query_context(query="MATCH (n) WHERE n.project_id = $project_id RETURN n")
```

`$project_id` is already bound for you. Think of it as a blank the server fills
in after you hand the form over. The complete list, and why the client refuses
these rather than letting the server quietly overwrite them, is in
[Arguments the server owns](#arguments-the-server-owns).

### Nodes: `key` names the identity field, it is not the identity value

This is the one shape worth reading twice, because `key` sounds like it holds an
id and it does not — it holds the *name of the property* that holds the id.

```python
{"label": "Refund", "key": "id", "properties": {"id": "rfnd_1001", "amount": 4200}}
#                    │                          └── the identity value
#                    └── the property to identify by
```

Read it as: *"identify this Refund by its `id` property."* That is what makes
writes idempotent — send the same node twice and Fabric updates it rather than
creating a duplicate, because it knows which field to match on. Point `key` at a
property that is genuinely unique, and never at something like `amount`.

### Relationships: endpoints are objects, not strings

A relationship has to say which node it means, and a bare `"rfnd_1001"` is
ambiguous — two labels could each have a node with that id. So each endpoint
repeats the address in full: `{label, key, value}`.

```python
{"type": "ISSUED_TO",
 "from": {"label": "Refund",   "key": "id", "value": "rfnd_1001"},
 "to":   {"label": "Customer", "key": "id", "value": "cust_8841"}}
```

Note `value` here, versus `properties` on a node: a node *carries* its data, an
endpoint only *points at* it.

### When a payload is wrong, S4 tells you

Validation is stage 4, so a malformed payload never reaches the graph:

```
ValidationError: S4:validate-input … cannot unmarshal string into Go struct
field Rel.relationships.from of type contextsvc.Ref
```

`Ref` is that `{label, key, value}` object. This exact error means an endpoint
was passed as a string.

---

## Common tasks

Every sample below is one continuous scenario: a refund issued to a customer.
Copy any of them as-is.

### Write to the context graph

```python
fabric.save_context(
    nodes=[
        {"label": "Refund", "key": "id",
         "properties": {"id": "rfnd_1001", "amount": 4200, "currency": "usd"}},
    ],
)
```

A node is `{"label", "key", "properties"}`, where `key` names the property that
is the node's identity — `key: "id"` means `properties.id` is the identity.

Relationship endpoints are **objects, not strings**. This is the single most
common payload error:

```python
fabric.save_context(
    nodes=[
        {"label": "Refund", "key": "id", "properties": {"id": "rfnd_1001"}},
        {"label": "Customer", "key": "id", "properties": {"id": "cust_8841"}},
    ],
    relationships=[
        {"type": "ISSUED_TO",
         "from": {"label": "Refund", "key": "id", "value": "rfnd_1001"},
         "to": {"label": "Customer", "key": "id", "value": "cust_8841"}},
    ],
)
```

Passing `"from": "rfnd_1001"` gets you:

```
ValidationError: S4:validate-input ... cannot unmarshal string into Go struct
field Rel.relationships.from of type contextsvc.Ref
```

`save_context` needs a project in **Simple/auto** mode, which is the default. On
an Advanced/manual project it fails validation outright and `create_ontology` is
the ingestion path instead — see [Project mode](#project-mode-decides-your-ingestion-path).

Needs `fabric:ontology.write`.

### Run an agent or a process flow

These start real work and return without waiting for it.

```python
run = fabric.run_agent(agentflow_id="af_1", goal="summarize last week's refunds")
```

```python
flow = fabric.create_process_flow(name="nightly-reconcile")
run = fabric.run_process_flow(
    process_flow_id=flow["process_flow_id"], goal="reconcile refunds",
)
status = fabric.get_execution(execution_id=...)   # id from run, see below
```

`create_process_flow` returns `{"process_flow_id": ..., "flow": {...}}`.
`run_process_flow` passes the data-service response through unchanged, so read
the execution id off `run` rather than assuming a field name.

`run_agent` and `run_process_flow` both need `fabric:agent.run` — including
`run_process_flow`, which does *not* accept `fabric:orchestrate.write` in its
place.

### Search code

```python
hits = fabric.search_code(query="def issue_refund", limit=20)

# fetch_code needs either a line span or a row_pk from a search hit
body = fabric.fetch_code(path="billing/refunds.py", start_line=1, end_line=80)
```

`path` alone fails validation: without `row_pk` you must give positive
`start_line` and `end_line` with `end_line >= start_line`. `search_code` also
takes `path_prefix` and `lang` filters.

These two return their body unwrapped, so `result.lifecycle_id` is `None` for
them and only for them. Needs `fabric:query.read`.

### Check what you can do, and where you stand on quota

```python
gov = fabric.docs(topic="governance")
gov["mode"]      # "auto" | "manual"
gov["grants"]
gov["quota"]     # project_window_used / _max, org_window_used / _max
gov["rules"]     # the live governance rules, in words
```

`docs` is the only place the gateway exposes live quota. Reading it up front
lets you pace writes against the real cap instead of discovering it as a
`QuotaExceeded`. Needs `fabric:docs.read`.

---

## Approvals

This is the part of the SDK worth reading twice.

Some calls do not return a result. They return a pending approval, because a
human has to look at them first. The SDK raises `ApprovalRequired` carrying
everything you need to finish the call later.

### What gates

Gating is **driven by the data you touch, not by the size or kind of the
operation**. There is no amount threshold. A call gates when:

- any graph label or argument it touches contains `pii`, `sensitive`,
  `confidential` or `personal` — substring match, case-insensitive; or
- the asset it touches is classified restricted in OpenMetadata.

So `save_context` with a node label of `Refund` runs straight through, and the
same call with a label of `CustomerPersonalData` comes back as an approval. On
an auto-mode project this gates **everyone, admins included**.

The second gate site is narrow: a non-admin `create_ontology(mode="commit")`,
and only on an Advanced/manual project.

Writes are not gated for being writes. Reads are not exempt for being reads —
a `query_context` whose Cypher mentions a `Personal` label gates too.

Because the trigger is the data, **handle `ApprovalRequired` on every governed
call**, not on the one step you expect to gate. Classification is populated at
runtime, so a call that sailed through yesterday can start gating today with no
change on your side.

### The complete round trip

Three actors, and they are genuinely three: **your code** requests, **an admin**
decides, **your code** resumes. The credential that requested is the only one
that can resume.

```python
from wexa import Fabric, ApprovalRequired

fabric = Fabric()

# 1. The call gates. HTTP 202, no result — an ApprovalRequired instead.
try:
    fabric.save_context(nodes=[
        {"label": "CustomerPersonalData", "key": "id",
         "properties": {"id": "cust_8841", "email": "ada@example.com"}},
    ])
except ApprovalRequired as e:
    print(e.approval_id)     # apr_000012
    print(e.resume_token)    # rtok_000012_17...   single use, keep it safe
    print(e.lifecycle_id)    # the lifecycle that parked
    token = e.resume_token
```

```python
# 2. A human decides. The deciding credential must hold an admin role —
#    OWNER / ORG_ADMIN / PROJECT_ADMIN. Usually it happens in the console.
admin = Fabric(api_key="fab_sk_admin_...")
[a for a in admin.approvals("pending")]
# [{'id': 'apr_000012', 'tool': 'save-context', 'what': '...',
#   'requested_by': 'usr_311', 'status': 'pending', ...}]
admin.approve("apr_000012")
```

A non-admin credential calling `approve()` gets
`ForbiddenError: approver must be an admin role` (HTTP 403). The role
check is the whole check — the gateway records `decided_by` but does not compare
it to `requested_by`, so an admin approving their own request is not blocked by
the API. If your policy needs that, enforce it on your side.

```python
# 3. Your code resumes. Send ONLY the token — the engine substitutes the
#    approved arguments, so anything else you pass is ignored.
result = fabric.save_context(resume_token=token)
print(result)                # the write landed
print(result.lifecycle_id)
```

The resume must come from the **same user and project** that requested it. An
admin cannot resume on your behalf, and the approved arguments win — you cannot
amend a call at approval time.

Approvals expire. The default TTL is 24 hours; after that the request goes to
`expired` and the token is dead.

### Blocking instead of handling it yourself

If your process can afford to sit and wait, opt in and the client polls for you:

```python
result = fabric.save_context(
    nodes=[{"label": "CustomerPersonalData", "key": "id", "properties": {...}}],
    wait_for_approval=True,
    approval_timeout=600,     # seconds; raises ApprovalError past this
    poll=5,                   # seconds between checks
)
```

This does the whole round trip: gate, poll `approvals()`, resume with the token.
A rejection or expiry raises `ApprovalError`.

### Resuming from a durable pipeline

`wait_for_approval=True` blocks in-process, so a crash or a deploy while a human
is deliberating loses an approval that was actually granted. If that matters,
checkpoint the token before you block, and resume later — possibly from another
process:

```python
try:
    fabric.save_context(nodes=[...])
except ApprovalRequired as e:
    checkpoint(e.approval_id, e.resume_token)     # durable, before you wait

# ... minutes or hours later, a different process ...
fabric.save_context(resume_token=load_token(), retry=True)
```

Passing `resume_token=` takes exactly the same code path as
`wait_for_approval`, so everything below behaves identically either way.

### A failed resume is not always safe to retry

The token is single-use, and the gateway redeems it **partway** through the
lifecycle:

```
S2 scope → S3 quota → REDEEM TOKEN → S4 validate → S5 policy → S8 execute
```

A refusal before redemption leaves the token intact. A refusal after redemption
has burned it: the write did not land, and re-running needs a *new* human
approval. Every exception carries `resume_spent` to tell the two apart.

```python
from wexa import WexaError

try:
    fabric.save_context(resume_token=token, retry=True)
except WexaError as e:
    if e.resume_spent:
        alert_human(e.lifecycle_id)   # approval consumed, write did NOT land
    else:
        pass                          # nothing consumed, safe to re-run as-is
```

| Refusal on resume | Stage | `resume_spent` | What to do |
|---|---|---|---|
| `QuotaExceeded` (429) | S3, before redemption | `False` | Re-send the same token |
| `ValidationError` (400/422) | S4, after redemption | `True` | Needs a new approval |
| `PolicyDenied` (403) | S5, after redemption | `True` | Needs a new approval |
| `UpstreamError` (502) | S8, after redemption | `True` | Needs a new approval |

`retry=True` covers the resume, but only for `QuotaExceeded` — the one stage
that refuses before redemption. Retrying anything later would re-send a spent
token and earn a misleading `ApprovalError: resume rejected: approval not
found`, burying the real error. Replaying a token you already spent gets you
that same message.

---

## Reference

### Methods

| Area | Methods | Grant |
|---|---|---|
| Graph read | `query_context` | `fabric:query.read` |
| Graph write | `save_context`, `create_ontology` | `fabric:ontology.write` |
| Code | `search_code`, `fetch_code` | `fabric:query.read` |
| Docs | `docs` | `fabric:docs.read` |
| Connectors | `connector_read` | `fabric:query.read` |
| Execution | `run_agent`, `run_process_flow` | `fabric:agent.run` |
| Orchestrate read | `get_process_flow`, `get_execution`, `list_skills`, `knowledge_base_retrieve` | `fabric:orchestrate.read` |
| Orchestrate write | `create_process_flow`, `update_process_flow` | `fabric:orchestrate.write` |
| Governance | `whoami`, `approvals`, `approve`, `reject`, `lifecycle` | — |

Two more grants exist for planes this client does not wrap:
`fabric:catalog.read` / `fabric:catalog.write` (catalog endpoints) and
`fabric:codesync.write` (the `/v1/codesync/*` ingest plane).

Grants live in the token's `scope` claim. Missing one fails at S2 with the exact
string you need:

```
ForbiddenError: token missing required grant "fabric:ontology.write"
```

`fabric.docs(topic="governance")["grants"]` lists what you actually hold.

`call()` reaches any tool by name and accepts the gateway's own hyphenated
spelling, so these three are the same call:

```python
fabric.run_process_flow(process_flow_id="pf_1", goal="reconcile")
fabric.call("run_process_flow", process_flow_id="pf_1", goal="reconcile")
fabric.call("run-process-flow", process_flow_id="pf_1", goal="reconcile")
```

### Errors

Every failure is typed by the stage that refused it and carries `trace_id`, and
usually `lifecycle_id`. Quote both in a support request.

`e.stage` is set only when the gateway returned an `S…` code — `S2:resolve-scope`
through `S8:execute`. Refusals that happen outside the lifecycle (`401`, the
`forbidden` 403 from `approve()`, `404`, `409`, `unconfigured`) carry
`stage=None`, and a 401 predates the lifecycle entirely so there is no
`lifecycle_id` to quote either. The stage column below is the conceptual stage,
not a promise about `e.stage`.

```python
from wexa import PolicyDenied, ForbiddenError, ValidationError, QuotaExceeded

try:
    fabric.run_agent(agentflow_id="af_1", goal="summarize last week")
except PolicyDenied as e:        # S5 — a policy rule refused it
    print(e.stage, e.lifecycle_id)
except ForbiddenError:           # S2 — the token lacks the grant
    ...
except ValidationError:          # S4/S7 — the request is malformed
    ...
except QuotaExceeded:            # S3 — over the window
    ...
```

| Exception | HTTP | Stage | Means | Do |
|---|---|---|---|---|
| `AuthError` | 401 | S1 | `invalid_token`, `unauthorized` — key wrong, revoked or expired | Refresh the key, then one retry |
| `ForbiddenError` | 403 | S2 | Token lacks the grant, or scope is invalid | Mint a key with the named grant. Terminal |
| `PolicyDenied` | 403 | S5 | A policy rule refused the call outright | Terminal. Change what you're asking for |
| `ValidationError` | 400/422 | S4, S7 | Malformed body, bad Cypher, failed dry-run | Fix the payload. Terminal |
| `ApprovalRequired` | 202 | S6 | A human must decide first | See [Approvals](#approvals) |
| `ApprovalError` | 403 | S6 | Resume token bad, expired, rejected or already spent | Needs a new approval |
| `QuotaExceeded` | 429 | S3 | Over 120/min project or 600/min org | Back off and retry |
| `NotFound` | 404 | — | No such approval, flow or execution in your org | Terminal |
| `ConflictError` | 409 | — | Approval not pending, agent not ready | Re-read state, then decide |
| `UpstreamError` | 502 | S8 | A downstream service refused or was unreachable | Retry with backoff |
| `TimeoutError_` | 504 | S8 | Upstream deadline exceeded | Retry with backoff |
| `ConfigurationError` | 502/503 | — | `unconfigured`, `unavailable` — a deployment problem, not load | Retrying will not help. Tell an operator |

Only `UpstreamError` (so `TimeoutError_` too) and `QuotaExceeded` are retried by
`retry=True`. Everything else refuses identically forever.

Three inheritance traps, because `except` order decides what you catch:

- `PolicyDenied` is a `ForbiddenError`. Catch `PolicyDenied` first or a policy
  denial looks like a missing grant.
- `TimeoutError_` is an `UpstreamError`, and it is *not* Python's builtin
  `TimeoutError` — hence the underscore.
- `ApprovalRequired` is a `WexaError`. In a shared helper, catch it **before**
  `except WexaError` or pending approvals get logged as failures.

`WexaError` only covers responses the gateway actually sent. An unreachable
workspace, DNS that does not resolve or a TLS failure never reaches the gateway
and surfaces as urllib's own `OSError` (`URLError` is a subclass). A top-level
handler needs both:

```python
try:
    fabric.query_context(query=...)
except WexaError:      # the gateway refused
    ...
except OSError:        # never got there
    ...
```

### Results

A **tool call** returns a `dict` subclass with the correlation ids attached
rather than nested, so you can treat it as the payload and still trace it. The
governance methods (`whoami`, `approvals`, `approve`, `reject`, `lifecycle`)
return plain dicts and lists — no `.lifecycle_id` on those.

```python
r = fabric.docs(topic="governance")
r["mode"]
r.lifecycle_id      # None for search_code and fetch_code only
r.trace_id
```

The client sends a W3C `traceparent` on every request and reads `X-Trace-Id`
back, so correlation works even where telemetry is switched off.

`fabric.lifecycle(lifecycle_id)` returns the full per-stage record for a call:
what each of the ten stages saw and decided.

### Retries

**Nothing retries unless you ask.** The gateway has no idempotency key, so a
retried `run_agent` starts a second real run.

```python
fabric.query_context(query=..., retry=True)     # safe: it's a read
```

`retry=True` uses full jitter, capped at the 60-second quota window, because
tool routes send no `Retry-After` and no `X-RateLimit-*` headers and the limiter
is per-pod — the client cannot know the real ceiling. Attempt count comes from
the constructor.

### Constructor

```python
Fabric(
    workspace=None,   # or WEXA_WORKSPACE
    api_key=None,     # or WEXA_API_KEY. Bearer JWT or API key
    timeout=70,       # seconds. Governed tool routes cap near 60s upstream
    retries=3,        # attempts when retry=True; ignored otherwise
)
```

Missing workspace or key raises `ConfigurationError` immediately, before any
network call.

### The ten stages

Useful when you are reading an error, not before.

| Stage | Name | Refuses with |
|---|---|---|
| S1 | authenticate | `AuthError` |
| S2 | resolve-scope | `ForbiddenError` |
| S3 | rate-quota | `QuotaExceeded` |
| S4 | validate-input | `ValidationError` |
| S5 | policy | `PolicyDenied` |
| S6 | approval | `ApprovalRequired`, `ApprovalError` |
| S7 | dry-run | `ValidationError` |
| S8 | execute | `UpstreamError`, `TimeoutError_` |
| S9 | post-process | — |
| S10 | record | — |

Two things about S7 that surprise people. It runs **after** approval, not
before — so a dry-run failure on a gated call has already spent the token. And
it produces a **one-line human-readable summary string**, not a structured
before/after diff. There is no client-side dry-run switch; the stage is
server-driven, and its output shows up in the lifecycle record.

Quota (S3) is a fixed 60-second window: 120 requests per minute per project,
600 per minute per org.

---

## Edge cases

### Arguments the server owns

The gateway binds these from your token scope, so the client refuses them before
building the request:

```
project_id   projectID   organization_id   executed_by
```

`query_context` rejects a mismatching `project_id` outright; the others were
silently overwritten, which is worse — your value accepted and ignored. Both
become one clear client-side `ValidationError` instead.

### Unknown argument keys are dropped silently

A misspelled key is a 200 that wrote nothing, not an error. The two shapes worth
double-checking:

```python
# create_ontology — domain is required; it's nodes/relationships, not entities
fabric.create_ontology(
    domain="risk",
    nodes=[{"name": "Vendor", "label": "Vendor", "primaryKey": "vendor_id"}],
    relationships=[{"name": "uses", "fromNode": "Vendor", "toNode": "Service"}],
)

# save_context — relationship endpoints are {label, key, value} objects
fabric.save_context(
    nodes=[{"label": "Vendor", "key": "vendor_id", "properties": {"vendor_id": "v1"}}],
    relationships=[{"type": "USES",
                    "from": {"label": "Vendor", "key": "vendor_id", "value": "v1"},
                    "to": {"label": "Service", "key": "svc_id", "value": "s1"}}],
)
```

### Project mode decides your ingestion path

A project is in Simple/auto or Advanced/manual mode, and the mode — not your
arguments — decides how ingestion works. The two halves are mutually exclusive:

| | Simple / auto | Advanced / manual |
|---|---|---|
| `save_context` | works | fails validation outright |
| `create_ontology(mode="commit")` | applies, never gated | gated for a non-admin |
| PII/sensitive gate | gates **everyone**, admins included | admins bypass, others gated |

**Check the mode before you write anything.** A pipeline that commits an
ontology under human approval and then calls `save_context` will spend a real
person's approval on a run that cannot finish.

Note the diagonal: on the only mode where `save_context` works, the ontology
gate cannot fire — so the data-classification rule is the *sole* gate in play,
and it attaches to calls by their data, not by their name. That is the concrete
reason to route every governed call through one shared helper that handles
`ApprovalRequired`.

```python
fabric.docs(topic="governance")["mode"]     # "auto" | "manual"
```

### Why isn't my call gating?

On a bare dev stack, usually because no gate is armed. The classification rule
needs OpenMetadata **configured** — `OM_SYNC_ENABLED=true` plus a host and a
project id. The token is not checked when arming, and the host need not be
reachable: the bridge is installed before any sync runs, and the label check is
a plain substring test that needs no synced data, so a dead OM host still gates.
With no OM configured at all, the rule is inert. Combined with the ontology gate
being short-circuited in auto mode, that leaves no gate able to fire and
`ApprovalRequired` unreachable.

To arm it against a host that does not exist:

```
OM_SYNC_ENABLED=true OM_HOST=http://127.0.0.1:1 OM_SYNC_PROJECT_ID=…
```

That is a deployment state, not a bug in your code, and no code change on your
side will alter it.

### Not covered yet

- **OAuth 2.1.** API keys and Bearer JWTs only for now.
- **`create-agent`.** The handler exists in the gateway but is not registered,
  so it is reachable over neither REST nor MCP. Build agents into a process flow
  manifest instead.
- **Chat completions.** `/v1/agents/{id}/chat/completions` is OpenAI-shaped —
  point the `openai` package at your API base. It has its own per-key rate limit
  that does send `Retry-After`, does not support streaming, and can block for
  several minutes.
- Catalog and code-sync endpoints, an async client, pagination helpers.

---

## What has been verified against a running gateway

Confirmed live — SDK method → route → auth → lifecycle → handler. Exercised on
the tools named below, **not** on all fifteen: eight of them
(`list_skills`, the four `*_process_flow` methods, `get_execution`,
`knowledge_base_retrieve`, `connector_read`) have no REST route on `dev` yet, and
REST is this client's only transport — so they cannot have been confirmed
through it. They are routed by the `pr/gateway-orchestrate-rest` change; until
that lands, treat them as unproven.

- `whoami` and `docs` return real scope, grant, mode and quota payloads.
- The full approval round trip. A `save_context` with an ordinary node label
  runs straight through; one with a `CustomerPersonalData` label returns 202
  with `{approval_id, resume_token, lifecycle_id}`; the request appears in
  `approvals()`; `approve()` succeeds from an admin credential; the resume with
  only the token lands the write.
- Approving is gated on **role, not on being a different person**: a non-admin
  credential gets `forbidden / "approver must be an admin role"`. This is not
  separation of duties — an admin approving her own request was verified to
  succeed (`requested_by == decided_by`, status `approved`), because
  `approval.decide()` records the decider and never compares it to the
  requester. Enforce requester ≠ approver in your own code if you need it.
- Resume tokens are single-use. A resume that fails after redemption reports
  `resume_spent = True`, and replaying that token yields
  `ApprovalError: resume rejected: approval not found`.
- Typed error mapping, including `ValidationError` carrying a real
  `lifecycle_id`, and the S2 missing-grant message.

Still source-derived only:

- `create_ontology(mode="commit")` gating for a non-admin on a manual project.
- The 429 boundary in practice. The limits themselves are configuration:
  120/min project, 600/min org, fixed 60s window.
- The response bodies of `run_process_flow` and `get_execution`. The argument
  shapes above come from the handlers' input schemas, not from an observed round
  trip.

---

## Development

```bash
python3 wexa.py                     # offline self-check of the approval flow
WEXA_WORKSPACE=... WEXA_API_KEY=... python3 examples/status.py
```

`wexa.py`'s `demo()` covers the branches that actually have logic: the 202 →
approve → resume round trip, the resume retry boundary, tool routing, and the
server-bound argument guard.
