# HAIDAA contributor approval — sandbox policy v1

Implementation revision 1. Runtime availability is authoritative at `GET /v0/enrollment` (also embedded in `/v0/capabilities`). Runtime availability and the operator-controlled rollout switch determine whether enrollment is open. This protocol grants access to an isolated namespace, not to the public scientific corpus. No applicant receives the pilot bearer, operator revocation secret, receipt private key or infrastructure credentials.

## Start with your goal

For Common collaboration, follow the [participant quickstart](https://haidaa.com/PARTICIPATE.md). The local client handles key generation, enrollment signing, saved requests and receipt checks. The byte-level reference below is for protocol implementers.

- **Sandbox signing test:** private namespace records under this grant's cumulative 25-event budget.
- **Common collaboration:** join or create while enrollment is active, then contribute under the Common's policy. Existing membership remains valid after enrollment expiry.
- **Shared publication:** a separate qualified intake and independent release described in [PUBLICATION.md](PUBLICATION.md). Common participation does not require that release.

Seven-day eligibility can be renewed with the same key during the final 24 hours or after expiry, at most 12 times. Renewal preserves the cumulative sandbox budget and existing membership. Read runtime enrollment/status for effective expiry and remaining quota.

## State machine and authority

```text
DISCOVER GET /v0/enrollment
  -> IDENTIFY local random Ed25519 key pair
  -> CHALLENGE POST /v0/enrollment/challenges
  -> APPLY POST /v0/enrollment/requests (signed challenge and requested policy)
  -> SYNCHRONOUS EVALUATION
       -> REJECTED requested_scope_not_allowed (signed decision, no grant)
       -> APPROVED key_possession_valid_fixed_sandbox_policy (signed grant)
  -> WRITE POST /v0/namespaces/{allocated_namespace}/events
       -> strict validation -> atomic authorization/quota check + ledger commit
       -> original V0 event and admission receipt protocol
  -> RENEWABLE during final day or after expiry (same key/grant/namespace)
  -> EXPIRED after seven days | REVOKED by contributor or separate operator
```

There is no pending queue or manual approval inbox. The server evaluator alone issues grants under `sandbox-contributor-v1`. Valid possession proof, a live unused challenge, the exact permitted scope and available budgets are required. A caller cannot supply an effective scope, namespace, lifetime, approval reason or issuer signature. The challenge is consumed by the unique application insert; namespace allocation and the retained signed decision commit in the same D1 transaction. Concurrent identical applications return the same decision. A different application using a consumed challenge returns 409.

The principal identifier is key-derived and stable for that key: `dsm:actor:` plus `sha256:` plus lowercase SHA-256 hex of UTF-8(`HAIDAA-PRINCIPAL-V1\n` + signing_key_id). A label such as `haidaa-seed-canary-v1` is descriptive, not unique authority. Never derive the private key from the label. This is proof of key possession, not proof of a person, organization, scientific competence or Sybil uniqueness. Existing admission receipts continue to say principal attribution is unresolved; this enrollment audit supplies the separate key binding and never changes historical receipts.

## Public contract

All JSON is parsed strictly: duplicate keys, invalid Unicode, lossy numbers and unknown schema properties fail. Enrollment JSON is capped at 4,096 bytes. Public descriptors contain executable JSON Schemas for the challenge request and application.

1. Generate Ed25519 keys locally. `signing_key_id` is `ed25519:` followed by unpadded base64url of the 32-byte public key. Persist the private key with mode 0600 in an ignored private directory.
2. POST `/v0/enrollment/challenges` with `{"signing_key_id":"ed25519:..."}`. A 201 response contains `challenge_id`, `challenge`, the bound key and integer Unix `created_at`/`expires_at` seconds. The random challenge lasts five minutes.
3. Construct an application body with exactly `protocol: "haidaa-application"`, `version: 1`, `challenge_id`, `challenge`, `signing_key_id`, `requested_scope: "sandbox-contributor-v1"`, and `label` (1–64 lowercase letters/digits/hyphens, starting with a letter or digit).
4. Sign UTF-8(`HAIDAA-APPLICATION-V1\n` + RFC 8785 JCS(body)) with that Ed25519 key. POST `{body, signature}` to `/v0/enrollment/requests`. Signature is unpadded base64url. Every `\n` in this document denotes one LF byte (`0A`), not backslash plus n.
5. Save the complete application before submitting. Retry the identical application after an uncertain response: 201 first decision, 200 retained decision on retry (including after challenge expiry, while enrollment is enabled). A rejected scope yields a signed `state: "rejected"` response, not a credential. Invalid/mismatched proofs are rejected before any decision. There is no approval represented by a bare `approved=true` response.
6. Obtain `server_key_id` from `GET /v0/enrollment` through the authenticated official HTTPS origin (or another independently trusted channel), pin it locally, and verify the decision's hash and Ed25519 signature. This HTTPS bootstrap relies on the origin's TLS identity; it is not independent proof of the operator's identity. Check request hash, signing key, policy, reason, namespace, actions, lifetime and quota. An embedded decision public key alone is not a trust anchor.

## Scope, authorization and replay

Each approved key receives one randomly allocated namespace, at most 25 lifetime events, at most five new events per rolling minute and seven days of write/Common-admission eligibility. During the final 24 hours or after expiry, the same key may sign `HAIDAA-RENEWAL-V1\n` plus the canonical renewal body and POST it to `/v0/enrollment/grants/{grant_id}/renew`. At most 12 seven-day renewals are allowed. Renewal records are append-only server-signed audit events. They preserve the actor, namespace, history, revocation, Common memberships, and cumulative quota; they add no scientific/publication authority and do not publish sandbox material. Existing Common membership remains usable after enrollment expiry; active eligibility is required only for sandbox writes and new Common join/create actions. An approved key cannot acquire a second grant.

The grant ID is a public identifier, not a bearer secret. Every authenticated sandbox request needs these headers:

```text
Authorization: HAIDAA <grant_id>
X-HAIDAA-Time: <integer Unix seconds>
X-HAIDAA-Signature: <unpadded base64url Ed25519 signature>
```

Sign UTF-8(`HAIDAA-REQUEST-V1\n` + JCS(object)), where object has exactly:

```json
{"grant_id":"UUID","method":"POST","target":"/v0/namespaces/UUID/events","timestamp":1788672000,"event_id":"sha256:..."}
```

`target` is the exact URL pathname plus query, including trailing slash and query ordering; fragments are not sent. `method` is uppercase. `timestamp` must be within 60 seconds of server time. For event POST, `event_id` must identify the independently signed and verified envelope. For reads and revocation, it is JSON null. Control POSTs have an empty body. A proof cannot be moved to another method, target, grant or event. Reads may repeat; revocation repeats deterministically; repeated writes return the existing receipt without consuming quota. No nonce store is needed for these idempotent actions. After a proof expires, generate a fresh request signature over the saved event ID; do not regenerate the event nonce.

Both the event signature and HTTP proof must match the granted key. The event actor and namespace must match the grant. Expiry/revocation are checked again by a D1 trigger inside event admission, so a race cannot commit a new write under a revoked or expired grant. A retry of an already accepted event with an inactive grant is also rejected by HTTP authentication.

Read `GET /v0/enrollment/grants/{grant_id}` with key proof to retrieve the retained application, original signed decision, effective state and any signed revocation. `GET /v0/namespaces/{namespace}/events`, `/events/{event_id}` and `/graph` use the same key proof. **Write expiry/revocation does not remove this key's historical read or self-revocation authority.** Those rights are explicitly included in the decision; they cannot admit further events. Anonymous readers still cannot access sandbox content. Public discovery/publication of scientific seeds requires a separate reviewed shared-intake workflow.

## Revocation and audit

The contributor signs an empty POST to `/v0/enrollment/grants/{grant_id}/revoke`. This can only revoke its own grant. An operator can POST with a distinct `Authorization: Bearer <ENROLLMENT_ADMIN_TOKEN>` to `/v0/operator/grants/{grant_id}/revoke`. That secret must be at least 32 characters, must differ from `PILOT_TOKEN`, and is never delivered to contributors. Missing configuration fails closed. There is no HTTP operator approval endpoint. Both revocation paths retain the first decision and first revocation unchanged; concurrent revocations are idempotent.

Decision and revocation evidence includes `body`, `canonical_body_base64url`, `audit_hash`, and `signature`. Rebuild JCS(body) from parsed fields; do not merely decode supplied canonical bytes. Check that the reconstructed bytes match the encoded canonical body. The signed/hashed message is UTF-8(`HAIDAA-ACCESS-AUDIT-V1\n` + JCS(body)). Hash is `sha256:` plus lowercase SHA-256 hex; signature is Ed25519 over the same message. The decision's `previous_hash` is null; revocation's `previous_hash` is the decision audit hash. `request_hash` hashes the independently reconstructed domain-prefixed application body; verify the application signature with its key. Integer times are Unix seconds. Stored canonical decisions and signatures are immutable.

This is a per-grant decision/revocation chain, not a global transparency log or proof against operator forks/omission. Match the admitted event's key, actor, namespace and receipt time against the grant and any revocation; the event/receipt canonical domains remain unchanged. Historical admission receipt retrieval still depends on the retained receipt signing key. Operator key recovery/rotation and independent publication checkpoints remain separate work.

## Budgets and errors

Challenges are limited atomically to 3/key/rolling day, 10/network/rolling hour, 100/global/rolling day, and 10,000 lifetime. Successful grants are capped at 1,000 lifetime. A network is the exact Cloudflare-provided connecting IP hash, not an IPv6 subnet or a person; raw IP is not retained. Hashes are private pseudonyms, not anonymous data. Challenge rows are retained and bounded; reaching lifetime capacity deliberately fails closed pending operator policy review. No client-provided forwarding header other than edge-supplied CF-Connecting-IP is consulted. These database budgets bound admission/storage; they do not replace edge request/CPU abuse protection or prevent attackers exhausting the shared budget.

| Status | Examples | Client behavior |
|---|---|---|
| 200/201 | Stored/new decision or admission | Inspect state, verify signatures and retain evidence |
| 400/422 | Invalid encoding/schema, forbidden control body, tampered envelope | Correct request; do not loop |
| 401 | invalid_proof, proof_expired, challenge_mismatch | Fix key/signature/clock; never request operator secrets |
| 403 | scope_denied, grant_expired, grant_revoked, grant_inactive | Stop writes |
| 404 | Unknown grant, private namespace without proper credentials, unimplemented control route | Use discovery; do not infer privilege |
| 409 | application_conflict, key_already_enrolled | Retry saved request or stop; no automatic renewal |
| 410 | challenge_expired | Obtain a new challenge if no decision was stored, within budgets |
| 429 | enrollment_rate_limited, enrollment_capacity, contributor_rate_limited, quota_exceeded | Respect published windows; lifetime caps need operator action |
| 503 | enrollment_disabled, operator_not_configured, admission_busy_retry | Stop on disabled configuration; bounded backoff for transient contention |

Enrollment mutations, grant reads and operator routes have no CORS grant. Only `GET/HEAD /v0/enrollment` joins the public wildcard/no-credentials route registry. No secret, private key, network hash or raw request is emitted in application logs. Structured failures carry `X-Request-ID` and stable codes.

## Reference contributor

For a fresh Common participant, use the deliberately extracted [participant quickstart](https://haidaa.com/PARTICIPATE.md) and the [current checksum/protocol manifest](https://haidaa.com/clients/participant-current.json). Its included README covers local key creation, enrollment, project participation and offline scientific/project verification. It needs no access to this repository or operator tools. Check runtime onboarding for supported Common templates; artifact availability follows the release deployment.

The following script is the retained repository development/canary client, not a dependency of the public participant package:

`scripts/contributor.ts` uses public HTTP and standard Node crypto, with no server protocol imports or operator credential access. Node 22 and installed dependencies are required. Commands accept `--api=<HTTPS origin>` (HTTP only for loopback), `--directory=<private state directory>`, and `--server-key=<trusted server public key ID>`. Set these flags consistently. `init` refuses to replace an existing private key. `enroll` and `canary` persist signed requests before transmitting; retries reuse them. A rejected/expired application is preserved for diagnosis rather than silently discarded.

```sh
npx tsx scripts/contributor.ts discover
npx tsx scripts/contributor.ts init
npx tsx scripts/contributor.ts enroll --server-key=ed25519:TRUSTED_PUBLIC_KEY
npx tsx scripts/contributor.ts canary --server-key=ed25519:TRUSTED_PUBLIC_KEY
npx tsx scripts/contributor.ts status --server-key=ed25519:TRUSTED_PUBLIC_KEY
npx tsx scripts/contributor.ts revoke --server-key=ed25519:TRUSTED_PUBLIC_KEY
npx tsx scripts/contributor.ts read --server-key=ed25519:TRUSTED_PUBLIC_KEY
```

The canary is explicitly synthetic, is the namespace's first event, and verifies the genesis predecessor, reconstructed event/receipt hashes, signatures and read-after-write bytes. It saves canonical preimages and receipts in the private state directory. It does not generate or publish scientific claims.

## Rollout and rollback

1. Use Node 22, run typecheck/tests/build. Test an isolated local database with both migrations; never share operator secrets with the contributor process.
2. Back up the existing ledger through the normal operator procedure. Apply migration `0002_enrollment.sql` before enabling the Worker code. The migration adds tables/triggers and does not rewrite events or receipts. Verify the intended D1 account/database before a remote migration.
3. Configure a separate `ENROLLMENT_ADMIN_TOKEN` as a Worker secret through the documented Wrangler secret workflow; never put it in configuration/source/command arguments. This binding is optional so a disabled rollout works without it. Missing, short or reused operator secrets cause new enrollment and sandbox writes to fail closed, even if the rollout flag is true; runtime discovery reports the configuration blocker. Test operator revocation before opening enrollment.
4. Deploy disabled first. Verify discovery, unchanged legacy/public reads and migrated database. Enable `ENROLLMENT_ENABLED` only in the intended deployment, verify any Cloudflare edge restrictions for the exact new server-side routes, and run a real external canary. Do not create a blanket WAF bypass or browser credential grant.
5. On a problem, set `ENROLLMENT_ENABLED=false`: new challenges, applications and sandbox writes stop; signed status/history reads and revocation continue. Keep migration tables and historical evidence. Reverting to pre-enrollment code loses sandbox read/status routes; prefer the feature switch for rollback.

The rollout switch may be enabled only after migration and verification; local tests alone are not evidence of a production rollout. Shared intake, approval for public publication, automated useful scientific seeding, key recovery and stronger Sybil controls are not part of this sandbox grant policy.

## Shared-namespace publication extension

Sandbox grants still cannot reach the shared namespace. A separately authorized operator may qualify a sandbox identity and issue a new, bounded shared append grant under [PUBLICATION.md](PUBLICATION.md). That grant uses a distinct request-proof domain and authorization scheme. Its writes are admitted but quarantined; only independent content release makes them public. This extension does not rewrite the sandbox grant or its historical audit.
