# OWP local evaluation profile 0.1 This document defines the complete **`owp-eval-local/0.1`** interoperability contract for the standalone kit. It is a deliberately scoped, executable local profile inspired by OWP's work/access/output lifecycle. The broader v0.4 source remains a separate draft. Every decision here applies only to this named synthetic profile. ## Wire conventions - Transport: HTTP/1.1 JSON at `http://127.0.0.1:8787` by default. The supplied server binds only to loopback, checks its literal Host header and rejects browser Origin headers. The supplied agent refuses redirects and non-loopback URLs. Responses are not signed or encrypted: trust the local process. This profile cannot be used for real data or remote deployment. - Every JSON request/response contains `profile: "owp-eval-local/0.1"`. Unknown fields, wrong profiles and unsupported enum values are rejected. Objects use snake_case. The server accepts only UTF-8 JSON and sends `application/json; charset=utf-8`, `Cache-Control: no-store`. - GET requests have zero body bytes. POST requires `Content-Type: application/json` (optional charset parameter). The body limit is 16,384 bytes. No request compression is supported. ASCII is required for agent names and output summaries in this fixture profile, making length bounds identical across runtimes. - IDs: UUIDs for request, session and server instance IDs; lowercase hexadecimal. Object IDs are `agent_`, `job_`, `grant_`, or `out_` followed by 24 lowercase hexadecimal characters. Agent ID is the first 24 hex characters of SHA-256 of the public key's DER, prefixed `agent_`. Other object IDs are server-generated. - Dates: UTC with seconds and `Z`, no fractional seconds. Timestamps in signing headers are unsigned decimal Unix seconds without leading zeros. A request may differ from the server clock by at most 300 seconds, inclusive. ## Discovery and enrollment `GET /v1/profile` is public. Its `ProfileResponse` identifies the exact profile, `instance_id`, capability `writing/brief`, output contract `Summary` and limits. Pin this instance ID for the entire run. Generate an Ed25519 key pair locally. The public key is the canonical unpadded base64url encoding of a 44-byte SubjectPublicKeyInfo DER structure (RFC 8410 Ed25519). Private keys remain inside the agent process. Send `POST /v1/register` with a `RegistrationRequest`, signed as below. Use `-` as the session header for this request. Registration proves possession of the supplied key; it does not verify a person or legal identity. The response is `201 RegistrationResponse`, containing the derived `agent_id`, fresh session UUID, server instance ID and expiry. One key has one session and one offered fixture job per server instance. A retry of the successful registration with the same request ID and identical bytes returns the original receipt. Another request ID for the same key returns `409 REGISTRATION_EXISTS`. Expired keys cannot register again within the same instance; intentionally start a new run with a new in-memory key or restart the disposable test server. ## Authentication frame Every operation except discovery requires these exact headers: | Header | Value | |---|---| | `OWP-Profile` | `owp-eval-local/0.1` | | `OWP-Instance-Id` | Pinned instance UUID from discovery | | `OWP-Agent-Id` | Derived agent ID | | `OWP-Session-Id` | Session UUID, or `-` for registration | | `OWP-Timestamp` | Current Unix seconds | | `OWP-Request-Id` | A request UUID; retain for retries | | `OWP-Signature` | Unpadded base64url of the 64-byte Ed25519 signature | Construct these nine UTF-8 lines in this order, with LF separators and **no trailing LF**: ```text owp-eval-local/0.1 ``` For GET, hash the empty byte string. Sign the complete frame directly with Ed25519 (`crypto.sign(null, bytes, privateKey)` in Node), without prehashing the frame. Header names are case-insensitive, values are exact; duplicate authentication headers reject. Verify against the registration key or the session's pinned key. The profile uses no HMAC, bearer secret or implicit key derivation. Path/query order, body whitespace and all body bytes are signature-bound. Examples of keys in schema fixtures are shape examples, not usable identities. `signing-vector.json` supplies exact body bytes, hash, frame, public key and a valid signature for offline verification in any language. Its private key was discarded. The deliberately expired timestamp and fictional instance/session make it unsuitable for a live request. Reconstruct the frame and agent ID and verify the signature; changing any body byte must fail verification. ## HTTP operations Every authenticated operation is scoped to the signed session's single job. Foreign job IDs return `403 FORBIDDEN`. No identity, score, state or grant authority can be set through unchecked extra request fields. | Method and path | Request body | Success | Rule | |---|---|---|---| | `GET /v1/profile` | None | `200 ProfileResponse` | Public discovery | | `POST /v1/register` | RegistrationRequest | `201 RegistrationResponse` | Self-signed registration | | `GET /v1/job` | None | `200 JobSnapshot` | Current owned snapshot | | `POST /v1/accept` | JobRequest | `200 JobSnapshot` | OFFERED → ACCEPTED | | `POST /v1/decline` | JobRequest | `200 JobSnapshot` | OFFERED → DECLINED | | `POST /v1/grant` | GrantRequest | `201 GrantReceipt` | ACCEPTED, one exact scope | | `POST /v1/input` | InputRequest | `200 FixtureInput` | Active grant; first read → RUNNING | | `POST /v1/output` | OutputRequest | `200 CompletionResponse` | RUNNING → SCORED, revoke grant | | `POST /v1/fail` | FailureRequest | `200 CompletionResponse` | ACCEPTED/RUNNING → FAILED, revoke grant | | `GET /v1/events?after=N` | None | `200 EventsResponse` | Ordered session events after N | No other paths, methods, or query parameters are supported. `/v1/events` may omit `after` (default 0); repeated parameters, leading zeros, negative values and cursors beyond the current sequence reject. The exact query is signed. These are the kit's endpoints, not claims about a production OWP service. ## Lifecycle and access Successful flow: ```text register → OFFERED → accept → ACCEPTED → grant → ACTIVE grant → input → RUNNING → output → SCORED + REVOKED grant ``` Job states and allowed commands: | State | Allowed new mutations | Terminal? | |---|---|---| | OFFERED | accept, decline | No | | ACCEPTED | grant (once), input (with grant), fail | No | | RUNNING | input (with active grant), output, fail | No | | SCORED | None | Yes | | DECLINED | None | Yes | | FAILED | None | Yes | | EXPIRED | None | Yes | Every unfinished job expires five minutes after registration. Expiry is applied on the next authenticated request and emits `job.expired`; any active grant is then revoked. Expired sessions reject before lifecycle processing. There is no background worker or production timeout guarantee. A grant expires at the job deadline; one grant can be issued only after acceptance. `scope` must be exactly `["fixture.brief"]`. The fixture owner has pre-approved this fictional data for the test: `synthetic_consent` is `fixture-owner-approved`. This is not consent on behalf of a real user. The grant is bound to the current session's agent and job. `POST /v1/input` checks current grant state and deadline even for an idempotent replay, so cached responses cannot bypass revocation. Grant IDs are identifiers, not bearer credentials. A replay of a successful grant issuance returns its historical receipt, which cannot authorize a later read after revocation. The `CompletionResponse` contains the current revoked grant. Job terminal state also guarantees that no new input access is allowed. The input is a fixed fictional record: source `fixture_garden01`, title `Elm Garden`, opening day `Saturday`, activity `planting workshop`. No mutable vault, user input, remote URL, model, tool, attachment or external action is involved. ## Output and scoring Return `OutputRequest` with the bound `job_id`, `grant_id` and content: ```json {"summary":"Elm Garden opens on Saturday. Join a planting workshop with your neighbors.","source_ids":["fixture_garden01"]} ``` Summary is 30–600 ASCII characters including allowed CR/LF line breaks; source IDs must equal the one-element tuple shown. Additional content fields reject. Schema-invalid output returns `422 INVALID_OUTPUT` before changing the job. A schema-valid output is scored by case-insensitive substring checks for `elm garden`, `saturday` and `planting workshop`. Each contributes one point. `passed_checks` is 0–3, `total_checks` is 3, and the three booleans show the exact breakdown. Even a 0/3 valid result finishes the local evaluation; no production eligibility or reputation is inferred. Output submission, deterministic scoring and grant revocation form one synchronous in-memory transition. An output ID is assigned by the coordinator. Job state remains SCORED; grant revocation is a separate object/event, resolving terminal-state ambiguity explicitly within this profile only. `POST /v1/fail` accepts `UNSUPPORTED_INPUT` or `EXECUTION_FAILED`. It produces FAILED and revokes access. To decline before accepting, use `/v1/decline`; it produces DECLINED. No free-form exception text or provider response is transmitted. ## Delivery, retries and restart Requests are serialized by the single process after each body is read. Successful POST receipts are retained in memory for the session's lifetime. A mutation's identity is its request UUID, scoped to the session. Registration has its own key/instance scope. The request digest is SHA-256 of `method + LF + exact_path + LF + hex(SHA-256(raw_body))`. Reuse with a different path or body returns `409 IDEMPOTENCY_CONFLICT`. Semantically equivalent JSON with different whitespace is different content. A changed timestamp/signature is allowed for retry; request ID and body remain identical. On network uncertainty or `500 INTERNAL_ERROR`, resend the same request ID and body, re-signed with a fresh timestamp. The reference adapter makes at most three total attempts. Read current job/events to reconcile if the outcome stays uncertain. It does not create another request identity or register another job automatically. Successful output replay returns the same output ID, score and revoked grant without another event. After completion, a new output request ID is rejected. State is intentionally not durable. Every process start creates a new `instance_id`; old registration retries and all old session requests receive `409 INSTANCE_CHANGED`. The client must stop, report that the prior outcome cannot be recovered, and require an intentional new test run. Never silently discover a new instance and replay old work. This is safe reset behavior for disposable synthetic work, not crash recovery for real effects. Session TTL is 15 minutes. At most 100 sessions and 64 successful post-registration receipts per session are retained until restart; exhausted capacity returns `429 CAPACITY_REACHED`. The session expiry and bounded session count limit retained resources. These limits, body bytes and clock window appear in discovery. Start a fresh server intentionally when this disposable lab is full. No credentials or runtime databases are written to disk. ## Events Session-local sequence IDs begin at 1. The successful path emits, in order: `agent.registered`, `job.offered`, `job.accepted`, `grant.issued`, `input.delivered`, `output.submitted`, `job.scored`, `grant.revoked`. Each event contains type, sequence ID, UTC time and job ID; no raw input/output or secrets. Decline, failure and deadline branches emit `job.declined`, `job.failed` or `job.expired` instead. Revocation follows when an active grant exists. Repeated reads and successful receipt replays add no events. Polling returns all available events after the cursor (at most 16 in this profile). No streaming, webhooks, external witness or cryptographic audit trail is claimed. ## Errors Non-2xx responses use `ErrorResponse`: `{profile, request_id, error: {code, retryable}}`. The request ID is null when absent or malformed. Only INTERNAL_ERROR is retryable. Exact status mapping: | HTTP | Codes | |---|---| | 400 | INVALID_REQUEST, UNSUPPORTED_PROFILE | | 401 | AUTH_REQUIRED, INVALID_SIGNATURE, CLOCK_SKEW, UNKNOWN_SESSION, SESSION_EXPIRED | | 403 | FORBIDDEN, GRANT_REQUIRED, GRANT_REVOKED | | 404 | NOT_FOUND | | 405 | METHOD_NOT_ALLOWED | | 409 | INSTANCE_CHANGED, IDEMPOTENCY_CONFLICT, REGISTRATION_EXISTS, INVALID_STATE, DEADLINE_EXCEEDED | | 413 | PAYLOAD_TOO_LARGE | | 422 | UNSUPPORTED_CAPABILITY, INVALID_OUTPUT | | 429 | CAPACITY_REACHED | | 500 | INTERNAL_ERROR | Validation precedence: host/origin and routing, body size/JSON, required headers, profile/instance/time, key/session/ownership, signature, expiry, receipt lookup, request schema/job ownership, grant/state, execution. Thus an expired input grant returns GRANT_REVOKED, while an expired accept request returns DEADLINE_EXCEEDED. A cached historical success may be returned after job completion, subject to live session checks and the input-disclosure exception above. HTTP parser failures or disconnected/timeout sockets may close without a JSON response and must be treated as network uncertainty. There is no Retry-After policy: 429 requires an intentional environment reset, not automatic retry. ## Compatibility evidence and scope Run `node --test conformance.mjs`. The kit tests a reference adapter and a separately constructed raw HTTP/signing client, including tampering, session ownership, duplicate delivery, scope checks, semantic output errors, deterministic event order, expiry and restart. Full machine-readable shapes are in `schemas/`, a definition library in `schemas.json`, and HTTP bindings in `openapi.json`. Schema format assertions must be enabled. This profile omits registration KYC/KYB, real consent, mTLS, signed responses, heartbeat challenges, grant delegation, marketplace matching, monetary settlement, distributed leases, audit chains and federation. Those are requirements or open decisions of other profiles; they cannot be assumed here. The profile neither changes the encrypted Coz coordinator/Vault boundary nor enables existing production gates. Broader reconciliation remains tracked in the repository's implementation-status document.