Operating CRAne from an AI agent

This page is written to be read by a coding agent — Copilot, Claude, or anything else that can run a command — and it is complete enough to drive CRAne without reading the rest of this site.

  1. Pointing an agent at this
  2. Which surface to use
    1. Invoking it in a container
  3. The rules that matter
  4. Exit codes
  5. Which commands speak JSON
  6. The normal loop
  7. Adding to what already exists
  8. Modelling mistakes an agent will make
  9. Decisions are governed acts
  10. Reading state without changing anything
  11. Reporting
  12. Over HTTP instead
    1. When you must prove what you loaded
  13. Do not
  14. Where the rest is

Pointing an agent at this

Three ways, in order of preference:

  1. Fetch /CRAne/llms-full.txt — this entire documentation set flattened into one plain-text file, meant to be read in a single request. /CRAne/llms.txt is the short routing index if you would rather fetch pages selectively.
  2. Read it out of the image, when there is no network:
    docker run --rm --entrypoint cat ghcr.io/oscarvalenzuelab/crane:latest /app/AGENTS.md
    
  3. Ask the running system. crane import spec --json and /api/openapi.json are generated from the code, so they cannot be out of date. When they disagree with this page, they are right.

For a project that uses CRAne regularly, paste the rules and the exit codes into .github/copilot-instructions.md and link to this page for the rest.

Which surface to use

Prefer the crane command. It needs no port and no token, and a few lifecycle things (init, migrate, job prune, reading local files) exist only there.

There is also an HTTP API, served by crane serve and documented at /api/docs, with its schema at /api/openapi.json. It mirrors the working loop end to end — upload, import, validate, run the worker, rescore, read the report — and every write calls the same function the command does. Reach for it when you have a port and a token and no shell.

Never write to the SQLite file directly. Every write should carry provenance, and a manual INSERT carries none.

Invoking it in a container

Everything below is written as crane <command>. Against the published image that is:

docker run --rm -v "$PWD/data:/data" --user "$(id -u):$(id -g)" \
    ghcr.io/oscarvalenzuelab/crane:latest <command>

Define it once and forget it:

crane() {
  docker run --rm -i -v "$PWD/data:/data" -v "$PWD/exports:/exports" \
      --user "$(id -u):$(id -g)" ghcr.io/oscarvalenzuelab/crane:latest "$@"
}

Every path you pass is resolved inside the container. Write input files into a mounted directory and refer to them by their container path (/data/batch.json), never the host path.

The rules that matter

  1. Discover the format, do not guess it. crane import spec --json prints every dataset, column, accepted value and rule. It is generated from the code.
  2. Pass --json to anything you intend to parse. See the table below for exactly which commands accept it.
  3. Check the exit code. They distinguish “your data is wrong” from “the setup is wrong”, and those need different responses.
  4. Dry run first. --dry-run on both importers validates and writes nothing.
  5. Never mark anything approved unless a human actually approved it. Loading data records observations. source_kind: approved in a scope row writes a decision with a named authority, and inventing one corrupts the audit trail this exists to keep.
  6. Do not invent values. A field you do not know is left empty. Empty means nobody has looked, which is a state the system reports honestly. A guess becomes a fact nobody can distinguish from a real one.

Rules 5 and 6 are the ones that matter most when the operator is a model. Everything else here is recoverable; a fabricated approval is not.

Exit codes

Code Meaning What to do
0 Worked Continue. Check findings anyway — see below
1 Setup problem: missing database, pending migrations, bad path Fix the environment. Do not retry blindly
2 The command is specified but not built, or its adapter is not installed Do not work around it. Report it
3 Loaded, but something did not resolve, and you asked to be told Read unresolved[]. Only import adapter --fail-on-unresolved returns this
4 Input read fine but did not validate Read errors[], fix the rows, retry
5 A worker job failed Read crane job list --status failed --json

0 does not mean “nothing to look at”. A successful import can carry findings and conflicts:

Field Exit Means
errors[] 4 The batch was rejected. "written": {} — nothing was written
findings{} 0 Loaded, and something is worth a person’s attention
conflicts 0 Loaded, and it disagrees with what another source already said

A value that is not on the accepted list for its column is usually a finding, not an error. Structural problems — an unknown column, an unresolvable reference — are errors and stop the whole batch.

Which commands speak JSON

--json Commands
Yes lens list, policy list, import spec, import data, import adapter, validate sbom, validate profiles, assess, worker run, job list, job show, job prune, scope list, scope set, report list, report templates, demo
No init, migrate, status, import template, job cancel

The ones without it report on the database rather than on the data in it, and print for a person. Check their exit code instead.

crane report <kind> --format json is a different flag from --json, and it is the one that controls the rendered report body.

The normal loop

# 1. What does the system accept?
crane import spec --json

# 2. Write what you collected as a JSON bundle: keys are dataset names,
#    values are arrays of row objects.
cat > data/batch.json <<'JSON'
{
  "products":   [{"slug": "gateway", "canonical_name": "Aurora Gateway", "lifecycle": "released"}],
  "components": [{"slug": "crypto-lib", "canonical_name": "Crypto Library"}],
  "product_components": [{"product_slug": "gateway", "component_slug": "crypto-lib"}]
}
JSON

# 3. Validate without writing. Fix anything reported, then load.
crane import data /data/batch.json --dry-run --json
crane import data /data/batch.json --json

# 4. Queue SBOM validation. Returns immediately; it does not run the tool.
crane validate sbom --requested-by agent --json

# 5. Do the queued work.
crane worker run --once --json

# 6. Score, and read the explanation rather than just the number.
crane assess cra --json

Steps 3 to 6 are idempotent. Re-running with unchanged input changes nothing.

Adding to what already exists

Load only the datasets you have. References resolve against rows already in the database, so this needs no other file:

echo '{"distribution": [{"subject_type": "product", "subject_slug": "gateway",
  "direction": "outbound", "channel_type": "nuget",
  "channel_locator": "pkg:nuget/Aurora.Gateway@2.4.0", "visibility": "public"}]}' \
  > data/dist.json
crane import data /data/dist.json --json

Modelling mistakes an agent will make

Three questions, three answers. Where code lives, where the build runs, and where output goes are separate. A component built by a vendor from their own source and shipped back as binaries has code_hosting: vendor, build_location: vendor_external, and an outbound release_asset distribution row. Do not collapse them.

Direction matters. outbound means we make it available, which is what puts a product on the market. inbound means we consume it. A third party library has an inbound row and often no build at all, which is normal and not a gap.

Record “not distributed” explicitly. For something used only internally, write an outbound row with channel_type: not_distributed. An absent row means nobody looked, and the report has to tell those apart.

Leave is_signed empty when unknown. Empty means not established. false means known to be unsigned. The first is a coverage gap, the second is a finding.

Attach ownership at the most specific level that is true. A product owned by a team reports under that team’s organization and division automatically. Do not repeat the hierarchy on every row.

One scope row per lens. The same product can be in scope under cra.regulatory and undecided under commercial. Both are kept. Do not reconcile them yourself.

Register evidence at the path the worker will see. If the worker runs in a container, a host path will not resolve there. crane job show <id> prints the path it tried.

Decisions are governed acts

Scope is the common case, and it is not a field update:

crane scope set product gateway cra.regulatory in_scope \
    --by "Regulatory counsel" \
    --rationale "Made available on the Union market in the course of a commercial activity"

Both flags are required. --rationale must be at least eight characters, because it is the sentence somebody reads in a year. Status is one of in_scope, out_of_scope, undecided, not_applicable, disputed.

Do not call this on your own initiative. Write the decision a human made, with their name in --by. If no human has decided, the correct state is no scope row at all, which CRAne reports as undecided and counts as neither passing nor failing.

Reading state without changing anything

crane status                          # database location, migrations, row counts
crane policy list --json              # obligations, their checks and weights
crane lens list --json                # the definitions of "product" in play
crane scope list --json               # scope decisions per lens
crane validate profiles --json        # which validator profiles exist, and which policy needs
crane job list --status failed --json
crane job show <job-id> --json        # parameters, events, artifacts, run directory
crane assess cra --subject gateway --json

For anything not covered by a command, query the SQLite file read only. Use the v_ prefixed views rather than the tables when counting relationships: edge tables store one row per assertion, so two sources agreeing produce two rows for one relationship.

Reporting

crane report list --json                             # what exists
crane report leadership --format json                # for a steering committee
crane report audit --format html --out /exports/audit.html
crane report worklist --format json                  # what to do next, by owner
crane report package --out /exports/handover         # hashed, for handing over

crane report templates --json
crane report render division-readiness --param division="Platform" --format json

Reports never write to the database, so running one is always safe. Each carries the policy hash that produced its numbers and a content hash that is stable when the data has not changed, which is how you tell “nothing moved” from “nobody looked”.

If asked for a report that does not exist, write a template rather than post-processing one that does — a Markdown file with TOML front matter in the templates directory. That way it can be run again next month.

Over HTTP instead

export T="$CRANE_API_TOKEN"
curl -H "Authorization: Bearer $T" http://host:8000/api/v1/status
curl -H "Authorization: Bearer $T" http://host:8000/api/openapi.json   # the full schema

The loop maps onto it directly:

Step Endpoint
Load data POST /api/v1/import/data
Upload an SBOM POST /api/v1/import/sbom
Queue validation POST /api/v1/validate/sbom
Queue a rescore POST /api/v1/assess
Watch a job GET /api/v1/jobs/{job_run_id}
Read the score GET /api/v1/overview
Read a report GET /api/v1/reports/{kind}
Settle a conflict POST /api/v1/conflicts/{conflict_id}

Every write the interface offers exists over HTTP too — this is the surface a migration drives. The full list with request shapes is the OpenAPI document above; the map:

Change Endpoint
Create / edit a product, component, pipeline or repository POST /api/v1/subjects, POST /api/v1/subjects/{subject_type}/{subject_id}
Wire the graph: contains, builds, source, relates POST /api/v1/connect
Turn a scanned repository into a component POST /api/v1/assets/adopt
Record a fact a check reads (support end, security contact) POST /api/v1/claims, end it with POST /api/v1/claims/{claim_id}/end
Assign or remove an owner POST /api/v1/owners, POST /api/v1/owners/{responsibility_id}/remove
People, teams, and the org tree POST /api/v1/parties/{party_id}, /api/v1/parties/{keep_id}/merge, /api/v1/settings/org-units
Distribution, dependencies, aliases POST /api/v1/distribution, /api/v1/dependencies, /api/v1/aliases
Identity mappings to other systems POST /api/v1/identities, /api/v1/identities/{identity_id}/remove
Evidence standing POST /api/v1/evidence/{evidence_id}/state
Decisions: scope, exceptions, findings, conflicts POST /api/v1/scope, /api/v1/exceptions, /api/v1/findings/{finding_id}, /api/v1/conflicts/{conflict_id}
Jobs POST /api/v1/jobs/{id}/cancel, /api/v1/jobs/{id}/retry, /api/v1/worker/run

When you must prove what you loaded

POST /api/v1/import/data validates and loads in one request, and its dry_run flag is enough for most feeds. It needs no filesystem path and fetches nothing. Use the staged flow when the two steps have to be tied together — an audited feed, or an integration that records the digest of what it shipped:

Step Endpoint
Read the contract and its fingerprint GET /api/v1/import/spec
Validate and stage, writing nothing POST /api/v1/imports
Apply the bundle that id names POST /api/v1/imports/{import_id}/apply
Read the receipt, whenever GET /api/v1/imports/{import_id}

The apply step takes no bundle. The id names the bundle that passed validation, so there is nothing you could send that differs from it. Rules that will otherwise surprise you:

  1. ok: false still returns 201. Received and refused; errors[] lists every row to fix. Do not retry the same bundle — fix it and stage again.
  2. Only a validated import can be applied. A rejected one cannot.
  3. Applying twice returns the first receipt, not a second load.
  4. Send contract_fingerprint from step 1. It is checked again at apply, so a contract that moved in between refuses the apply rather than reinterpreting your rows.
  5. Send an Idempotency-Key. A retried upload is then one import; a different bundle under a used key is refused, which is how a bug in your feed gets caught.
  6. 202 means the worker has it. Poll the receipt for status: applied.

crane import spec prints the same fingerprint, and both surfaces run the same importer.

Two migration rules worth restating here. Bulk data belongs in POST /api/v1/import/data — one validated, idempotent, all-or-nothing batch in the import spec shape — not in a thousand single-row POSTs; the single-row operations are for corrections and decisions. And the write operations share dedupe keys with the importer, so a row recorded by hand and the same row arriving later in a batch land as one fact, never twins.

Long running work returns a job id and 202. Poll GET /api/v1/jobs/{id}.

A write that breaks a rule returns 422 with a sentence explaining it, and writes nothing at all — including the decision that would have authorised it. Treat 422 as “fix the request”, not “retry”.

Do not

  • Write to the database directly. A manual INSERT carries no provenance.
  • Retry a code 4 without changing the input. It will fail identically.
  • Retry a code 1 at all until the environment is fixed.
  • Fill a required field with a placeholder to get past validation. Leave the row out.
  • Approve, decide scope, or grant an exception on your own authority.
  • Treat a passing SBOM profile as a compliant product. It is one check out of several.
  • Assume ok: true means there is nothing to report. Read findings and conflicts.

Where the rest is

Question Page
Every column of every dataset Data contract, or crane import spec --json
Every command, flag and route Interface contract, or /api/openapi.json
The database, with worked queries Data model
Why it is shaped this way Design decisions

Back to top

CRAne. The database is one SQLite file and it is yours.

This site uses Just the Docs, a documentation theme for Jekyll.