Value Map API: Needs, Capacities, Slices and Commitment Discovery¶
Status: Working reference implementation of WP-012 steps 1 and 2
Version: 0.1
Date: September 2026
Implements: WP-012 – The Value Map
Needs and capacities recorded on a node, shared as verifiable slices, and made findable by commitment without publishing a catalogue of what the node holds.
Built: map items, slices, commitments, local matching, peering, forwarding across hops with path proofs, introductions in which an offer travels to the far end by several routes at once, and agreements that become WP-010 contributions or supplier agreements.
1. Record what you have spare, and what you lack¶
POST /map/items
{
"item_type": "capacity", // capacity | need
"item_class": "covered_workshop",
"title": "Two bays at Harbour Court",
"quantity": 2, "unit": "bays",
"region": "GCC-E", // coarse on purpose
"available_from": "2027-01-15",
"asset_id": "…", // optional: ties it to an asset (WP-011)
"discoverable": false // opt in when you are ready
}
GET /map/items lists your own. PATCH /map/items/{id} changes discoverability, status or quantity. DELETE removes it. Nobody else can read, change or delete your items.
Discoverability is opt-in and reversible. An item that is not discoverable never matches anything, whoever asks.
2. See yourself as a searcher sees you¶
GET /map/items/{id}/commitment
{
"discoverable": true,
"epoch": "E2952",
"coarse_attributes": { "item_type": "capacity", "item_class": "covered_workshop",
"region": "GCC-E", "period": "2027-Q1" },
"commitment": "9f2c…"
}
Those four attributes are all a searcher can match on. Not the quantity, not the description, not the site, not who holds it. Availability is rounded to a quarter.
3. Ask whether anything matching exists¶
GET /map/discovery # public: the epoch, the salt, the attributes, the limits
POST /map/query
{ "item_type": "capacity", "item_class": "covered_workshop",
"region": "GCC-E", "period": "2027-Q1" }
The answer is match or no match:
{ "match": true, "node_id": "riyadh-node-1", "k_anonymity": 2,
"note": "Something matching exists on this node. Ask for an introduction; the holder decides." }
No contents, no identities, no quantities. You may also pass a commitment you formed yourself.
What protects the map from being enumerated, given that the attribute space is small:
| Control | Effect |
|---|---|
| No listing endpoint | Commitments are answered, never published in bulk. A node cannot be scraped for a catalogue |
| k-anonymity (default 2) | A node answers only where at least k of its items share the commitment, so a match never points at a single item |
| Rate limit (default 120 an hour, per caller) | Probing the attribute space is slow and visible |
| Rotating epoch salt (default 7 days) | Commitments collected in one period do not carry into the next |
| Opt-in per item | What should not be findable simply is not |
These are published at GET /map/discovery and set in ledgers/value_assurance.yaml under discovery.
4. Local matching first¶
GET /map/matches/{item_id}
{ "looking_for": "need", "matches_on_this_node": 1,
"note": "Counts only. Ask the holder for an introduction to go further." }
The nearest capacity is often on the same node. This looks for the opposite kind of item with the same commitment, and returns a count, never the items.
5. Share a slice¶
POST /map/slice
{ "asset_ids": ["…"], "include": ["capacity"], "purpose": "introduction to a lender" }
A slice is a document (profile: can.map.v1) with the same properties as the value documents in the Working API:
- every item carries a salted hash, and the root covers them all;
includediscloses chosen record types (capacity,need,asset) and withholds the rest while keeping their hashes, so the receiver knows nothing was quietly removed;- signed by the node when
NODE_SIGNING_KEYis set; - verified by anyone at
POST /value/documents/verify, with no account on the issuing node.
You can only share your own items and assets; trying to include someone else's is refused.
5a. Peering and forwarding across hops¶
A node talks only to peers it has deliberately added. Peering is an operator's act:
POST /map/peers { "node_id": "node-b", "public_key": "…", "base_url": "https://…",
"trust_weight": 0.8 } # can_admin only
GET/PATCH/DELETE /map/peers[/{id}] # suspend, reweight, remove
Then a query can travel:
POST /map/query/federated
{ "item_type": "capacity", "item_class": "covered_workshop",
"region": "GCC-E", "period": "2027-Q1", "max_degree": 3, "min_confidence": 0.1 }
{ "found": 2,
"results": [
{ "node_id": "node-b", "path": ["node-a","node-b"], "degree": 1, "confidence": 0.48, "match": true },
{ "node_id": "node-c", "path": ["node-a","node-b","node-c"], "degree": 2, "confidence": 0.144, "match": true }
],
"note": "Each result is a path and its confidence. To go further, ask the nodes on the path for an introduction." }
A result is a path and a confidence: how far away a match is, through which nodes, and how much the chain of trust weights supports. Nothing about what was found, whose it is, or how much of it there is.
Confidence is the product of the trust weights along the path, discounted once per hop (hop_decay, 0.6 by default). Four hops of weak links are correctly worth very little.
What each hop enforces¶
| Rule | How |
|---|---|
| Peering is by relationship | A node answers and forwards only for peers it has added; strangers get 403 |
| Requests are signed | Each hop signs the body with its node key; the receiver verifies against the peer's public key on file |
| No loops | A node already in the path neither answers nor is called again, in both directions |
| Hops are bounded | The time to live decrements per hop and is capped by the receiving node's own max_degree_limit, whatever the sender asked |
| Each peer is rate-limited | Per-peer hourly limit, separate from the per-caller limit |
| Everything is logged | GET /map/queries (operator or auditor) shows direction, peer, path, time to live and result, with the commitment recorded as a fingerprint, so the log does not reveal what was sought either |
A node with no signing key cannot forward: it can answer for itself, but it cannot speak in anyone's name.
POST /map/peer/query # peer-to-peer; signed envelope, no user account involved
Path proofs: a degree cannot be shortened¶
Without proofs, a node in the middle can say whatever it likes: there is a match, one hop away, through me. That is how a helpful-looking intermediary becomes a toll gate. Every result carries a proof, and the asking node verifies it:
- a match attestation, signed by the node that actually holds a match, over the query id, the commitment and its own node id. Nobody else can produce it without that node's key;
- a chain of hop attestations, one per relay, each signing the node it received the answer from and a hash of the inner proof, so a link cannot be dropped without breaking the chain.
Each result comes back with proven: true|false and, where it fails, proof_problems saying why. Ask with proven_only: true to drop anything that does not stand up.
| Attempt | What happens |
|---|---|
| Claim a match at a node you do not control | Refused: the attestation is not signed by the node the path ends at |
| Shorten a three-hop path to one | Refused: the claimed path needs fewer relay signatures than the chain carries |
| Forge a peer's signature | Refused: the key does not match the one on file for that peer |
| Replay a proof from an earlier question | Refused: attestations are bound to the query id |
| Drop a link from the middle | Refused: the next signature commits to the hash of what it received |
| Answer without signing at all | Carried, but marked unproven — a node with no key can still take part |
An intermediary can still invent extra nodes beyond itself, which only makes a path look longer and weaker. What it cannot do is appear closer than it is, or speak for a node whose key it does not hold.
5b. Introductions: the offer travels, the far end decides, the near side commits¶
A path tells you a match exists. An introduction is how the two ends actually meet.
POST /map/introductions
{ "paths": [["node-a","node-b","node-c"], ["node-a","node-d","node-c"]],
"item_type": "capacity", "item_class": "covered_workshop",
"region": "GCC-E", "period": "2027-Q1",
"message": "We need covered space for an 18-month refit.",
"offer": "Refit work in kind, or rent, whichever suits." }
Three things make this different from a ping:
- It is an offer, not a request for attention.
offeris required and travels with the message. The far end decides knowing what is on the table. - It travels by itself, and by several routes at once. Relays carry offers without being asked (
relay_policy: auto). Give more than one route and the offer takes them all, so one hop cannot stop it. The same offer arriving twice is recognised and decided once. - The far end decides. Only a holder of the matching items — or the node's operator — can answer. Accepting means choosing how to be reached, and optionally sharing a verifiable slice of exactly what they choose.
POST /map/introductions/{id}/decision
{ "accept": true, "reply_contact": "harbour@example.org", "share_items": ["…"] }
Then the near side stands behind its offer:
POST /map/introductions/{id}/commit
{ "contact": "boatbuilders@example.org", "note": "we will start in January" }
The far end learns who it is dealing with only when the asker commits. Until then it sees the offer and not the asker; after commitment, the contact travels up the route that worked.
Carrying builds connection value; not carrying is a missed chance¶
| What happens | What it does |
|---|---|
| A peer carries an offer | carried_count rises |
| An offer it carried ends in an acceptance | connections_count rises and its trust weight increases (connection_weight_gain, 0.05 by default) |
| A peer will not carry, or cannot be reached | missed_count rises. Nothing is deducted |
There is no penalty for refusing. A node that does not carry simply does not build weight, and because weight is what later paths are ranked by, traffic gradually flows through the nodes that connect people. Value flows where connection flows.
A node may set relay_policy: review and decide each request by hand. It may; and a refusal is recorded in its own name (blocked_by), and travels back to the asker. Holding things up is a choice anyone can see.
5c. From a commitment to a stake¶
An agreement should not evaporate into an email. Once an introduction is committed on both sides, either party records what was agreed:
POST /map/introductions/{id}/agreement
{ "kind": "contribution", // contribution | access | supply
"terms": "400 hours of refit work over 18 months",
"value": 60000, "currency": "USD" }
That produces a signed agreement document (profile: can.agreement.v1) which both sides keep and anyone can verify, carrying the terms, the original offer and which introduction it came from.
Then the project's sponsor turns it into a stake (WP-010):
POST /map/agreements/{id}/link
{ "project_id": "…", "counterpart_user_id": "…", "cash_share": 0.8 }
| Agreement kind | Becomes | In WP-010 |
|---|---|---|
contribution |
An in-kind contribution | earns participation units once accepted |
access |
A pre-committed use contribution | earns an access right on agreed terms |
supply |
A supplier agreement | sets the split between cash and a verified stake |
Both are created as proposals: the sponsor still decides the valuation, and a supplier still chooses whether to take part of the margin as a stake. Recording is done by a party to the introduction; attaching to a project is done by that project's sponsor; an agreement links once.
Across nodes, honestly¶
POST /map/agreements/import takes the other side's document, verifies it, and stores it. It does not create a contribution there. A contributor on a node needs an identity someone local has vouched for, and a document arriving over the network is not that. So the agreement travels; turning it into a stake is a deliberate local act by a local party.
6. Endpoints¶
| Method | Path | Who |
|---|---|---|
| GET | /map/discovery |
anyone, unauthenticated: the terms for forming a query |
| POST/GET | /map/items |
the holder |
| PATCH/DELETE | /map/items/{id} |
the holder |
| GET | /map/items/{id}/commitment |
the holder: what a searcher can see |
| POST | /map/query |
any signed-in person or agent, rate-limited: this node only |
| POST | /map/query/federated |
any signed-in person or agent: this node and its peers, returning paths |
| GET | /map/matches/{id} |
the holder: counts of matching items on this node |
| POST | /map/slice |
the holder |
| POST/GET/PATCH/DELETE | /map/peers[/{id}] |
the node operator (can_admin); auditors may read |
| POST | /map/peer/query |
a peered node, by signature; no user account |
| GET | /map/queries |
operator or auditor: what this node was asked, and by whom |
| POST/GET | /map/introductions |
any signed-in person or agent: make an offer, see yours and any waiting on you |
| POST | /map/introductions/{id}/decision |
the far end: a holder of the matching items (or the operator); a relay in review mode: the operator |
| POST | /map/introductions/{id}/commit |
the asker, once the far end has accepted |
| POST | /map/peer/introduction, /reply, /commit |
a peered node, by signature |
| POST | /map/introductions/{id}/agreement |
a party to the introduction |
| GET | /map/agreements |
the recorder; operators and auditors see all |
| POST | /map/agreements/{id}/link |
the project's sponsor: turns it into a WP-010 stake |
| POST | /map/agreements/import |
any user: store the other side's verified copy |
7. What is deliberately not here¶
- No listing of discoverable items. Being findable is not the same as being published.
- No automatic introductions. A match tells a searcher to ask. The holder decides whether to answer, and what slice to share.
- No contact without consent. An offer travels on its own, but nobody's details do. The far end reveals contact only by accepting; the asker only by committing.
- No protection against invented extra nodes. A proof stops a path being shortened or a match being borrowed; a node may still pad a path with nodes of its own, which only makes it look further away.
- No scoring of people. Connection value sits on peer nodes, from what they carried and connected. Nothing ranks holders, and matching is on attributes, never on reputation.
8. Next¶
- Shared rate-limit storage, since the current limiter is per process.
- Expiry sweeping: offers past their time to live are treated as expired when read, but nothing clears them yet.
- Connections as signed objects — see below. That is what closes the last two gaps.
Where the remaining gaps actually close¶
Two things are still awkward, and both come from the same root:
- Cross-node identity. An agreement travels, but the stake is created locally, because a contributor here needs an identity someone here has vouched for.
- Reciprocity. Carrying queries and offers costs something, and at scale goodwill is not a mechanism.
Neither is really a protocol gap. They close when the connections themselves are signed, portable objects rather than rows in one node's database: an edge that carries its own identity, terms and provenance can be verified by whoever receives it, so a counterpart needs no local account to be named in a contribution; and a hop's carrying can be recorded as an object in its own right, which settles later like any other contribution (WP-010) instead of depending on reciprocal favour.
The framework already leaves the seam: profile is a field on every document, and the value, map and agreement profiles here are one implementation of the idea. A deployment may carry another object format — including a proprietary one — through the same endpoints, without changing the protocol.
Tests: backend/tests/test_value_map.py covers recording items, opt-in discoverability, commitment formation matching between holder and searcher, match and no-match answers carrying no contents, k-anonymity suppressing a single-item match, rate limiting, local matching by count, slice verification, redaction that still verifies, tamper detection, and the refusal to share what is not yours.