# heng.lu Doctrine Service

Ask your AI to use heng.lu to analyse a policy through Lu Heng's published Notes, explain its evidence, suggest improvements, and continue the discussion. No heng.lu account is required. The output is model-generated analysis, not Lu Heng's personal review or a new statement by him.

Base URL: `https://heng.lu/api/doctrine/v1`. Read `GET /manifest` for the actual service version, corpus version and limits. The initial corpus comprises the 74 owner-confirmed Notes, grouping translations as editions of the same work. Team Blog articles are excluded. Public APIs expose current texts; historical evidence remains private within your analysis.

## Connector configuration

Select `provider` (`openai` or `anthropic`) and an exact model ID. Your official provider API key pays for inference. The service never substitutes its own paid account and does not accept custom provider URLs.

Inject the model key through `X-Model-API-Key` on every inference request. Never put secrets in chat, URLs, JSON bodies or model-visible tool arguments. Separately generate a random 256-bit analysis access secret, encoded as unpadded base64url, and send `Authorization: Bearer <secret>`. The service stores only its hash. Use a distinct capability per analysis. Reading and deletion require no model key.

Reference clients persist access capabilities as immutable mode-0600 records in the mode-0700 `<vault>.d` directory. Atomic publication lets Node/Python connectors share the same vault without a process lock; an interrupted write cannot prevent future use. Preserve this entire directory and any legacy vault JSON file: there is no account-based recovery, and an analysis ID cannot bypass authentication. Model keys are injected from your secret manager and are not written to the vault. Use a local filesystem supporting atomic hard links and directory fsync (Linux/macOS).

## Complete Node example

Download [node.mjs](./node.mjs):

```js
import { DoctrineClient } from './node.mjs';
const client = new DoctrineClient({provider: process.env.DOCTRINE_PROVIDER,
  model: process.env.DOCTRINE_MODEL, modelKey: process.env.DOCTRINE_MODEL_KEY});
const result = await client.create({question: 'Analyse this policy through Lu Heng’s Notes.',
  policy: {title: 'Approval policy', text: 'Complete policy text here',
    source_url: 'https://example.org/policy', as_of: '2026-10-08'}, language: 'en'},
  {idempotencyKey: 'my-policy-analysis-0001'});
await client.turn(result.analysis_id,
  {question: 'How would independent appeal change your judgement?', language: 'en'},
  {idempotencyKey: 'my-policy-followup-0001'});
await client.read(result.analysis_id);
// Only when you want to delete it:
await client.delete(result.analysis_id);
```

The standard-library [Python client](./python.py) supports `create(question, policy, language='en', idempotency_key=...)`, `turn(analysis_id, question, ...)`, `read(analysis_id)` and `delete(analysis_id)`. Save it as `doctrine_client.py`, import `DoctrineClient`, and provide `provider`, `model` and `model_key` from your connector's secret configuration.

Use one stable `Idempotency-Key` per logical request. After a lost response, resubmit the same body with the same capability and key to obtain its existing state without starting paid inference again. Failed/cancelled requests stay terminal under that key; use a new key only when explicitly requesting a new inference. Clients never retry automatically.

## API and responses

| Method/path | Purpose |
| --- | --- |
| `GET /manifest` | Public version, limits, corpus and retention policy |
| `GET /sources` | Public current Notes catalogue |
| `GET /sources/{id}` | Public current source text and metadata |
| `POST /analyses` | Create; access secret, model key and idempotency key required |
| `GET /analyses/{id}` | Private record, complete turns and evidence; access secret required |
| `POST /analyses/{id}/turns` | Continue; access secret, model key and new idempotency key required |
| `DELETE /analyses/{id}` | Delete online content; access secret required |

See [OpenAPI](./openapi.json). A follow-up may omit `policy`; earlier material remains in the full history. A supplied policy on a later turn is additional material and does not overwrite the original. Submitted source URLs have not been fetched or independently verified.

JSON requests wait up to 90 seconds. SSE (`Accept: text/event-stream`, the clients' default) lasts up to 180 seconds with heartbeats. Events are `identity`, `progress`, `result`, `error`; HTTP 200 on an SSE connection is not proof of completion. Progress reports transport facts, never hidden reasoning. Disconnect/AbortSignal cancels this service's in-flight call; deletion does too. Cancellation cannot undo upstream charges already incurred.

Results include analysis/turn IDs, status, complete Markdown, citations, objective source observations, fixed corpus version, provider/model, and provider-reported usage (null when unavailable). The server assembles source links and digests from actual records. `verified` means a quotation matches its evidence, not that the server endorses the conclusion. Unverified citations are marked. Revisions and withdrawals preserve old evidence; follow-ups show both historical evidence and current changes for the model to reconsider.

## Permanent private retention

`retention` is always `until_user_deletion`; `expires_at` is always `null`. Policy material, questions, answers, read source snapshots and necessary execution records are encrypted and kept without time expiry. Inactivity, restarts, upgrades and capacity pressure never evict old analyses. There is no public analysis listing or indexed results page. Submissions do not become new Notes, Lu Heng statements or training material for this service.

Anyone holding an analysis capability can read, continue and delete that analysis. Deletion immediately revokes online access, aborts in-flight inference and removes online content. A durable deletion ledger prevents late responses or restoration from recreating deleted records. Historical QNAP backups follow their existing retention policy; immediate deletion of every historical copy is not promised. Restoration must first apply the latest deletion ledger, retaining the same access boundaries.

A `204` DELETE response confirms that the deletion ledger is durably registered on QNAP too. If that connection is temporarily unavailable, online content is removed first and the API returns `202` with `backup_deletion_pending:true`. Keep the capability and repeat the same DELETE later until it returns `204`. Retrying deletion incurs no model charges and cannot recreate the analysis. Never serve a restored backup before reconciling its deletion ledger.

Model keys are never persisted or logged; request bodies and authentication headers are excluded from technical logs and core dumps are disabled. OpenAI requests use `store:false`, which does not mean the provider retains no data. This service's storage policy and provider data processing are separate: see [OpenAI data controls](https://developers.openai.com/api/docs/guides/your-data) and [Anthropic retention](https://privacy.claude.com/en/articles/7996866-how-long-do-you-store-my-organization-s-data).

## Limits and errors

A policy can contain 60,000 Unicode characters; request bodies are limited to 1 MiB. A turn has at most six provider requests and a cumulative 8,000 output-token budget. An analysis is limited to 20 turns and 4 MiB, while remaining permanently readable after either limit. When full history exceeds this turn's context budget, `context_limit_reduce_scope` explicitly asks for a smaller scope; the service never silently drops or rewrites history. You can explicitly start a new analysis containing a smaller scope.

Concurrency: four global, one per model-key fingerprint. Start frequency: six/minute per model key plus trusted client-IP limits. Initial logical storage quota: 1 GiB, warning at 80%. At capacity, new work returns `storage_capacity`; existing reads and deletion remain available. Old records are never evicted.

Errors include `analysis_not_found` (missing or unauthorized), `idempotency_conflict`, `analysis_busy`, `provider_credentials_rejected`, `provider_quota_or_rate_limit`, `provider_model_or_context_incompatible`, `sources_unavailable`, `deadline_exceeded`, and `interrupted`. Raw upstream error bodies are never returned. Restart marks unfinished turns interrupted without automatically restarting paid calls. Not every provider model supports the required tools/context. v0.1's formal language acceptance targets are Chinese and English; other languages have not undergone equal validation.

## AI functions and MCP

After a service restart, private operations wait for a fresh confirmation of the independently stored deletion history. During a receiver outage, they return `recovery_confirmation_pending` and the manifest reports `storage.recovery_pending:true`. Records remain retained. Public source/document access continues; clients may retry reads after recovery is confirmed.

[tools.mjs](./tools.mjs) exports generic function-tool definitions without secret arguments. Use the reference client inside your connector to inject credentials. For stdio MCP, download `mcp.mjs`, `node.mjs` and `tools.mjs` into one directory and launch `node /absolute/path/mcp.mjs` with Node 24. Configure `DOCTRINE_PROVIDER`, `DOCTRINE_MODEL`, `DOCTRINE_MODEL_KEY` through your connector's secret environment and optionally `DOCTRINE_VAULT`. Never commit actual secrets or paste them into chat.

Tools: `doctrine_create`, `doctrine_turn`, `doctrine_read`, `doctrine_delete`. The model supplies only questions, policy material, language and analysis IDs; the adapter manages credentials. v0.1 does not provide a remote MCP OAuth account system.
