The problem with policy-level privacy
A privacy policy is a promise a server makes. It can be changed by a config flip, bypassed by a misrouted debug endpoint, ignored by a new hire, leaked in a JSON dump, rescinded by an acquirer. Every layer of software above the policy is a place the promise can come undone.
Protocol-level privacy is a guarantee the data schema makes. The Tier 1 shape has no description field. The server cannot leak a description from the Tier 1 endpoint because the object does not contain one. There is nothing to redact, because nothing was ever serialized.
This is the same reason end-to-end encrypted messengers beat corporate-promise messengers — the stronger the mechanical guarantee, the less trust is load-bearing.
Three tiers
| Tier | Who sees it | What's in | What's out |
|---|---|---|---|
| Tier 1 · Public | Any caller. No auth. | category, budget (min/max), urgency, metro, tags, status | description, attributes, constraints, ZIP, identity, address |
| Tier 2 · Engaged | Vendor whose offer was accepted. | everything in Public, description, attributes, constraints, ZIP | identity, street address, payment details |
| Tier 3 · Transactional | Vendor on confirmed purchase only. | everything in Engaged, identity, full address | nothing |
Tier 1 — Public. Served from the registry endpoints with no auth.
Tier 2 — Engaged. Requires an x-session-token header issued when the offer is accepted. Scoped (vendor, intent), expires in 24h.
Tier 3 — Transactional. A new transactional-scope session token is issued when the purchase is confirmed.
How the filter works
Tier filters are pure functions. Given an intent, they produce a new object containing only the fields that caller tier is allowed to see. Route handlers call them before serialization, so omitted fields never leave the process.
src/schema/intent.ts · toTier1export function toTier1(intent: Intent): Tier1Intent {
return {
id: intent.id,
category: intent.category,
budget: {
min: intent.budget?.min,
max: intent.budget?.max,
currency: intent.budget?.currency,
},
urgency: intent.urgency,
metro: intent.location?.metro,
tags: intent.tags,
status: intent.status,
createdAt: intent.createdAt,
// Omitted: description, attributes, constraints, zip, userId,
// autoApproveBelow. These never leave the server at Tier 1.
};
}src/schema/intent.ts · toTier2export function toTier2(
intent: Intent,
token: SessionToken
): Tier2Intent {
assertTokenMatches(token, intent); // throws 403 on mismatch / expiry
return {
...toTier1(intent),
description: intent.description,
attributes: intent.attributes,
constraints: intent.constraints,
zip: intent.location?.zip,
// Still omitted: userId, street address, payment details.
};
}src/schema/intent.ts · toTier3export function toTier3(
intent: Intent,
token: SessionToken
): Tier3Intent {
assertTokenMatches(token, intent);
assert(token.tier === "transactional", 403);
return {
...toTier2(intent, token),
userId: intent.userId,
address: intent.location?.address,
// Payment details belong to the payment integration, not this API.
};
}Session tokens
A session token is the key that unlocks Tier 2 for a specific (vendor, intent) pair. It's short, scoped, and time-boxed.
SessionTokenFull shape, issued on offer acceptance.{
"token": "st_abc123def456",
"vendorId": "ven_abc",
"intentId": "int_7f3a1b2c",
"tier": "engaged",
"issuedAt": "2025-11-22T19:11:05Z",
"expiresAt": "2025-11-23T19:11:05Z"
}- Format:
st_<uuid>. Length and character-class make it unambiguous in logs. - Created: on offer acceptance. A fresh token per acceptance — vendors cannot reuse tokens across intents.
- Tier:
"engaged" | "transactional". The second tier is reissued on confirmed purchase. - Expires: 24 hours after issue. Purchases that take longer require a token refresh (planned).
- Validated: on every request to
GET /api/registry/intents/:id/details. Mismatched vendor, mismatched intent, or expired token →403.
A worked example
One intent's journey, from public registry to transactional unlock.
- Buyer creates the intent. Pink-sink running example.
autoApproveBelow: 300. - Registry shows Tier 1. Vendors browsing the registry see
category, budget, urgency, metro, tags. No description, no ZIP, no identity. - Vendor submits an offer.
price: 280,deliveryDays: 2, attributes on spec. - Auto-accept fires. Score 82, price under
autoApproveBelow. The server createsst_abc123…, binds it to(vendor, intent), scoped"engaged", and returns it with the offer. - Vendor unlocks Tier 2.
GET /api/registry/intents/:id/detailswith thex-session-tokenheader returns the full intent — now with description, all attributes, constraints, and ZIP. - Still hidden. The buyer's identity, street address, and payment info never appear in the Tier 2 response, even with the token.
- Purchase confirmed. A new session token issues with
tier: "transactional". Only now doestoTier3return identity and full address.