Chapter 9: Integration and Migration — Working With the Chandra API Directly

CH. 9 OF 19 DRAFTED
Listen to this chapter
Can't play? Download the MP3 directly.
Reference build: chandra-enterprise-editor.html, badge v31c425 (2026-08-09). Every endpoint, payload shape, and response field in this chapter is taken directly from CEE's own client code — this is the same API CEE itself calls, not a separate integration surface. If you're building a migration script, a sync job, or a wrapper around existing infrastructure, you are using exactly what the browser does.

Integration and Migration — Working With the Chandra API Directly

Reference build: chandra-enterprise-editor.html, badge v31c425 (2026-08-09). Every endpoint, payload shape, and response field in this chapter is taken directly from CEE's own client code — this is the same API CEE itself calls, not a separate integration surface. If you're building a migration script, a sync job, or a wrapper around existing infrastructure, you are using exactly what the browser does.

Note: James — you mentioned you've written integration notes elsewhere already. This chapter is built strictly from what's provably in the shipped client code, so treat it as the "ground truth from the wire protocol" half; fold in your existing notes wherever they add context (auth/deployment topology, worker routing specifics, etc.) that isn't visible from the client alone.


9.1 What "the API" actually is

There is no separate public API distinct from what CEE calls. Every action in the browse toolbar (Chapters 1–10) is a plain HTTP call to a JSON REST endpoint under /api/enterprise/* (plus a small /api/health and /api/enterprise/self surface for instance identity). CEE is a thick client over this API — nothing happens server-side that CEE's JavaScript couldn't equally call from a script, a migration tool, or another application entirely.

That means integrating Chandra into existing infrastructure is, at the protocol level, just calling these same endpoints from wherever you need to — a cron job, an ETL pipeline, a webhook handler, another product's backend.

Base URL. Every endpoint is relative to an instance address — the worker (or spine) you're talking to. CEE resolves this per-Hub via levelBase(lv); there is no single fixed base URL system-wide, because a given deployment may have multiple worker instances (Ch. 8) each fronting a different isolation surface. When you write an integration, you supply the instance address explicitly — it's the same value CEE shows as "instance address" in diagnostics.

Auth, as observed from the client. CEE's own request helpers (getJson/postJson) send only Content-Type: application/json and Accept: application/json — no bearer token or API key header is added client-side. Every mutating call does carry an operator field in its JSON body (see §9.3), which is how a human/service identity gets attributed to the resulting CU, but that's an attribution field, not an authentication credential. This chapter documents the request/response contract, not the deployment's access-control layer — how a given instance is actually gated (network placement, reverse-proxy auth, mTLS, etc.) is an infrastructure decision outside what's visible in this client file. If you've already written notes on that elsewhere, that's the piece to bring in here.

Every response is JSON, and CEE's own parsing convention is worth adopting in your own integration: a non-2xx HTTP status or a {"ok": false, ...} body (even on HTTP 200) both mean failure, and the message/error field carries the human-readable reason.


9.2 Reading data: listing and fetching records

List records in a Hub:

GET {base}/api/enterprise/records/list?hub_id={hub_id}&status=active
GET {base}/api/enterprise/records/list?hub_id={hub_id}&parent_subject_id={parent_id}&status=active
async function listRecords(base, hubId, parentSubjectId) {
  const q = new URLSearchParams({ hub_id: hubId, status: "active" });
  if (parentSubjectId) q.set("parent_subject_id", parentSubjectId);
  const r = await fetch(`${base}/api/enterprise/records/list?${q}`, {
    cache: "no-store",
    headers: { Accept: "application/json" }
  });
  const j = await r.json();
  if (!r.ok || j.ok === false) throw new Error(j.message || j.error || `HTTP ${r.status}`);
  return j;
}

Fetch instance identity (useful as a first call in any integration, to confirm you're pointed at the instance you think you are, and to get its immutable instance ID for logging):

GET {base}/api/enterprise/self

Health check (confirms the worker is reachable and its backing database is open — CEE polls this for every worker tier's status pill):

GET {base}/api/health

A healthy response is HTTP 200 with {"ok": true, "instance_id": "...", "instance_name": "...", "db_open": true, ...}. db_open: false means the process is up but its database isn't — CEE surfaces this distinctly as "DB CLOSED (restart required)" rather than treating it the same as unreachable, and your integration should probably do the same.


9.3 The operator field and the optimistic-lock pattern

Two conventions show up on every mutating call in this API, and both matter for a correct integration:

operator — a plain string identifying who's making the change, sent on every create/update/delete call. In the CEE client it defaults to a value read from localStorage.getItem("chandra.operator"), falling back to "cee-acceptance-user" if unset. For a script or service integration, set this to something meaningful and stable (e.g. "migration-script-v1", "salesforce-sync") — it becomes part of the permanent CU attestation for anything that endpoint creates or changes, so it's your integration's identity in the audit trail going forward. Don't reuse a human operator string for automated writes; give the integration its own.

loaded_tail_hash — Chandra's optimistic-concurrency-control token. Every record carries a hash-chain tail; to update or delete a record you must supply the tail hash you last read, not a placeholder. The server rejects the write if that tail no longer matches the record's current state (i.e., someone else changed it since you read it), which is the mechanism that makes concurrent writes from multiple integrations safe rather than silently clobbering each other. This is also why a read (records/list or a record's own current state) has to precede any write in an integration — you cannot construct a valid update without first fetching the record's current tail.


9.4 Creating records

POST {base}/api/enterprise/records/create
Content-Type: application/json
{
  "hub_id": "...",
  "parent_subject_id": null,
  "level_number": 2,
  "target_level_number": 2,
  "name": "New Record",
  "values": { "field_key_1": "...", "field_key_2": 123 },
  "operator": "migration-script-v1"
}
async function createRecord(base, payload) {
  const r = await fetch(`${base}/api/enterprise/records/create`, {
    method: "POST",
    cache: "no-store",
    headers: { "Content-Type": "application/json", Accept: "application/json" },
    body: JSON.stringify(payload)
  });
  const j = await r.json();
  if (!r.ok || j.ok === false) throw new Error(j.message || j.error || `HTTP ${r.status}`);
  // CEE's own unwrapping pattern — the created record may be nested under `.record`
  const record = (j && j.record) || j || {};
  const subjectId = String(record.subject_id || record.record_id || record.id || j.subject_id || "");
  return { response: j, record, subjectId };
}

9.5 Updating and deleting records

Update — requires the tail hash from your most recent read of that record:

POST {base}/api/enterprise/records/update
{
  "subject_id": "...",
  "loaded_tail_hash": "...",
  "values": { "field_key_1": "new value" },
  "operator": "migration-script-v1"
}

Soft-delete (the standard delete path — recoverable, per the lifecycle/archive model in Ch. 1–2):

POST {base}/api/enterprise/records/delete
{
  "subject_id": "...",
  "loaded_tail_hash": "...",
  "operator": "migration-script-v1"
}

Archive / restore / restore-to-deleted exist as separate endpoints (/api/enterprise/records/archive, /api/enterprise/records/restore, /api/enterprise/records/restore-to-deleted) for moving records through the fuller lifecycle states beyond a simple soft-delete — same subject_id + loaded_tail_hash + operator shape.

A missing or stale loaded_tail_hash is the single most common integration failure mode worth designing around defensively: always read-immediately-before-write in a migration script rather than caching a tail hash from an earlier stage of a long-running job, since the record may have changed underneath you (by CEE, by another integration, or by your own prior step) between the read and the write.


9.6 File attachments

Attachments are their own small endpoint family, bound to a record's subject_id rather than embedded in the record payload itself — the field's value just stores the filename/metadata; the bytes live behind these endpoints:

POST {base}/api/enterprise/attachments/upload
{
  "subject_id": "...",
  "filename": "invoice.pdf",
  "content_base64": "...",
  "content_hash": "sha256-hex-of-file-bytes",
  "operator": "migration-script-v1"
}

10MB hard limit per file in CEE's own client (worth matching in an integration, though the authoritative limit is whatever the backend enforces). content_hash is a SHA-256 of the raw file bytes, computed client-side before upload — CEE computes and sends it for integrity verification but doesn't hard-fail locally if hashing fails, so treat it as strongly recommended rather than strictly required.

POST {base}/api/enterprise/attachments/delete
{ "subject_id": "...", "filename": "invoice.pdf", "operator": "migration-script-v1" }
GET {base}/api/enterprise/attachments/download?subject_id={id}&filename={name}

Note the two-step pattern CEE itself uses when a new record has a file field: create the record first, upload the file second, then a follow-up records/update writes the real filename into the field value (using the tail hash returned by the create call). A file field's value is provisional until that second update lands — don't treat a bare records/create response as having a fully-resolved file field.


9.7 Reading provenance: the CU chain

Every governed mutation produces a CU (Chandra Unit — Ch. 1's append-only attestation), and the chain is independently queryable, which is the piece that makes this genuinely useful for migration verification (confirming a batch import actually attested, not just that the HTTP call returned 200):

GET {base}/api/enterprise/chain/index?subject_id={subject_id}

Returns the list of CUs for a given record/subject — its full history.

GET {base}/api/enterprise/chain/detail?cu_id={cu_id}

Returns one CU's full detail — the attested artifact itself.

A migration script that wants a real audit trail (not just "the API said OK") should, at minimum, capture the cu_id/tail hash returned from each write and spot-check a sample against chain/detail — this is the same verification path CEE's own historical-CU viewer uses.


9.8 Bulk ID pre-allocation

If a migration needs to mint canonical record IDs ahead of the actual write (for example, to build cross-references between records before any of them exist yet):

GET {base}/api/enterprise/ids/new?count={n}

Returns {"instance_id": "...", "ids": [...]}. IDs are instance-scoped (prefixed/derived from the issuing instance's own identity), so pre-allocated IDs from one worker instance are not portable to another — request them from the instance you'll actually be writing to.


9.9 A minimal end-to-end migration example

Putting the pieces together — importing external records into a Hub, with verification:

const BASE = "https://your-worker-instance.example.com";
const OPERATOR = "migration-script-v1";

async function migrateRecord(hubId, externalRow) {
  // 1. Create
  const { record, subjectId } = await createRecord(BASE, {
    hub_id: hubId,
    parent_subject_id: null,
    level_number: 2,
    target_level_number: 2,
    name: externalRow.name,
    values: {
      external_id: externalRow.id,      // preserve source-system provenance as a plain field —
                                          // never as the canonical subject_id; Chandra always
                                          // mints its own identity on create (same rule CSV
                                          // import in Ch. 5 follows)
      status_field: externalRow.status,
    },
    operator: OPERATOR
  });

  // 2. Verify the write actually attested
  const chain = await fetch(`${BASE}/api/enterprise/chain/index?subject_id=${subjectId}`,
    { headers: { Accept: "application/json" } }).then(r => r.json());
  if (!chain || !chain.length) throw new Error(`No CU attested for ${subjectId}`);

  return { subjectId, cuId: chain[chain.length - 1].cu_id };
}

This mirrors, at small scale, exactly what CEE's own CSV import batch loop does internally: create through the ordinary record endpoint, never write the source system's ID into the canonical identity field, and treat "the write returned 200" and "a CU actually attested" as two different things worth checking separately.


9.10 Endpoint reference (as verified in this build)

Endpoint Method Purpose
/api/health GET Liveness + DB-open check for a worker instance
/api/enterprise/self GET Instance identity manifest (name, immutable ID, environment, spine state)
/api/enterprise/records/list GET List records for a Hub (hub_id, optional parent_subject_id, status)
/api/enterprise/records/create POST Create a record
/api/enterprise/records/update POST Update a record (requires loaded_tail_hash)
/api/enterprise/records/delete POST Soft-delete a record
/api/enterprise/records/archive POST Move a record into the archive lifecycle state
/api/enterprise/records/restore POST Restore an archived/deleted record
/api/enterprise/records/restore-to-deleted POST Step a record back to the deleted (not archived) state
/api/enterprise/records/deleted GET List soft-deleted records
/api/enterprise/records/archived GET List archived records
/api/enterprise/attachments/upload POST Upload a file bound to a record's subject_id
/api/enterprise/attachments/delete POST Delete a bound file
/api/enterprise/attachments/download GET Download a bound file
/api/enterprise/chain/index GET List CUs for a subject (its full history)
/api/enterprise/chain/detail GET Fetch one CU's full attested detail
/api/enterprise/ids/new GET Pre-allocate canonical IDs from an instance
/api/enterprise/hub/form-design/latest GET Fetch a Hub's currently published form design
/api/enterprise/hub/form-design/save POST Publish a form design change
/api/enterprise/sub-hub/create POST Create a governed sub-Hub (related table, Ch. 7)
/api/enterprise/spine/environment/status GET Spine/environment status for a given environment label

This list is everything directly observed in the current client build — it is not necessarily the complete server-side API surface (Test Plans, saved searches, and help-context endpoints exist too but are CEE-internal tooling rather than data-integration surfaces, so they're omitted here as out of scope for a migration chapter).


This chapter covers the wire protocol only. Deployment topology — which instance fronts which isolation surface, how routing/auth is actually enforced at the network layer, and how this maps onto the worker architecture in Ch. 8 — is the next layer up, and is where your existing notes should slot in.

A sharper public framing of this same integration story, confirmed live from chandrahub.net (Ch. 10): "Integrating an existing application with Chandra requires one column addition per auditable database table. No schema redesign. No audit middleware. No separate compliance system. Every write your application makes appends a context unit to the corresponding Chandra chain. The chain is the compliance record." This is a more citable, examiner-facing version of the same claim this chapter makes operationally — worth using directly when explaining the integration story to a non-technical stakeholder rather than re-deriving it from the endpoint reference above.

← All chapters  ·  General Reasoning, Inc. · Birmingham, Alabama · Manual v1.0
Join the beta. Two ways in: we can help you set up a closed beta -- use the Industry Configurator to generate a custom personality for your organization, then email the resulting JSON to inquiries@genreason.com along with the subdomain you'd like for an unpublished test site (integration assistance available). Or explore the public-facing Chandra Marshaller directly -- a number of companies are already populated there, no setup required. See current beta deployments.