# Skheria generic client API — implemented local V1

Contract version: 1.0.0 / RE-4, 2026-09-20. This replaces the earlier proposed 0.1 contract. The executable route/request registry is [routes.ts](../src/resident-engine/api/routes.ts); generated [OpenAPI](resident-engine/openapi.json) describes all 18 implemented operations. Recorded requests and responses are in the [actual HTTP trace](handoffs/RE-4_HTTP_TRACE.json).

**YourKa NOT connected. Remote webhooks NOT enabled. Real payment verification NOT implemented.** These are synthetic local clients and disposable databases. Hosting and client integration require separate authorization. No LLM, human chat import or notification sending occurs.

## Transport and provisioning

Base URL: `http://127.0.0.1:8787/v1`. Only `/v1` routes exist. `api/routes.ts` and `api/service.ts` own V1; a future V2 can have its own registry/service behind the transport. Breaking behavior needs a new version or an explicitly agreed contract revision before integration.

Use Node 24.13.0 (the tested patch):

```sh
npm run resident:api -- --disposable --db /absolute/local.sqlite --port 8787
npm run resident:app:create -- --disposable --db /absolute/local.sqlite \
  --world re_<32hex> --resident re_<32hex> --name 'Test Alpha' \
  --scopes resident:read,events:read --expires 2027-01-01T00:00:00.000Z
```

The local CLI returns a random credential secret once. It never logs requests or credentials while serving. The stored credential contains a SHA-256 digest of the 256-bit random secret, a random lookup ID, expiry, scopes, app and binding. Verification compares equal-length digests in constant time, including for unknown lookup IDs. Send `Authorization: Bearer <returned-token>` from a trusted client backend; never put it in a URL/browser bundle. No OAuth or unauthenticated signup exists.

A RESIDENT credential binds exactly one resident grant and one opaque relation. Composite foreign keys enforce app/world/resident/grant/relation consistency. Same-app relations require separate credentials. Client-supplied `app_id`, `requested_by`, account IDs and arbitrary metadata are rejected by request schemas. Credential identity is authoritative.

Revoking an app, credential, resident grant or relation blocks access. Effective scopes are the intersection of current credential and current resident-grant scopes. Every request checks current authorization inside the SQLite transaction, including old idempotency retries and outbox acknowledgments. Provision/revoke/edit grants through trusted local operator code (`access/auth.ts` and the store adapter); there is no public administration endpoint. Omit `--resident` and supply `residents:create` to provision a world-bound PROVISIONER. That credential cannot read resident data.

## Permissions

| Scope | Operations / fields |
|---|---|
| `residents:create` | Controlled reference import, PROVISIONER only |
| `resident:read` | Basic profile, place/transit, activity, mood/needs, simulation time |
| `events:read` | Approved world facts; contact fact references also require visibility for their source events |
| `simulation:advance` | Bounded chronological simulation |
| `actions:write` | Closed action intents |
| `funds:transfer` | GIFT_SKR, additionally bound to a live local funding grant |
| `plans:read` | Own relation's plans and plan events |
| `plans:respond` | Responses; `plans:read` is also required for the returned plan |
| `wallet:read` | Resident wallet aggregates and filtered financial records |
| `inventory:read` | Owned possessions; also required by CARRY_ITEM |
| `relation:signal` | Current credential's sanitized contact signals |
| `contact:read`, `contact:ack` | Bound contact intents and handling acknowledgment |
| `outbox:read`, `outbox:ack` | Bound polling/receipts; underlying topic scope still required |

A resident grant authorizes that resident's shared simulated life, not another human's relationship. `wallet:read` explicitly grants the resident's aggregate ledger truth, including all income/expenses/balances. Those amounts can change after another gift; they are resident facts. This scope is appropriate only where sharing that resident's overall finances is intended. Individual gifts and purchase transaction IDs belonging to other relations are omitted from transaction pages. Consequently a filtered transaction page need not sum to the complete wallet/financial summary. No donor, app/relation, source-account, issuance, receipt or funding-ancestor identifiers appear in financial DTOs.

## Requests, errors and idempotency

JSON only for POST (`Content-Type: application/json`). All bodies, query parameters and route IDs have strict Ajv schemas; unknown properties at every request object level are rejected. Instants must be valid canonical UTC strings with milliseconds, e.g. `2026-09-26T09:12:00.000Z`. Money uses integer minor units (100 = 1 SKR). IDs match `re_` plus 32 hex digits.

Body limit: 16 KiB, including chunked bodies. Header limit: 8 KiB; URL limit: 8 KiB. Duplicate authentication/idempotency/content-type headers, repeated query keys, GET bodies, content encodings and browser Origin requests are rejected. Request/header timeouts are 10/5 seconds. Defaults per app per injected-clock minute: 600 reads and 120 mutations; configurable in `ResidentApi`. Quotas are shared across that app's credentials and reset on process restart. No distributed limiter is claimed.

Every POST requires `Idempotency-Key` matching `[A-Za-z0-9:_-]{1,128}`. There is no body alias. Durable key: `(app_id, relation-or-provisioner-binding, canonical operation/target, key)`. Fingerprint includes V1, binding, target, validated body and query. Identical successful requests return exactly the saved status/body after restart. Changed input returns 409. Different apps and different relations can reuse textual keys. Failed/rolled-back commands do not reserve the key.

Authentication, scope, binding, resource ownership and current funding permission are checked **before** returning cached content. All domain effects, resource ownership, funding allowance, idempotency result and outbox insertion share one outer `BEGIN IMMEDIATE` transaction. Existing domain operations reuse that transaction. An exception rolls back the entire API mutation.

Errors have exactly this envelope; messages never contain IDs, SQL, internal preconditions or stack traces:

```json
{"error":{"code":"NOT_FOUND","message":"Resource not found.","request_id":"re_<32hex>"}}
```

| HTTP | Codes |
|---|---|
| 400 | VALIDATION_ERROR, INVALID_JSON, INVALID_CURSOR, IDEMPOTENCY_KEY_REQUIRED |
| 401 | UNAUTHORIZED (invalid, expired, revoked/disabled credential or app) |
| 403 | FORBIDDEN, FUNDING_LIMIT |
| 404 | NOT_FOUND (absent and inaccessible resources; revoked resident/relation grant) |
| 409 | IDEMPOTENCY_CONFLICT, CONFLICT, BACKWARDS_SIMULATION, CURSOR_STALE, PLAN_NOT_ACTIONABLE, CONTACT_NOT_ACTIONABLE, INSUFFICIENT_FUNDS, ITEM_OUT_OF_STOCK, COMMAND_REJECTED |
| 413 / 415 | BODY_TOO_LARGE / JSON_REQUIRED |
| 429 / 503 / 500 | RATE_LIMITED / ENGINE_BUSY / INTERNAL_ERROR |

## Endpoints

| Method / path | Behavior |
|---|---|
| POST `/residents` | `{ "profile_key": "lea_reference_v1" }`; world-bound provisioner imports the fixed source-keyed reference fixture, returning 201 with Lea's stable ID/world/canon version. The fixture includes her six existing canonical peers. Re-registration returns that identity, never a clone. No arbitrary profile/template generation. |
| GET `/residents/{id}` | Basic `resident_id`, `display_name`, `kind`, `canon_version`; no private fictional body details or source bindings. |
| GET `/residents/{id}/state` | Consistent read only: basic profile, place/transit, activity, displayed mood/needs, last simulated time. Optional wallet summary, last 10 allowed events, own open plans and last 5 inventory highlights require corresponding scopes. No sequence/revision, internal event pointer, raw memories, relation signals or unimplemented open threads. |
| GET `/residents/{id}/events` | Allowed events, ordered by time and internal stable sequence. `since` inclusive, `until` exclusive, `types` comma-separated registered types, `limit`, `cursor`. |
| POST `/residents/{id}/simulate` | `{ "until": "...", "reason": "app_open" }`; reasons `app_open`, `scheduled`, `interaction`, `client_request` (maps to interaction). Reject backwards or future trusted-clock time. Default 100 jobs/100 ms budget, finishing equal-time groups. |
| POST `/residents/{id}/actions` | GIFT_SKR, CREATE_PURCHASE_PLAN, CARRY_ITEM only; shapes below. |
| GET `/residents/{id}/plans` | Own relation's plans, `limit`/`cursor`. |
| GET `/plans/{id}` | Owned plan, version/status/times/budget/SKU and safe alternative choices. No funding transaction/account/source event IDs. |
| POST `/plans/{id}/respond` | BUY_ALTERNATIVE or CANCEL with relation and expected version. Existing RE-3 validation rechecks state, expiry, position, stock, hold and funds. |
| GET `/residents/{id}/wallet` | SKR balance/held/available integer minor units. Ledger-derived balance; no account IDs. |
| GET `/residents/{id}/transactions` | Filtered ledger records, `since`/`until`, existing category enum, `limit`/`cursor`. Only approved transaction/date/category/provenance/amount/direction fields. |
| GET `/residents/{id}/financial-summary` | Required `from`/`to`, half-open interval; existing RE-3E service's resident balances, category income/expense/net totals, period and as-of. No recent transaction list or institution report. |
| GET `/residents/{id}/inventory` | Owned item ID, bag name/type/variant, condition, acquired time and source=PURCHASE; `limit`/`cursor`. No donor/private funding IDs. |
| POST `/external-relations/{id}/signals` | Exact bound relation; typed expiring permission/preferences, described below. |
| GET `/residents/{id}/contact-intents` | Unacknowledged, unexpired intents for exact app/resident/relation, respecting current contact permission, quiet time and cooldown; `limit`/`cursor`. |
| POST `/contact-intents/{id}/ack` | `{ "status": "delivered", "handled_at": "..." }` or suppressed. Delivered updates confirmed-contact time; suppressed creates no emotional event. |
| GET `/outbox` | Unacknowledged authorized deliveries, `limit`/`cursor`; rechecks topic scope and contact consent. |
| POST `/outbox/{id}/ack` | Empty `{}` body. Persistent receipt; it does not mean the human was contacted. |

The bounded simulation response contains `status`, `actual_until`, `requested_until`, `simulation_cursor.last_simulated_at` and `required_followup`. Completed is 200; pending is 202. Repeat the same horizon with a **new** idempotency key to continue. Replaying the old key returns its original progress response. Pending work/cursor persist in the existing scheduler; there is no background worker or simulation status URL. Same completed horizon with a new key creates no additional domain effects. Simulation returns no internal job/event counts or IDs.

### Safe actions

```json
{"type":"GIFT_SKR","parameters":{"amount_minor":62000},"external_relation_id":"re_<bound-relation>"}
```

Local provisioning must first bind that relation to a USER_CREDITS account, a synthetic verified purchase transaction and an integer spending allowance. Only the trusted `provision(..., funding)` function supplies these fields; clients cannot choose a source account or mint. The existing RE-3 service performs the gift and preserves the separate purchase-credit issuance. Successful gift returns 200 with `accepted`, `action_id`, `transaction_id`, null reason/followup and no created plans.

```json
{"type":"CREATE_PURCHASE_PLAN","parameters":{"sku_id":"re_<black-sku>","funding_transaction_id":"re_<own-gift>","budget_minor":62000,"window_start":"2026-09-26T09:00:00.000Z","window_end":"2026-09-26T12:00:00.000Z"},"external_relation_id":"re_<bound-relation>"}
```

Returns 202 with `accepted=true`, stable action/plan ID, `created_plan_ids`, null reason/followup. This API currently accepts its own relation's gift as plan funding; generalized salary-funded app plans are deferred. Travel, holds and expiry use the existing RE-3 path.

```json
{"external_relation_id":"re_<bound-relation>","expected_version":3,"response":"BUY_ALTERNATIVE","sku_id":"re_<burgundy-sku>"}
```

POST to the owned plan's `/respond`. CANCEL instead omits SKU. Result is 200 `{ "accepted": true, "plan": { ...safePlan } }`. A new alternative response is also rejected if the trusted server clock has passed the deadline, even when the world cursor is stale. An idempotent retry is resolved before version/deadline validation, because it returns an already committed result rather than another purchase. CARRY_ITEM takes exactly `{ "item_id": "re_<owned-item>", "carried": true }` inside action parameters plus the bound relation. It changes carried state through RE-3; it cannot create a possession or encounter.

SKR is closed-loop virtual currency only. No fiat redemption, withdrawal, real bank transfer, crypto or exchange guarantee exists. The recorded demonstration preserves 1000 SKR synthetic purchase → USER_CREDITS, separate 620 SKR gift → Lea, separate 620 SKR purchase → Maison Veya.

### Signals and contacts

Signals require exactly `importance` and `attachment` (`normal|high`), `contact_allowed` boolean, `contact_frequency_preference` (`low|normal|high`), `min_interval_seconds` (3600..604800), nullable UTC `quiet_until`, and UTC `expires_at`. Signal lifetime is at most 24 hours from the current simulated cursor. App/resident/relation, active state and effective time are server-derived; clients cannot set last-confirmed contact time. No human identity, chat, relationship-stage or arbitrary metadata is accepted. Multiple relations on a resident have independent signal/pending-contact bindings.

A contact intent contains its ID, resident and *own* opaque relation, reason category, urgency, emotion summary, allowlisted structured facts, visible source IDs and creation/expiry. Invisible facts and their IDs are removed before serialization; an empty context is omitted. No causal ancestor traversal occurs. Outbox rendering applies this same filter again at polling time.

Acknowledgment needs an accessible actionable contact and source facts. `handled_at` cannot precede creation or exceed the trusted clock/current world cursor: simulate catch-up before acknowledging handling later than that cursor. Expired or newly disallowed contacts cannot be revived. Contacts remain pending in deterministic RE-2 projection until expiry; acknowledged ones are hidden by the control plane. This conservatively avoids another trigger during the original pending interval. The client independently decides whether/how to verbalize and notify; no final romantic/intimate message is produced.

## Pagination, visibility and local outbox

List responses are `{ "items": [...], "next_cursor": null | "opaque-token" }`, default 50 / maximum 100. Filtering happens before limit/offset selection, with no total or hidden-count fields. Cursors use authenticated AES-256-GCM encryption and bind app, relation, resident, operation, resource, query filters and effective scopes. They reveal no sequence or offset and survive restart. They bind a digest of the eligible view: visible changes produce 409 CURSOR_STALE and require a fresh first page; hidden-only changes do not invalidate the page. Changing filters/limit or reusing another relation's cursor is invalid. These are view-bound pages, not retained historical snapshot pagination. Queries currently materialize eligible local records before paging; scale optimization is deferred.

Visibility is an explicit allowlist, not an ordinal hierarchy:

- SYSTEM_ONLY is always denied.
- PRIVATE_RESIDENT exports only approved owned-plan/purchase DTOs; actor identity alone never grants access to canon, signals, memories or administrative facts.
- SOCIAL_PARTICIPANTS requires the granted resident to participate and exports only the separately recorded social fact, not other participants' state/relations.
- PUBLIC_WORLD is supported by the same safe world-fact DTO gate; arbitrary new event types still fail closed.
- EXTERNAL_RELATION_ALLOWED requires the exact bound app/resident/relation and contact scope.

Event DTOs contain `event_id`, `type`, `schema_version`, `timestamp`, `provenance`, `visibility` and explicitly selected `data`. No world sequence, aggregate revision, cause/dependency/correlation pointer or raw metadata is exported. Structured contacts do not reveal private purchase ancestors. Physical possessions/shared world observations can be authorized resident facts; the private human who funded them is not.

Only these high-value topics are currently queued: `resident.contact_intent`, `resident.plan_requires_input`, `resident.purchase_completed`, `resident.social_event`. No routine micro-event stream, state_changed or important_event publisher is claimed. Active grants with `outbox:read` and the underlying scope serve as local subscriptions. Each row is already filtered for its target relation at insertion, then filtered again with current permissions at polling. Envelope: delivery ID, topic, schema version, occurred time, granted resident and safe data. Receipts persist across restart and projection rebuild. Domain replay does not republish or reset acknowledgments. Delivery is at-least-once until acknowledged.

## Explicit limits and deferred work

No YourKa code, credentials, account or database is used. No remote webhook destination/delivery, TLS hosting, OAuth, frontend, payment verification, notification sending, broad character creator, private-canon API, raw memory API, background simulation worker or production deployment exists. Separate `/transfers`, action aliases, plan status query filters and `/simulations/{id}` from the old proposed contract are not routes; funding uses GIFT_SKR and progress uses bounded `/simulate`. Local fixture/operator code supplies catalog IDs and authored encounters; there is no public catalog or encounter-authoring endpoint. No general recurring payment control is exposed.

OpenAPI request schemas/paths/scopes come directly from executable routes. Success schemas currently promise an object envelope, with detailed field behavior in this contract and scenario tests; generated strongly typed response schemas are deferred. Generate/check the artifact with `npm run resident:openapi`. Run `npm run resident:test` and `npm run typecheck` locally. `npm run resident:demo-api -- /absolute/fresh.sqlite /absolute/trace.json` executes real loopback requests and saves responses without credentials. It uses an explicitly injected fixture clock; the normal server uses trusted current time.

This is ready for **client integration review**, not a claim of production security certification, RE-5 completion or authorization to connect/deploy.
