Skip to content

Latest commit

 

History

History
242 lines (143 loc) · 74.9 KB

technical.md

File metadata and controls

242 lines (143 loc) · 74.9 KB

openrep, technical notes

this is a reference document for anyone picking this codebase back up, including us. it explains how the pieces fit together and how the system works end to end. everything described here is implemented except where a section explicitly says otherwise: the marketplace app and its adapter are planned (ingestion section), and the known limitations section lists the written deferrals. nothing is silently stubbed.

architecture overview

openrep is a pnpm monorepo with three packages.

  • packages/sdk, published as @openrepso/sdk. the core library. plain typescript, no framework, no react, no next. it owns the identity model, the attestation model, score calculation, ingestion, and resolution. everything that matters lives here so it can be reused by every surface without duplicating logic.
  • packages/cli, published as @openrepso/cli. a commander based command line tool. it depends on @openrepso/sdk and exists for developers who want to script openrep into pipelines, ci checks, or their own agent tooling without a browser. every command is a thin wrapper around an sdk function.
  • apps/web, named web in the workspace. a next.js app router application using typescript and tailwind. it also depends on @openrepso/sdk. it is the human facing product: create agents, chat with them, watch reputation build live.

the dependency graph is a simple chain. the sdk depends on nothing local. the cli depends on the sdk. the web app depends on the sdk. nothing depends on the web app or the cli. that keeps the sdk the single source of truth for all reputation logic.

it is structured as a monorepo for two reasons. first, the sdk, cli, and web app all need to share the exact same types and logic, and a local workspace link means they compile against the same source rather than against a published copy that can drift. second, the sdk is small and pure, so keeping it as its own package means it can be published on its own later without dragging in react or next.

workspace links are handled by pnpm through the workspace protocol. both the cli and web app declare "@openrepso/sdk": "workspace:*", and pnpm creates a symlink from each of their node_modules to packages/sdk. the root pnpm-workspace.yaml lists packages/* and apps/* as the package globs. root scripts: pnpm dev runs the web app, pnpm build builds every workspace with pnpm -r build.

identity model

an agent's identity is two keypairs plus a human readable name. when a new agent is created, createAgent(options) does four things: validate the caller's options, generate two keypairs, generate a human readable name, and produce a signed manifest that is persisted through the injected StorageAdapter. the caller passes only the interface, never a concrete database, so the function cannot depend on a specific backend.

each keypair is ed25519. ed25519 is chosen because it is the de facto standard for this kind of thing, it is fast, the signatures are compact (64 bytes), and it is available across languages, which matters for a platform that is meant to be read by many ecosystems. the private keys stay with the agent and are never transmitted. the public keys are what get published in the manifest and used by anyone verifying the agent's attestations.

the two keys are deliberately separated by role. the identity keypair (publicKey/privateKey) signs routine attestations; the owner keypair (ownerPublicKey/ownerPrivateKey) authorizes revocation alone. the separation exists because the identity key is the one used constantly and therefore the most exposed, and it must never double as the kill switch: whoever holds the owner private key can permanently revoke the agent, so losing it is far more consequential than losing the daily-use identity key, and is expected to be stored with correspondingly more care. the two keys are generated independently and are compared at creation so they can never coincide.

the implementation uses @noble/ed25519 v3, the async api only (keygenAsync, signAsync, verifyAsync), which is webcrypto backed and avoids the sync api's requirement to wire a global sha512 signal by hand. verification always uses the strict rfc8032 branch ({ zip215: false }), not the more permissive zip215 default, because for a reputation ledger the strict branch is the correct default. all keys and signatures are lowercase hex strings, a decision that was confirmed across the whole sdk and reflected in the manifest fields below.

the human readable name is generated from three exported word lists (ADJECTIVES, NOUNS, COLORS, 36 words each) into the adjective-noun-color.agent shape, chosen from the same webcrypto csprng used for keys. the name is not security, it is ergonomics. it gives people and logs something memorable to refer to instead of a raw hex public key. the cryptographic identity is the keypair.

name uniqueness is deliberate. safety comes from a unique constraint on the name column in the storage backend, never from a client side check: two concurrent createAgent calls can both see a name as free, and the constraint is what makes exactly one of them win. the sdk's optimistic getAgentByName read is an optimization only, and adapters surface a constraint violation by throwing an error whose code is exactly "DUPLICATE_NAME", which the sdk maps to a retry with a fresh name, bounded at 10 attempts and then reported as NAME_GENERATION_EXHAUSTED. a caller supplied name that collides returns DUPLICATE_NAME and is never silently renamed.

one explicit decision drives the whole type system: an agent is canonically identified by its public key everywhere, in attestations, storage keys, and lookups. the human readable name is a mutable, resolvable alias on top of the public key, never the canonical id, since names can be renamed later. this is documented at the top of packages/sdk/src/types/identity.ts so every later module follows the same rule.

the signed manifest is the portable identity card. field by field:

  • name: string. the human readable name described above, for example beautiful-pig-black.agent. this is the mutable alias, not the identity.
  • publicKey: string. the agent's ed25519 public key, lowercase hex. this is the canonical id and what signatures are verified against.
  • ownerPublicKey: string. the agent's owner ed25519 public key, lowercase hex. this is the separate authorization key described above: the identity key signs attestations, the owner key authorizes revocation, and the two never coincide.
  • memoryPointer: string | null. an optional uri (ipfs://, https://) pointing at the agent's memory or profile, or null when unset.
  • permissions: AgentPermission[]. a closed string-literal union of granted scopes, currently "attest:self" | "ingest:external", never a free-form array.
  • createdAt: string. an iso 8601 utc timestamp of when the agent was created. this format is used consistently everywhere in the codebase.
  • manifestVersion: number. a schema version so the manifest can evolve without breaking old manifests. version 2 added ownerPublicKey; manifests still at version 1 (created before the key split) are verified against their exact original six signed fields, so legacy signatures stay valid.
  • signature: string. the ed25519 signature over the manifest fields above it, produced with the identity private key, lowercase hex.

the signature proves that whoever holds the private key generated and stands behind this manifest. the signed bytes come from canonicalize, the deterministic serializer in src/canonical.ts: recursive, object keys sorted, no whitespace, so the same logical manifest always produces the same bytes regardless of key order or formatting, which is what keeps verification portable across implementations. verification is the inverse: take the public key, re-canonicalize the signed fields (excluding the signature itself), and check the ed25519 signature with the strict rfc8032 branch. because the schema is versioned and manifestVersion is itself signed, verifyManifest can branch on the version without exposing a tampering path: a v1 manifest re-canonicalizes exactly the original six fields, a v2 manifest the seven including ownerPublicKey, and relabeling a version breaks the signature. verifyManifest is async (matching the noble v3 async api) and shape-validates every field first, returning { valid: false, reason } with a specific reason string for malformed or tampered manifests instead of throwing. it also consults the stored agent record: a manifest whose agent has been revoked fails with reason key revoked, and a manifest whose agent has no record at all fails closed with agent not found. if it verifies, the manifest came from the keypair that owns that public key. this is what lets a verifier trust an agent without trusting the openrep server, because verification needs only the public key and the payload, both of which travel with the agent.

AgentIdentity extends the manifest with the private key. it exists only inside the sdk right after generation and is never the return type of anything exposed to an api route or the browser, by construction of the types in types/api.ts.

key rotation is implemented as owner-authorized successor issuance, not silent re-keying. KeyRotationRecord (old public key, new public key, signed_by owner public key, request timestamp, owner signature over canonicalize({ agentId, timestamp })) is what the key_rotations audit table stores, one row per rotation, written in the same atomic transaction as the successor agents row. the stored signature makes the audit row self-verifying offline against the owner key, exactly as the live request was verified. full semantics, the authorization chain, and the old-id-stays-live policy are in the known limitations section below (the rotation bullet there is about the remaining edge, not about absence of rotation).

key custody policy

private keys are generated server-side and never transmitted to or stored in the browser, at any point, including the moment of agent creation. the frontend receives only the public manifest. this is not because of financial risk, attestations are not funds, it is because the agent's identity should be attributable and controlled by the server-side execution context that acts on its behalf, not exposed as a client-side secret with no real protection.

the policy is typed in packages/sdk/src/types/security.ts as KeyCustodyPolicy plus comments, not just prose. the rules:

  • if an agent's private key does not need to persist beyond a single request, it exists only in process memory for the duration of that request and is never written anywhere.
  • if it needs to persist across a session (an agent signing multiple attestations across a multi-turn chat), it is encrypted into an EncryptedKeyRecord with the server master key before being placed in the SessionKeyStore, and decrypted only in memory, only at the moment of signing.
  • raw, unencrypted private keys are never written to disk, a database, logs, or any persistent store, under any circumstance.

for createAgent, the private key is returned only in memory to the server side caller and nothing but the public manifest is persisted (the AgentRecord is constructed field by field, never by spreading the identity, so the record type structurally cannot carry key material). the session key persistence primitives are sdk functions: getMasterKey(), encryptPrivateKey/decryptPrivateKey in src/security.ts, the InMemorySessionKeyStore in src/session-store.ts, and the storage-backed DurableSessionKeyStore in src/session-store-durable.ts (all described below). the web app wires them into the chat flow through DurableSessionKeyStore (see the web app section); the primitives are also tested standalone so the wiring has nothing left to prove about the crypto or the expiry.

the encryption is aes-256-gcm, stated explicitly on EncryptedKeyRecord.algorithm. the server's own symmetric key is loaded from the environment by getMasterKey() in src/security.ts, which reads the canonical OPENREP_MASTER_ENCRYPTION_KEY env var, the same variable loadEnvConfig() validates, so boot-time validation and runtime key loading cannot drift apart. it throws a coded error with code MISSING_MASTER_KEY when the variable is absent or blank, and never proceeds with an empty or default key. the key value is never logged, never committed, and never included in any error message or stack trace. a fresh random 96-bit iv is generated per encryption and never reused; the gcm auth tag is embedded tag-prefixed inside the base64 encryptedPrivateKey blob (the record has no separate tag field) and is verified unconditionally on decryption, so tampering with the ciphertext or the iv fails closed as KEY_DECRYPTION_FAILED. EnvConfig requires masterEncryptionKey and databasePath at load time through loadEnvConfig(), which fails loudly with a typed result rather than proceeding with undefined.

the SessionKeyStore interface is storage agnostic: get, set, delete, and clearExpired. session-scoped keys must have a real expiry, they do not live forever. the concrete implementation InMemorySessionKeyStore in src/session-store.ts keeps entries in process memory with a 30 minute sliding inactivity window. set() stores the already-encrypted EncryptedKeyRecord, and encryption is the caller's job via encryptPrivateKey, so a raw private key cannot enter the store by type, structurally rather than by convention. get() returns null for absent or expired entries and slides the expiry forward on a live hit, so an active session never expires under its own activity. delete() removes an entry outright, no soft-delete, and clearExpired() sweeps every expired entry. expiry is pruned lazily on access plus the explicit sweep; the store never starts a background timer (an unmanaged interval inside a frozen serverless process is a bug), and a long lived server schedules clearExpired() on its own interval. calling code depends only on the interface, so the store can be swapped for a real backing store without changes. sessions are process scoped by design: a server restart ends every session, the documented trade-off at this scale.

the web app calling pattern, as wired: the chat api route holds the master key from loadEnvConfig().masterEncryptionKey or getMasterKey(). agent creation stores store.set(agentId, encryptPrivateKey(privateKey, masterKey, agentId)) bound to the creating session. each chat request calls store.get(agentId) — a null result is MISSING_SESSION — otherwise decryptPrivateKey(record, masterKey) recovers the key only in memory, at the moment of signing, and the raw key is never written anywhere (see apps/web/src/app/api/agents/[id]/chat/route.ts).

abuse prevention

what stops an agent or user from spamming attest() to inflate a score, stated explicitly: for this hackathon, basic rate limiting only. enforcement is wired: the web app applies an in-memory fixed-window limiter (apps/web/src/server/rate-limit.ts, configured by OPENREP_RATE_LIMIT_WINDOW_MS and OPENREP_RATE_LIMIT_MAX_REQUESTS) to agent creation, chat, and scoring routes, returning RATE_LIMITED (429). the limiter is per process/instance, not global across serverless instances; production would move to a shared store (see known limitations). producing production-grade protection would also require stronger anti-sybil measures, per-source trust caps, and identity verification of attestors. that is an explicit, written decision, not silence, and it is listed in known limitations below.

attestation model

an attestation is proof that an agent completed a specific piece of work. it is not a claim, it is a signed record of what actually happened. openrep's own attestations all use the same shape.

the Attestation type, field by field:

  • id: string. a unique id for this attestation, distinct from the agent id.
  • agentId: string. the canonical id (the public key) of the agent this attestation is about, per the identity decision.
  • task: string. a description of what the agent was asked to do. this is the prompt or task statement, kept as evidence of the work.
  • output: string. what the agent actually produced. for a chat run this is the model's response text. for a tool run it can be the tool result, serialized.
  • toolsUsed: ToolCall[]. an array of { tool, input, output? } records of the tools invoked during the run, empty when none.
  • source: string. who is vouching for this attestation. values for openrep's own runs are native or a named surface like chat or cli. for ingested external work this carries the external source name from the adapter, for example the marketplace source described in the ingestion section. registration in registered_sources is optional and affects only the trust weight; an unregistered source still shows in the score breakdown at weight 0. the source is what lets scores be broken down.
  • contentHash: string. a hash of the deterministic serialization of task, output, and toolsUsed. this is what actually gets signed.
  • signature: string. the ed25519 signature over contentHash, produced with the agent's private key.
  • signedBy: string. the public key that produced the signature.
  • timestamp: string. an iso 8601 utc timestamp of when the work happened.
  • schemaVersion: number. a schema version so the shape can evolve without breaking old rows.

producing an attestation during a real run is the job of wrapAgent. wrapAgent(params) takes explicit dependencies and runs a real multi-turn model conversation against the configured provider (claude, gpt, gemini, or an openai-compatible endpoint), then signs an attestation for what happened. params carries agentId (the agent's public key), signingKey (the identity key used by attest), storage (the StorageAdapter), config (an AgentConfig with provider, model, and tools; baseUrl is additionally required and validated only when provider is openai-compatible), tools (a map of executable implementations keyed by exact tool name), apiKey (the provider key, never stored or logged), task (the incoming prompt), optional history (prior conversation turns from an ongoing chat, see below), and optional options (source, idempotencyKey). wrapAgent never reads environment variables or calls loadEnvConfig itself; everything it needs is passed in. it selects a provider adapter by config.provider, runs the loop below, and on success calls attest to sign and persist the run, returning { output, toolsUsed, turns, attestation }.

params.history is context only. it is a list of prior { role: "user" | "assistant", content } turns matching whatever shape the caller already persists as chat messages; the loop prepends them to the conversation sent to the provider before the new task is added as the latest user turn. history plays no part in the content that gets hashed, signed, or persisted as this call's attestation: attest signs exactly { task, output, toolsUsed }, so the signed record always covers the new turn and its output, never the prior conversation. history never appears in toolsUsed and is never persisted by wrapAgent itself. it is bounded so an unbounded, ever-growing transcript cannot balloon the provider request: only the last MAX_HISTORY_TURNS (20) turns are kept, and older turns are additionally dropped until the total content fits MAX_HISTORY_TOTAL_CHARACTERS (16,000), always from the oldest end, never silently dropping the newest turns; a single turn over MAX_HISTORY_TURN_LENGTH (4000) characters or any entry outside the user/assistant roles is rejected up front as INVALID_INPUT before any provider call. callers (the web app's chat route and the cli's chat screen) are responsible for fetching and passing the right slice of their own transcript; wrapAgent fetches nothing itself.

the stock toolset (createStockToolset in packages/sdk/src/tools/) is the demo registry of model-usable tools: web_fetch, wikipedia_lookup, calculator, weather, package_info, hacker_news_lookup, arxiv_search, web_search, self_reputation, lookup_agent, and verify_attestation. every network-bound tool goes through safeFetch in tools/guards.ts, the shared ssrf/dns-rebinding/redirect/body-size/timeout gate, and package_info additionally reads OPENREP_GITHUB_TOKEN at request time: github lookups use it if present, for a higher rate limit, falling back to unauthenticated reads with no functional difference in what the tool can access (it is a public-repo-read-only rate-limit credential, never account scoping, never logged or emitted).

the run loop is bounded and fail closed. the model conversation continues across turns until the model returns a final text response: the loop sends the accumulated messages, and each provider response is either a text answer (the run converges) or a tool request. a tool request is only honored if the tool name appears in the offered config.tools and has a matching implementation in tools; anything else is a protocol violation (UNREGISTERED_TOOL) and is never executed. the model-supplied arguments are validated against the tool's inputSchema before the real implementation runs (TOOL_ARGUMENT_INVALID otherwise), so model-generated input never reaches an implementation unchecked. tool results, including thrown errors captured as structured failure output, are fed back to the model so it can recover. the loop enforces MAX_TURNS = 10 turns (TURN_LIMIT_EXCEEDED on non-convergence) and a whole-run wall-clock budget of MAX_RUN_MS = 120s via a single AbortController that also aborts the in-flight provider request (RUN_TIMED_OUT). a provider api failure that prevents a final answer is PROVIDER_API_FAILURE. in every one of those terminal states no attestation is produced; attest is only ever called once the loop converges on final text, so a run that timed out, hit the turn cap, or failed at the provider leaves no record behind. the provider-specific request and response parsing lives only inside the adapters (AnthropicClient, OpenAiClient, GeminiClient, and OpenAiCompatibleClient in src/providers/), which expose a common ProviderClient interface so the loop never sees provider wire shapes.

attest(input, signingKey, storage) is the signing primitive, where input includes agentId, task, output, toolsUsed?, source, and an optional idempotencyKey, and storage is the StorageAdapter the attestation is persisted through. input is validated at the boundary before any crypto or storage work: agentId and signingKey must be 32-byte lowercase hex, the payload has documented size limits (task up to 4000 characters, output up to 8000, toolsUsed up to 50 entries, tool names up to 100 characters, each tool entry up to 4000 canonical characters), and a well formed key that does not derive to the claimed agent is rejected with KEY_MISMATCH. source must be exactly native; anything else is rejected with INVALID_SOURCE, because external sources belong to ingest and this is the line that keeps the native ledger distinguishable from imported data. it computes the contentHash as the sha-256 of the canonical serialization of exactly { task, output, toolsUsed }, signs the content hash bytes, not the raw fields, so any change to task, output, or toolsUsed invalidates the signature, records a server side timestamp, and assigns a random uuid as the attestation id (not a content derived id, so two legitimately identical runs can both exist). only then does it persist through saveAttestation, with no agent existence pre-check: the foreign key decides, and a violation surfaces as AGENT_NOT_FOUND. the optional idempotencyKey lets a retried submission from a flaky pipeline collapse into the same attestation instead of being double counted toward the score. idempotency is enforced by a composite unique index on (agent_id, idempotency_key), not by the client: attest does an optimistic read first, but if a concurrent call wins the insert between the read and the write, the storage layer surfaces DUPLICATE_IDEMPOTENCY_KEY and attest resolves it by fetching and returning the existing record. signing ties the attestation to the identity: anyone with the public key can verify the attestation was produced by the agent's keypair and that the payload was not tampered with.

verifyAttestation(attestation) is the matching verifier and never throws. it shape-checks each cryptographic field with a specific reason, recomputes the content hash from { task, output, toolsUsed } and compares it (content hash mismatch on divergence), then verifies the signature over the content hash bytes under zip215: false (signature verification failed on a bad signature). canonicalize, which all of this rests on, rejects circular references and nesting beyond a depth of 6, so crafted tool input cannot hang or crash the hashing path.

for the claude/gpt case specifically, the flow is: the user sends a message, wrapAgent sends it to the model api through the provider adapter (prepending any caller-supplied history as context and appending the new message as the latest user turn), the model returns a response and possibly tool calls, the loop executes the offered tools (validated, captured, fed back) until the model produces final text, and wrapAgent turns the request text plus the response text and the recorded tool calls into the attestation payload. the model's response is the output. the tool calls are recorded in toolsUsed as part of the evidence chain and become part of the hash, so a later change to what the model actually did would invalidate the signature. the history turns are deliberately outside that evidence chain: a signed attestation is one call, one record of one piece of work, and prior conversation is context the model sees, never part of what the agent is attested to have done.

the provider layer recognizes four providers today: anthropic-style, openai, gemini, and the openai-compatible tier. the gemini adapter (GeminiClient in src/providers/gemini.ts) talks to https://generativelanguage.googleapis.com/v1beta/models/{model}:generateContent with the key in the x-goog-api-key header, sends tool definitions as functionDeclarations with the inputSchema forwarded verbatim (the rest endpoint accepts the same lowered json-schema subset the sdk validates against), and parses candidates[0].content.parts into text or tool_call responses, passing any id and thoughtSignature through so the run loop can echo them. the openai-compatible tier (OpenAiCompatibleClient) speaks the same chat-completions protocol as openai against any caller-supplied config.baseUrl: the factory requires a present http(s) baseUrl when provider is openai-compatible (a missing or non-http value is a ProviderConfigError, never a guessed default endpoint), and the adapter appends /chat/completions when the base url does not already end with it. known real-world endpoints that work with this tier include deepseek (https://api.deepseek.com/v1), groq (https://api.groq.com/openai/v1), xai grok (https://api.x.ai/v1), mistral (https://api.mistral.ai/v1), together (https://api.together.xyz/v1), openrouter (https://openrouter.ai/api/v1), and a local openai-compatible server such as ollama (http://localhost:11434/v1); the caller supplies whichever base url applies, the sdk never guesses it. multi-turn tool history on gemini is explicit: after a tool call the loop keeps the exact functionCall part the model produced, including its id and, on gemini 3, the thoughtSignature, and feeds the result back as a paired functionResponse that echoes both, the shape the api documents as valid. the additive structured fields on the normalized messages are inert for anthropic and openai, whose adapters keep their historical wire format byte for byte. opt-in live tests exercise each new provider behind OPENREP_LIVE_TEST=1 with GEMINI_API_KEY (plus GEMINI_MODEL, defaulting to gemini-3.8-flash) or OPENAI_COMPATIBLE_API_KEY, OPENAI_COMPATIBLE_BASE_URL, and OPENAI_COMPATIBLE_MODEL (defaulting to gpt-4o-mini); the gemini multi-turn live case is the empirical check of the functionResponse round trip.

ingestion model

ingestion is how openrep merges reputation that was created somewhere else. not every platform writes openrep format attestations, so ingest exists to normalize external records into the same schema.

external platforms hand over raw records shaped like ExternalAttestation: a loose Record<string, unknown> plus a required sourceName. this is intentionally loose because it is raw input before normalization. ingest(externalAttestation, sourceAdapter, signingKey, storage, options?) is the ingestion primitive: it takes the raw record, the SourceAdapter for that source name, the local agent's signing key, the StorageAdapter, and an optional idempotencyKey, and normalizes the raw record into a native Attestation through the adapter, verifies it where the adapter provides a validate, and persists the result through the same shared signing core as attest.

ingest fails closed in stages, and each stage maps to a distinct error code so the caller knows exactly what went wrong. the adapter itself is checked first: its sourceName must be a non-empty string of at most 64 characters and must not be native (that name is reserved for the sdk's own runs), and it must expose a normalize function; any violation is INVALID_SOURCE. the signingKey must be the usual 32-byte lowercase hex, otherwise INVALID_INPUT. the raw record's sourceName must exactly match the adapter's sourceName; a mismatch is MALFORMED_EXTERNAL_ATTESTATION, because it means the record was shaped for one adapter and submitted through another, which is exactly the cross-adapter substitution that could smuggle foreign content under a trusted source name. if the adapter provides a validate, its verdict is enforced before any normalization: a false verdict, a throw, or a malformed verdict shape is EXTERNAL_ATTESTATION_INVALID and nothing is persisted. an adapter with no validate is legitimate: the marketplace provides none, because unverifiable is the truth rather than a gap, and the persisted record then honestly carries externalVerification: { checked: false, valid: null, reason: null }; a successful check carries { checked: true, valid: true, reason: null }. normalize output is treated as untrusted adapter output: a normalize that throws, returns null or a non-object, returns content the ledger's own shape and size validation rejects, or returns an unparseable timestamp is MALFORMED_EXTERNAL_ATTESTATION and never reaches the signing core. the external timestamp is honored as the attestation timestamp so the record reflects when the work actually happened; it must be a parseable instant because getScore feeds it straight into Date.parse for the breakdown's lastUpdated.

from there the record goes through the same private signAndPersistAttestation core attest uses, so the native and ingested paths share one implementation and can never drift: the derived public key of signingKey must equal the normalized agentId (KEY_MISMATCH), the target agent must exist and not be revoked, the composite (agent_id, idempotency_key) unique index arbitrates dedup, and the content hash is sha-256 of the canonical serialization of exactly { task, output, toolsUsed }. externalVerification is deliberately not part of the signed bytes: it is metadata about how, or whether, an external validator checked the record before openrep ever saw it, not a fact about the work itself. the invariant is locked by a test: identical content with different externalVerification values produces the same content hash and the same signature, with only the attestation id differing.

identity is bound by key correspondence, not by name lookup. ingest does no lookup of the external agent by name: the caller presents the local agent's signingKey, and the normalized agentId must be exactly the public key that key derives to. an adapter cannot mint an attestation for a local agent it does not hold the signing key for, which is what keeps ingest from becoming a reputation forgery channel.

the earlier plan exercised ingest against a static sample json file with sample adapters named after other platforms. that plan is replaced. the real-world source is a small, genuinely working task marketplace app at apps/marketplace (post a task, claim it, complete it, rate it, with real persistence and a real api). it is planned, not deferred indefinitely, but it is not built yet. the app itself (backend, persistence, api) may be built with ai assistance; the one part that will not be is the ui/frontend, which will be built by hand by the person building this project, without ai assistance, on their own timeline, deliberately not by an ai coding agent.

the contract the marketplace will eventually expose, once it exists:

  • GET /api/agents/:agentId/completed-tasks. returns real completed and rated tasks for the given agent.
  • an OpenRep-side MarketplaceAdapter that calls that endpoint over real http and normalizes the response into the NormalizedExternalAttestation shape (the shared core type, already in the sdk; the adapter itself is not in code yet).
  • the adapter has no validate(). the marketplace has no cryptographic signing, so externalVerification.checked is honestly false for anything that came from it; unverifiable is the truth, not a gap in the adapter.

the sdk side of the ingestion model is implemented and tested: ingest, the shared signing core, the externalVerification provenance field, and the sqlite column are real, and the ingestion path is exercised in the sdk test suite through synthetic normalized input (no sample external data files). the MarketplaceAdapter and the marketplace app itself are the remaining pieces; until the app exists, ingest has no live external source to pull from, so the end-to-end ingestion demo stays blocked on the marketplace while the sdk-side implementation does not.

each external source is registered with a RegisteredSource that carries a trustWeight. the trust weight is a real multiplier applied when computing the composite score, not all sources count equally. a source that is more vetted counts more than a brand new one. this is what makes ingestion pluggable instead of hardcoded per platform: adding a platform means registering an adapter, not editing ingest logic.

the source field is what carries platform awareness through the unified schema. every attestation, native or ingested, records where it came from. that single field is what makes the score breakdown by source possible, and it is the mechanism that keeps openrep platform agnostic: it does not care which chain or marketplace produced an attestation, it only records the origin and folds it into the same per-agent ledger.

the composite score is computed by getScore(agentId, storage, options?), which returns Result<AgentScore>. AgentScore has an agentId, a single composite number, a breakdown array of ScoreBreakdown entries, and a computedAt timestamp. each breakdown entry has a source, a value (the per-source score in the same scale as composite), a count (the number of attestations that source has contributed), and a lastUpdated timestamp (the newest attestation the source has on record). the formula is exact and implemented once:

composite = sum over sources of ( value_source * trustWeight_source )
value_source = count_source

every number in the result traces to real rows read through the storage adapter, never to a hardcoded value. getScore pages the full attestation history until exhausted (rows are immutable and the cursor is over row_id, so a page can never shift or duplicate); a history larger than the exported SCORE_MAX_ATTESTATIONS (50,000) fails closed with SCORE_COMPUTATION_LIMIT_EXCEEDED rather than returning a truncated score. an existing agent with zero attestations scores a valid 0 with an empty breakdown; an unknown agent is AGENT_NOT_FOUND. per-source weights: native is pinned to trust weight 1.0 and never needs (and cannot be overridden by) a row in registered_sources, registered external sources use their stored trustWeight (non-finite or negative values are clamped to 0), and an unregistered non-native source contributes 0 to the composite while staying visible in the breakdown so its history is not hidden. the breakdown is returned sorted by source name for deterministic output. decay defined in ScoringConfig is not enabled in this pass (the type exists so enabling it later is not a breaking change), and nothing is cached: every call recomputes from the ledger, which stays append-only. openrep stays platform agnostic because the score is always presented as a composite plus a per-source breakdown, never as a single number locked to one chain's semantics.

resolve(name, storage) ties the two models together: it looks up an agent by its human readable name alias and returns a ResolveResponse containing both the manifest and the live AgentScore. the resolved manifest is ResolvedManifest (AgentRecord minus the storage-internal rowId), so it carries ownerPublicKey and revokedAt without leaking an internal index into a network type. resolve is a thin composition of getAgentByName + getScore with no scoring logic of its own, so there is exactly one implementation of the math. a caller that only knows a name gets everything needed to verify the identity and read the reputation.

storage

storage has two concrete backends behind the same StorageAdapter contract, selected at boot by loadEnvConfig(). the first is the original local store: sqlite through the node built-in node:sqlite (DatabaseSync), which ships in node itself so the local path has no extra install step and no sdk-attributed native dependency. the second is a hosted libsql/turso backend through the official @libsql/client library (pinned to 0.17.4), which speaks both an embedded dialect (used by the hermetic tests over :memory:) and the remote wire protocol for a turso database. selection is explicit and fail closed: TURSO_DATABASE_URL set selects turso and requires TURSO_AUTH_TOKEN, OPENREP_DB_PATH is required and only meaningful in local mode, and a partial hosted configuration (url without token, or a stray token without url) fails loudly instead of picking a backend by guessing. using the local path requires node >= 22.13, which the engines field of packages/sdk/package.json enforces.

the storage contract is StorageAdapter in types/storage.ts, and calling code depends only on that interface, never on a concrete backend directly. loadEnvConfig() in types/config.ts is implemented and returns a typed Result<EnvConfig>: it reads the environment once at boot, sets the explicit storageBackend discriminator ("local" | "turso"), requires OPENREP_MASTER_ENCRYPTION_KEY in both modes, parses the rate limit integers (OPENREP_RATE_LIMIT_WINDOW_MS, OPENREP_RATE_LIMIT_MAX_REQUESTS) as positive ints, and fails with typed codes on any missing or malformed variable: MISSING_TURSO_AUTH_TOKEN, MISSING_TURSO_DATABASE_URL, MISSING_DATABASE_PATH, INVALID_ENV, MISSING_MASTER_KEY. failure messages name the offending variable and never echo its value, so a token or key can never leak into a log line through a config error. optional OPENREP_ANTHROPIC_API_KEY and OPENREP_OPENAI_API_KEY pass through untouched. the master key env var name OPENREP_MASTER_ENCRYPTION_KEY is canonical and getMasterKey() in src/security.ts reads exactly this variable.

application code opens a local connection through getSqliteStorage(databasePath), which memoizes one open connection per path per process, and a hosted one through the async createLibsqlStorage({ url, authToken }), which creates one client per invocation and returns an adapter that also exposes close(). there is deliberately no memoized libsql accessor: holding a websocket open across serverless invocations would pin a socket that may die between requests, so a fresh client per request with an explicit close is the serverless-safe lifecycle. the database path and turso url are never hardcoded in either adapter, they come from the caller. the cli resolves its local path from OPENREP_DB_PATH (default ~/.openrep/openrep.db, with the parent directory created when needed and ":memory:" honored) in its own environment contract.

file-backed databases run in WAL journal mode as the cross-process concurrency hardening. in delete mode a writer takes an exclusive lock and blocks every reader, and concurrent writers fail with database is locked; WAL lets readers proceed while a writer holds the short-held write mutex, with busy_timeout = 5000 on every adapter connection covering the remaining contention. journal_mode is a persistent property stored in the database file, so a later cli-style fresh connection inherits WAL from the first open (the two-process contention test in test/concurrency.integration.test.ts proves it: two real child processes write agents and attestations while the parent holds an open read transaction and a long-lived adapter connection, every write lands and is visible cross-process, and a delete-mode spike run during the pass showed the same harness failing with ERR_SQLITE_ERROR database is locked, so the test is non-vacuous). synchronous is per-connection by sqlite design, so each connection the adapter opens sets synchronous = NORMAL, the canonical WAL durability setting: crash-safe against app and os crashes, with the rare tradeoff that a power loss immediately after commit may lose the very last transaction, which is exactly why the sqlite docs recommend NORMAL over FULL for WAL. :memory: databases cannot be WAL and are skipped; hosted libsql/turso databases are a network server with WAL configured server side and are deliberately skipped. operational note: WAL writes live in -wal and -shm sidecar files next to the .db, so a filesystem backup, cp, or archive that moves only the .db file can silently miss committed-but-not-checkpointed data; move all three files or checkpoint first.

the schema is three tables, created by an idempotent CREATE TABLE IF NOT EXISTS bootstrap that runs at construction. both backends run the same statements; the libsql adapter submits them through client.batch([...], "deferred") because executeMultiple is a no-op on the embedded engine (verified by probe before the adapter was written), and it never relies on multi-statement strings through execute(). the libsql adapter also sets PRAGMA foreign_keys = ON at bootstrap and asserts the readback is 1, even though libsql already defaults to foreign keys on, because the enforcement setting must be a guarantee the adapter verifies rather than a default it assumes. this is a deliberate migration choice: the project is pre-release with no deployed databases to migrate, so a numbered migration runner (sql files plus a schema_migrations table) would be processional. additive schema changes are appended with IF NOT EXISTS. the moment real user data exists anywhere, a numbered migration strategy becomes warranted.

  • agents. columns: row_id (internal index), name (unique, the mutable alias), public_key (canonical id, also unique), owner_public_key (nullable), memory_pointer, permissions (json), created_at, manifest_version, signature, and revoked_at (nullable). the agents table never contains either private key, ever. owner_public_key is nullable because rows created before the key split genuinely have no owner key on record, and the sdk fails closed on those (they can never be revoked, surfaced as OWNER_KEY_MISSING) rather than inventing an authorization path for old data. revoked_at is null until a valid, authorized revocation request stamps it. the unique constraint on name is the enforceable source of truth behind createAgent's collision handling (the sdk's optimistic read is an optimization only), the unique constraint on public_key is a second line of defense, and saveAgent additionally asserts at runtime that the record carries no private key so key material can never reach the disk even if the type system is bypassed. the private keys live only in memory or in an encrypted envelope in the session key store per the key custody policy.
  • attestations. columns: row_id, id (unique), agent_id (foreign key to agents.public_key, enforced per connection), idempotency_key (nullable), task, output, tools_used (json), source, content_hash, signature, signed_by, timestamp, schema_version, and external_verification (nullable json, the provenance metadata recorded by ingest; null on native rows). the foreign key is what makes saving an attestation for a nonexistent agent fail at the schema level, surfaced as an error with code AGENT_NOT_FOUND. the unique index on (agent_id, idempotency_key) is the authoritative guard behind attest's idempotency contract: the same pair can only ever insert once, while null keys remain unbounded because sqlite treats nulls as distinct in unique indexes.
  • registered_sources. columns: source_name (primary key), registered_at, and trust_weight. this is where the trust weights used by scoring live, one row per approved external source.

the unique and primary key constraints create their own indexes, so the only additional indexes are on attestations.agent_id (the attestation paging lookup) and the composite (agent_id, idempotency_key) (the idempotency guard). databases created before a new column landed are upgraded in place when the adapter opens them: because the original CREATE TABLE IF NOT EXISTS cannot alter an existing file and sqlite has no ADD COLUMN IF NOT EXISTS, the opener checks PRAGMA table_info(<table>) and runs a guarded ALTER TABLE ADD COLUMN per missing column, and the whole sequence is idempotent across repeated opens. this covers the idempotency and external-verification columns on attestations and both revocation columns on agents (owner_public_key and revoked_at). a pre-existing database that was created before the key split keeps its legacy agent rows with a null owner_public_key, and those rows fail closed on revocation exactly as described above.

both AgentRecord and AttestationRecord extend their portable counterparts with an internal rowId, so the persisted rows carry an index without polluting the signed payload. on AgentRecord the rowId is optional: it is assigned by storage at insert time, so the sdk never needs it when building a record to save.

the storage error contract is part of the interface. saveAgent throws an error whose code property is exactly DUPLICATE_NAME on a name collision and DUPLICATE_PUBLIC_KEY on a public key collision, saveAttestation throws AGENT_NOT_FOUND when the referenced agent does not exist and DUPLICATE_IDEMPOTENCY_KEY when the (agent_id, idempotency_key) index fires, revokeAgent throws AGENT_NOT_FOUND when zero rows match (a disappearing agent), and saveRegisteredSource throws DUPLICATE_SOURCE_NAME on a duplicate source name. the libsql adapter upholds the identical error contract with its own classifier, calibrated empirically against both the embedded engine and the hosted primary: constraint violations surface as code SQLITE_CONSTRAINT with the same static index strings in the message (UNIQUE constraint failed: agents.name, FOREIGN KEY constraint failed), and the classifier matches those substrings instead of extendedCode, because the hosted engine leaves extendedCode undefined and prefixes its message SQLITE_CONSTRAINT: SQLite error: .... tests assert the adapter's mapped StorageError codes, never the raw engine strings. on the sqlite side the classifier matches the node:sqlite error shape (code ERR_SQLITE_ERROR, unique violations carrying UNIQUE constraint failed: <table>.<column> in the message, foreign key violations carrying FOREIGN KEY constraint failed), verified against node 24.10.0 before the mapping was written. everything else propagates as the raw engine error, so a disk failure or a turso network/server failure is never miscategorized as a name conflict.

agents.name is unique because resolve looks agents up by name. the attestations.agent_id foreign key is how all reputation funnels back to one identity. scores themselves are not stored, they are computed on demand from the attestation table by getScore, which keeps storage append only and avoids stale scores. list-returning storage methods take { cursor?, limit? } and return { items, nextCursor }, the standard paginated shape defined once in storage.ts and reused by the activity feed. getAttestations pages newest first with a cursor over the immutable row_id (row_id < cursor for the next page), so rows inserted between page loads can never shift or duplicate an already returned page, unlike offset pagination.

this design is fine for a demo, and it is a real weakness for production. the local mode has no durability story across machines and no access control; anyone who could read the file could read or forge the data, which matters because without a trusted service the only real protection is the signatures themselves. the turso mode moves the bytes to a hosted database (durability across machines, token-authenticated transport), but access control and multi tenancy are still not built: the auth token is a shared secret, not per-agent authorization, and the application layer remains the enforcement point. both modes are treated as honest but single user.

activity feed

the ActivityEvent type in types/activity.ts includes an agent_created variant for a future live feed. in this pass createAgent intentionally does not emit activity events: the feed is not built yet, and emitting events now would be dead code. this is a written deferral, not an oversight. when the feed is built, wiring agent_created into the create path is part of that work, not a new decision.

the web app

the web app is a next.js app router application that imports @openrepso/sdk. because the sdk is plain typescript with no server dependency at runtime, it can run on the server, and that is where the reputation state should live.

the setup keeps the sdk on the server side, behind next.js api routes or server actions. the flow is: the server imports the sdk, and the client browser components never hold the private keys. a user creates an agent through a server call that runs createAgent and returns the public manifest and a handle. the private key stays server side, tied to the session or the agent record. letting private keys reach the browser would defeat the whole signing model, so the boundary between client and server is also a trust boundary.

the chat flow, end to end: the user selects an agent in the sidebar, types a message in the chat panel, and hits send. the message goes to a server action or api route. the server loads that agent's keypair and the provider api key from its own configuration, runs the model call with wrapAgent (the deployed wiring uses the groq openai-compatible endpoint) by passing agentId, signingKey, storage, the agent's AgentConfig, the tool implementations, apiKey, the message as the task, and the stored transcript so far as history (fetched owner-scoped and passed as a plain { role, content } slice, per the sdk contract above). wrapAgent runs the bounded loop, captures the task, the tool calls, and the output, and signs and persists an attestation through the storage adapter. the server returns the model's reply to the browser. the browser renders the reply. the score component on screen calls getScore for that agent again, and because a new attestation was just stored, the composite and the breakdown tick up. nobody in that loop manually bumps a number, the score update is a pure consequence of storing a new attestation. the api key travels from server configuration to the provider adapter and nowhere else; it never enters the captured attestation data, the logs, or the browser, and neither does the transcript except as model context inside the provider request.

multiple agents are modeled as multiple rows in the agents table. each has its own id, name, keypair, and attestation history, and therefore its own independently computed score. the app shows one score per agent, and the score view renders the composite plus the per-source breakdown with a per-attestation verification verdict. all of this is built: the landing page, agent creation/listing, the chat panel (each ai reply renders its own attestation id under the bubble), the score screen, authentication and guest sessions, and a styled not-found page. the private keys never reach the browser: the client holds only the public manifest and a session cookie.

the cli

the cli maps one to one onto sdk functions, and every command is wired to a real, tested sdk call. openrep create calls createAgent (positional [name], generated when omitted; --reveal-keys prints the generated private keys, off by default). openrep attest calls attest with -a/--agent, -t/--task, -o/--output, --tools <json>, and --idempotency-key, with source pinned to native. openrep ingest --file <path> --source <name> --agent <ref> reads the file and calls ingest. openrep score <name> calls getScore. openrep resolve <name> calls resolve. openrep revoke -a <ref> calls revokeAgent, signing the canonicalized { agentId, timestamp } request with the agent's owner key (from --owner-key, OPENREP_OWNER_KEY, or custody resolution). parsed argument shapes live in packages/cli/src/types.ts, kept separate from sdk input types so file parsing failures are distinguishable from sdk validation failures. agent references accept either the canonical 64-char public key or the mutable name.

every failing command exits non-zero, including verify: failures print a single <CODE>: <message> line to stderr and set the exit code, and commander errors (unknown command, missing required option) are mapped to exit 1 as well, so no failure mode can be mistaken for success in a pipeline. create, attest, and ingest print machine readable json on stdout; score and resolve print human readable blocks. create never prints a private key unless --reveal-keys is passed, so keys do not reach scrollback, shell history, or ci logs by default; custody resolution notices (for example, a loud downgrade to the encrypted-file fallback) go to stderr, never stdout.

openrep verify is the outlier and the most important one for trust out of a pipeline. it does not call the score functions. it takes an agent, verifies the signed manifest against raw stored fields first, then pulls the full attestation history with cursor pagination until exhausted (a truncated audit is not an audit), re-hashes each attestation's payload, and checks each signature against the agent's public key, all locally and with no need to trust an openrep server. it exits 0 only when the manifest and every attestation pass, and a revoked agent reports key revoked reasons with a non-zero exit, the honest report of a revoked ledger. this is what makes verify usable for ci or third party audits: it needs nothing from openrep's infrastructure beyond the data on disk, and it produces an independent, cryptographic answer.

openrep ingest resolves its source adapter through the cli-side registry in packages/cli/src/registry.ts, which is intentionally empty: the marketplace adapter ships with the marketplace app, and until one is registered there, openrep ingest --source <name> fails honestly with UNKNOWN_SOURCE: no adapter registered for source <name> instead of a placeholder adapter pretending to understand a platform it does not.

the interactive tui

running openrep with no subcommand on a tty opens a four-screen ink app: splash, dashboard, chat, score. the state machine is splash -> dashboard (any key), dashboard -> chat (enter) / score (s) / create (n) / revoke (r then y), chat -> dashboard (esc), score -> dashboard (esc) or up/down to the neighboring agent in list order, any screen exits on ctrl+c. every screen is wrapped in a round-bordered box so the layout reads as one piece regardless of terminal width. screen-level keys are never swallowed by a focused prompt: ink dispatches every keystroke to every useInput hook, so esc (chat back) and ctrl+r (retry) stay live mid-edit; the retry chord is matched exactly (ctrl+r, telegraphed by ink as the DC2 control char), not by the generic ctrl flag, so the ctrl+u/ctrl+k line kills reach the input.

the chat prompt is a real cursor editor, not a readline emulation: src/ui/input-machine.ts applies pure { value, cursor } operations and src/ui/input-keys.ts decodes keystrokes, accepting both the macos spelling (option+arrow, option+backspace) and the linux/windows spelling (ctrl+arrow, ctrl+backspace) of word movement plus the short csi forms some terminals send, so edits behave identically on every platform with a single implementation and no os branch. an unmatched escape sequence is dropped rather than spliced into the edit buffer. the bindings follow readline: left/right move the cursor, option/ctrl+left/right jump by word, option/ctrl+backspace deletes a word, ctrl+u kills to line start, ctrl+k kills to line end, up/down recall previously submitted messages (recall history is per prompt and joins nothing secret). the provider key prompt stays masked and append-only: no cursor movement, no recall, no word or line kills, so a pasted key can never be redisplayed or partially revealed by an edit.

assistant replies render markdown. the renderer is marked@15 plus marked-terminal@7 wired through the markedTerminal() factory, not marked-terminal's default export: the factory returns a marked extension object (useNewRenderer: true) that bridges the token-object renderer api marked@13+ uses, and it forwards marked's own options/parser into every render call. the legacy default-export renderer class still speaks pre-13 string arguments and crashes on marked 13-15 (in Renderer.heading), even though marked-terminal's declared peer range claims marked >=1 <16; that peer range is only true through the factory. showSectionPrefix: false drops the # heading markers marked-terminal adds by default so chat replies read like terminal prose. rendered output is an ansi string inside one ink Text node; ink v7 tokenizes the escapes itself, so styled multi-line replies lay out correctly.

each chat turn carries the prior transcript as history to wrapAgent (context only, never attested; see the wrapAgent section). the cli keeps its own in-memory UiChatEntry transcript per agent and passes it through historySliceForTurn, a pure helper that maps entries to { role, content } and drops a trailing user entry whose content equals the current task: handleSend pushes the user entry before the turn runs (so on the first read the push may not be visible yet) while handleRetry runs with it already present, and the drop rule is idempotent across both. the slice is capped at the sdk's MAX_HISTORY_TURNS before leaving the process as a courtesy bound; the sdk's normalizeHistory enforces the real turn and character budgets at its own boundary when the run starts. tool input and attestation ids never leave the transcript: the history carries only role and content.

cli key custody policy

the cli needs its own custody policy because it has no separate server. in the web app, the browser is untrusted and the server is the trusted custodian holding keys on the user's behalf. in the cli there is no such split: the process runs directly on the user's own machine under their own os account, and each command is a separate process that does not share memory with the previous one. the user is both the operator and the owner of the agent, so the question is not "keep the key away from the user", it is "where does the key persist on their machine between separate cli invocations". the cli supports two usage modes and the policy differs for each.

interactive local use

a developer running commands on their own machine. the private key is stored in the os-level secure keychain: macos keychain, linux secret service, or the windows credential manager, accessed through the platform's native credential tool invoked as a child process with argument arrays, never through a shell and never through a third-party keychain library. on macos and linux that is security and secret-tool; on windows it is powershell.exe running a small inline c# snippet loaded via Add-Type, p/invoking advapi32's CredWrite/CredRead/CredDelete directly, so the stored entries are the real windows credential manager and appear in the credential manager control panel. the secret always rides stdin, never a command-line argument, and the only value interpolated into the generated powershell script is the credential target, re-validated to the same account shape below before it can influence the script. entries live under the service name openrep, keyed by the agent's canonical id with a per-key suffix: <publicKey>.identity for the daily signing key and <publicKey>.owner for the revocation key, so the two can never collide or be confused, and the entry survives name renames. if no os keychain is available (some minimal linux environments), the cli falls back to an encrypted file on disk, ~/.openrep/credentials.enc (overridable with OPENREP_CREDENTIALS_FILE), encrypted with aes-256-gcm under a key derived from a user-supplied passphrase via scrypt (node's default cost, memory hard by design), in a versioned base64 envelope ({ version, salt, iv, tag, data }). posix permissions are enforced to owner read/write only, enforced on read as well as write so a loosened file fails closed rather than silently decrypting. the derivation secret is the passphrase, never a key stored next to the file, so the fallback keeps a real secret at its root. the passphrase is prompted once per invocation with a masked readline prompt on stderr, memoized by the store so a set that must decrypt the old file and encrypt the merged one does not re-prompt. under every fallback path the private key is never written to disk in plaintext. this mirrors the pattern used by git credential managers and ssh agent for the same reason: a real secret that needs to persist across separate cli invocations on a single-user machine.

in interactive mode, openrep create automatically stores the generated identity and owner private keys into the first available store, keychain first, encrypted file second, because custody between separate invocations is the whole point of the mode. both keys land in the same store, so a successful create never leaves an agent half-revocable, and if no store accepts them the whole create fails rather than printing unrecoverable keys. the stored entry is keyed by the agent's canonical id, the public key, never by the mutable name, since names can be renamed. a store that is broken (corrupt file, missing keychain tool) is skipped loudly with a stderr notice, never silently.

headless and ci use

a pipeline calling the cli non-interactively, for example github actions. here openrep does not attempt to take custody of the key at all. the expected pattern is the one used for aws credentials and ssh deploy keys: the pipeline's own secret manager holds the key and injects it into the cli's environment per run, via an environment variable or a mounted file path. the cli reads the key for that single invocation only and never persists it, not to a keychain, not to a file, not anywhere. openrep taking custody in this context would be worse practice than not doing so, because it would duplicate a responsibility that already belongs to the pipeline's existing secret management.

mode selection

the cli supports both modes, it does not choose one: OPENREP_SIGNING_KEY / OPENREP_OWNER_KEY select the headless mode, everything else selects interactive. when a key is supplied via environment variable at invocation time, the cli validates it (64-char lowercase hex), uses it directly for that call, and never persists it, not to a keychain, not to a file, not anywhere; a malformed injected key is a hard error, not a silent fall-through. an injected key wins over persistence because the pipeline's existing secret manager is the custodian there. in interactive mode the resolver walks env (absent) then keychain then encrypted file, with OPENREP_KEYCHAIN_PATH letting a power user or a test point at a dedicated keychain database instead of the login keychain, and OPENREP_CREDENTIALS_FILE overriding the fallback path. custody downgrades are loud: falling back to the encrypted file prints a stderr notice, and a missing tty for the passphrase prompt fails with a clear error rather than hanging.

known limitations

these are the honest simplifications, so nobody mistakes this for production grade infrastructure.

  • storage is local sqlite or hosted turso, and beyond that it is single user. local mode has no durability story across machines and no access control beyond file permissions; turso gives durability and token-authenticated transport, but the auth token is a shared secret, not per-agent authorization, and multi tenancy is not built. both modes are treated as honest but single user.
  • ingest has no live external source yet. the earlier plan of sample json files with adapters named after other platforms is replaced: ingestion's real-world source is meant to be the task marketplace app at apps/marketplace, whose ui/frontend is to be built by hand without ai assistance (the rest of the app may be built with ai assistance), and it does not exist yet. until it does there is no real external data to pull from, so the end-to-end ingestion demo is blocked on the marketplace; the sdk-side ingest implementation is not. the marketplace does no cryptographic signing, so ingested marketplace records are honestly unverified (externalVerification.checked is false), and a validate() on the adapter would be dishonest.
  • there is no payment or staking layer. reputation here is purely attestation derived; there is no economic component, no token, and no way for reputation to carry monetary weight.
  • the web app has authentication and session-scoped agent ownership, but it is not a multi-tenant product. sessions are nextauth accounts (email/password with scrypt-stored hashes, plus google oauth when GOOGLE_CLIENT_ID/GOOGLE_CLIENT_SECRET are set) or hmac-signed http-only guest cookies; agent creation binds the agent's session key to the creating session, and chat, history, and score routes are all gated on that binding. there are no account roles, admin surfaces, or cross-account isolation of the ledger beyond that session ownership, and the underlying storage remains the honest-but-single-user ledger described in the storage section.
  • abuse prevention is rate limiting only: an in-memory fixed-window limiter on agent creation, chat, and scoring routes, throttling per process/instance and not globally across serverless instances (a shared store is the production move). production would also need stronger anti-sybil measures, per-source trust caps, and attestor identity verification.
  • rotation is successor issuance, not silent re-keying. rotateAgent in the sdk is implemented: a request signed by the agent's owner key (never the identity key) over { agentId, timestamp } inside the same symmetric five minute replay window as revocation (ROTATION_REQUEST_MAX_AGE_MS is the shared binding, so the two policies cannot drift) mints a successor identity with a fresh id, fresh identity keypair, and fresh generated name, inheriting memoryPointer and permissions, with the owner key unchanged and owner custody untouched. the successor row and a self-verifying key_rotations audit row land in one atomic transaction, and getKeyRotations(agentId) reads the lineage newest first from either end. because the ledger keys agents by public key and is append-only, rotation cannot silently re-key an existing agent: the old id stays live and keeps signing and verifying, so retiring a leaked identity key is the documented composite of rotateAgent then revokeAgent on the old id, both owner-authorized. a wrong signer fails as UNAUTHORIZED_ROTATION, an out-of-window request as STALE_ROTATION_REQUEST, a legacy row with no owner key as OWNER_KEY_MISSING, and a request for an already-revoked agent as AGENT_REVOKED: rotation can never resurrect a revoked agent.
  • revocation is authorized by owner key only. revokeAgent takes a request carrying a fresh ed25519 signature over { agentId, timestamp }, verified against the agent's stored ownerPublicKey. holding the day to day identity key is not enough to revoke, which is the whole point of the two-keypair split. the timestamp must fall inside a symmetric five minute replay window (REVOCATION_REQUEST_MAX_AGE_MS), so a captured old request can never be replayed: outside the window it fails as STALE_REVOCATION_REQUEST. a wrong signer fails as UNAUTHORIZED_REVOCATION, a legacy row with no owner key fails closed as OWNER_KEY_MISSING, and revoking an already-revoked agent is an idempotent success that writes nothing twice. once an agent is revoked, attest refuses to sign for it (AGENT_REVOKED) and both verifyManifest and verifyAttestation report key revoked, so revocation wins over an otherwise valid signature.
  • attest and verifyAttestation gate on revocation with a read of the stored record before doing any crypto, and that gate is a pre-check, not an atomic compare-and-swap with the attestation insert. AGENT_REVOKED is decided by the state as read at the start of attest; there is a theoretical window between that read and the save where a concurrent revoke could land. in-process, on the single memoized sync sqlite connection, the window effectively cannot occur, so this is not a live race in the current build. the wal/concurrency hardening is done, but closing the window entirely still requires a transactional compare-and-swap write path in the storage layer (begin, re-read the agent, insert, commit), which is a recorded, conscious deferral in check.md, not an accident. the ledger stays append-only: revoking an agent does not delete or rewrite its past attestations, it only stops future signing and makes the existing ones fail verification.
  • revoked agents keep their historical score. this is a decided policy, not an accident: getScore counts a revoked agent's past attestations toward reputation unchanged (revocation is not a score reset, it stops future signing), and resolve surfaces revocation via revokedAt on the resolved manifest so a caller can see the revocation alongside the score. a separate policy for revoked history, if ever wanted, is a scoring-pass change, not a schema one.
  • attest and createAgent emit no activity events in this pass. the activity feed is not built, and emitting events now would be dead code. agent_created already has a written deferral next to the feed design; the same deferral covers attestation_created style events until the feed exists.
  • tool outputs are fed back into the model conversation during a wrapped run. this is a real, general, unmitigated prompt-injection surface: a tool that returns content from an untrusted source (a web search result, a fetched page) could influence the model's subsequent decisions in ways the attestation records but does not prevent. tool call requests from the model are gated (only offered tools execute, arguments are validated), so a model cannot be tricked into calling an arbitrary tool, and the attestation records exactly what did run. but the content of tool results is model input by design, so prompt injection via that content remains inherent to agentic tool use. it is documented here on purpose, not papered over.
  • the sdk is fully implemented for its declared scope. createAgent, verifyManifest, attest, ingest, verifyAttestation, revokeAgent, rotateAgent, getScore, resolve, the canonical serializer with its cycle and depth guards, wrapAgent with its provider adapters and bounded run loop, loadEnvConfig, and the two storage adapters (createSqliteStorage/getSqliteStorage in src/storage/sqlite.ts and createLibsqlStorage in src/storage/libsql.ts) are real and tested (see packages/sdk/test/agent.test.ts, test/canonical.test.ts, test/attestation.test.ts, test/revocation.test.ts, test/rotation.test.ts, test/storage.test.ts, test/wrapagent.test.ts, test/providers.test.ts, test/validate.test.ts, test/score.test.ts, test/resolve.test.ts, test/score.integration.test.ts, test/ingest.test.ts, test/ingest.integration.test.ts, test/config.test.ts, test/libsql.test.ts, test/libsql.integration.test.ts, and test/integration.test.ts). the cli is real: all seven commands are wired to the sdk and tested (see packages/cli/test/custody.test.ts, test/cli.flow.test.ts, and test/cli.e2e.test.ts). the web app is real as well: the next.js app at apps/web wires the sdk to session-gated, rate-limited api routes (/api/session, /api/agents, /api/agents/[id]/chat, /api/agents/[id]/score) that create and list agents, run real model calls through wrapAgent (groq openai-compatible endpoint), produce signed attestations per chat turn, and serve verification verdicts, with the ui in apps/web/src/app.
  • the windows credential manager path (powershell.exe + inline c# p/invoke of advapi32 CredWrite/CredRead/CredDelete, secret over stdin) is implemented and covered by a mocked-executor test matrix (see packages/cli/test/keychain.windows.test.ts), but it has not been exercised against real windows hardware in this dev loop; real-device verification on an actual windows machine remains manually required before it is fully trusted, the same honest caveat already given for the darwin/linux real-keychain interactive paths. if the inline-c# approach proves too fragile on a given machine (encoding or execution-policy trouble), the documented fallback is a dpapi-encrypted file under ~/.openrep via ConvertTo-SecureString/ConvertFrom-SecureString in the same spawned powershell call; the store adopts that weaker path loudly, never silently.
  • session key persistence primitives are implemented (getMasterKey, encryptPrivateKey/decryptPrivateKey in src/security.ts, InMemorySessionKeyStore in src/session-store.ts, DurableSessionKeyStore in src/session-store-durable.ts) and standalone tested, and wired into the web app (phase 7, done): the chat route reads the encrypted envelope from the store, decrypts only in memory for the signing moment, and never writes a raw key. the durable store persists encrypted envelopes on the ledger backend, so sessions survive a restart and expire on the 30 minute sliding inactivity window; the in-memory store remains for contexts that want process-scoped sessions.
  • scoring is recomputed from the full ledger on every getScore/resolve call with no cache. correct and fast at demo scale, but a history at or near SCORE_MAX_ATTESTATIONS (50,000) fails closed with SCORE_COMPUTATION_LIMIT_EXCEEDED instead of truncating; caching with a defined invalidation story is the natural follow-up when the cap matters.
  • score weighting is a placeholder policy, not a researched formula. the implemented formula is exact (composite = sum of count * trustWeight per source) but the trustWeight numbers themselves are a starting point, not a standard, and the model is trivially gameable by a single source flooding attestations: nothing caps a source's contribution per run, and per-source caps are an anti-sybil measure deferred with the rest of the abuse prevention.