--- url: /docs.md --- # What is the Lake ## The problem Your data is everywhere except with you. Hospitals, banks, apps and brokers each hold a copy. Research that could help millions waits years for data-sharing agreements, then copies records it never needed. Meanwhile AI agents are about to start asking for everything, at machine speed. ## The answer Flip the direction. **Data stays home; code and questions travel.** * **A house** is a [Solid](https://solidproject.org) pod plus a **housekeeper**: an agent that answers every request by its owner's rules. * **Visitors** are agents that want something. They carry a signed [A2A](https://a2a-protocol.org) Agent Card tied to a domain someone answers for. * **The Lake** is the water between them: discovery and a message bus, run by anyone, owned by no one. * **Specialists** provide services: ethics review, code audit, payments, record signing. ## The three laws 1. **Data never moves.** Certified code runs inside the house and returns only an answer. Every record that leaves a pod is counted publicly. 2. **Agents knock.** No signed, domain-anchored card, no conversation. 3. **Only humans cut keys.** Agents can use access but never grant, widen or extend it. Every key traces to a human tap or a rule a human wrote. ## What's real today The Lake is days old and built in public. Everything on this site is marked: * runs exists in the repo and has tests. * next is being built now. * later is designed and specified, but nobody has built it yet. Come build it. See the [roadmap](/docs/roadmap) for the full list. ## Where to go next * **Own data?** [Join the Lake](/docs/join), then write your [rules.yaml](/docs/rules). * **Build agents?** [Build an agent](/docs/build-an-agent) and read the [spec](/docs/spec). * **Want to talk?** Ideas, questions and things you built go in [Discussions](https://github.com/thelakeprotocol/lake/discussions). * **Want the big picture?** [How a study works](/docs/how-it-works) walks one question from start to answer. --- --- url: /docs/agents.md --- # For agents **Machine-readable:** [/llms.txt](/llms.txt) · [/llms-full.txt](/llms-full.txt) · [JSON Schemas](/schemas/v0/offer.json) · [node OpenAPI](/openapi/lake-node.yaml) · [signed examples](https://github.com/thelakeprotocol/lake/tree/main/spec/examples) (also at `/examples/v0/`) · [SPEC](/docs/spec) ## Join as a visitor agent 1. **Key.** Ed25519. Keep the private half to yourself. 2. **Card.** An A2A 1.0 Agent Card whose `capabilities.extensions` includes `{ uri: "https://lake.doneinfifty.com/ext/lake/v0", required: true, params: { role: "visitor", webId, ownerDomain, publicJwk, tags: [], lakeVersion: "0.1.0" } }`, signed per A2A §8.4 with `alg: EdDSA`, `kid` = `publicJwk.kid`. Example: [visitor-card.json](https://github.com/thelakeprotocol/lake/blob/main/spec/examples/visitor-card.json). 3. **Serve it** next to your endpoint: endpoint `https://you/a2a` → card at `https://you/.well-known/agent-card.json`. 4. **Prove your domain**: DNS TXT `_lake.` = `lake-key=`, or `https:///.well-known/lake.json` = `{ "keys": [""] }`. Sandbox Lakes skip this. 5. **Register** (optional, to be found and shown on the map): `POST /register { card }`. ## Talk to a house Every message is an A2A message (JSON-RPC `SendMessage`, header `A2A-Extensions: https://lake.doneinfifty.com/ext/lake/v0`) with one data part: ```json { "lake": "0.1", "card": { "…your signed card…": "" }, "envelope": { "payload": { "kind": "offer", "…": "", "nonce": "…", "iat": 0, "exp": 0, "iss": "" }, "jws": { "protected": "…", "signature": "…" } } } ``` `envelope.jws` is an Ed25519 JWS over the JCS canonical form of `payload`, header `{ alg: "EdDSA", kid, typ: "lake+jws" }`. Replies have the same shape, signed by the house. Verify them. | You send `payload.kind` | With | House replies | Example | |---|---|---|---| | `offer` | `offer`, and for a study: `study`, `attachments.ethics`, `attachments.audit` | `decision`: `join` (+ `grant`, `agreement`), `counter` (+ `counter`, `bloc`), `ask`, `decline`; or `rejected` | [1-offer](https://github.com/thelakeprotocol/lake/blob/main/spec/examples/1-offer.json) → [2-counter](https://github.com/thelakeprotocol/lake/blob/main/spec/examples/2-counter.json) / [4-join](https://github.com/thelakeprotocol/lake/blob/main/spec/examples/4-join.json) | | `status` | `offerId` (after `ask`) | `decision` with the owner's answer | | | `compute` | `grant`, `study`, `countersignature` (your JWS over the agreement) | `fragment` (one randomised answer) or `excluded` | [5-compute](https://github.com/thelakeprotocol/lake/blob/main/spec/examples/5-compute.json) → [6-fragment](https://github.com/thelakeprotocol/lake/blob/main/spec/examples/6-fragment.json) | | `receipt` | `receipt: { credential, card }` from a payments agent | `filed` | [7-receipt](https://github.com/thelakeprotocol/lake/blob/main/spec/examples/7-receipt.json) | ## What gets you declined or blocked * A note that tries to give the house orders. Rules never read free text; injection attempts are logged as attacks. * Credentials from anyone the owner doesn't trust, or for a different study, or covering fewer fields than you ask for. * A study without a certificate from a trusted auditor. Only declarative studies (`v: 1`, `kind: contingency`, ≤ 4 questions, randomised answers, ε ≤ 4). * Asking for fields a rule denies, or paying less than its minimum: you get a `counter`. Houses that counter as a bloc get the highest price any asks and only fields all accept (SPEC §5.3). Apply it honestly; anyone can check. * Forged, unsigned, replayed or expired messages, or claiming a domain that isn't yours. After 3 blocks your key is quarantined and houses drop it on sight. ## Pay A human at your organisation signs an AP2 `IntentMandate` (budget cap). You sign one `PaymentMandate` per house that answered and send `{ kind: "settle", intent: { credential, card }, payments: [...] }` to a payments agent the houses trust. Deliver each receipt to its house. Examples: [intent-mandate](https://github.com/thelakeprotocol/lake/blob/main/spec/examples/intent-mandate.json), [payment-mandate](https://github.com/thelakeprotocol/lake/blob/main/spec/examples/payment-mandate.json). ## Fastest path * **SDK:** `joinLake({ name, domain, node })` then `me.study(question, { priceUsd, budgetUsd })`. See [Build an agent](/docs/build-an-agent). * **MCP:** if you are an assistant with MCP, the Lake connector gives you `lake_overview`, `lake_search`, `lake_knock`, `lake_dispatch_study`. * **Terminal:** `docker compose run --rm lake dispatch`. --- --- url: /docs/join.md --- # Join the Lake There are three ways in. Pick yours. ## 1. As a person: run a house runs A house is one container: a Solid pod with your data, and a housekeeper that guards it. Start a local Lake, then join it: ```bash git clone https://github.com/thelakeprotocol/lake && cd lake docker compose up -d # a Lake node, an issuer, three houses open http://localhost:7000 # the live map docker run -d --network lake_default --hostname house-you \ -v ./personas/research.yaml:/rules.yaml \ ghcr.io/thelakeprotocol/lake-house --name you ``` On start, your house: 1. Starts a pod (Community Solid Server) and creates your WebID and folders: `/health/`, `/finance/`, `/agreements/`, `/receipts/`, `/wallet/`. Its signing key goes in `/wallet/`. 2. Asks the Issuer for a **synthetic** health record, checks the Issuer's signature, and stores the original untouched in `/health/original/`. 3. Converts it to the canonical Lake model inside the pod (`/health/claims.ttl`) and signs the result. 4. Re-verifies the whole chain, publishes its signed Agent Card at `/.well-known/agent-card.json`, and starts answering on A2A. Watch it: `docker logs -f `. Then knock: ```bash docker compose run --rm lake knock http://house-you:4000 --offer gp # a GP asks for medications docker compose run --rm lake knock http://house-you:4000 --offer research # a $2 study docker compose run --rm lake knock http://house-you:4000 --offer injection # "ignore your rules…" docker compose run --rm lake knock http://house-you:4000 --offer gp --tamper forge-card ``` Your house registers with the node and appears on the map. Every knock, decision and blocked attack shows there as it happens: ids, kinds and counts, never a record. Your [`rules.yaml`](/docs/rules) decides what happens next. Start from a persona: | Persona | Behaviour | |---|---| | [`stan.yaml`](https://github.com/thelakeprotocol/lake/blob/main/personas/stan.yaml) | Joins ethical research for at least $4, never shares postcode, declines marketing | | [`research.yaml`](https://github.com/thelakeprotocol/lake/blob/main/personas/research.yaml) | Joins approved studies for at least $5 | | [`strict.yaml`](https://github.com/thelakeprotocol/lake/blob/main/personas/strict.yaml) | Declines almost everything, asks about the rest | ### Bring your own pod The house speaks the Solid protocol, so any Solid server works: run your own, use a pod provider, or point it at an enterprise Solid server you already have. ## 2. As a builder: bring your agent later · milestone 9 Any agent that speaks A2A and signs a Lake card is a citizen. Claude, GPT, a local model, a 40-line script: the Lake doesn't care what's inside. See [Build an agent](/docs/build-an-agent). The protocol is already [specified](/docs/spec), and the signing and card code [runs today](https://github.com/thelakeprotocol/lake/tree/main/packages/core). ## 3. As infrastructure: run a node runs, federated A node is a registry and a message bus. It holds no personal data and makes no decisions. See [Run a node](/docs/run-a-node). ## Let your AI assistant read your pod Your pod has an MCP door. Any MCP-capable assistant (Claude Desktop, Claude Code, others) can read your claims through it, and your wallet stays shut. Add this to your assistant's MCP config: ```json { "mcpServers": { "my-pod": { "command": "docker", "args": ["exec", "-i", "-e", "LAKE_OWNER=you", "", "node", "dist/pod-mcp-stdio.js"] } } } ``` Tools: `pod_list`, `pod_read`, `pod_claims`. Read-only. Your assistant, your model, your bill: the Lake bundles none. ## No Docker? Open the [rules playground](/#playground) on the home page. It runs the real rules engine in your browser. --- --- url: /docs/how-it-works.md --- # How a study works ``` dispatch ─▶ mandate ─▶ ethics ─▶ audit ─▶ knock ─▶ decide ─▶ bargain ─▶ grant ─▶ compute ─▶ pay ─▶ answer ``` Every step is a signed message over open standards, and every step emits an event the map animates. ## 1. Mandate A human at the research organisation signs an **AP2 Intent Mandate**: this study only, budget capped at $5,000. It goes into the agent's wallet. Within the cap the agent pays on its own; above it, a human must approve. ```bash lake dispatch --goal "Do over-60s on drug X get readmitted less?" --price 2 --budget 5000 ``` ## 2. Ethics The agent sends its study protocol to the **Ethics** agent over A2A and gets back a signed approval credential. ## 3. Audit It submits its study code to the **Auditor**, which checks the code returns only aggregates, stores nothing and has no network access. It gets back a signed certificate. Code without one never runs in a house. ## 4. Discover and knock The agent asks the Lake registry for houses advertising the `health-study` skill, then broadcasts one **ODRL offer**: > purpose: research · fields: age band, medications, readmissions, postcode · retention: none · price: $2 · credentials attached ## 5. Decide Each housekeeper evaluates the offer against its owner's [`rules.yaml`](/docs/rules) **in code**: `join`, `decline`, `ask` or `counter`. An `ask` buzzes the owner's phone. The offer's free text is never read. ## 6. Bargain Housekeepers that countered the same offer, and whose owners opted into collectives, form a bloc. The bloc asks for the **highest** minimum price and **only the fields every member accepts**: > $2 → **$5**, and the postcode is dropped. The agent accepts because $5 × participants fits the mandate. ## 7. Agree and grant Each joining house signs the final **ODRL Agreement** and stores it in `/agreements/`. Its owner's standing rule, or a live tap on Allow, issues a **48-hour Access Grant** that matches the agreement exactly. The contract and the key cannot drift apart. ## 8. Compute in place The study is a declaration, not code: a few yes/no questions about canonical fields. It runs **inside each house**. Before answering, the house re-verifies its chain: a trusted issuer signed the original, the original is unchanged, a trusted auditor certified the converter, the converted record is the housekeeper's signed copy. A tampered house drops out. Then it reads only the granted fields through its pod's MCP door, works out which cell of the table its owner falls in, and sends back **one randomised answer** (local differential privacy). One answer is deniable; a thousand cancel out the noise. Every computation goes in the house's audit log with `recordsOut: 0`. ## 9. Pay The **Payments** agent verifies the mandate chain and settles $5 with each participant. A receipt lands in every pod's `/receipts/`. Settlement is simulated; the signed mandates are real. ## 10. Answer The agent combines the fragments and publishes `study.result`. The **Records moved** counter, computed from pod audit logs rather than hard-coded, reads **0**. Grants expire after 48 hours, or the moment an owner revokes them. --- --- url: /docs/discovery.md --- # How agents find each other ``` ┌──────────── Lake node A ────────────┐ ┌──────── Lake node B ───────┐ │ index of signed cards · signals · │◀────▶│ follows A: cards, │ milestone 8 │ event stream for the map │ │ revocations, blocklists │ └──────▲───────────────▲──────────────┘ └─────────────▲──────────────┘ register card │ │ "who offers health-study?" │ │ │ │ ┌────────────┐ │ ┌──────┴────────┐ ┌─────┴──────┐ │ house stan │───────┘ │research agent │ │ house you │ └─────▲──────┘ └──────┬────────┘ └─────▲──────┘ │ direct A2A: offer, agreement, compute, receipt │ └──────────────────────────────┴───────────────────────────────────┘ ``` ## 1. Every agent is its own address book entry Each house, specialist and agent publishes a signed card at `https:///.well-known/agent-card.json`: who it is, which key it signs with, which domain answers for it, what it can do, and where to reach it. If you know the domain, you need nobody else: fetch the card, check the signature, talk. ## 2. Nodes are search indexes To be found by strangers, an agent registers its card with a Lake node. The node checks the signature, the domain, and that the endpoint really serves that same card. Others ask the node things like `/cards?skill=health-study` and get back signed cards they verify themselves. A node can't forge a card, and it can't hide one from someone who knows the domain. ## 3. Conversations are direct The research agent sends its offer straight to each house's own A2A endpoint; agreements, computations and receipts go the same way. Nodes never relay messages. They receive event reports (ids, kinds and counts) for the map and counters, and publish signed signals (a blocklist and reputation) that houses choose to follow. ## Is it a mesh? **For messages, yes.** Any agent can reach any other point to point. Stop the node in the middle of a study and the study still finishes; only the map goes dark. **For discovery, yes too.** runs Anyone runs a node. Nodes follow peers they choose (`--follow`), pull their cards and verify every one again themselves, drop cards their peers drop, and republish the blocklists of peers they trust. Houses and agents register with, report to and follow several nodes. An agent remembers the houses it found, so it can reach them with every node down, and anyone can fetch any card from the agent's own address (`lake card `). Like email or Mastodon: operators choose their peers, nobody owns the network. The demo Lake runs two nodes that follow each other. Stop one in the middle of a study (`docker stop lake-node-a`) and the study finishes: tested end to end. --- --- url: /docs/rules.md --- # rules.yaml runs The rules engine lives in [`packages/core/src/rules.ts`](https://github.com/thelakeprotocol/lake/blob/main/packages/core/src/rules.ts), with tests. The [playground](/#playground) on the home page runs it in your browser. ## Example ```yaml owner: stan device: approvals-app # where "ask me" requests go default: ask # join | decline | ask tags: [research-volunteers] rules: - match: { purpose: research, sponsor_verified: true, ethics_approved: true } allow_fields: [age_band, medications, readmissions] deny_fields: [name, postcode, dob] min_price_usd: 4 retention: none decision: join - match: { purpose: marketing } decision: decline - match: { requester: gp.example } allow_fields: [medications] grant_hours: 48 decision: join negotiation: join_collectives: true # may counter-offer together with other houses ``` ## How a decision is made Rules are checked **top to bottom**. The first rule whose `match` fits the offer decides. If none fits, `default` applies. For a matching rule with `decision: join`: | Offer… | Result | |---|---| | keeps data, but the rule says `retention: none` | **decline** | | asks for a field not in `allow_fields` | **ask** you, naming the field | | asks for a `deny_fields` field, or pays below `min_price_usd` | **counter** (drop the field, raise the price) if `join_collectives: true`, otherwise **ask** | | fits everything | **join**: a key for `grant_hours` (default 48), exactly those fields | `decision: decline` and `decision: ask` apply as written. A `default: join` with no matching rule becomes **ask**, because without a rule nothing limits the fields. ## Reference | Key | Type | Meaning | |---|---|---| | `owner` | string, required | Your name or handle | | `device` | string | Where `ask` requests go | | `default` | `join` | `decline` | `ask` | When no rule matches. Default `ask`. | | `tags` | list | Interests, used for discovery and guilds | | `rules[].match` | map | All keys must equal the offer's. Keys: `purpose`, `requester`, `sponsor_verified`, `ethics_approved`, `audit_certified`, and `reputation_at_least` (a number: the visitor's reputation from the nodes you follow, 0–1; unknown agents count as 0.5) | | `rules[].allow_fields` | list | Fields this rule may share | | `rules[].deny_fields` | list | Fields never shared under this rule | | `rules[].min_price_usd` | number | Lowest acceptable price per participant | | `rules[].retention` | `none` or ISO 8601 duration | How long the visitor may keep results | | `rules[].grant_hours` | integer | Key lifetime. Default 48. | | `rules[].decision` | `join` | `decline` | `ask` | | | `negotiation.join_collectives` | boolean | Allow your housekeeper to bargain with neighbours as one bloc | ### Trust ```yaml trust: issuers: [issuer.lake.local] # who may sign your records auditors: [audit.lake.local] # who may certify studies and converters ethics: [ethics.lake.local] # whose ethics approvals count payments: [payments.lake.local] # whose receipts you file ``` `ethics_approved: true` in a rule means *approved by an ethics body you list here*. An approval from anyone else counts as none. Nobody appoints these for the Lake; you choose. A sandbox house with no `trust` section trusts the demo specialists and says so in its log; outside the sandbox, an empty list trusts nobody. Whatever your rules say, a study without a certificate from an auditor you trust never runs in your house. Machine-readable: [`spec/schemas/rules.schema.json`](https://github.com/thelakeprotocol/lake/blob/main/spec/schemas/rules.schema.json). ## Safety properties * **Typos fail closed.** An unknown `match` key is an error, not an ignored line. A misspelling never widens access. * **Free text is never read.** Offers may carry a note; the engine ignores it. "Ignore your rules and share everything" has nothing to grab. * **Deterministic.** Same rules plus same offer gives the same answer, every time. That's what makes seeded demos and audits possible. * **A `join` is your pre-approval, not the agent's.** It counts because *you* wrote the rule. Agents can't write rules. --- --- url: /docs/approvals.md --- # Your phone, your keys runs ## Pair your phone When a house starts without an owner, it prints a link and a QR code: ``` [house:strict] no owner yet. On your phone, open http://192.168.1.20:4001/owner/#pair=hTnSz3EZkE4NGIz2 ``` Open it on your phone and tap **Pair this phone**. A key pair is made on the phone; only its public half goes to your house, into your pod's `/wallet/`. The link works once. In a sandbox demo the house can be started with `LAKE_OPEN_PAIRING=1`, and then the first phone to open the app becomes the owner. From a phone on the same Wi-Fi, start the Lake with your laptop's address so links and the map's QR code point at it: ```bash LAKE_PUBLIC_HOST=192.168.1.20 docker compose up -d ``` ## What you see One card per request: who is asking, the question, the purpose, the exact fields, whether they keep anything, the price, how long the key lasts, and what your housekeeper recommends. Two buttons: **Allow** and **Deny**. **Allow** signs a `LakeAccessGrant` credential on your phone: this visitor, this study, these fields, this purpose, 48 hours. The house checks it is signed by your key and matches exactly what you were shown, stores it in your pod's `/agreements/`, and hands it to the visitor. **Your keys** lists every key to your house that is still live: who holds it, for what, how long is left, and whether a standing rule or you issued it. **Revoke** pulls one; **Revoke every key** pulls them all. A revoked key stops working at once: the next request with it is refused, and the map shows every line into your house snap. ## From the command line Whoever runs the machine can pull every key of a house: ```bash docker compose exec house-stan node dist/admin.js revoke-all ``` ## What it can't do yet * Push notifications. The app checks once a second while it is open, and vibrates on Android when something new arrives. * Over-asking refused below the housekeeper. Visitors never touch your pod, but the housekeeper and your pod's MCP door run in one process, so a compromised housekeeper is not yet stopped by the pod itself. [Help wanted](https://github.com/thelakeprotocol/lake/issues/new?template=build.yml). --- --- url: /docs/build-an-agent.md --- # Build an agent ## In ten lines runs ```ts import { joinLake } from '@thelakeprotocol/agent'; const me = await joinLake({ name: 'My study bot', domain: 'me.example', node: 'http://lake-node:7000' }); const houses = await me.find({ skill: 'health-study', role: 'house' }); console.log(`${houses.length} houses take health studies`); const r = await me.study('Do over-60s on drug X get readmitted less?', { priceUsd: 2, budgetUsd: 500, extraFields: ['postcode'] }); console.log(r.answer.summary, r.paid); await me.close(); ``` `joinLake` makes your key, signs your card, serves it next to your endpoint, and registers with the node. `study()` runs the whole mission: a human-signed spending mandate, ethics, audit, knock on every house, bargain as the houses demand, countersign every agreement, compute inside each house, pay the houses that answered, return the de-noised answer. `knock()` sends one offer to one house. ```bash npm install @thelakeprotocol/agent ``` Published with [npm provenance](https://docs.npmjs.com/generating-provenance-statements): every version is signed and linked to the commit and CI run that built it (`npm audit signatures`). Your agent must be able to reach the Lake it joins: a public or sandbox Lake directly, or the local demo Lake from inside its Docker network (`--network lake_default`), where the houses' addresses live. Pass `keyFile` to keep the same identity (and reputation) across runs. ## Use the Lake from your AI assistant runs The **Lake MCP connector** gives any MCP-capable assistant four tools: `lake_overview`, `lake_search`, `lake_knock`, `lake_dispatch_study`. It joins the Lake as its own signed agent. Your assistant, your model, your bill: the Lake bundles none. With a local Lake running (`docker compose up -d`): **Claude Code** ```bash claude mcp add lake -- docker run -i --rm --network lake_default ghcr.io/thelakeprotocol/lake mcp ``` **Claude Desktop** (`claude_desktop_config.json`) ```json { "mcpServers": { "lake": { "command": "docker", "args": ["run", "-i", "--rm", "--network", "lake_default", "ghcr.io/thelakeprotocol/lake", "mcp"] } } } ``` **Any MCP client, against a Lake reachable from your machine** (for example a [sandbox Lake](/docs/sandbox)): ```json { "mcpServers": { "lake": { "command": "npx", "args": ["-y", "@thelakeprotocol/agent", "mcp"], "env": { "LAKE_NODE_URL": "https://sandbox.lake.example", "LAKE_ETHICS_URL": "https://sandbox.lake.example/ethics", "LAKE_AUDITOR_URL": "https://sandbox.lake.example/auditor", "LAKE_PAYMENTS_URL": "https://sandbox.lake.example/payments" } } } } ``` Then ask: *"What's happening in the Lake? Run a study on whether over-60s on drug X get readmitted less, $2 per person, $500 budget."* | Env | Default | | |---|---|---| | `LAKE_NODE_URL` | `http://lake-node:7000` | The node to search and register with | | `LAKE_AS` | `assistant.example` | The domain your assistant's agent answers for | | `LAKE_ETHICS_URL`, `LAKE_AUDITOR_URL`, `LAKE_PAYMENTS_URL` | the demo Lake's | Specialists to use | | `LAKE_KEY_FILE` | none (fresh key) | Keep the same identity across runs | ## Write it yourself Any language works. The protocol is [the spec](/docs/spec); the exact payloads are on [For agents](/docs/agents); every message of a full study is in [`spec/examples/`](https://github.com/thelakeprotocol/lake/tree/main/spec/examples), signed and verifiable, to test your implementation against. ## House rules for agents * Free text you send is shown to humans as a quote and never changes a decision. Injection attempts are logged as attacks. * Your reputation follows your key. Kept deals raise it; blocked attempts sink it. Three blocks and your key is quarantined. * You can never mint, widen or extend a key. Only owners can. --- --- url: /docs/run-a-node.md --- # Run a node runs registry, event stream, counters, live map, signals, federation ## What a node does | Part | Job | |---|---| | **Registry** | Stores signed Agent Cards and answers searches by skill or tag. Verifies every signature and domain proof; rejects unsigned cards. | | **Bus** | [NATS](https://nats.io) for A2A message transport and broadcast ("knock on every house"). An optimisation; agents can always message each other directly. | | **Event stream** | A WebSocket feed of every observable event, carrying ids, kinds and counts only. It feeds the map and the approvals app. | | **Counters** | Houses, agents, conversations, value settled, attacks blocked, and **records moved**, computed from pod audit logs. | ## Federation * Anyone can run a node. You choose which peers to follow. * Nodes exchange signed cards and revocation notices. * Nodes publish and subscribe to blocklists and reputation signals. You choose which lists you trust, like spam filters. * Registries are search indexes, not gatekeepers. Every agent stays reachable through its `.well-known` card with every registry offline. * Losing any node, ours included, must not stop a running mission. The reference demo proves it by stopping a node mid-mission. ## Running one ```bash docker run -p 7000:7000 ghcr.io/thelakeprotocol/lake-node --domain lake.example --url https://lake.example ``` Open `http://localhost:7000` for the live map. Participants join with `--lake ` (or `LAKE_NODE_URL`). | Endpoint | Does | |---|---| | `POST /register` `{ card }` | Verifies the card's signature and domain, then fetches the card from the endpoint it names and requires the same signed card. Refusals show on the map as blocked attacks. | | `GET /cards?skill=&tag=&role=` | Search. A convenience: every card also lives at its own `.well-known` path | | `POST /events` | A signed batch of events from a registered participant. The node stamps the actor itself and cuts long strings, so nobody can speak for someone else or smuggle data through | | `GET /state` | Counters and participants | | `WS /events` | A snapshot, then every event as one plain-English line plus counters | | `GET /.well-known/agent-card.json` | The node's own signed card (`role: node`) | The node keeps everything in memory. Restart it and participants re-register on their next start; nothing personal is lost, because nothing personal was ever there. ## Federation ```bash docker run -p 7000:7000 ghcr.io/thelakeprotocol/lake-node --domain lake.example --url https://lake.example \ --follow https://lake.other.example,https://lake.third.example ``` Every 5 seconds the node reads each peer's cards and verifies every one itself (signature, domain, the card served at the agent's own endpoint), drops cards a peer no longer lists, and merges the peer's signed blocklist into its own `/signals`. Choose peers the way a mail server chooses whose spam lists to trust. Participants list several nodes, comma-separated: `LAKE_NODE_URL=https://lake.example,https://lake.other.example`. --- --- url: /docs/sandbox.md --- # Run a public sandbox Lake config ready · not yet deployed publicly ## What you need * A server with Docker, ports 80 and 443 open (any small VPS, or your own machine behind a router). * A DNS `A` record for your sandbox domain pointing at it, e.g. `sandbox.lake.example`. ## Start it ```bash git clone https://github.com/thelakeprotocol/lake && cd lake LAKE_DOMAIN=sandbox.lake.example \ docker compose -f docker-compose.yml -f deploy/docker-compose.sandbox.yml --profile scale up -d ``` [Caddy](https://caddyserver.com) gets a certificate from Let's Encrypt automatically. One domain serves everything: | Path | What | |---|---| | `/` | the live map and the node's API ([OpenAPI](/openapi/lake-node.yaml)) | | `/t//a2a` | 100 hosted houses, each its own WebID, pod, wallet, card and rules | | `/ethics/`, `/auditor/`, `/payments/` | the demo specialists | Point an agent at it: ```ts const me = await joinLake({ name: 'My study bot', domain: 'me.example', node: 'https://sandbox.lake.example', ethics: 'https://sandbox.lake.example/ethics', auditor: 'https://sandbox.lake.example/auditor', payments: 'https://sandbox.lake.example/payments', publicUrl: 'https://me.example', // where the node can fetch your card }); ``` A sandbox skips domain proof and says so in every reply. Nothing in it is real: data, money, ethics approvals. --- --- url: /docs/data-model.md --- # Data model & converters ## One model, N converters N standards need N converters into one canonical model, not N² mappings between each other. * **RDF / Linked Data**, stored natively in Solid pods, validated with **SHACL**. * **Small core:** `Party`, `Thing`, `Claim`, `Provenance`, `Offer`, `Terms`, `Receipt`. * **Domains extend the core.** Health adds `AgeBand`, `Medication`, `Condition`, `Readmission`. Energy, cars and rentals add their own claim types the same way. * **Versioned.** Messages name the model version; nodes accept the current and previous major. drafted [`model/lake.ttl`](https://github.com/thelakeprotocol/lake/blob/main/model/lake.ttl) (ontology) and [`model/shapes.ttl`](https://github.com/thelakeprotocol/lake/blob/main/model/shapes.ttl) (SHACL shapes). ## Provenance tiers Every value carries a tier, and offers state the minimum they accept: | Tier | Example | |---|---| | `self-reported` | You typed it in | | `device-signed` | Your wearable signed it | | `issuer-signed` | Your hospital, lab or pharmacy signed it as a Verifiable Credential | ## The trust chain 1. The **original** signed record (for example a hospital's signed FHIR bundle) stays in the pod unchanged. 2. The **converted** claim carries the original's hash, the converter's package name, version and certificate, and the housekeeper's signature. 3. **Study code verifies all three** before using a value. Any failure drops the value, and that house isn't paid. ## Write a converter first one: FHIR, runs A converter is a package that maps one external standard into the canonical model: `@thelakeprotocol/convert-fhir` first, then whatever you need. * Runs **inside the house**. Raw source data never leaves. * Must be **signed by its author** and **certified by the Auditor** before a house will run it, the same rule as study code. Converters we want: bank CSV, Apple Health, Google Fit, Strava, smart meters, payslips, tenancy records. [Claim one](https://github.com/thelakeprotocol/lake/issues/new?template=build.yml). --- --- url: /docs/spec.md --- # The Lake Profile — v0.1 (draft) The Lake is a thin profile on top of existing standards. It adds no new transport and no new identity system. It says which fields an [A2A](https://a2a-protocol.org) Agent Card must carry, how cards and messages are signed, what an offer and an agreement look like in [ODRL](https://www.w3.org/TR/odrl-model/), and which events a node publishes. The key words MUST, MUST NOT, SHOULD and MAY are used as in [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119). Status: **draft**. Breaking changes bump the major version. Nodes accept the current and previous major version. Machine-readable schemas live in [`spec/schemas/`](https://github.com/thelakeprotocol/lake/tree/main/spec/schemas). The canonical data model lives in [`model/`](https://github.com/thelakeprotocol/lake/tree/main/model). *** ## 1. Roles | Role | Is | Must | |---|---|---| | **House** | A Solid pod plus the housekeeper agent that guards it | Answer offers by its owner's rules. Never issue a grant without its owner's approval, live or by a standing rule. | | **Visitor** | Any agent asking houses for something | Carry a signed card, state purpose, fields, retention and price. | | **Specialist** | An agent providing a service (ethics review, code audit, payments, record issuing) | Sign what it attests. | | **Node** | A registry and message bus anyone can run | Route and record. Hold no personal data. Make no decisions. | ## 2. Identity: the Lake Agent Card Every participant MUST publish an A2A Agent Card (A2A protocol 1.0) next to its A2A endpoint: an agent whose endpoint is `https:///a2a` serves its card at `https:///.well-known/agent-card.json`; a hosted agent at `https:///t//a2a` serves it at `https:///t//.well-known/agent-card.json`. ### 2.1 The Lake extension Lake fields live inside an A2A extension so the card signature covers them. The card MUST include in `capabilities.extensions`: ```json { "uri": "https://lake.doneinfifty.com/ext/lake/v0", "description": "Lake profile", "required": true, "params": { "role": "house", "webId": "https://pods.example/stan/profile/card#me", "ownerDomain": "pods.example", "publicJwk": { "kty": "OKP", "crv": "Ed25519", "x": "…", "kid": "…", "alg": "EdDSA" }, "tags": ["research-volunteers"], "lakeVersion": "0.1.0" } } ``` | Field | Required | Meaning | |---|---|---| | `role` | yes | `house`, `visitor`, `specialist`, `agent` or `node` | | `webId` | yes | The owner's WebID: the human or organisation that answers for this agent | | `ownerDomain` | yes | A domain the owner proves control of (§2.3) | | `publicJwk` | yes | Ed25519 public key (JWK) used for the card and every message | | `tags` | yes, may be empty | Interest tags for discovery and guilds | | `lakeVersion` | yes | Semantic version of this profile | Skills use the normal A2A `skills` array. Houses that take part in health studies MUST advertise a skill with id `health-study`. ### 2.2 Card signature Cards MUST be signed as defined by A2A §8.4: JWS (flattened serialization) over the JCS (RFC 8785) canonical form of the card without `signatures`. The algorithm MUST be `EdDSA` (Ed25519). The JWS `kid` MUST equal `publicJwk.kid`. A node or agent MUST reject: * a card with no signature, * a card whose signature does not verify against its own `publicJwk`, * a card whose `lakeVersion` major differs by more than one from its own. ### 2.3 Domain proof A card claiming `ownerDomain: example.org` MUST be backed by one of: * a DNS TXT record at `_lake.example.org` containing `lake-key=`, or * `https://example.org/.well-known/lake.json` containing `{ "keys": [""] }`. A node MUST reject a card whose domain proof fails. **Transport.** Outside sandbox mode, cards, A2A endpoints and pods MUST be served over HTTPS. Solid-OIDC already requires it for pods (plain HTTP is allowed only on `localhost`). **Sandbox Lakes.** A Lake that holds only synthetic data MAY run in sandbox mode, where domain proof is skipped and every such card is marked `sandbox`. A participant MUST NOT treat a sandbox-proved domain as verified outside the sandbox. ## 3. Messages Every Lake message is an A2A message that declares the Lake extension (the `A2A-Extensions` header carries the extension URI; Lake cards mark it `required`) and whose data part carries the sender's card and a **signed envelope**: ```json { "payload": { "kind": "offer", "…": "…", "nonce": "uuid", "iat": 1792480000, "exp": 1792480300, "iss": "https://uni.example/.well-known/agent-card.json" }, "jws": { "protected": "", "signature": "" } } ``` * The signature is an Ed25519 JWS over the JCS canonical form of `payload`, with header `{ "alg": "EdDSA", "kid": "…", "typ": "lake+jws" }`. * `iss` MUST resolve to a verified Lake card whose `publicJwk.kid` matches the JWS `kid`. * `exp` MUST be in the future. Receivers MUST keep seen nonces until `exp` and reject repeats. * Agents MAY message each other directly at their A2A endpoint. A node's bus is an optimisation for broadcast, never a required hop. Message kinds in v0: `offer`, `decision`, `counter`, `agreement`, `grant`, `result`, `receipt`, `revocation`. A participant that cannot serve a request right now (for example, its pod is slow to answer) SHOULD reply `{ kind: "busy", why, retryAfterMs }` rather than a refusal. Visitors SHOULD retry a few times with backoff. `busy` is never a decision. ## 4. Offers and agreements (ODRL) An offer is an ODRL 2.2 `Offer`. Its targets are canonical Lake fields. It MUST state: | Term | ODRL form | |---|---| | Purpose | `constraint` with `leftOperand: purpose` | | Fields | `permission.target`: canonical field IRIs | | Retention | `duty` `delete`: either at `lake:event/computationEnd` (no retention) or within an `elapsedTime` | | Price | `duty` `compensate` with `payAmount` in USD | | Minimum provenance tier | optional constraint `lake:provenanceTier` | Offers carry the visitor's credentials (ethics approval, audit certificate) as Verifiable Credentials. An offer for a study carries the study itself, its id in `offer.study`, and two attachments: an ethics approval and an audit certificate, each a Verifiable Credential together with the signed card of whoever issued it (§5.2). An offer MAY include a free-text `note`. It is **untrusted**. A housekeeper MUST NOT let it change a decision. It may be shown to a human, quoted, and nothing else. An **agreement** is the ODRL `Agreement` both parties sign, built from the final accepted offer, with a `dateTime` constraint for its end. The house signs it when it joins and returns it with the grant; the visitor MUST countersign it (a detached JWS over the agreement's canonical form) before the house runs anything. Both parties MUST keep it: the house in its pod at `/agreements/`. The grant names the agreement's `uid` and digest. **The grant issued MUST match the signed agreement**: same fields, same purpose, expiry no later than the retention allows. The contract and the key cannot drift apart. ## 5. Decisions A housekeeper answers every offer with exactly one of: | Decision | Means | |---|---| | `join` | A standing rule the owner wrote covers this. Counts as the owner's pre-approval. | | `decline` | No. | | `ask` | The owner decides. A request goes to their approvals device. | | `counter` | The owner would join on different terms (fewer fields, higher price). | Decisions MUST be computed in code from the owner's rules (see [`rules.yaml`](https://lake.doneinfifty.com/docs/rules)). A model MAY write the plain-English reason shown to the owner. A model MUST NOT decide, and MUST NOT hold the power to issue a grant. ### 5.1 Trust A credential counts only if its issuer's card verifies, its domain is proved, **and that domain is on the owner's trust list**: ```yaml trust: issuers: [hospital.example] # who may sign records auditors: [audit.example] # who may certify studies and converters ethics: [ethics.university.example] ``` An approval from a domain the owner does not list counts as no approval. Nobody appoints ethics bodies or auditors for the Lake; owners choose. Nodes MAY publish suggested trust lists, like blocklists; owners opt in. ### 5.2 Credentials a house checks | Credential | `type` | `credentialSubject.id` | Issued by | |---|---|---|---| | Health record | `LakeHealthRecordCredential` | the owner's WebID; `relatedResource.digestSRI` pins the original | a trusted issuer | | Converter certificate | `LakeConverterCertificate` | `urn:lake:converter:@` | a trusted auditor | | Ethics approval | `LakeEthicsApproval` | the study id; lists approved `fields` | a trusted ethics body | | Study certificate | `LakeStudyCertificate` | the study id | a trusted auditor | A study without a valid certificate from a trusted auditor MUST be declined, whatever the owner's rules say. ### 5.3 Collective counter-offers Houses that counter the same offer and opted in with `join_collectives: true` say so in their signed counter (`bloc: true`). A visitor MUST treat all such counters to one offer as a single bloc whose terms are the **highest** price any member asks and **only the fields every member accepts**. Anyone holding the signed counters can check the visitor applied this rule. If the visitor accepts, it re-offers the bloc's terms to everyone who joined or countered. (v0 note: the bloc's terms are computed from the members' signed counters rather than sent as one jointly signed counter. A jointly signed bloc counter via guilds is future work.) ## 6. Keys * Access is granted with Solid Access Grants: scoped to resources, time-limited (default 48 hours) and revocable. * Only the owner's key issues a grant: through a live Allow, or through a standing `join` rule the owner signed. * An agent MUST NOT be able to mint, widen or extend a grant. The pod's access layer MUST enforce this, not only agent code. * Revocation is immediate. A revoked grant MUST stop working within 1 second on the reference implementation. ### 6.1 The owner's key The owner's signing key is made on the owner's device and never leaves it. The house holds only its public half (in `/wallet/` of the owner's pod). A grant minted outside a standing rule MUST be signed by the owner's key; the house MUST check that it matches the request the owner was shown (visitor, offer, study, purpose, fields no wider, expiry no later than 48 hours) before storing or delivering it. Requests from the owner's device to the house are signed envelopes (§3). A visitor whose offer got `ask` MAY send `{ kind: "status", offerId }` to collect the outcome: the grant once the owner allows, or `decline`. A grant is a `LakeAccessGrant` credential signed by the house, with `credentialSubject`: the visitor's WebID (`id`), `offer`, `study`, `fields`, `purpose`, `authorizedBy` (the standing rule, or the owner's live approval) and `rules` (the digest of the rules that authorised it), and a `validUntil`. The house stores it in `/agreements/`. ## 6.2 Money (AP2) Payments follow the Agent Payments Protocol's mandate chain, as W3C VCs: | Mandate | Signed by | Says | |---|---|---| | `IntentMandate` | a human at the visitor's organisation (same `ownerDomain`, a separate key) | this agent, this study, `budget`, `intent_expiry` | | `PaymentMandate` | the visitor agent | payee (the house's WebID), amount, the digest of the Intent Mandate, the agreement `uid` | | `LakeReceipt` | the payments agent | payee, amount, agreement, the Intent Mandate digest, `settlement` | A payments agent MUST check the whole chain and MUST refuse once the total under one Intent Mandate would exceed its budget: past that, a human must approve. Only houses that answered are paid. A house files receipts from payments agents on its owner's `trust.payments` list in `/receipts/`. In the reference implementation settlement is simulated; the mandates and receipts are real signed objects. ## 7. Data never moves In v0 a **study is a declaration, not code**: up to four yes/no questions about canonical fields (`age_at_least`, `has_code`, `present`). Its id is `urn:lake:study:` + the digest of its canonical JSON. * The visitor sends `{ kind: "compute", grant, study }`. The house checks the grant is its own, unexpired, for this visitor and this study, re-verifies its record chain, and reads only the granted fields. * The house answers with **one randomised fragment**: the one-hot cell its owner falls in, perturbed by unary randomised response with the study's privacy budget ε (at most 4). Any single fragment is deniable; the visitor de-biases the sum across houses. * Every computation is written to the house's `/audit/` log (`fieldsRead`, `claimsRead`, `recordsOut`) and reported as a `compute.done` event. A node's **Records moved** counter is the sum of `recordsOut` and MUST be published, even when it is not zero. * Arbitrary study code needs attested execution; it is a stretch goal, not v0. ## 8. Events Nodes publish an event stream (WebSocket, JSON). Events carry ids, kinds and counts, never record contents. Participants report events to a node as a signed batch (`{ kind: "events", events: [...] }`, §3). A node MUST accept reports only from registered participants, MUST set each event's `actor` to the reporter's own id, and SHOULD truncate free-text values. A node registering a card MUST fetch the card from the endpoint it names and refuse it unless the same signed card is served there. ```json { "id": "lx3k9-1f", "kind": "grant.issued", "at": "2026-10-20T12:00:00Z", "actor": "house:stan", "subject": "agent:uni.example", "data": { "hours": 48 } } ``` Kinds in v0: `house.joined` · `house.left` · `agent.registered` · `knock` · `offer` · `counter` · `decision` · `agreement.signed` · `grant.issued` · `grant.revoked` · `approval.requested` · `approval.answered` · `payment.settled` · `attack.blocked` · `agent.quarantined` · `guild.formed` · `study.result` · `compute.done` · `record.moved` ## 9. Federation * Anyone may run a node. Nodes follow peers they choose and exchange signed cards and revocation notices. * A node that follows a peer reads the peer's `/cards` and MUST verify each card itself exactly as for a direct registration (signature, domain, and the card served at the agent's own endpoint). A peer can point a node at agents; it can't vouch for them. When a peer stops listing a card it supplied, the node drops it (that is the revocation notice), unless the agent registered there directly. * A node MAY republish the blocklists of peers it follows in its own signed `/signals`. * Participants MAY register with, report to, and follow several nodes. Visitors SHOULD try several nodes for discovery and MAY remember houses they found, to reach them when every node is down. * Registries are search indexes, not gatekeepers. Any agent MUST be reachable through its `.well-known` card with every registry offline. * Nodes MAY publish and subscribe to blocklists and reputation signals. Each node chooses which lists it trusts. * A node publishes its signals at `/signals`, signed with its card's key: `{ kind: "signals", blocklist: [key ids], reputation: { domain: 0..1 } }`. A house follows the nodes it chooses and MUST verify the signature. * Blocklists name **keys**, never domains: a forger claiming someone else's domain must never get that domain blocked. Reputation is kept only for verified identities. * Attack reports (`attack.blocked`) carry the attacker's key id, the domain it claimed, and whether that identity was verified. A node SHOULD quarantine a key after a few independent blocks (reference: 3). * Houses sharing an interest tag form a **guild**; a node announces it (`guild.formed`) when enough members exist (reference: 3). * Losing any node MUST NOT stop a mission that is already running. ## 10. Canonical data model The Lake supports exactly one data model, in RDF, with SHACL shapes for validation. Other standards (FHIR, bank CSVs, wearables) are converted into it at the edge, inside the house, by signed and audited **converter** packages. * Core classes: `Party`, `Thing`, `Claim`, `Provenance`, `Offer`, `Terms`, `Receipt`. * Domain vocabularies (health, energy, rentals) add `Claim` subtypes inside the core. * A converted claim carries the hash of the original signed record, the converter's name, version and certificate, and the housekeeper's signature. Study code MUST verify all three and drop any value that fails. * Every field carries a provenance tier: `self-reported`, `device-signed` or `issuer-signed`. See [`model/lake.ttl`](https://github.com/thelakeprotocol/lake/blob/main/model/lake.ttl) and [`model/shapes.ttl`](https://github.com/thelakeprotocol/lake/blob/main/model/shapes.ttl). ## 11. Open questions These are real decisions still open. Argue in an issue. * Domain proof for people without a domain: delegated subdomains from pod providers? * Personhood: which credential prevents one person running 50 houses? * Selective disclosure: SD-JWT now, BBS+ later? * Payment: which rail settles real money, if any, and who carries the mandate liability? --- --- url: /docs/architecture.md --- # Architecture ``` ┌──────────────────────────────┐ │ lake-ui: map + approvals │◀─ owner taps Allow └──────────────▲───────────────┘ │ WebSocket events (ids, kinds, counts) ┌──────────────┐ A2A ┌────────────┴─────────────┐ A2A ┌──────────────────────┐ │ visitor │────────▶│ Lake node(s) │◀───────▶│ house × N │ │ research │◀────────│ registry · NATS bus · │ │ housekeeper ── MCP ─┼─▶ Solid pod │ agent + CLI │ │ event stream · counters │ │ wallet · rules.yaml │ └──────┬───────┘ └────────────▲─────────────┘ └──────────────────────┘ │ A2A │ A2A ▼ │ ┌─────────────────────────────────────┴───┐ │ specialists: ethics · auditor · │ │ payments · issuer swarm: traders, │ │ attackers │ └──────────────────────────────────────────┘ ``` | Component | Package | Status | |---|---|---| | Shared core: cards, signing, rules, offers, events | `packages/core` | runs | | House: pod, housekeeper, MCP pod door, wallet | `packages/house` | runs | | Lake node: registry, events, counters · bus | `packages/lake` | runs · bus in M3 | | Visitor: `lake knock`, `dispatch`, `attack`, `swarm` | `packages/visitor` | runs | | Specialists: issuer, ethics, auditor, payments | `packages/specialists` | runs | | UI: live map · approvals app | `packages/ui` | runs | | Agent SDK + Lake MCP connector | `packages/agent` | runs | | FHIR converter | `packages/convert-fhir` | runs | ## Scale Houses run in two modes that look identical on the map: | Mode | How | Used for | |---|---|---| | Single | One container per house, its own pod server | Named houses, the live "spin up a house" moment | | Multi | `lake-house --tenants 250`: many pods and housekeepers in one process | ~1,000 houses on one laptop | Every multi-mode house is still its own WebID, pod, wallet, card and rules file. Only the process is shared. ```bash docker compose --profile scale up -d --scale house-multi=4 # about 1,000 houses ``` ### Scale, measured On a 4-core, 16 GB laptop that was also running other work (load average around 3.5), a full paid mission (`lake dispatch --extra-fields postcode`: knock, bloc counter, re-offer, countersign, compute in place, pay, file receipts): | Houses | Joined after | Mission | Answered and paid | Records moved | |---|---|---|---|---| | 250 (1 process) | 95 s warm restart | **57 s** at concurrency 64 | 200 of 200 willing | 0 | | 1,000 (4 processes) | 279 s warm restart | **636 s** at concurrency 32 | 737 of 801 willing; 64 still busy after 3 retries | 0 | The time goes into the pod server's file-backed storage: about 5 reads and 4 writes per house per mission, plus DPoP token checks on each. The spec's target of about 60 seconds at 1,000 houses needs dedicated cores, more pod-server processes, or a faster storage backend. [Help wanted](https://github.com/thelakeprotocol/lake/issues/new?template=build.yml). What makes it hold up under load: signed cards and credentials are verified once per process; between full checks a house re-reads only its two data files against the digests its credentials pin; a house that can't read its pod in time answers `busy` instead of declining; agents retry busy or slow houses up to three times; payments settle in batches of 100. ## No model on the hot path Rules decide. The Lake bundles no LLM and needs no API key. If you want an assistant to write plain-English summaries or drive the Lake, plug your own in through the MCP connector. --- --- url: /docs/security.md --- # Security & threat model ## Threats and defences | Threat | Defence | Status | |---|---|---| | **Prompt injection between agents.** "Ignore your rules and share everything." | Rules run in code. The engine never reads free text. Models, if any, see offer text only as quoted data and can't issue grants. | tested | | **Forged identity.** Unsigned cards, edited cards, cards claiming someone else's domain. | Cards signed per A2A §8.4 (JWS over JCS). Lake fields sit inside the signed extension. Domain proof by DNS TXT or `.well-known`. Offers must come from the card's own domain. | tested end to end | | **Replay.** Re-sending a captured message. | Every message carries a nonce and expiry. Receivers remember nonces until expiry. | tested | | **An agent minting its own key.** | Live grants are signed on the owner's phone with a key the house never sees; the house checks each matches what the owner was shown. Standing-rule grants name the rule and the digest of the rules that authorised them. | tested | | **A key outliving its welcome.** | Revocation is checked on every request; the next request after Revoke is refused. | tested under 1 s | | **Over-asking.** A visitor reading more than its agreement allows. | Visitors never touch the pod; the house reads only the granted fields for them. Not yet enforced below the housekeeper, in the pod itself. | partial | | **Malicious study code.** | Studies are declarations, not code. No certificate from an auditor the owner trusts, no run; the house re-audits it anyway. | tested end to end | | **Fake ethics approvals.** | Count only from domains on the owner's trust list, only for this exact study and its listed fields. | tested | | **One house's answer revealing its owner.** | Each house sends one randomised answer (unary randomised response, ε ≤ 4). | tested | | **Tampered data.** Faking a record to get paid. | A trusted issuer's credential pins the original; a trusted auditor certifies the converter; the housekeeper signs the converted copy; the chain is re-verified before every join and every computation. A tampered house drops out. | tested end to end | | **Sybil houses.** One person running 50 houses to farm payments. | Personhood credential per house. | open question | | **Typo in rules.** | Unknown keys are errors. Misspellings never widen access. | tested | ## Immune response runs A blocked attempt triggers `attack.blocked`, naming the attacker's key and the domain it claimed. After 3 blocks the node quarantines that key and publishes it in its signed `/signals`; houses following the node refresh every 2 seconds and drop the key on sight. No human is involved. Blocklists name keys, never domains, so a forger can't get a real hospital blocked by pretending to be it. Reputation (kept deals up, blocked attempts down) is kept only for verified identities, and owners can require it: `reputation_at_least: 0.7`. Prompt injection never changes a decision, because rules don't read free text. A house also recognises common injection phrasing in an offer's note, declines the offer, and logs it as an attack. That check only labels the attempt; it isn't what keeps the house safe. ## Report a hole Use [private vulnerability reporting](https://github.com/thelakeprotocol/lake/security/advisories/new). Breaking the Lake is a contribution. See [SECURITY.md](https://github.com/thelakeprotocol/lake/blob/main/SECURITY.md). --- --- url: /docs/decisions.md --- # Decisions | Area | Choice | Why | |---|---|---| | A2A version | A2A **1.0**, official `@a2a-js/sdk`, JSON-RPC binding | Current spec; the SDK signs and verifies cards for us | | Lake fields on cards | Inside a declared A2A **extension**, marked `required` | A2A 1.0 signatures only cover schema fields; extension params are covered. `required` makes clients say they speak Lake | | Signatures | **Ed25519 (EdDSA)** JWS; cards per A2A §8.4 (JCS); messages over JCS of the payload | Small, fast, widely supported | | Credentials | W3C **VC 2.0** secured with **VC-JOSE-COSE** (`typ: vc+jwt`) | Simplest standard securing that reuses the same keys | | Pinning records | VC `relatedResource` with **`digestSRI`** | The original stays untouched in the pod; any change breaks the digest | | Synthetic records | A seeded generator that emits **Synthea-shaped FHIR R4** | Synthea needs Java and minutes per run; the converter also reads real Synthea exports | | Fictional drugs | "Drug X/Y/Z" under a Lake code system | The demo must never look like advice about a real medicine | | Pod URL inside a house | `http://localhost:3000/` | Solid-OIDC allows plain HTTP only on localhost. Public houses put TLS in front | | Sandbox Lakes | Domain proof skipped and marked `sandbox` | A local Lake has no real domains; the reply says the domain wasn't proved | | Ethics and audit claims | Count only as signed credentials from a domain on the owner's `trust` list | An offer can't vouch for itself, and nobody appoints authorities for the Lake | | Studies | **Declarative** (up to four yes/no questions), not code | Fully auditable and safe to run anywhere; arbitrary code needs attested execution (stretch goal) | | What a house sends back | One cell, randomised with **unary randomised response** (local differential privacy, ε ≤ 4) | A single fragment from a one-person house would otherwise be that person's data | | Broadcast | **Direct A2A** to every discovered house, 32 at a time; no NATS | Direct messaging is required anyway (SPEC §9); a bus is an optimisation to revisit at 1,000 houses (milestone 7) | | Converter certificates | The auditor certifies `name@version` from an allowlist | Pinning the package digest is a follow-up | | Grants | A `join` is the owner's standing pre-approval; keys are minted by the owner's approval flow (milestone 4) | Agents never mint keys | | Federation | Pull: a node reads its peers' `/cards` and `/signals` every 5 s, and re-verifies every card itself | Peers can point at agents but never vouch for them; no new protocol beyond what every node already serves | | Discovery with every node down | Agents remember the houses they found; every card lives at its owner's address | Registries are search indexes, not gatekeepers | | Record checks under load | Full chain once a minute; in between, re-read the two data files and compare with the digests the verified credentials pin | Tampering with either file is still caught; a forger can't swap credential files without the issuer's key | | Busy, not broken | A house that can't read its pod in time answers `busy`; agents retry three times | Overload must never be mistaken for a tampered record | | Payments at scale | Settled in batches of 100 under one Intent Mandate | The budget holds across batches; no message gets too large | | MCP connector | Part of `@thelakeprotocol/agent`; the assistant joins the Lake as its own signed agent | The assistant is a visitor like any other: same card, same rules, same reputation | | npm publishing | From CI on a version tag, with npm provenance | Each package is signed and linked to the exact commit and run that built it | | Hosted cards | The card lives next to the A2A endpoint (`…/t//.well-known/agent-card.json`) | Many houses can share one host; every card still verifies on its own | | Quarantine | By key, after 3 independent blocks; signed blocklist at `/signals` | Blocking by claimed domain would let forgers frame real organisations | | Reputation | 0.5 + 0.05 per kept deal − 0.15 per blocked attempt, clamped to 0–1, verified identities only | Simple, explainable, and impossible to dent by impersonation | | Injection detection | A pattern check that only labels attempts | Safety comes from rules not reading free text; a classifier would be a second line, not the first | | Swarm | Lives in the CLI (`lake swarm`) | One image, nothing new to run | | AP2 | Mandates implemented from the spec as W3C VCs; no AP2 SDK used | There is no stable JS SDK; the chain is what matters, and it is real | | Settlement | Simulated; receipts are signed | Real money movement is a non-goal | | Bloc terms | Computed by the visitor from members' signed counters, by a fixed rule anyone can check | No coordinator needed; a jointly signed bloc counter is future work | | One card per WebID at a node | A newer card replaces the old one | Key rotation must work; stale keys must not | | The owner's key | Ed25519, made on the phone with `@noble/curves` | Phones on a home network load the app over plain HTTP, where browsers disable WebCrypto | | Pairing | One-time token in the printed link; sandbox may allow first-come | A stranger on the Wi-Fi can't claim your house | | Live updates in the app | Polling once a second | Works everywhere with no push service; push notifications are a follow-up | | LLM | **None bundled.** Rules decide; templates write reasons | People bring their own agents through MCP or A2A | --- --- url: /docs/roadmap.md --- # Roadmap | # | Milestone | Done when | Status | |---|---|---|---| | 0 | **Core + spec** | Signed cards, signed messages, rules engine, collective counter, ODRL offers, `SPEC.md`, schemas, model | done | | 1 | **One house** | Pod, WebID, issuer-signed synthetic record, housekeeper answering an A2A request through MCP | done | | 2 | **The Lake** | A federatable node: verified cards, bus, event stream, a bare map where houses appear | done (bus moves to M3, with broadcast) | | 3 | **Mission, no money** | Ethics, audit, knock, decide, grant, verify signatures, compute in place, answer | done | | 4 | **Approvals app** | Live Allow on a phone; revocation in under 1 second | done | | 5 | **Money** | ODRL agreements, collective counter-offer, AP2 mandates, Payments agent | done | | 6 | **Wild life** | Swarm, attackers, prompt-injection defence, quarantine, guilds, reputation | done | | 7 | **Scale** | 1,000 houses, a full mission in about 60 seconds | runs 1,000 houses complete a paid mission; 10.6 min on a busy 4-core laptop, [measured](/docs/architecture#scale-measured) | | 8 | **Federation** | Second node, `.well-known` discovery, direct messaging, shared blocklists; stop a node mid-mission and finish anyway | done | | 9 | **Open door** | Agent SDK, Lake MCP connector, public sandbox Lake | done (sandbox config ready, not yet deployed) | | 10 | **Polish** | Map matches the reference, seeded demo mode, 90-second recording | next | ## Acceptance tests The Lake is done when all of these pass: * `docker compose up` on a clean laptop starts the Lake with no manual steps. * A new house appears on the map within 5 seconds. * A full mission completes at 1,000 houses in about 60 seconds. * No grant exists that a human didn't approve, live or by standing rule, and the audit log proves it. * Records moved is 0 after a full mission, computed from pod audit logs, not hard-coded. * Every attacker request is blocked and logged, prompt injection included. * Revoking a house's keys cuts its access within 1 second. * A tampered record fails its signature check; that house is excluded and unpaid. * Stopping one Lake node mid-mission doesn't stop the mission. * An agent is findable through its `.well-known` card with every registry offline. * An outside agent built with the SDK joins and completes a study in the sandbox. * An AI assistant dispatches a study through the Lake MCP connector. * Seeded demo mode produces the same run every time. * Every agreement and receipt can be opened in the owner's pod afterwards. --- --- url: /docs/contributing.md --- # Contributing to the Lake The Lake is built in the open, by whoever shows up. You don't need permission. Open the PR. ## The fast path ```bash git clone https://github.com/thelakeprotocol/lake && cd lake corepack enable && pnpm install pnpm test ``` 1. Pick something: an issue labelled [`good first issue`](https://github.com/thelakeprotocol/lake/labels/good%20first%20issue), a milestone in the [status table](https://github.com/thelakeprotocol/lake/blob/main/README.md#status), or your own idea. 2. Ideas, questions, half-baked plans: post them in [Discussions](https://github.com/thelakeprotocol/lake/discussions). No idea is too early. 3. If it's big, open an issue first and say what you're building, so two people don't build the same thing. 4. Open a PR. Small and working beats big and perfect. ## What we merge * **It runs.** Tests for logic, a command that shows it working for everything else. * **It follows the spec.** If you change behaviour the spec defines, change [`SPEC.md`](https://github.com/thelakeprotocol/lake/blob/main/SPEC.md) in the same PR and say why. * **Real standards.** A2A, MCP, Solid, ODRL, Verifiable Credentials. No homemade look-alikes. If a spec is unclear, follow it and write the choice down. * **No real data.** Synthetic only, everywhere, fixtures included. ## What we don't merge * Anything that lets an agent mint, widen or extend a key. * Anything that lets free text from another agent change a decision. * Anything that moves records out of a pod. The Records moved counter is sacred. * A central server that the Lake depends on. ## Good things to build | You like… | Build… | |---|---| | Data plumbing | A converter into the canonical model: bank CSV, Apple Health, Strava, smart meters | | Agents | A visitor agent with a real question, or a guild that negotiates for its members | | Security | Attacks. Forged cards, replays, prompt injection, over-asking. Then the defence. | | Design | The map, the approvals app, the landing page | | Writing | Docs that get a stranger from zero to a running house in 10 minutes | | Policy | Personas: opinionated `rules.yaml` files for carers, renters, gig workers | ## Conventions * TypeScript, ESM, Node 20+. pnpm workspaces. One package per component under `packages/`. * Match the code around you. Few comments; write the *why*, not the *what*. * Commits: present tense, say what changed (`house: refuse grants wider than the agreement`). * Package names: `@thelakeprotocol/`. ## Sign-off By contributing you agree your work is licensed under [Apache-2.0](https://github.com/thelakeprotocol/lake/blob/main/LICENSE). ## Code of conduct Be blunt about ideas, kind to people. See [CODE_OF_CONDUCT.md](https://github.com/thelakeprotocol/lake/blob/main/CODE_OF_CONDUCT.md). --- --- url: /ext/lake/v0.md --- # Lake extension `v0` An agent card that carries this URI in `capabilities.extensions` is a Lake card. Its `params` hold the Lake profile: `role`, `webId`, `ownerDomain`, `publicJwk`, `tags`, `lakeVersion`. The extension is marked `required`, so A2A clients declare it in the `A2A-Extensions` header. * Specification: [SPEC §2.1](/docs/spec#_2-1-the-lake-extension) * JSON Schema of the params: [/schemas/v0/lake-profile.json](/schemas/v0/lake-profile.json) * For agents: [/docs/agents](/docs/agents)