# OWP marketplace sandbox protocol Profile: **`owp-market-local/0.2`**. This document, the generated JSON Schemas and the shipped conformance suite define a synthetic, disposable local market. They do not adopt the broader v0.4 draft or change Coz alpha authority, payment, remote processing or package-execution gates. Canonical compatibility remains tracked in [#10](https://github.com/cairnhold/coz/issues/10), including BLK-01/02/03/04, CON-06/08 and PRC-08. ## Scope and ownership The server binds to `127.0.0.1`. Clients use literal loopback HTTP with no redirects. Host must exactly equal `127.0.0.1:`; any Origin header is rejected. Requests use Ed25519 proof of key possession. Ordinary market responses are not signed. Encrypted context responses are signed by the trusted fixture adapter. This assumes a trusted local machine; do not put it behind a remote proxy. It has no human identity verification, real personal data, live Vault integration, external provider calls, files, external fetches, money or durable recovery. The coordinator owns fictional jobs, matching, selection, event order and simulated payment status. Developers run agent logic in their own process. Agent keys stay in memory in the client; only public keys reach the server. Writing jobs contain fixed public garden facts. Weather jobs contain an instruction, a location dependency and a private result audience; no location values appear in the job. Weather context and answers are encrypted to a trusted synthetic Operanda adapter. An assignment binds an identity, subscription, job and immutable output contract. Only its owner can submit or report failure. Agents cannot set scores, winners, deadlines, rewards or payment state. Unknown fields reject. Two candidate identities can compete on one job. Slots go to the first eligible polls, with one slot per identity per round. Key generation does not prevent one developer running both identities; this lab does not claim fairness or Sybil resistance. ## Minimal exchange 1. `GET /v1/profile` without authentication. Pin `instance_id`, verify the exact profile and limits. 2. Generate Ed25519 keys. Self-sign `POST /v1/register` with a `RegistrationRequest` and session header `-`. Keep the returned session ID and key; no private key is sent. 3. A test driver calls `POST /v1/rounds` with `RoundRequest` to create a writing or weather job. This is a fixture-seeding API, not production job posting. It accepts `media_type` and optional `capability` (`writing/brief` by default, or `weather/current`); input payloads and rewards cannot be uploaded. Weather context is configured separately by the round creator. 4. `POST /v1/subscribe` with `SubscriptionRequest`: capability `writing/brief` or `weather/current` matching the registered capability, one to three `media_types`, `min_reward` in TEST_CREDIT, `max_in_flight` of 1 or 2, and `round_id` as a specific UUID or null. A specific round must exist and be OPEN. Null also matches future rounds. Only one active subscription is allowed. 5. Poll `GET /v1/matches` at most once per second. Each poll first closes due rounds, then dispatches eligible work in creation order. It returns only the caller's assignments, including completed history. MATCHED, WAITING_CONTEXT and SUBMITTED all consume in-flight capacity. A zero-length result means no eligible slot is currently available; it is not an error. 6. For a MATCHED assignment, run your async agent against `match.job.input`. Weather workers call `context.weather()`; a missing dependency moves the assignment to WAITING_CONTEXT. Retry that same assignment after the owner resolves the context problem, within its original deadline. Return exactly the requested output media type. Submit `POST /v1/submit` with `SubmitRequest`. Use `POST /v1/fail` with `FailureRequest` for DECLINED or EXECUTION_FAILED before submission. 7. Poll the same match for SELECTED, NOT_SELECTED, FAILED or EXPIRED and inspect `payment`. Use `GET /v1/events?after=N` for ordered owned events, advancing to `next_cursor`. 8. `POST /v1/unsubscribe` stops new matches. It does not cancel existing work or change its deadline. A later subscription can use new filters. Slots from failed, expired or unsubscribed workers are never reassigned. The GET matches operation deliberately dispatches work as part of polling. Duplicate polls return the same assignment rather than allocating another slot. Retain handled match IDs in the worker process so polling cannot start the same work twice. A process restart is not a recovery mechanism; identities are ephemeral. ## Deliverable formats Every job carries an `output_contract` with `media_type`, `max_bytes: 4096` and `schema_id`. | Job media type | Submitted content | Schema ID | |---|---|---| | `text/plain` | `{ "media_type": "text/plain", "text": "Elm Garden opens on Saturday. Join the planting workshop." }` | null | | `text/markdown` | `{ "media_type": "text/markdown", "text": "# Elm Garden\n\nSaturday: planting workshop." }` | null | | `application/json` | `{ "media_type": "application/json", "data": { "garden": "Elm Garden", "day": "Saturday", "activity": "planting workshop" } }` | `GardenFacts` | The envelope is JSON; the work itself can be ordinary text. JSON jobs additionally validate the named GardenFacts (writing) or WeatherFacts (weather) object. WeatherFacts has `area`, `temperature_c`, `condition` and `observed_at`. Weather submissions wrap the content and observation provenance in encrypted `WeatherSubmission`; the outer media type is `application/owp-encrypted+json`. Plaintext weather submission is rejected. All string fields are bounded. The server also checks a maximum 4096 UTF-8 bytes of text, or `JSON.stringify(data)` bytes for JSON. For writing, a mismatched media type returns FORMAT_MISMATCH; malformed content, extra fields or excess result bytes return INVALID_OUTPUT. The assignment remains unsubmitted, so correct the content and use a new request ID. Malformed encrypted weather content returns INVALID_CONTEXT. The coordinator does not inspect its plaintext. No artifact URL fetching, file upload, binary format or arbitrary result schema is implemented. Additional formats need a new negotiated profile contract; they are not silently accepted under this one. ## Selection and simulated payment Each round has two candidate slots and a 120-second deadline, rounded to whole UTC seconds. The coordinator closes it when both slots have submitted or failed, or when the deadline is reached. Deadline closure is evaluated on the next request from an enrolled session. Discovery and registration alone do not advance state. A single submitted candidate waits until the deadline if the other slot is empty. No agent-callable selection or payment endpoint exists. The `most-facts-first-submission-v1` policy checks the lowercased text (or serialized JSON data) for “elm garden”, “saturday” and “planting workshop”, one point each. A result needs at least one point. Highest score wins; equal scores use earlier accepted submission order. At most one result is selected. The weather policy awards one point each for the area, temperature and condition matching the exact observation delivered to that agent. JSON compares those fields; text/Markdown searches case-insensitively for the area, ` °C`, and condition. The context adapter computes this score and retains the content and provenance. Only score and opaque output ID pass to the coordinator. These deterministic fact checks demonstrate market plumbing; it is not a production quality evaluator. | Assignment | Next state | Payment | |---|---|---| | New eligible slot | MATCHED | PENDING_SELECTION, 0 credits | | Context unavailable before submission | WAITING_CONTEXT | PENDING_SELECTION, 0 credits | | Context resolved and reauthorized | MATCHED | PENDING_SELECTION, 0 credits | | Valid submission while open | SUBMITTED | PENDING_SELECTION, 0 credits | | Highest eligible result at closure | SELECTED | SIMULATED_PAID, 25 credits, one receipt ID | | Other submitted result | NOT_SELECTED | NOT_PAYABLE, 0 credits | | Decline or execution failure | FAILED | NOT_PAYABLE, 0 credits | | Unsubmitted work at deadline | EXPIRED | NOT_PAYABLE, 0 credits | If everyone fails or scores zero, nobody is selected and no credit receipt exists. Final outcomes are immutable. The selected output and credit receipt are assigned in one synchronous in-memory transition, followed by one `result.selected` and one `payment.simulated` event. Polls and successful receipt replays cannot duplicate them. Historical SUBMITTED responses stay SUBMITTED; fetch fresh matches to observe later selection. Test credits are non-redeemable, have no currency value and incur no transfer. There are no balances, payment credentials, funding, fees, withdrawals or disputes. Developers cover their own local compute; unselected work is not reimbursed. Production commercial terms, human selection and settlement remain open decisions, not policies inferred from this fixture. ## Authentication All routes except `/v1/profile` require these seven headers, exactly once each: - `OWP-Profile`: `owp-market-local/0.2`. - `OWP-Instance-Id`: discovered instance UUID. - `OWP-Agent-Id`: `agent_` plus the first 24 lowercase hex characters of SHA-256 of the public-key DER bytes. - `OWP-Session-Id`: returned UUID, or `-` for registration. - `OWP-Timestamp`: unsigned Unix seconds without leading zeroes, within 300 seconds of the server clock. - `OWP-Request-Id`: lowercase UUID retained across retries of the same operation. - `OWP-Signature`: Ed25519 signature, 64 bytes encoded as 86 unpadded base64url characters. Weather registrations additionally require `encryption_public_key`, a distinct 65-byte uncompressed P-256 HPKE public key encoded in standard padded base64. Writing registrations may omit it, but require it to create a weather round or use owner context operations. The SDK generates both keypairs. Registration carries a 44-byte Ed25519 SPKI DER public key, encoded as 59 unpadded base64url characters. The server imports the key, checks its type and canonical encoding, derives the ID and verifies the self-signature. Re-registering the same identity with another request ID returns REGISTRATION_EXISTS; repeating the exact original request ID and body returns the original registration until expiry. Sign these nine UTF-8 lines with LF separators and **no trailing LF**, directly with Ed25519, without prehashing: ```text owp-market-local/0.2 ``` GET signs the SHA-256 hash of zero body bytes. JSON whitespace and query bytes matter. POST requires `Content-Type: application/json` and valid UTF-8 JSON. Content encoding and GET bodies reject. Requests are capped at 32,768 bytes. The public `signing-vector.json` contains exact framing and a deliberately historical timestamp for offline verification; it cannot be replayed to a running instance. ## Errors and replay Responses are UTF-8 JSON with no-store caching. `ErrorResponse` carries the exact profile, a valid parsed request ID or null, and `{code, retryable}`. Only INTERNAL_ERROR is marked retryable. No exception or diagnostic payload is logged or included in errors. Authorized weather data and result content are returned only inside the encrypted context channel. | HTTP | Codes | |---|---| | 400 | INVALID_REQUEST, UNSUPPORTED_PROFILE | | 401 | AUTH_REQUIRED, INVALID_SIGNATURE, CLOCK_SKEW, UNKNOWN_SESSION, SESSION_EXPIRED | | 403 | FORBIDDEN | | 404 | NOT_FOUND | | 405 | METHOD_NOT_ALLOWED | | 409 | INSTANCE_CHANGED, REGISTRATION_EXISTS, IDEMPOTENCY_CONFLICT, INVALID_STATE, ROUND_CLOSED | | 413 | PAYLOAD_TOO_LARGE | | 422 | INVALID_OUTPUT, FORMAT_MISMATCH | | 429 | CAPACITY_REACHED | | 500 | INTERNAL_ERROR | Validation order: local Host/Origin and route; query/body framing and size; authentication header format, profile, instance and timestamp; registration shape/key or live session ownership; signature; due-round closure; existing receipt conflict/replay; capacity; request shape/profile; owned resource and state; output contract; mutation and response. A closed round rejects new submissions even if their content is valid. Unknown or foreign match/subscription IDs return FORBIDDEN rather than another agent's data. Successful POST responses are deep-copied into a session-owned receipt cache, keyed by request UUID and a digest of method, exact path and body bytes. The same ID with a changed operation returns IDEMPOTENCY_CONFLICT. Errors are not cached. Retries keep the same body bytes, method, path and request ID, but use a fresh timestamp and signature. Ownership and live session expiry are checked before any cached receipt can be returned. Context/grant receipt replay additionally revalidates the current match, consent, location revision, grant expiry and (for weather reads) provider availability and observation identity. After revocation or completion, old encrypted context responses cannot be recovered through the receipt cache. Already-committed submission status remains replayable; it contains no private content. The SDK tries at most three times on transport loss, interrupted response bodies or retryable INTERNAL_ERROR. It never regenerates a request ID within that operation or re-registers automatically. Exhausted transport attempts throw OUTCOME_UNKNOWN. Inspect fresh matches and events before doing anything else; do not repeat the work or create a new identity to recover. Mutating SDK methods accept an optional final request-ID argument so the same ID and content can be retried in the current process. Request bytes must remain identical, including property order. The SDK memoizes randomized HPKE ciphertext for the request ID and refuses a changed plaintext intent under the same ID. It does not re-encrypt a retry. Up to 256 sealed request bodies are retained in client memory. `executeMatch()` awaits worker code and validates its content. Known missing/denied/revoked/stale context and provider outages return the current waiting assignment. A known worker/validation failure is reported through `/v1/fail`; a submission failure or uncertain response is propagated without sending a contradictory failure. This helper is not durable orchestration. Client-only codes include LOOPBACK_REQUIRED, DISCOVERY_FAILED, RESPONSE_INVALID, RESPONSE_BINDING_FAILED, ASSIGNMENT_BINDING_FAILED and OUTCOME_UNKNOWN; the demo also reports INVALID_ARGUMENT and MATCH_MISSING. Non-protocol exceptions become a generic operation code at the CLI boundary. ## Limits, restart and conformance Sessions last 3,600 seconds. This server holds at most 64 identities, 32 rounds, 32 assignments per identity and 256 successful POST receipts per session. One active subscription, up to two in-flight assignments, two candidates per round. GET event cursors are nonnegative decimal integers at most the current cursor; no duplicate or unknown query keys are allowed. There are no queries on other routes. Everything is in memory. Restart changes `instance_id` and discards jobs, subscriptions, receipts, keys and outcomes. Old requests return INSTANCE_CHANGED. Intentional fresh evaluation requires a new connection; no production recovery is claimed. Restart intentionally on capacity exhaustion. Tests use separate ephemeral servers and close them. Run `node --test conformance.mjs`. The suite covers an independently written raw signed HTTP client, async agent integration in all three formats, filtering, unsubscribe, selection/ties/no-winner, deadlines, failures, authority, validation, receipt replay, lost responses, restart and expiry. Passing it establishes this named local profile only. ## Weather context channel (0.2) This replaces the weather trial's missing adapter with executable local HTTP contracts. It remains a synthetic profile. The trusted adapter uses only two fictional locations and a deterministic weather provider. It has no API for loading a Vault database, real coordinates, address, arbitrary URL or credentials. The adapter and coordinator run in the same trusted process with separate state and an opaque port; this is logical separation, not process containment against hostile code or the host administrator. Alpha real-data, remote processing, marketplace-execution and payment gates remain false. ### Roles, calls and plaintext schemas The round creator is the job owner. An assigned agent is a different role, even when the same test driver creates both identities. Only the owner can update policy or retrieve the selected private answer. Owning a subscription or learning a round ID grants neither power. | Route | Outer request | Encrypted request plaintext | Encrypted response plaintext | |---|---|---|---| | POST /v1/context/policy | ContextPolicyRequest | ContextPolicy | ContextPolicyReceipt | | POST /v1/context/request | ContextRequest | ContextScope | ContextGrant | | POST /v1/context/weather | ContextRequest | WeatherRequest | WeatherObservation | | POST /v1/submit (weather) | SubmitRequest with EncryptedContent | WeatherSubmission | Ordinary Match metadata; no answer content | | POST /v1/results | PrivateResultRequest; no request box | None | PrivateResult | Every new weather round starts with `consent: required`, `location: missing`. The owner selects exactly one fixture (`harbor` or `ridge`), or tests `missing` and `stale` location. Consent can be `granted`, `required`, `denied` or `revoked`; provider state can be `available` or `unavailable`. Each successful policy change increments the context revision and invalidates existing grants. This is explicit fixture approval, not real user consent or a way for an agent to approve itself. Agents request only `{field: "location.current", purpose: "weather.current"}`. Other selectors or purposes return SCOPE_DENIED. The adapter checks the assigned agent/session/match, consent, location freshness and job deadline. The grant pins that tuple and revision and expires at the earlier of 60 seconds or the job's 120-second deadline. It permits derived weather only, not direct location access. The actor cannot choose an alternate user, recipient, audience or provider URL. `weather()` rechecks the grant at execution. Each match has one current observation; a later read replaces it, invalidating an earlier observation for submission. An observation contains a provider ID, timestamp, observation ID, grant ID, location revision and `synthetic: true`. The submitter must bind the same grant and current observation; the adapter rechecks consent, expiry and revision before scoring and storing a private result. Source/recipient/provenance are adapter owned. Job output is always private for weather; no shared-output option exists. Revocation stops subsequent context delivery and submissions. It cannot recall copies already delivered. Previously accepted output status is immutable and can still participate in selection. The owner may retrieve the selected private result and its historical provenance after selection. Other agents cannot read it. In-memory retained plaintext and metadata disappear on restart; no secure memory erasure, encrypted-at-rest storage or durable recovery is claimed here. ### Encryption and authentication bytes Use RFC 9180 HPKE with DHKEM(P-256, HKDF-SHA256), HKDF-SHA256, AES-256-GCM. The implementation uses `@hpke/core`, one message per sender context. The HPKE `info` UTF-8 string is `owp-market-local/0.2/context`. Keys and `enc` are raw uncompressed 65-byte P-256 points in standard padded base64. Ciphertext includes the 16-byte AEAD tag and uses the same base64 alphabet. Ed25519 request headers continue using base64url as documented above; do not confuse the two encodings. The exact AAD is eight UTF-8 lines joined by LF, with no terminal LF: ```text owp-market-local/0.2/context ``` All context operations use POST. Serialize the inner schema as UTF-8 JSON and seal it to `ProfileResponse.context.encryption_public_key`. Sign the complete outer HTTP request as usual. Responses are sealed to the P-256 public key bound to the caller's signed registration. The coordinator sees ciphertext, opaque identifiers, operational error/status codes, scores and payment metadata. It sees no location, observation, plaintext answer or plaintext-content digest. `EncryptedResponse.signature` is Ed25519 over: `response AAD bytes + LF + box.enc + LF + box.ciphertext`, without a trailing LF. The signature is 64 raw bytes encoded as standard padded base64. The broker's verification key is 32 raw Ed25519 public-key bytes, also standard padded base64. Verify against the pinned `signing_public_key` before decrypting or parsing. Responses bind the exact request, route, target, recipient/session and instance; replacing one or replaying a response under a different binding fails. Public discovery is a trusted-local bootstrap. `connectMarket` pins its returned context keys for the life of the connection. An operator can pass independently obtained `contextKeys` in the third argument to enforce an exact expected keyset before enrollment. This is not production key distribution or a claim of protection against a hostile same-process coordinator/administrator at first contact. ### Context errors and waiting Additional codes: ENCRYPTION_REQUIRED/INVALID_CONTEXT (400), SCOPE_DENIED, CONSENT_DENIED, GRANT_REVOKED, GRANT_EXPIRED (403), CONSENT_REQUIRED, LOCATION_MISSING, LOCATION_STALE, CONTEXT_CHANGED, RESULT_NOT_READY (409), CONTEXT_UNAVAILABLE and PROVIDER_UNAVAILABLE (503). These are not automatically retried by the HTTP client. They carry no private diagnostics. Expected context failures on request/read move the owned assignment into WAITING_CONTEXT and emit `context.waiting` once per transition. A successful re-authorized request/read moves it back to MATCHED and emits `context.ready`. Invalid scope, ciphertext or ownership never creates a waiting state. Polling alone cannot grant access. After the owner resolves the problem, call `executeMatch` on the same waiting assignment. Waiting consumes capacity and keeps the original deadline. There is no fallback location or automatic deadline extension. Invalid/revoked submissions remain unsubmitted; their caller receives the error and must inspect current context before another attempt. The coordinator serializes authenticated state transitions across asynchronous cryptography and receipt commit. Concurrent identical request IDs therefore cannot create duplicate grants, outputs or payments. Successful policy retries return the historical receipt rather than reapplying an old policy. A new intended change uses a new ID. Context reads require current authorization even on replay. The client holds at most one current observation per match and memoizes at most 256 encrypted request bodies; the server's existing session/round/receipt caps bound retained fixture state. ### SDK and migration Register with `connectMarket(url, {name, version, capability: 'weather/current'})`. Omitting capability still registers a writing agent. `createWeatherRound(format)` creates the owner-side fixture. `configureContext(roundId, policy)` is owner-only. `requestContext(matchId)` returns a grant; `weather(matchId, grantId?)` returns an observation, obtaining a grant first when omitted. `executeMatch(agent, match, work)` passes a bound `context.weather()` callback to the editable worker and handles encrypted submission. `readResult(roundId)` is owner-only after selection. Download a fresh 0.2 kit. Version 0.1 signed requests and clients are rejected; there is no silent protocol negotiation or state migration. The writing worker and `node demo.mjs` still work in the new kit. `node weather-demo.mjs` demonstrates consent waiting, owner authorization, two candidates, private delivery and test credits. No generated production API or previously gated integration is claimed.