CRAP

CRAP — Conditional Resource Access Protocol

HTTP can say yes. It can say no. It can't ask a question.

CRAP adds the third answer: hold on, I have some questions. A server responds to an otherwise-valid request with a machine-readable set of questions. The client answers them. The original request is retried and succeeds.

GET /v1/records                    →  430 Input Required + questions
POST /v1/records (answers)         →  204 + Input-Proof
GET /v1/records (Input-Proof: …)   →  200 OK

The questions are whatever you want. Purpose of use. Retention period. Which model is calling. Whether a human approved this. Which licence is being accepted. Whether it's a dry run. The protocol carries JSON Schema; it doesn't care what you ask.

Status: experimental, v0.2, nothing is registered with IANA yet. The normative wire format is 403 + application/problem+json; 430 is an optional negotiated profile and a provisional squat on an unassigned code. See SPEC.md.

Install

npm install @thomasdavis/crap-server   # protecting a resource
npm install @thomasdavis/crap-client   # calling one

Server

import { CrapServer, allow, inputRequired } from '@thomasdavis/crap-server';

const crap = new CrapServer({
  issuer: 'https://data.example',
  secret: process.env.CRAP_SECRET,

  evaluate(ctx, satisfied) {
    if (satisfied?.answers.purpose?.value === 'model_training') {
      return deny('this collection is not licensed for training');
    }
    if (satisfied) return allow();

    return inputRequired([
      {
        id: 'purpose',
        kind: 'declaration',
        actor: 'client',
        interaction: 'inline',
        message: 'What is this data for?',
        reason: 'The collection has purpose-specific access conditions.',
        required: true,
        schema: {
          type: 'string',
          enum: ['academic_research', 'commercial_product', 'model_training'],
        },
      },
    ]);
  },
});

// Anywhere you have a request:
const result = await crap.handle(ctx);
if (result.kind === 'allow') { /* serve it; result.satisfied has the answers */ }
else { /* send result.response */ }

Or as Express/Node middleware:

app.use('/v1/records', crapMiddleware(crap, { origin: 'https://data.example' }));

Client

import { crapFetch, answer, decline } from '@thomasdavis/crap-client';

const res = await crapFetch('https://data.example/v1/records', {
  resolver: {
    declaration: (req) => {
      if (req.id === 'purpose') return answer('academic_research');
      return decline('not something I disclose');
    },
    approval: (req) => askTheHuman(req.message),
  },
});

The client decides. Nothing in a challenge can make it answer a question your policy didn't approve — and a challenge that fishes for system prompts, keys or conversation history is refused outright before your resolver ever sees it.

Four facets, not four modes

A requirement is described independently along four axes, because form/proof/approval/url were never peers (OAuth is out-of-band, user-mediated and evidence-producing all at once):

Facet Values
kind declaration · evidence · approval · task
actor client · user · organization · third_party
interaction inline · out_of_band
binding none · client_key · user_identity · organization_identity

Secrets never travel inline — inline answers pass through the agent's context window. That's what out_of_band is for.

task (work as a condition of access) is experimental and separated on purpose: it has a different cost and abuse profile, must declare limits.max_duration_ms, and the reference client refuses it by default.

Evidence classes: what an answer is actually worth

An agent saying "I promise not to train on this" is not evidence. It's a string. Every accepted answer carries a class describing how it was established:

Class Meaning
self_asserted the client said so — all declarations, all task output
client_signed signed by an identified client key
delegated a user or organisation conferred the authority
independently_verified the server checked it with the issuing authority
third_party_attested a recognised third party vouches

These are not ranked. independently_verified is not "less than" third_party_attested; delegation answers a question about authority, not identity. A server lists what it accepts and membership is checked:

accepted_evidence: ['delegated', 'independently_verified']

Your verifier assigns the class, and rejects everything by default, because an unverified proof is just a string that says "proof":

new CrapServer({
  verifyEvidence: async ({ request, answer }) => {
    if (request.id !== 'authority') return null;
    if (!(await checkDelegation(answer.proof))) return null;
    return { class: 'delegated', authority: 'organization-delegation',
             verification: 'issuer-verified' };
  },
});

Two profiles, so you can ship today

Registering an HTTP status code takes years. So:

The server picks automatically. Clients detect a challenge by the problem type, never by the status code, so 430 staying unregistered forever costs nothing.

Security properties (and the tests that hold them)

Every one of these is covered in test/protocol.test.mjs:

npm test   # 28 tests, real HTTP, no mocks
npm run example

What this is not

Not authentication (401). Not payment (402, x402). Not a CAPTCHA — it does not try to tell humans from machines, and it never should. Not a truth oracle: it moves claims and evidence, and grades them honestly.

Contributing

This is a v0.1 protocol sketch with a working implementation, and the design space is wide open. Ideas, objections, and "you've got this wrong because…" are all welcome — open an issue.

Especially wanted: interesting requirements to put in front of an agent, evidence-type integrations (Web Bot Auth, verifiable credentials, OAuth token exchange), a Rust/Go/Python implementation, a gateway that speaks CRAP in front of an API that doesn't, and arguments about the open questions at the end of the spec.

MIT.