§4 · Progressive disclosure

Privacy as a type, not a policy.

Every intent lives in three shapes. The shape a caller receives depends on their relationship to the intent. The bytes they aren't supposed to see are never in the response.

For: anyone who's seen a privacy policy quietly change.

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 1 · PUBLICTIER 2 · ENGAGEDTIER 3identityaddresscategorybudgetmetrourgencytagsdescriptionattributesconstraintszip
TierWho sees itWhat's inWhat's out
Tier 1 · PublicAny caller. No auth.category, budget (min/max), urgency, metro, tags, statusdescription, attributes, constraints, ZIP, identity, address
Tier 2 · EngagedVendor whose offer was accepted.everything in Public, description, attributes, constraints, ZIPidentity, street address, payment details
Tier 3 · TransactionalVendor on confirmed purchase only.everything in Engaged, identity, full addressnothing

Tier 1Public. Served from the registry endpoints with no auth.

Tier 2Engaged. Requires an x-session-token header issued when the offer is accepted. Scoped (vendor, intent), expires in 24h.

Tier 3Transactional. 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 · toTier1
export 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 · toTier2
export 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 · toTier3
export 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.

  1. Buyer creates the intent. Pink-sink running example. autoApproveBelow: 300.
  2. Registry shows Tier 1. Vendors browsing the registry see category, budget, urgency, metro, tags. No description, no ZIP, no identity.
  3. Vendor submits an offer. price: 280, deliveryDays: 2, attributes on spec.
  4. Auto-accept fires. Score 82, price under autoApproveBelow. The server creates st_abc123…, binds it to (vendor, intent), scoped "engaged", and returns it with the offer.
  5. Vendor unlocks Tier 2. GET /api/registry/intents/:id/details with the x-session-token header returns the full intent — now with description, all attributes, constraints, and ZIP.
  6. Still hidden. The buyer's identity, street address, and payment info never appear in the Tier 2 response, even with the token.
  7. Purchase confirmed. A new session token issues with tier: "transactional". Only now does toTier3 return identity and full address.
intent.ts on GitHub