# CRAne, complete documentation Compliance evidence and audit workflow platform. Product and component inventory, scored against the obligations that apply. This file is every page of https://oscarvalenzuelab.github.io/CRAne concatenated, for an agent that would rather read one document than crawl a site. Generated by scripts/build-docs-reference.py. The running system is always more authoritative than this file: `crane import spec --json` for the data contract, `/api/openapi.json` for the API. ============================================================================== PAGE: Home (index.md) ============================================================================== # CRAne Compliance evidence and audit workflow platform. It tracks which products and components exist, how they relate, who owns them, whether they are in scope under each definition, and whether their evidence holds up. [Install](install) [For AI agents](agents) --- It exists to answer questions that are usually spread across several systems and several spreadsheets: how many products there are, how many components, which components ship in more than one product, which pipelines build what, which products are in scope under whose definition, which have an SBOM, whether that SBOM is any good, and where the whole effort actually stands. ## Sixty seconds You need Docker. Nothing else, and no checkout of anything. ```bash mkdir -p crane/data && cd crane # The directory has to be writable by the container's user, so hand it your own uid. docker run --rm -v "$PWD/data:/data" --user "$(id -u):$(id -g)" \ ghcr.io/oscarvalenzuelab/crane:latest init # Eight invented products, chosen to show the situations this exists for. docker run --rm -v "$PWD/data:/data" --user "$(id -u):$(id -g)" \ ghcr.io/oscarvalenzuelab/crane:latest demo docker run --rm -p 127.0.0.1:8000:8000 -v "$PWD/data:/data" \ --user "$(id -u):$(id -g)" -e CRANE_ALLOW_UNAUTHENTICATED_BIND=1 \ ghcr.io/oscarvalenzuelab/crane:latest serve --host 0.0.0.0 --port 8000 --worker ``` Open . Everything after this is the same tool with your data in it instead of the invented portfolio, which [Populate](populate) covers. The database is a single SQLite file at `data/crane.db`. Open it with any SQLite browser. That is deliberate: nothing here is locked inside a format only this tool can read. ![The overview page, showing every obligation side by side](screenshots/overview.png) The overview is the consolidated pane: every obligation at once. Every other page works one obligation at a time, chosen in the header and carried in the URL, so a filtered view is a link you can send someone. ## Why it is shaped this way **Nothing is a tree.** A component ships in many products. A product is bundled inside another product. A component has several pipelines, or none. One pipeline feeds several components. The model is a graph with typed, provenance carrying edges, and fan in and fan out are the same mechanism read in opposite directions. **Several organizations disagree about what a product is.** The ship room, legal, the commercial catalog, and the regulatory reading each have their own definition, and they are not going to be reconciled before the inventory has to exist. Each is a **lens**. A product can be in scope under one and out of scope under another, and both statements are kept. Every reported number names the lens it used. **Obligations are plural.** The CRA is the first policy, not the shape of the system. FedRAMP and the NTIA minimum elements ship alongside it, each with its own checks, weights and reporting lens. Adding another obligation is a policy row and a profile file, not a code change. **A source fact is not a decision.** Everything imported lands as an observation needing review. Approving something is a separate, recorded act with an authority and a rationale. A lower authority claim never silently overwrites a higher authority one, and conflicts are retained and surfaced rather than merged away. ## It stands on its own CRAne assumes nothing about which build system, product catalog or ownership directory you use. A fresh install has no adapter and no imported data. It is populated through `crane import data`, which takes CSV or JSON, and it works that way permanently if that is all you need. `crane import spec --json` prints the contract, so whatever is producing the files, a person or an agent, can read the format rather than guess it. Connecting a source system is optional and additive, and published images deliberately carry no adapters at all: each one describes somebody else's system. See [Adapters](populate#adapters). ## Where to go | You want to | Page | |---|---| | Get it running | [Install](install) | | See it working before feeding it anything | [First run](first-run) | | Put your own portfolio in | [Populate](populate) | | Validate, score, report, schedule | [Operate](operate) | | Environment variables, authentication, exposure | [Configure](configure) | | Drive it from Copilot or another agent | [For AI agents](agents) | | Every command, route and column | [Data contract](reference/data-contract), [Interface contract](interfaces) | | Something is broken | [Troubleshooting](troubleshooting) | | Report a vulnerability, or harden a real deployment | [Security](security) | ## Licence Apache-2.0. The full text and the attribution notice travel inside the image, at `/app/LICENSE` and `/app/NOTICE`. ============================================================================== PAGE: Install (install.md) ============================================================================== # Install --- ## What you need Docker Engine 20.10 or newer. `docker compose` (the v2 subcommand, not the old `docker-compose` script) if you want the web interface or a background worker. Nothing else. No Python on the host, no database server, no checkout. ## Which image ``` ghcr.io/oscarvalenzuelab/crane:latest # no validator bundled ghcr.io/oscarvalenzuelab/crane:X.Y.Z # pinned ghcr.io/oscarvalenzuelab/crane:ossbomer # ossbomer baked in ghcr.io/oscarvalenzuelab/crane:X.Y.Z-ossbomer # pinned, ossbomer baked in ``` The same four tags are published to Docker Hub under `ovalenzuela/crane`, from the same build. Either registry pulls the identical image; the examples here use ghcr.io. ``` docker pull ovalenzuela/crane:latest ``` Both variants are built for `linux/amd64` and `linux/arm64`, and no release is pushed with a fixable HIGH or CRITICAL vulnerability in it — the scan runs before the push, so a tag that exists is a tag that passed. Two variants, because the validator question has no single answer. SBOM conformance is checked by [ossbomer](https://pypi.org/project/ossbomer/), a separate tool. The plain image runs everything except validations, which fail saying the executable was not found. That is recorded as a coverage gap rather than a compliance verdict, and nothing is scored as failing because a tool was never installed. The `-ossbomer` variant carries it, which is the right default if you are measuring against the CRA or NTIA profiles. Pin a version for anything you depend on. `latest` moves. The version reported by `crane --version` and by `/healthz` is the version on the image label. The release workflow refuses to publish when the tag, `pyproject.toml` and `crane.__version__` disagree, so those three cannot drift apart. ## One-shot commands Every CRAne command is the image's entrypoint, so the container name is followed directly by the command: ```bash mkdir -p data docker run --rm -v "$PWD/data:/data" --user "$(id -u):$(id -g)" \ ghcr.io/oscarvalenzuelab/crane:latest init ``` Three things about that line matter, and skipping any of them produces an error that looks like a bug and is not: **The directory has to exist first.** Docker creates a missing bind mount source as root. The container is not root, so its first write fails. **`--user` has to be yours.** The image runs as uid 10001. A bind mount keeps the host's ownership, so without this nothing under `data/` is writable. On Docker Desktop for macOS the file sharing layer papers over this and you can leave `--user` off; on Linux you cannot. **Anything CRAne reads must be under a mount it can see.** A path that exists on your host does not exist inside the container. This is the single most common mistake and it has [its own section](troubleshooting#a-path-that-exists-on-the-host-does-not-exist-in-the-container). Shorten the repetition with a shell function: ```bash crane() { docker run --rm -i -v "$PWD/data:/data" -v "$PWD/exports:/exports" \ --user "$(id -u):$(id -g)" ghcr.io/oscarvalenzuelab/crane:latest "$@" } crane status crane assess cra --json ``` ## Compose, for anything that stays up The web interface, a background worker, and any deployment you do not want to retype need compose. The file is published alongside these docs and refers to the image by name, so there is still nothing to check out and nothing to build: ```bash curl -O https://oscarvalenzuelab.github.io/CRAne/assets/compose.yaml mkdir -p data exports runs report-templates export CRANE_UID=$(id -u) CRANE_GID=$(id -g) docker compose run --rm crane init docker compose run --rm crane status ``` Then bring up the interface: ```bash docker compose --profile ui up ``` . The port is mapped to `127.0.0.1` on purpose, so by default nothing outside this machine can reach it. Publishing it further is a deliberate act with a token attached, covered in [Configure](configure#exposing-it-beyond-this-machine). The compose file defines three services, each behind a profile so that bringing up one does not start the others: | Service | Profile | What it is | |---|---|---| | `crane` | `cli` | One-shot commands. `docker compose run` enables its profile for you | | `ui` | `ui` | The interface and the API, with a worker in the same process | | `worker` | `worker` | A worker on its own, for when the interface runs elsewhere | Running a worker inside the UI process and a second one as its own service at the same time is safe. A claimed job is leased, so two workers cannot pick up the same job. ## Volumes | Mount | Mode | Holds | |---|---|---| | `/data` | read write | The SQLite database, and uploaded evidence under `/data/uploads` | | `/exports` | read write | Generated reports and evidence packages | | `/runs` | read write | One directory per executed tool run: stdout, stderr, artifacts, manifest. Plus `objects/`, where each artifact is stored once under its own hash and linked into the runs that produced it — so this must be one filesystem, not several mounts stitched together | | `/report-templates` | read only | Your own report templates, if you write any | | `/sources` | read only | Anything you are importing from | Only `/data` is required. The rest are needed by the features that write to them, and CRAne says so rather than failing obscurely when one is absent. Mount `/sources` read only, and not by convention: let Docker enforce it. Importers also open source databases with SQLite's `mode=ro`, so there are two independent barriers between an importer bug and the system it is reading. ## Verify it worked ```bash docker run --rm -v "$PWD/data:/data" --user "$(id -u):$(id -g)" \ ghcr.io/oscarvalenzuelab/crane:latest status ``` You should see the database path, `18 migration(s)` or more, `pending: 0`, and a table of row counts that are all zero on a fresh install. If `pending` is not zero, run `migrate`. ## Upgrading The database is migrated forward in place, so an upgrade is a new image and one command: ```bash docker pull ghcr.io/oscarvalenzuelab/crane:latest docker run --rm -v "$PWD/data:/data" --user "$(id -u):$(id -g)" \ ghcr.io/oscarvalenzuelab/crane:latest migrate --dry-run # what would run docker run --rm -v "$PWD/data:/data" --user "$(id -u):$(id -g)" \ ghcr.io/oscarvalenzuelab/crane:latest migrate ``` Applied migrations are immutable and recorded with a checksum. A build that tried to change one already applied is refused by the runner rather than silently reapplied. Back up first. The database is one file, so a backup is a copy of it: ```bash cp data/crane.db "data/crane.db.$(date +%F)" ``` There is no automatic backup. That is [not covered yet](operate#what-is-not-covered-yet). There *is* a retention policy, and `crane job prune` applies it: run directories, ledger rows past their window, stored objects nothing refers to, and a `VACUUM` so the file actually shrinks. Run it on a schedule. `--dry-run` prints the plan and removes nothing. The windows, and the rule that keeps a run row that is provenance for a claim however old it is, are in [interfaces](interfaces#crane-job-listshowcancelprune-built). ### Upgrading past 0.11.1: identifiers changed CRAne derives an identifier for a row by joining the parts that describe it. That join used a bare `:` and escaped nothing, so a part that itself contained a colon could produce the identifier of a different arrangement of parts — two different facts sharing one identity. The delimiter is now escaped. **Most identifiers do not move.** The escape leaves any value without a colon in it exactly as it was, so an id derived from a slug, a numeric key or a table name is unchanged. What changes are ids derived from a URL, a package URL, a `registry/image:tag`, a timestamp, or another key — measured on the example portfolio, about 40% of derivations. A row keyed the old way cannot be found the new way, so importing the same source into a database built before this would **insert beside** the existing rows rather than update them, doubling the portfolio with no error anywhere. `crane migrate` records which recipe your database's rows came from, and the importers refuse rather than let that happen. Two ways forward: ```bash # Re-derive everything, which is the clean one: crane --database data/new.db init crane --database data/new.db import adapter /sources/.db # Or carry on with the database you have, accepting that rows already in it keep the # identifiers they were given: crane --database data/crane.db migrate --accept-id-scheme ``` A fresh `crane init` is unaffected and needs neither. ## Downgrading There is no down migration. Restore the file you copied before upgrading, and run the older image against it. ## Without Docker Python 3.11 or newer, and the SQLite that Python is linked against must be **SQLite 3.35.0** or later — the version `RETURNING` arrived in. That is a property of the Python build rather than a package you can upgrade separately, so a host with an older one needs a newer Python or the container image, which carries a supported one. CRAne checks on the first command rather than failing later from inside a write. No other system dependencies. The container image is the supported distribution and everything else on this page assumes it; this path needs a checkout of the repository instead: ```bash python3 -m venv .venv && source .venv/bin/activate pip install -e . export CRANE_DB=/secure/volume/crane.db crane init ``` The import and migration path uses only the standard library, and a test asserts that no module on it reaches for a web dependency. The web interface needs FastAPI, uvicorn and Jinja2, which install alongside it. ============================================================================== PAGE: First run (first-run.md) ============================================================================== # First run An empty inventory is a fair first impression and a useless demonstration. Load the invented portfolio, run the whole loop against it, and then throw it away. --- ## Load the demo ```bash docker run --rm -v "$PWD/data:/data" --user "$(id -u):$(id -g)" \ ghcr.io/oscarvalenzuelab/crane:latest demo ``` ``` loaded 140 rows: 8 products, 8 components, 5 SBOM records SBOM files written to /data/demo-sboms Next: crane validate sbom && crane worker run --once && crane assess cra ``` Eight invented products, chosen to show the situations this exists for: a component shared by several products, one with no build at all, SBOMs that pass and SBOMs that fail, a document that cannot be read, and a product nobody has scoped. `demo` refuses to run against a database that already has anything in it, so it cannot be mistaken for real inventory. `--force` overrides that, and you should not need it. The demo portfolio is fiction. Do not build a real deployment on top of it. Start over with an empty database instead, which is [one file to delete](#starting-over). ## Run the loop The working loop is four commands, and they are the same four whether the portfolio is invented or real. ```bash crane validate sbom --requested-by you # queue conformance checks. Returns immediately crane worker run --once # actually execute them crane assess cra # score, and store the reasoning crane report leadership # a document for somebody who did not run this ``` Queueing and executing are separate on purpose. `validate` never runs a tool; it registers work and returns. That is what makes it safe in cron and safe over an API, and it is why a deployment with no shell can still ask for validation. ### What you will see on the plain image ``` claimed 15, succeeded 0, failed 15, retrying 0 ``` All fifteen failed, and the exit code is `5`. This is correct and it is not a misconfiguration: the plain image carries no validator. Ask a failed job why: ```bash crane job list --status failed --json ``` ```json { "definition_key": "ossbomer.validate", "error": "the SBOM file does not exist at /data/demo-sboms/cirrus-sensor-missing.spdx.json", "attempts": 1, "max_attempts": 3 } ``` Two different failures are mixed together here, and telling them apart is the point of the exercise: - **The validator is not installed.** Use the `-ossbomer` image, or [install ossbomer next to the worker](operate#the-validator). - **The SBOM file genuinely is not there.** One demo product ships a deliberately missing document, because a source you cannot read is a real state and the report has to distinguish it from a source nobody asked about. ### The score ```bash crane assess cra ``` ``` obligation: cra v2 lens: cra.regulatory denominator: in scope 6 scope undecided 1 out of scope 1 products total 8 bands: amber 4 red 2 average score 59.2 checks across the population: composition.known 2 pass, 4 warn owner.accountable 4 pass, 2 warn sbom.conformance 6 insufficient evidence sbom.present 1 fail, 4 pass, 1 source inaccessible scope.decided 6 pass support.period.declared 2 fail, 4 pass vuln.contact.published 3 fail, 3 pass ``` Read the denominator before the score. Eight products exist; six are in scope under this lens; one has no scope decision and is counted neither as passing nor as failing, and one is out of scope. A number that does not say which population it measured is not an answer. Note the three distinct non-passing states, which most tools collapse into one: | State | Means | |---|---| | `fail` | Checked, and it does not hold | | `warn` | Checked, and it is weak rather than absent | | `insufficient evidence` | Nobody could check. Here, because no validator ran | | `source inaccessible` | The document exists in the inventory and could not be read | `insufficient evidence` is not a failing grade. Nothing is scored as failing on account of a tool that was never installed. ## Look at it ```bash docker run --rm -p 127.0.0.1:8000:8000 -v "$PWD/data:/data" \ --user "$(id -u):$(id -g)" -e CRANE_ALLOW_UNAUTHENTICATED_BIND=1 \ ghcr.io/oscarvalenzuelab/crane:latest serve --host 0.0.0.0 --port 8000 --worker ``` | Page | What it answers | |---|---| | `/` | Every obligation side by side. The only consolidated view | | `/attention` | What to do next, ranked. The page to open first on a working day | | `/products`, `/components` | The inventory, filterable, with a CSV at `/products.csv` | | `/graph` | How they interact: repositories feed components, components compose products. Anything nothing relates to is drawn as an island | | `/scope` | Scope decisions per lens, and what is undecided | | `/findings`, `/exceptions` | What failed, and what has been formally excused | | `/sbom` | Evidence, its content hashes, and its validation state | | `/jobs` | Queued and finished work, with stdout and stderr per run | | `/health` | Whether the build itself is coherent | | `/reports` | The three reports, rendered | | `/api/docs` | The HTTP API, browsable and complete | Every page except `/` works one obligation at a time, chosen in the header and carried in the URL as `?o=`, so a filtered view is a link you can send someone. `--worker` runs queued tools in the same process. Without it the interface will happily queue validations that nothing ever executes. ## Starting over The database is one file. Deleting it is the reset: ```bash rm -rf data/crane.db data/demo-sboms docker run --rm -v "$PWD/data:/data" --user "$(id -u):$(id -g)" \ ghcr.io/oscarvalenzuelab/crane:latest init ``` Now go to [Populate](populate) and put your own portfolio in. ============================================================================== PAGE: Populate (populate.md) ============================================================================== # Populate Getting your own portfolio in. CSV or JSON, validated as a batch, idempotent, and with no assumption about where the data came from. --- ## Discover the contract, do not guess it ```bash crane import spec --json ``` This prints every dataset, every column, every accepted value and every rule. It is generated from the same code that validates the import, so it cannot drift out of date and it is the authority when this page and it disagree. There is also a human-readable form: ```bash crane import spec ``` ## What it accepts - A **directory** of CSV files, one per dataset, named `.csv` - A **single .csv** file, named after its dataset - A **single .json** file whose top level keys are dataset names and whose values are arrays of row objects The JSON bundle is usually easiest to generate and the easiest to diff. ## The six rules 1. **Unknown columns are an error**, not ignored, so a typo is never silent data loss. 2. **Only the datasets you provide are touched.** Loading `distribution.csv` alone against an existing database is normal and is not a partial overwrite. 3. **References resolve** against rows in the same batch and rows already in the database. 4. **Everything loads as an observation needing review**, never as an approved decision, unless `source_kind` says otherwise and names an authority. 5. **Loading is idempotent.** The same input twice changes nothing the second time. 6. **Validation runs over the whole batch before anything is written.** Either every row loads or none do. Rule 6 is why a failed import leaves nothing behind to clean up. ## The smallest useful batch Three datasets are enough to have a portfolio that scores: ```bash 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 crane import data /data/batch.json --dry-run --json # writes nothing crane import data /data/batch.json --json ``` `/data/batch.json`, not `data/batch.json`. The path is resolved inside the container. Write the file into a directory you have mounted, and refer to it by its container path. Always dry run first. It reads, resolves and reports, then rolls back. ## Blank files to fill in ```bash crane import template /data/incoming ``` Writes one empty CSV per dataset with the correct header row. Fill in the ones you have, delete the ones you do not, and point `import data` at the directory. There is no requirement to provide all twenty. ## The datasets | Dataset | What it is | Required columns | |---|---|---| | `source_systems` | A system that holds metadata about the portfolio, and how to link back to it | `key`, `display_name` | | `org_units` | The accountability hierarchy: division, organization, team | `name`, `level` | | `parties` | People, teams and service principals that can own things | `display_name`, `party_type` | | `products` | A reviewable shipping or distributed software unit | `slug`, `canonical_name` | | `components` | A building block that may ship in several products | `slug`, `canonical_name` | | `build_workflows` | A build, release, packaging, signing or publication definition | `slug`, `canonical_name` | | `source_assets` | A repository, package, service record, catalog entry, image or binary | `canonical_locator`, `asset_type` | | `identities` | The same thing as known by another system. One row per subject per system | `subject_type`, `subject_slug`, `source_system`, `external_id` | | `product_components` | Which components ship in which products | `product_slug`, `component_slug` | | `component_workflows` | Which build workflows produce which components | `component_slug`, `workflow_slug` | | `product_relationships` | Product to product: containment, bundling, succession | `from_product_slug`, `to_product_slug`, `relationship_type` | | `workflow_assets` | What a build workflow reads and what it produces | `workflow_slug`, `asset_locator`, `relationship_type` | | `workflow_dependencies` | A build that depends on another build. The fan in and fan out case | `downstream_slug`, `upstream_ref` | | `responsibilities` | Who is accountable for what | `subject_type`, `subject_slug`, `party`, `role` | | `aliases` | Other names a subject is known by, so search and matching find it | `subject_type`, `subject_slug`, `alias` | | `scope` | Whether a subject is in scope, under one named definition | `subject_type`, `subject_slug`, `lens`, `status` | | `build_topology` | Where code lives and where the build runs. Two different questions | `subject_type`, `subject_slug` | | `distribution` | Where something enters and leaves the organization | `subject_type`, `subject_slug`, `direction`, `channel_type` | | `claims` | Anything else known: a support end date, a disclosure contact, a licence | `subject_type`, `subject_slug`, `claim_kind`, `value` | | `evidence` | Pointers to evidence. The file itself is never copied into the database | `subject_type`, `subject_slug`, `kind`, `uri` | The four subject types referenced throughout are `product`, `component`, `build_workflow` and `source_asset`. ## Loading incrementally References resolve against what is already stored, so adding one dataset later needs no other file: ```bash 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 ``` This is the normal shape of an ongoing feed: a nightly job writes whatever it knows, and CRAne merges it without disturbing anything it was not told about. ## Loading over HTTP Everything above works over the API as well as the command line, with no filesystem access and nothing fetched from a URL: the request body carries the rows. ```bash # One request, same importer, same all-or-nothing validation. curl -s -X POST $CRANE/api/v1/import/data \ -H "Authorization: Bearer $CRANE_API_TOKEN" \ -d '{"data": {"products": [...]}, "dry_run": true}' ``` That is the shorter path and usually the right one. Use the two-step flow when you need to be able to prove that what was loaded is what passed validation — an audited nightly feed, or an integration that has to record the digest of what it shipped: ```bash # 1. The contract, and the fingerprint that identifies it. curl -s $CRANE/api/v1/import/spec | jq '{fingerprint, limits}' # 2. Validate and stage. Writes nothing. An Idempotency-Key makes a retry one import. curl -s -X POST $CRANE/api/v1/imports \ -H "Authorization: Bearer $CRANE_API_TOKEN" \ -H "Idempotency-Key: nightly-$(date +%F)" \ -d '{"data": {...}, "requested_by": "nightly-sync", "source_system": "catalogue", "contract_fingerprint": ""}' # -> 201 with import_id, bundle_sha256, ok, errors[], warnings[], reconciliation # 3. Apply it. No bundle here: the id names the one that was validated. curl -s -X POST $CRANE/api/v1/imports/$IMPORT_ID/apply \ -H "Authorization: Bearer $CRANE_API_TOKEN" \ -d '{"requested_by": "nightly-sync"}' # -> 200 applied, or 202 applying when the worker takes it # 4. The receipt, whenever it is wanted. It persists. curl -s $CRANE/api/v1/imports/$IMPORT_ID ``` The apply step accepts no bundle, so there is nothing that could differ from what passed validation. What to know before writing the integration: * **`ok: false` still returns 201.** The bundle was received and refused, and `errors[]` names every row to fix. The attempt is on record either way, which is the point of a receipt. * **Only a validated import can be applied.** Fix the rows and stage again. * **Applying twice returns the first receipt.** It does not load twice. * **The fingerprint is checked at both steps.** If the contract moved in between, the apply is refused rather than reinterpreted. Re-read the spec and re-stage. * **A `202` means the worker has it.** Poll the receipt for `status: applied`. There are no scoped import permissions and no per-tenant isolation: the token is all or nothing, so anyone who can import can import anything. ## Things that are easy to get wrong **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 into one. **Direction matters.** `outbound` means you make it available, which is what puts a product on the market. `inbound` means you 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 two 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. **Do not invent values.** A field you do not know is left empty. Empty means nobody has looked, which is a state CRAne reports honestly. A guess becomes a fact nobody can later distinguish from a real one. **Dates are ISO, and anything else is rejected.** `2026-08-01`, or a full ISO timestamp whose date part is read. `08/01/2026` is refused because CRAne will not guess whether that is January or August, and because a date is compared as text once stored — an end date that is not a date decides whether a relationship is still in force by comparing something that cannot be compared. This used to be a warning that still loaded the row, which meant a feed was told it had succeeded and then read back wrong. ## Observations and decisions are not the same thing Everything you import is an **observation**. It is what some system says, recorded with where it came from. It is not an approval, and CRAne will not treat it as one. A **decision** is a separate, recorded act with a named authority and a rationale. Scope is the common case: ```bash 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" ``` `--by` and `--rationale` are both required. An approved decision with no name against it is not one, and the rationale must be at least eight characters because it is the sentence somebody reads in a year. The status is one of `in_scope`, `out_of_scope`, `undecided`, `not_applicable` or `disputed`. That writes the decision first, then the claim citing it. The two are linked, and the report can always answer "who said so, and when". Never write `source_kind: approved` in an imported scope row unless a human actually approved it. Inventing an authority corrupts the audit trail this exists to keep. This matters especially when [an agent is doing the loading](agents). When two sources disagree, both statements are kept. A lower authority claim never silently overwrites a higher authority one. Conflicts surface at `/api/v1/conflicts` and on the `/findings` page, where a person settles them. ## Evidence Evidence is registered by URI. The file is never copied into the database: ```json {"evidence": [{ "subject_type": "product", "subject_slug": "gateway", "kind": "sbom", "uri": "/data/sboms/gateway-2.4.0.spdx.json" }]} ``` **Register the path the worker will see.** The worker resolves that URI on its own filesystem. Register `/home/me/sboms/x.spdx.json` and then run the worker in a container, and every validation fails with "the SBOM file does not exist", because inside the container that path is not there. Mount the documents and register the container path. `crane job show ` prints the path it tried. Uploading an SBOM through the interface (`/import`), `POST /api/v1/import/sbom`, or `crane import sbom PATH` stores the file under `/data/uploads` and registers it for you, which sidesteps the whole problem. All three call the same function: the document is identified by content, stored by hash, attached to a product, its packages become inventory unless you ask for evidence only, and validation plus a rescore are queued behind it. ## Adapters An adapter reads a source system directly instead of you exporting from it. Published images carry **no adapters at all**. Each one describes somebody else's system, its table names and by implication its data model, and none of that belongs in a public image. A deployment that needs one installs its own. ```bash crane import adapter # what this deployment actually has crane import adapter /sources/legacy.db --dry-run crane import adapter /sources/legacy.db --report /exports/migration-report.md ``` Read the reconciliation report before doing anything else. Its unresolved list is the work queue for the first review pass, and its counts have to add up to the source row counts. If they do not, that is a bug worth reporting rather than working around. Re-running an adapter import is safe: identifiers are derived from the source rows, so a second run updates in place rather than duplicating the portfolio. ## Collecting from GitHub Where the code lives on GitHub, the inventory can build itself: ```bash crane collect github acme --dry-run # scan one batch, report, write nothing crane collect github acme # queue the scan; a worker does the reading ``` Repositories become source assets with external identities, workflow files become build workflows with their last run, releases become outbound distribution, SBOM release assets become evidence for the ordinary validator path, and the catch-all CODEOWNERS rule becomes unreviewed maintainer assignments. Everything goes through the same importer as the files above, so the six rules apply unchanged — in particular, nothing a scanner writes is approved, and **no products are created**: attaching repositories to products is a decision you record afterwards, not one a scanner makes. Private repositories need `CRANE_GITHUB_TOKEN` in the worker's environment. ## Other CI/CD platforms GitHub is the only platform with a built-in collector. GitLab, Azure DevOps, Jenkins and the rest are populated through the bulk contract on this page, which is not a lesser path: the GitHub collector itself writes nothing directly. It scans, builds a bundle in exactly the format documented above, and hands it to the same importer, subject to the same six rules. Anything it can express, your own script can express. So the shape for another platform is the shape of any ongoing feed: 1. Read the platform's API with whatever already has credentials for it. 2. Write the datasets you can fill — `source_assets` for the repositories, `build_workflows`, `workflow_assets`, `component_workflows`, `distribution`, `evidence`, `identities` — leaving out what you do not know. Absent is silence, not a denial. 3. Send it to `/api/v1/imports`, then apply it, as under *Loading over HTTP*. 4. Run it on a schedule. Re-sending the same bundle is a no-op, and a bundle that carries no `review_status` never disturbs an approval somebody recorded. The things worth getting right are the same ones a collector has to get right: derive stable slugs so a second run updates rather than duplicates, mark everything `imported` rather than `approved`, and do not invent products — attaching repositories to products is a decision a person records, not one a scanner makes. Native endpoints for the common platforms are tracked in issue #58. Nothing waits on them; they would remove the script, not unlock the data. ## When an import fails Exit code `4` means the input was read and did not validate. The errors are records, not prose, each naming a dataset, a row number, a column and what is wrong: ```bash crane import data /data/batch.json --json ``` ```json { "ok": false, "written": {}, "errors": [ { "dataset": "products", "row": 1, "column": "nonsense_column", "value": null, "message": "unknown column. Unknown columns are rejected so a typo is never silent data loss" } ], "warnings": [] } ``` `"written": {}` is the important part. Nothing was written, so there is no half-loaded state to undo. Fix the rows and retry; retrying unchanged input fails identically. ### Errors, findings and conflicts are three different things A successful import can still tell you about problems, and the distinction decides who has to do something about it: | In the result | Exit | Means | |---|---|---| | `errors[]` | `4` | The batch was rejected. Nothing was written | | `findings{}` | `0` | Loaded, and something about the data is worth a person's attention | | `conflicts` | `0` | Loaded, and it disagrees with what a different source already said | A value that is not on the accepted list for its column is usually a **finding**, not an error: the row loads, and the finding says the value could not be interpreted. Structural problems, like an unknown column or an unresolvable reference, are errors and stop the batch. So `ok: true` is not the same as "nothing to look at" — check `findings` too. ============================================================================== PAGE: Writing an adapter (writing-an-adapter.md) ============================================================================== # Writing an adapter You already have an inventory somewhere — a spreadsheet, a build tracker, a CMDB, a home-grown SQLite file — and no reason to retype it. An adapter reads that system and writes what it says into CRAne's model. `crane.adapters.example` is a complete worked one, in the repository, exercised by the suite. Read it first: it is small enough to hold in your head, and everything below is visible in it. ```bash crane import adapter # lists what is installed crane import adapter example # runs the example against its invented source crane import adapter example --dry-run --json ``` ## Where an adapter lives `crane.adapters.` is what `crane import adapter ` imports. Discovery is by module name and nothing else, and nothing in the core imports an adapter — so a deployment that carries none works normally, and `crane import adapter` with no name says what is not installed and exits 2. **Your adapter is not committed to this repository, and that is deliberate.** An adapter reads somebody else's system, so it necessarily exposes that system's table names and by implication its data model. That is tenant tooling, not part of the product: it stays out of the repository and out of every published image. `.gitignore` keeps `src/crane/adapters/*.py` out, with `example.py` as the single exception — it reads an invented source that describes nobody's real system, which is what lets the contract be exercised in CI. Keep your adapter in version control somewhere of your own. Nothing here backs it up. ## The contract Two functions. The CLI calls nothing else: ```python def run_import(conn, path, *, options=None, dry_run=False) -> Report: ... def render_report(report) -> str: ... ``` `run_import` returns anything with `as_dict()` and `unresolved`. `render_report` returns the reconciliation report as text. `cmd_import_adapter` checks for both by name and exits 2 without them, so an adapter that is half-written is refused rather than half-run. ## Five rules **1. Open the source read only.** ```python conn = sqlite3.connect(f"file:{path}?mode=ro", uri=True) ``` Not a convention — a URI the driver enforces. An adapter reads somebody's system of record, and a bug in one must not be able to write there. **2. Account for every row.** A row that imports is counted; a row that cannot be placed becomes an `unresolved` entry naming the table, the row and the reason. Nothing is dropped silently, because a migration that loses one row in a thousand is worse than one that fails outright — nobody finds out about the first. `"2 rows unresolved"` is not something anybody can act on. The table, the row and the reason are. **3. Register vocabulary, do not translate it.** ```python self.w.register_vocabulary(NAMESPACE, VOCABULARY, owning_adapter=NAMESPACE) ``` Your source says things CRAne has no core term for. Declaring them at run time, in your own namespace, means installing an adapter is never a core schema change — and a value keeps the meaning it had at the source instead of being flattened into the nearest core term and losing what it said. **4. Discovered is not decided.** A row carrying an approver becomes an approved decision with that person named as the authority. Everything else lands as an unreviewed observation. Inventing an approval produces an audit trail nobody can distinguish from a real one, which is the one thing this tool exists to prevent. **5. Open a transaction explicitly.** ```python conn.execute("BEGIN") try: ... conn.rollback() if self.dry_run else conn.commit() except BaseException: conn.rollback() raise ``` `db.connect` opens SQLite with `isolation_level=None`, so a connection is in autocommit and every statement is already its own transaction. Without the `BEGIN`, `rollback()` has nothing to undo: `--dry-run` writes the entire import to disk while reporting that it did not, and a failure part-way leaves half a portfolio behind. ## Deterministic ids Every target id is derived from a stable description of the source row, so a second run updates in place rather than duplicating. ```python ids.legacy_id(LEGACY_DB, "products", row["id"]) # a UUID for one source row ids.dedupe_key(LEGACY_DB, "products", row["id"], "tier") # a readable idempotency key ``` Re-running an import is the ordinary way to pick up changes at the source, and it has to converge. An adapter whose second run doubles the portfolio is worse than one that refuses to run twice. ## The invented source the example reads One SQLite file, three tables. Small on purpose, and shaped to exercise the parts of the model an adapter usually has to reach. | Table | Columns | What it exercises | |---|---|---| | `teams` | `division`, `organization`, `team` | the three-level accountability hierarchy | | `products` | `name`, `lifecycle`, `owning_team`, `tier`, `in_scope`, `scope_approved_by` | ownership, registered vocabulary, approved versus observed scope | | `components` | `product_id`, `name`, `purl`, `source_repo`, `repo_public`, `build_location`, `open_source` | composition, distribution in both directions, and keeping *where the build ran* separate from *where the code lives* | The fixture in `tests/fixtures/example_source.py` fills it with rows that are each a different case rather than three of the same thing — including two that cannot be placed, so the unresolved path is exercised rather than described. ## Testing yours `tests/test_the_adapter_contract.py` tests the contract rather than the example's behaviour: the two functions exist, unknown options are refused by name, the source is genuinely read only, every row is imported or named, vocabulary is namespaced, approvals are never invented, a dry run writes nothing and reports what a real run would do, and a second import changes nothing. Point it at your adapter and the same assertions apply. The adapter-agnostic tests in `test_core_independence.py`, `test_deployment.py` and `test_organization_and_distribution.py` name no adapter at all — they read `ADAPTER_MODULE` and `build_all()` from the fixture module, so swapping which adapter is exercised is one import away. ============================================================================== PAGE: Operate (operate.md) ============================================================================== # Operate Running it as a going concern: validating evidence, scoring, reporting, and putting the whole thing on a schedule. --- ## Obligations, checks and lenses ```bash crane policy list --json # the obligations, their checks and their weights crane lens list # the definitions of "product" in play ``` CRA ships as the first policy, alongside FedRAMP and the NTIA minimum elements. Each names its own checks, weights, reporting lens and the validator profile it depends on. Adding another obligation is a policy row and a profile file, not a code change. A **lens** is a definition of what counts as a product. Four ship by default: | Lens | Whose definition | |---|---| | `cra.regulatory` | The regulatory denominator, and the default for leadership reporting | | `commercial` | What the business sells as a distinct purchasable unit | | `legal.oss` | What legal treats as a distributable unit for open source obligations | | `shiproom` | What the ship room reviews and approves as a release | A product can be in scope under one and out of scope under another, and both statements are kept. Every reported number names the lens it used. Do not reconcile them. ## The validator SBOM conformance is checked by [ossbomer](https://pypi.org/project/ossbomer/), a separate tool. Use the `-ossbomer` image and it is already there. Otherwise install it wherever the worker runs and point CRAne at it: ```bash pipx install "ossbomer==2.2.0" export CRANE_OSSBOMER_BIN=/path/to/ossbomer # only if not on PATH export CRANE_OSSBOMER_PROFILE_PATH=/opt/our-profiles # your own regulation profiles crane validate profiles # confirms the wiring ``` `crane validate profiles` exits `1` when a policy needs a profile this installation does not have, **or when a profile it names has been withdrawn upstream**. Run it after changing policies or the profile directory, and after upgrading the validator. A withdrawn profile is worse than a missing one. ossbomer keeps withdrawn profiles in the catalog so old results stay explainable, and refuses to run them, so every validation fails as a tool error rather than a verdict and the check sits unmeasured while looking transient. This has happened once already: `eu-cra-annex-vii` was withdrawn for citing CRA Annex VII 8(a)-(c), which do not exist, and the CRA policy moved to `eu-cra-annex-i`. Custom profiles are ossbomer's own mechanism. A profile you write for another regulation is a YAML file in that directory, and an obligation policy referring to it by id is all CRAne needs to know about it. If ossbomer is absent, validations fail saying the executable was not found and the job is not retried. Nothing is scored as failing on account of a tool that was never installed. ## Evidence registered at a URL A validation job reads local files and refuses a remote URI. That is deliberate: a job reaching the network would be a surprise on the machine it runs on. The refusal says to fetch the document first and register where it landed, and there is a command for that: ```bash crane manage evidence-fetch --dry-run # what would be fetched crane manage evidence-fetch --by "Priya" # fetch it, under your network policy crane validate sbom # now they can be read ``` Only SBOMs, only `http` and `https`, only documents reachable without credentials, and from the network of the host the command runs on. Each document is identified by content and stored under its hash in the uploads directory, exactly as an upload is, so the same document arriving both ways is one file. The artifact keeps its URI, which is where the document lives and came from, and gains the content hash, which is how the validator finds the local copy; the fetch is on the change record. A document that turns out to be a different format from the one it was registered as is refused and left alone. A fetch that fails marks the document `inaccessible` with the reason, which is the one place that state is literally true: somebody tried and could not see, and it is tried again next time. The job itself still never fetches, and neither does an import or a collection. ## Queue, then execute ```bash crane validate sbom --requested-by nightly --json # queues, returns immediately crane worker run --once --json # claims and runs, then exits ``` `validate` never runs a tool. It registers work for evidence that has not been validated at its current content hash, and returns. Re-queueing unchanged evidence queues nothing. One exception. A run that timed out is a verdict on the budget, not on the document, so `validate` queues that document again. The budget is the job definition's limit plus `CRANE_OSSBOMER_SECONDS_PER_MB` (default 60) for every megabyte of the file, so a large document gets more time than a small one rather than the same time and the same timeout. When one still runs out, the job's error says how large the file was and what it was given; raise the rate on the worker and run `validate` again. The SBOM page counts these separately from documents nobody has validated yet, because waiting is a strategy for the second and not the first. Prefer `worker run --once` over a long-lived process. There is nothing to keep alive and nothing to restart, and if one tick runs long the next cannot pick up the same job because the first still holds the lease. `--follow` exists for a container that must stay up. Exit `5` from the worker means at least one job failed. That is a condition a person should look at, and it is not an outage. ## Scoring ```bash crane assess cra --json # the whole portfolio crane assess cra --subject gateway --json # one subject crane assess cra --detail # per check, with the reasoning crane assess cra --queue # queue it instead of running it now ``` Scoring stores the per-check explanation behind every number, not just the number. **Nothing is approved by scoring.** It is a measurement, and it never changes a decision. Read the denominator before the score. A product with no scope decision is counted neither as passing nor as failing, and a score that does not say which population it measured is not an answer. `POST /api/v1/assess` queues the same job, which is how the score moves in a deployment with no shell. ## Reports ```bash crane report list # what exists crane report leadership --format json # for a steering committee crane report audit --format html --out ./audit.html # for an auditor crane report worklist --format json # what to do next, by owner crane report package --out ./handover # hashed, for handing over ``` Three audiences, three documents, because a steering committee, an auditor and the people doing the work want different documents rather than different sections of one. 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". ### Your own reports ```bash crane report templates # what you have written crane report render division-readiness --param division="Platform" --format json ``` A template is a Markdown file with TOML front matter in `$CRANE_REPORT_TEMPLATES`. Mount that directory; it is your content and is deliberately not baked into the image. Without the mount, CRAne correctly reports that you have written none. When somebody asks for a report that does not exist, write a template rather than post-processing one that does. That way it can be run again next month. ## Changing data Everything the web interface can change is also a command, calling the same function, so the two cannot disagree about what a decision is: ```bash crane manage create|edit|own|resolve|review|except|revoke|finding|exceptions|identity|unidentify|link ``` Governed acts take a reason and a name. Reference data does not, because it asserts nothing about the portfolio. Nothing is deleted. A removed owner is rejected, a revoked exception keeps its grant. What used to be true, and when it stopped being true, remains a question you can ask. ## Jobs and run directories Every executed tool gets a directory under `$CRANE_RUNS` holding its stdout, stderr, artifacts and a manifest. ```bash crane job list --status failed --json crane job show --json # parameters, events, artifacts, the run directory crane job cancel ``` ### Where an artifact actually lives An artifact is stored once, under `$CRANE_RUNS/objects`, named by its own SHA-256. The familiar copy inside the run directory is a hard link to those same bytes, so one copy carries two names and somebody debugging a failure still finds the file where they expect it. Five runs producing one 200 KB artifact occupy 195 KiB rather than 977 KiB. This is worth knowing for two reasons. Both names have to sit on one filesystem, so `$CRANE_RUNS` cannot span mount points. And an artifact's recorded URI now outlives its run directory: a pruned run no longer leaves its rows pointing at a path that is gone. ### Pruning Run directories accumulate, so something has to remove them: ```bash crane job prune --dry-run # what would go crane job prune # remove it ``` Failures are kept 90 days and successes 14, because a failure is the one somebody opens. Only finished runs are considered; a pending or running job still owns its directory. Nothing in the database is touched: every artifact that mattered was registered with its hash when the run finished, so what goes is the working copy and the logs. `crane job show` says so explicitly when a run directory has already been swept, rather than printing a path that is not there. The bytes freed are only the bytes the disk actually gives back. A file in the run directory that another name still holds is not counted, because removing one of two names frees nothing. The same command then sweeps the object store, removing objects no `job_artifact` row refers to. Pruning a run deliberately cannot free an object: an object is referenced by every run that produced those bytes, so the store needs its own sweep or it only ever grows. What that sweep reclaims today is narrower than it sounds, and worth stating plainly: nothing yet deletes a `job_artifact` row, so an object stays claimed for the life of the installation. What it collects now is an object orphaned by a crash between storing one and recording it. It becomes the general thing when run retention starts removing rows. One refusal to expect. Pointed at a database that records no job artifacts at all, the sweep stops and says so rather than treating every object as unreferenced — a typo in `--database`, a fresh `crane init`, or a restored backup is far likelier than a portfolio that genuinely produced a store and no rows. `crane job prune` prints `objects not swept:` with the reason. Run directories are still pruned normally, because that half deletes what the database lists rather than what it omits. ## On a schedule Two things run on a schedule: something feeds data in, and a worker executes queued tools. Both are safe to fire repeatedly, which is the property that makes cron usable here. ```cron # Load whatever the collection scripts produced. Nothing happens if the batch is unchanged, # and a batch that does not validate is rejected whole rather than half applied. */30 * * * * cd /opt/crane && crane import data /var/lib/crane/incoming --json >> /var/log/crane/import.log 2>&1 # Queue validations for evidence not yet validated at this content hash. 15 * * * * cd /opt/crane && crane validate sbom --requested-by cron --json >> /var/log/crane/validate.log 2>&1 # Do the queued work and exit. Overlapping runs are safe: a claimed job is leased. */5 * * * * cd /opt/crane && crane worker run --once --json >> /var/log/crane/worker.log 2>&1 # Sweep run directories past their retention window. 30 3 * * * cd /opt/crane && crane job prune ``` With the container, the same thing wrapped: ```cron */5 * * * * cd /opt/crane && docker compose run --rm crane worker run --once ``` Exit codes worth wiring into alerting: `4` from an import means the data did not validate, `5` from the worker means at least one job failed. Both want a person, neither is an outage. ## Backups The database is one file. A backup is a copy of it, and there is no built-in schedule: ```bash 30 2 * * * cp /opt/crane/data/crane.db /backup/crane-$(date +\%F).db ``` Copy it while nothing is writing, or use `sqlite3 data/crane.db ".backup"` for a consistent copy under load. Uploaded evidence under `/data/uploads` and generated packages under `/exports` are files too, and are not in the database. So are run artifacts. `$CRANE_RUNS/objects` holds the stored copy of every artifact a job produced, and the database records the hash rather than the bytes — back it up and a restored database can still resolve what its rows point at. Copy it with a tool that understands hard links, or the run directories beside it turn one shared copy back into many. The rest of `$CRANE_RUNS` is working files and logs, and is expendable. ## What is not covered yet - **No backup policy.** It is one file, so copy it on a schedule. Nothing does that for you. - **One shared token, no accounts, no roles.** Every authenticated user is the same user. - **No secrets handling beyond the environment.** A live connector's credential belongs in an environment or a secret provider, never in the database or the image. - **No down migrations.** Restore the file you copied before upgrading. ============================================================================== PAGE: Configure (configure.md) ============================================================================== # Configure Everything is an environment variable. No configuration file, no hardcoded path. --- ## Environment variables | Variable | Default | Meaning | |---|---|---| | `CRANE_DB` | `./data/crane.db` | Database file | | `CRANE_MIGRATIONS` | searched | Migration directory | | `CRANE_EXPORTS` | `./exports` | Where reports and evidence packages are written | | `CRANE_RUNS` | `./runs` | Working directory for each executed tool run | | `CRANE_UPLOADS` | next to the database | Where the import wizard stores uploaded documents | | `CRANE_REPORT_TEMPLATES` | `./report-templates` | Directory holding your own Markdown report templates | | `CRANE_API_TOKEN` | unset | Token the web interface and API require. [See below](#authentication) | | `CRANE_ALLOW_UNAUTHENTICATED_BIND` | unset | Assert the port is restricted outside this process. [See below](#exposing-it-beyond-this-machine) | | `CRANE_OSSBOMER_BIN` | `ossbomer` on PATH | The validator executable, when it is not on PATH | | `CRANE_OSSBOMER_PROFILE_PATH` | unset | Directories holding your own validator profiles | | `CRANE_OSSBOMER_SECONDS_PER_MB` | `60` | Validation time each megabyte of document adds to the job's own 600 s limit, so a large document gets more time rather than the same timeout. See [troubleshooting](troubleshooting#a-validation-ran-out-of-time) | | `CRANE_GITHUB_TOKEN` | unset | Token the GitHub collector uses to see private repositories. Unset, public organizations still scan inside GitHub's anonymous rate limit | | `CRANE_GITLAB_TOKEN` | unset | Token the GitLab collector uses to see private projects. Unset, public projects still scan inside GitLab's unauthenticated rate limit | | `CRANE_ADO_TOKEN` | unset | Personal access token the Azure DevOps collector uses. Azure sends anonymous callers at a private organization to a sign-in page rather than refusing them, so without this a private organization reports a refusal naming this variable | | `CRANE_PURL2SRC_BIN` | `purl2src` on PATH | The source resolver executable, when it is not on PATH | | `CRANE_SUPHM_BIN` | `suphm` on PATH | The upstream health executable, when it is not on PATH | | `CRANE_AGENT_BIN` | the configured agent's name on PATH | The interpretation agent executable, when it is not on PATH. Read only where **this server** runs the agent — by default a runner does, and that machine names the executable in its own `[commands]` instead | | `SOURCES_DIR` | `./sources` | Host directory mounted read only at `/sources`. Compose only | | `CRANE_UID`, `CRANE_GID` | `10001` | Uid and gid the container runs as. Compose only | The image sets `CRANE_DB`, `CRANE_EXPORTS`, `CRANE_RUNS` and `CRANE_MIGRATIONS` to their container paths already. Every path is named explicitly rather than left to a default, because a default would resolve relative to the installed package inside `site-packages`, which is not writable and is not data. A test reads these names back out of the source and fails if one is missing from this table, because the table drifted once already and nothing was watching it. ### On a runner These are read by `crane-runner`, which runs on the machine executing a tool rather than on this one. None of them are read here, and nothing here is read there — a runner holds a runner token and has no database. Every one of them can also come from the runner's configuration file, and the file is where the token belongs: it can be mode 600, and it keeps the secret out of a shell history and out of `ps`. The environment wins over the file where both say something. | Variable | Default | Meaning | |---|---|---| | `CRANE_RUNNER_CONFIG` | `~/.config/crane-runner/config.toml`, then `/etc/crane-runner/config.toml` | The configuration file. Named and missing is an error, not a fallback | | `CRANE_RUNNER_SERVER` | unset | The CRAne to claim from, as an `https://` URL | | `CRANE_RUNNER_TOKEN` | unset | The runner's own credential, from `crane runner token issue`. For a container with no persistent disk, so the token need not be in a file | | `CRANE_RUNNER_ID` | the hostname | Which machine this is. Recorded against every run it claims | | `CRANE_RUNNER_CAPABILITIES` | none | Comma separated. What this machine can execute. Narrows what it is offered and never widens it — the token is the ceiling | | `CRANE_RUNNER_MAX_OUTPUT` | `262144` | Bytes of combined stdout and stderr the runner will carry back. **Above this the job is refused**, with the size and the ceiling recorded against the run, and none of the output is sent. There is no setting for unbounded | | `CRANE_RUNNER_MAX_ARTIFACT` | `33554432` | Bytes of any single file the runner hands over as an artifact. Separate from the output ceiling and much larger, because a tool that writes an SBOM has not printed it. **Above this the job is refused** rather than the file being skipped, so a run never succeeds with its answer missing. The server enforces the same number; there is no setting for unbounded | | `CRANE_RUNNER_WAIT_SECONDS` | `20` | How long the server may hold a claim open waiting for work before answering "nothing". Replaces asking repeatedly: the fleet makes one request per wait rather than one per poll interval, and a job queued a moment after a runner asked is picked up straight away instead of after a backed-off sleep. `0` asks once and returns, which is what a runner did before this existed. The server clamps anything above `30` | | `CRANE_RUNNER_LEASE_SECONDS` | `300` | How long each claim asks to hold a job. Between 30 and 3600; the server refuses a claim outside that. Heartbeats extend it while a command runs, so this is how long a runner that *dies* strands a job for | | `CRANE_RUNNER_POLL_SECONDS` | `5` | How often an idle runner asks for work | | `CRANE_RUNNER_MAX_POLL_SECONDS` | `60` | The ceiling the idle poll backs off to | | `CRANE_RUNNER_MAX_TIMEOUT_SECONDS` | `3600` | The longest any one command may run on this machine, whatever the job definition asks for. The smaller of the two wins | | `CRANE_RUNNER_WORK_DIR` | the system temporary directory | Where each command's scratch directory is made, and removed afterwards | `[commands]` — which argv runs for which job definition — is file-only, and deliberately. It is the allowlist of what the machine will execute, and a mapping squeezed into an environment variable is one nobody can read. CRAne never sends a command: it sends a definition key and parameters, and the parameters reach the command on its stdin as JSON. ## Authentication There are no accounts and no roles. Authentication is **one shared token**, and every authenticated user is the same user. Set `CRANE_API_TOKEN` and the whole interface and API require it: ```bash docker run --rm -p 127.0.0.1:8000:8000 -v "$PWD/data:/data" \ --user "$(id -u):$(id -g)" -e CRANE_API_TOKEN="$(openssl rand -hex 32)" \ ghcr.io/oscarvalenzuelab/crane:latest serve --host 0.0.0.0 --port 8000 --worker ``` **From a program**, send it as either header: ```bash curl -H "Authorization: Bearer $CRANE_API_TOKEN" http://127.0.0.1:8000/api/v1/status curl -H "X-API-Token: $CRANE_API_TOKEN" http://127.0.0.1:8000/api/v1/status ``` Without one, every route answers `401`. **From a browser**, go to `/login` and paste the token. That sets a session cookie and the rest of the interface works normally. `/login` itself is reachable without the token, for obvious reasons; nothing else is. Note that a browser hitting `/` directly gets a bare `401` rather than a redirect, so `/login` is the URL to hand people. `/healthz` is public on purpose, even with a token set. The thing that restarts the container has no token, and "which build is actually running" is the first question after a deploy. There is no sign-out control, because the session cookie is not what lets anybody in: the token is, and revoking it means changing `CRANE_API_TOKEN` and restarting. ## Exposing it beyond this machine Inside a container the server must bind `0.0.0.0` for a published port to reach it at all. It cannot see how the port was published, so it refuses the combination it cannot verify: ``` error: refusing to bind to 0.0.0.0 without CRANE_API_TOKEN. There is no authentication, so this would put the whole inventory on the network. Set CRANE_API_TOKEN, which is the right answer almost always. Or bind to 127.0.0.1 if this is your own machine. Or set CRANE_ALLOW_UNAUTHENTICATED_BIND=1 if the port is already restricted by something outside this process. ``` Three configurations, and only the first two are safe to publish: | Situation | What to set | |---|---| | Reachable by other people | `CRANE_API_TOKEN`. Nothing else | | Your own machine, port mapped to `127.0.0.1` | `CRANE_ALLOW_UNAUTHENTICATED_BIND=1` | | Your own machine, no container | Bind `127.0.0.1`, set nothing | `CRANE_ALLOW_UNAUTHENTICATED_BIND=1` is an assertion you are making, and CRAne cannot check it. It says "something outside this process restricts who can reach the port". In the published compose file that something is the `127.0.0.1:8000:8000` mapping. Change that mapping to `8000:8000` and the assertion becomes false, and the entire portfolio is readable by anyone who can route to the host. Set a token and delete the override. Put a reverse proxy in front of it for TLS. CRAne serves plain HTTP and does not terminate TLS itself. ## File ownership The image runs as uid `10001`. A bind mount keeps the host's ownership, so the container's user has to be able to write it: ```bash export CRANE_UID=$(id -u) CRANE_GID=$(id -g) # compose docker run --user "$(id -u):$(id -g)" ... # plain docker ``` Create the directories before the first run. Docker creates a missing bind mount source as root, and the container is not root, so its first write fails with a permission error that looks like a bug and is not. Named volumes avoid all of this, at the cost of the database no longer being a file you can open with a SQLite browser. That is usually the wrong trade for this tool. On Docker Desktop for macOS the file sharing layer maps ownership for you and `--user` can be omitted. On Linux it cannot. ### SELinux On Fedora, RHEL and derivatives, add `:z` to bind mounts or the container cannot read them: ```bash docker run -v "$PWD/data:/data:z" ... ``` ## Paths inside the container CRAne resolves every path on its own filesystem. A path that exists on your host does not exist inside the container unless you mounted it there. | You want to | Do this | |---|---| | Import a file | Put it under `data/`, refer to it as `/data/` | | Import from a source system | Mount it at `/sources`, read only | | Get a report out | Write it to `/exports` | | Register SBOM evidence | Register the **container** path, not the host path | This is the single most common mistake, and it has [its own section](troubleshooting#a-path-that-exists-on-the-host-does-not-exist-in-the-container). ## Validator profiles ```bash export CRANE_OSSBOMER_BIN=/usr/local/bin/ossbomer # only if not on PATH export CRANE_OSSBOMER_PROFILE_PATH=/opt/our-profiles crane validate profiles # confirms the wiring ``` Covered in full under [The validator](operate#the-validator). ============================================================================== PAGE: For AI agents (agents.md) ============================================================================== # 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. --- ## Pointing an agent at this Three ways, in order of preference: 1. **Fetch [`/CRAne/llms-full.txt`](llms-full.txt)** — this entire documentation set flattened into one plain-text file, meant to be read in a single request. [`/CRAne/llms.txt`](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: ```bash 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](#the-rules-that-matter) and the [exit codes](#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 `. Against the published image that is: ```bash docker run --rm -v "$PWD/data:/data" --user "$(id -u):$(id -g)" \ ghcr.io/oscarvalenzuelab/crane:latest ``` Define it once and forget it: ```bash 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](#which-commands-speak-json) 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 --format json` is a different flag from `--json`, and it is the one that controls the rendered report body. ## The normal loop ```bash # 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: ```bash 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 ` prints the path it tried. ## Decisions are governed acts Scope is the common case, and it is not a field update: ```bash 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 ```bash 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 --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 ```bash 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 ```bash 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](reference/data-contract), or `crane import spec --json` | | Every command, flag and route | [Interface contract](interfaces), or `/api/openapi.json` | | The database, with worked queries | [Data model](erd) | | Why it is shaped this way | [Design decisions](adr/0001-foundation) | ============================================================================== PAGE: Troubleshooting (troubleshooting.md) ============================================================================== # Troubleshooting Every entry here is a real message you can hit, what causes it, and what to do. Most are CRAne telling you something true about your environment rather than failing. --- ## Permission denied writing to /data ``` sqlite3.OperationalError: unable to open database file ``` The container runs as uid `10001`, and a bind mount keeps the host's ownership. Two causes, usually together: **The directory did not exist.** Docker created it as root when it mounted it. Create it yourself first: ```bash mkdir -p data exports runs ``` **The container's user is not you.** Hand it your uid: ```bash docker run --user "$(id -u):$(id -g)" ... # plain docker export CRANE_UID=$(id -u) CRANE_GID=$(id -g) # compose ``` If the directory is already owned by root from an earlier attempt: ```bash sudo chown -R "$(id -u):$(id -g)" data exports runs ``` On Fedora, RHEL and derivatives add `:z` to the mount, or SELinux blocks it regardless of ownership: `-v "$PWD/data:/data:z"`. ## could not store the upload ``` could not store the upload: Permission denied on '/data/uploads' ``` The sneakier sibling of the problem above: the database opens fine, pages render, and then the first upload through the wizard or `POST /api/v1/import/sbom` is refused. The server could read everything and write nothing new — usually a `/data` volume restored from a backup with different ownership, or an `uploads/` directory created as root by an earlier attempt. The refusal names the directory and the uid the server runs as. Fix the ownership as shown above, or point `CRANE_UPLOADS` at a directory the server's uid can write. Nothing was registered for the refused file: fixing the volume and uploading again is the whole recovery. ## database has pending migrations, run 'crane migrate' first The image is newer than the database. Expected after an upgrade: ```bash crane migrate --dry-run # what would run crane migrate ``` Back up the file first. There are no down migrations, and the way back is the copy you made. ## A path that exists on the host does not exist in the container ``` input not found: batch.json ``` or, from a validation job: ``` the SBOM file does not exist at /home/me/sboms/gateway.spdx.json ``` This is the single most common mistake with the container, and it costs an afternoon if you meet it cold. **CRAne resolves every path on its own filesystem.** A path that is real on your host is not real inside the container unless you mounted it there. For input files: write them under a directory you mounted, and pass the container path. ```bash cp batch.json data/ # data/ is mounted at /data crane import data /data/batch.json --json ``` For registered evidence, the worker resolves the URI you stored. Either mount the documents and register container paths, or mount the host path at the same location inside the container so both agree. `crane job show ` prints the path it tried, which is the fastest way to tell which of the two you have. Uploading through `/import` or `POST /api/v1/import/sbom` stores the file under `/data/uploads` and registers it for you, which sidesteps the problem entirely. ## refusing to bind to 0.0.0.0 without CRANE_API_TOKEN Working as intended. Inside a container the server must bind `0.0.0.0` for a published port to reach it, and it cannot see how that port was published. Pick one: - Set `CRANE_API_TOKEN`. Correct almost always. - Map the port to `127.0.0.1` on the host and set `CRANE_ALLOW_UNAUTHENTICATED_BIND=1`. - Bind `127.0.0.1` if you are not in a container. Full detail under [Exposing it beyond this machine](configure#exposing-it-beyond-this-machine). ## address already in use Something already holds the port — often a CRAne you forgot was running. ```bash lsof -nP -iTCP:8000 -sTCP:LISTEN # macOS, Linux docker ps --filter publish=8000 ``` Stop it, or publish a different host port: `-p 127.0.0.1:8001:8000`. ## The interface queues work and nothing ever runs `crane serve` without `--worker` serves the interface and executes nothing. Uploads register, validations queue, and the queue grows. ```bash crane serve --host 0.0.0.0 --port 8000 --worker ``` Or run a worker separately — `docker compose --profile worker up -d`, or `crane worker run --once` on a schedule. Running both is safe; a claimed job is leased. ## Every validation fails ``` claimed 15, succeeded 0, failed 15, retrying 0 ``` Exit code `5`. Ask why: ```bash crane job list --status failed --json ``` **"executable not found"** — the plain image carries no validator. Use the `-ossbomer` image, or [install ossbomer next to the worker](operate#the-validator). Nothing is scored as failing because of this; the check is recorded as `insufficient evidence`, which is a coverage gap and not a verdict. **"the SBOM file does not exist"** — a path problem, [above](#a-path-that-exists-on-the-host-does-not-exist-in-the-container). **A profile error** — the profile a policy names may have been withdrawn upstream, which is worse than missing because it looks transient. Run `crane validate profiles` and read [The validator](operate#the-validator). ## the evidence URI is https, which this job cannot read The document is registered at a URL and the validation job will not fetch it. Fetch it yourself, under your own network policy, and the job then reads the local copy by its hash: ```bash crane manage evidence-fetch --dry-run crane manage evidence-fetch --by "your name" crane validate sbom ``` A fetch that fails leaves the document `inaccessible` with the reason on its change record. Fix the URL or the network and run it again; a document in that state is retried. ## A validation ran out of time The job ended `timed_out`, and its error says how large the document was and the budget it had. The budget is the job definition's limit (600 seconds) plus `CRANE_OSSBOMER_SECONDS_PER_MB` (default 60) for each megabyte, so a document that runs out is either very large or on a slow machine. Retrying from the job's page runs the same budget again and ends the same way. Raise the rate where the worker runs, then queue the document again: ```bash export CRANE_OSSBOMER_SECONDS_PER_MB=180 crane validate sbom # queues the timed-out document again, with anything not yet queued crane worker run --once ``` The SBOM page shows these under "Ran out of time", apart from documents that are merely still queued. ## this database already has data ``` this database already has data. Seeding is only for an empty one so it cannot be mistaken for real inventory. Use --force if you are sure. ``` `crane demo` deliberately refuses to load fiction on top of anything real. To start over, delete the file: ```bash rm -rf data/crane.db data/demo-sboms crane init ``` ## exit 4, and nothing was written The batch did not validate. `"written": {}` confirms nothing was applied — validation runs over the whole batch before anything is written, so there is no half-loaded state. ```json {"dataset": "products", "row": 1, "column": "nonsense_column", "message": "unknown column. Unknown columns are rejected so a typo is never silent data loss"} ``` Fix the rows and retry. Retrying unchanged input fails identically. ## exit 2 The command is specified but not built yet, or the adapter you named is not installed. `crane import sbom` is the current example: upload through the interface or `POST /api/v1/import/sbom` instead. For adapters, published images carry none by design. `crane import adapter` with no name lists what this deployment actually has. Do not work around a `2`. It means the thing you asked for does not exist yet. ## The score looks wrong Read the denominator before the score. Products with no scope decision are counted neither as passing nor as failing, so a portfolio where nobody has decided scope will report a small denominator and a score that means very little. ```bash crane assess cra --detail # per check, with the reasoning crane assess cra --subject # one subject ``` Check which lens you are reading. `cra.regulatory` is the reporting default, and a product in scope under `commercial` may be out of scope under it. Both statements are kept on purpose. ## Reports say I have no templates Templates are your content and are deliberately not baked into the image. Mount the directory: ```bash mkdir -p report-templates docker run -v "$PWD/report-templates:/report-templates:ro" \ -e CRANE_REPORT_TEMPLATES=/report-templates ... ``` The published compose file does this already. ## Still stuck `crane status` first — it prints the database location, what is applied, what is pending and the row counts, which resolves most "is it even looking at the right database" questions. Then `crane job show ` for anything that ran, which prints the parameters, the events, the artifacts and the run directory holding stdout and stderr. ============================================================================== PAGE: Security (security.md) ============================================================================== # Security How to report a problem, and what the design already assumes so you can tell the two apart.
On this page - TOC {:toc}
--- ## Reporting a vulnerability Do not open a public issue. Use [GitHub's private reporting form](https://github.com/oscarvalenzuelab/CRAne/security/advisories/new), or email **ovalenzuela@ainvirion.com** with `CRAne security` in the subject if you would rather not use GitHub. Include the version — `crane status` prints it — what you did, what happened, and what you expected. A proof of concept is welcome and never required. This is a single-maintainer project. Expect acknowledgement within five working days, best effort, no paid support and no response-time guarantee. Fixes ship in a new tagged release rather than as a patch to an old tag, so upgrading is the remediation path. Coordinated disclosure, and credit in the advisory unless you would rather not be named. ## Decisions that are not findings Some behaviour looks like a vulnerability and is a documented choice. Report it anyway if you disagree — this is just the answer you will get. | Behaviour | Why | |---|---| | One shared token, no accounts, no roles | `CRANE_API_TOKEN` is the whole authentication story. [ADR 0002](adr/0002-authentication) covers why, and what would have to change first | | An unauthenticated mode exists | For localhost only. A bare `0.0.0.0` bind is refused without either the token or an explicit override | | The database is not encrypted at rest | It is a plain SQLite file on purpose, openable with any SQLite browser. Controlling who can read the file is the host's job | | An unfixed base-image CVE | The release gate fails on anything HIGH or above **that has a fix**. A finding Debian marks unfixed in every stable suite cannot be resolved by rebuilding, so it does not block a release — it gets named instead. Where the affected code is something CRAne never calls, it gets deleted rather than accepted: `perl-base` was removed from the image outright for that reason | | Validators are not bundled | Each runs as a separate process the worker invokes. A flaw in a validator belongs to that project; a flaw in how CRAne invokes it, or in what it does with the output, belongs here | ## In scope Anything that reads or writes data it should not, escapes the intended process boundary, or leaks a credential: - Authentication or authorization bypass on any route, the API included. - Injection of any kind, template injection included, in the interface or in reports. - Path traversal through an imported document, an export path, or a run directory. - Credential exposure. `CRANE_GITHUB_TOKEN` is read from the environment at the moment of use and must never reach the database, a job's parameters, a job event, an export, or a log line. - Anything that makes CRAne record an approval, a scope decision, or an exception that no human made. The line between an observed fact and a recorded decision is a security property here, not only a modelling one. ## Hardening the deployment you run The defaults are safe for a laptop. Four things matter once it is not one: 1. **Set `CRANE_API_TOKEN`.** Everything in [Configure](configure#authentication) about exposure applies the moment the port is reachable by anyone else. 2. **Publish the port to `127.0.0.1`, not to every interface**, unless a reverse proxy with TLS is doing the listening. There is no TLS inside the container. 3. **Treat `data/` as the sensitive artifact.** It is the whole inventory in one file. Back it up somewhere with the same access controls, not somewhere convenient. 4. **Scan the tag you are about to run**, with `docker scout cves` or trivy, and rebuild or repull rather than pinning an old digest forever. ## Licence CRAne is Apache-2.0. The full text and the attribution notice travel inside the image: ```bash docker run --rm --entrypoint cat ghcr.io/oscarvalenzuelab/crane:latest /app/LICENSE docker run --rm --entrypoint cat ghcr.io/oscarvalenzuelab/crane:latest /app/NOTICE ``` Dependencies keep their own licences and are listed in `NOTICE`. The optional Postgres backend pulls in an LGPL-3.0 library; it is an extra and is not installed in the published images. ============================================================================== PAGE: Data contract (reference/data-contract.md) ============================================================================== # Data contract Every dataset, every column, every rule. **Generated from the code** by `scripts/build-docs-reference.py`, so it cannot drift from what the importer actually accepts. The authoritative copy is always the running system: ```bash crane import spec --json ``` Contract version `1`. --- ## What it accepts - a directory of CSV files, one per dataset, named .csv - a single .csv file, named after its dataset - a single .json file whose top level keys are dataset names and whose values are arrays of row objects ## Rules 1. Unknown columns are an error, not ignored, so a typo is never silent data loss. 2. Only the datasets you provide are touched. Loading distribution.csv alone against an existing database is normal. 3. References resolve against rows in the same batch and rows already in the database. 4. Everything loads as an observation needing review, never as an approved decision, unless source_kind says otherwise and names an authority. 5. Loading is idempotent. The same input twice changes nothing the second time. 6. Validation runs over the whole batch before anything is written. Either every row loads or none do. ## Subject types `product`, `component`, `build_workflow`, `source_asset`. ## Datasets | Dataset | File | What it is | |---|---|---| | [`definitions`](#definitions) | `definitions.csv` | Vocabulary this deployment uses that the core does not define: a relationship type its catalogue has and CRAne has no term for, a claim kind a feed carries. Registering one gives it a name and a description instead of leaving a bare key nobody can explain. | | [`source_systems`](#source-systems) | `source_systems.csv` | A system that holds metadata about the portfolio, and how to link back to it. | | [`source_system_links`](#source-system-links) | `source_system_links.csv` | Where a subject lives in a source system, per kind of thing that system distinguishes. A catalogue exposing services, products and projects addresses them one path segment apart, and all three arrive here as one subject type. | | [`source_scopes`](#source-scopes) | `source_scopes.csv` | What a feeder is allowed and configured to look at inside a source system. | | [`sync_runs`](#sync-runs) | `sync_runs.csv` | One execution of a feeder against one scope, with what it covered. | | [`source_snapshots`](#source-snapshots) | `source_snapshots.csv` | A hashed record that a source looked a particular way at a particular time. | | [`coverage_gaps`](#coverage-gaps) | `coverage_gaps.csv` | Something a feeder could not see, recorded so that absence is not read as evidence. | | [`org_units`](#org-units) | `org_units.csv` | The accountability hierarchy. Division is the sub legal entity, organization is director level, team is where products are usually owned. | | [`parties`](#parties) | `parties.csv` | People, teams, and service principals that can own or be accountable for things. | | [`products`](#products) | `products.csv` | A reviewable shipping or distributed software unit. | | [`components`](#components) | `components.csv` | A building block that may ship in several products. | | [`build_workflows`](#build-workflows) | `build_workflows.csv` | A build, release, packaging, signing, or publication definition. | | [`source_assets`](#source-assets) | `source_assets.csv` | A repository, package, service record, catalog entry, image, or binary. | | [`identities`](#identities) | `identities.csv` | The same thing, as known by another system. A subject can hold several identities in one system, and status retires one. | | [`external_properties`](#external-properties) | `external_properties.csv` | Typed metadata another system holds about a thing, attached to the identity it holds it under. | | [`product_components`](#product-components) | `product_components.csv` | Which components ship in which products. | | [`component_workflows`](#component-workflows) | `component_workflows.csv` | Which build workflows produce which components. | | [`product_relationships`](#product-relationships) | `product_relationships.csv` | Product to product: containment, bundling, succession. | | [`workflow_assets`](#workflow-assets) | `workflow_assets.csv` | What a build workflow reads and what it produces. | | [`subject_assets`](#subject-assets) | `subject_assets.csv` | Which source asset a product or component actually lives in, builds from, or is catalogued as. | | [`workflow_dependencies`](#workflow-dependencies) | `workflow_dependencies.csv` | A build that depends on another build. This is the fan in and fan out case. | | [`responsibilities`](#responsibilities) | `responsibilities.csv` | Who is accountable for what. | | [`aliases`](#aliases) | `aliases.csv` | Other names a subject is known by, so search and matching find it. | | [`scope`](#scope) | `scope.csv` | Whether a subject is in scope, under one named definition. | | [`build_topology`](#build-topology) | `build_topology.csv` | Where code lives and where the build runs. Two different questions. | | [`distribution`](#distribution) | `distribution.csv` | Where something enters and leaves the organization. | | [`claims`](#claims) | `claims.csv` | Anything else known about a subject: a declared support end date, a disclosure contact, a licence. The general purpose dataset for facts that are not relationships. | | [`scope_rules`](#scope-rules) | `scope_rules.csv` | One scope judgement covering every subject that matches its criteria. | | [`evidence`](#evidence) | `evidence.csv` | Pointers to evidence. The file itself is never copied into the database. | | [`exceptions`](#exceptions) | `exceptions.csv` | Queues and checks somebody has decided do not apply to a subject. | | [`findings`](#findings) | `findings.csv` | Something wrong with a subject, and where it got to. | | [`decisions`](#decisions) | `decisions.csv` | A recorded human judgement: what was decided, by whose authority, and why. | | [`actions`](#actions) | `actions.csv` | Work somebody owes, with an owner and a due date. | | [`action_dependencies`](#action-dependencies) | `action_dependencies.csv` | An action that cannot start until another finishes. | | [`state_changes`](#state-changes) | `state_changes.csv` | A field on a subject changing value, with when and why. | | [`verifications`](#verifications) | `verifications.csv` | Somebody checked that a finding was really resolved, or an action really done. | | [`campaigns`](#campaigns) | `campaigns.csv` | A bounded audit: one lens, one obligation, one date range, one frozen population. | | [`campaign_subjects`](#campaign-subjects) | `campaign_subjects.csv` | Which subjects a campaign counts, and which it deliberately does not. | ## definitions Vocabulary this deployment uses that the core does not define: a relationship type its catalogue has and CRAne has no term for, a claim kind a feed carries. Registering one gives it a name and a description instead of leaving a bare key nobody can explain. File: `definitions.csv`. Natural key: `namespace`, `definition_class`, `key`. | Column | Required | Kind | Meaning | |---|---|---|---| | `definition_class` | **yes** | text | Which vocabulary this key belongs to. Closed: a class nothing reads would be a registry entry no part of the system consults. One of: `asset_type`, `channel_type`, `check_key`, `claim_kind`, `decision_type`, `environment`, `evidence_kind`, `obligation`, `org_level`, `platform_type`, `relationship_type`, `subject_type`. | | `key` | **yes** | text | The value as it appears in the data, for example 'associated_with'. Written exactly as rows carry it. | | `display_name` | | text | What to call it on screen. The reason to register at all: without one a page can only show the raw key. Optional so a later row can correct a description or a status without restating the name -- blank leaves whatever is recorded alone, and on a first declaration falls back to the key. | | `description` | | text | What it means in the source system. This is the field that answers 'what was this value supposed to mean' a year later. | | `namespace` | | text | Whose vocabulary this is. Defaults to 'local'. 'crane.core' is reserved for what ships. Defaults to `local`. | | `status` | | text | Retired keeps the row so existing data stays explainable, and stops the key being offered for anything new. One of: `active`, `retired`. Defaults to `active`. | ## source_systems A system that holds metadata about the portfolio, and how to link back to it. File: `source_systems.csv`. Natural key: `key`. | Column | Required | Kind | Meaning | |---|---|---|---| | `key` | **yes** | text | Short stable identifier, for example 'metadataservice'. This is what the source_system column on other datasets refers to. | | `display_name` | **yes** | text | What to call it on screen, for example 'Metadata service'. | | `link_template` | | text | URL pattern with {id}, {key} or {type} substituted from the external identity, for example https://metadataservice.internal/#/Service/{id}. | | `link_label` | | text | Text for the link. Defaults to 'Open in '. | | `connector_type` | | text | Defaults to `crane.core/declared`. | | `status` | | text | One of: `active`, `archived`. Defaults to `active`. | ## source_system_links Where a subject lives in a source system, per kind of thing that system distinguishes. A catalogue exposing services, products and projects addresses them one path segment apart, and all three arrive here as one subject type. File: `source_system_links.csv`. Natural key: `source_system`, `subject_type`, `object_type`. Leaving `object_type` blank is a value rather than an absence: two rows that both leave it out name the same thing and the second is refused as a duplicate. Repeating another column's value there is a different identity, not the same one written out. | Column | Required | Kind | Meaning | |---|---|---|---| | `source_system` | **yes** | text | | | `subject_type` | **yes** | text | Which kind of thing here the pattern addresses. One of: `product`, `component`, `build_workflow`, `source_asset`, `party`. | | `object_type` | | text | Which kind of thing there. Blank is the pattern for any kind, which is what a system addressing everything alike needs. | | `url_template` | | text | http or https, containing {id}, {key} or {type}. Blank means this system has no addressable page for that kind, which is a real answer and not a gap. | | `label` | | text | Link text. Defaults to 'Open in '. | ## source_scopes What a feeder is allowed and configured to look at inside a source system. File: `source_scopes.csv`. Natural key: `source_system`, `scope_key`. | Column | Required | Kind | Meaning | |---|---|---|---| | `source_system` | **yes** | text | | | `scope_key` | **yes** | text | The scope's identifier in that system: an organization, a project, a folder, a query. | | `display_name` | | text | | | `permissions_json` | | text | What the credential could actually see, as JSON. A gap between this and the scope is a coverage gap, not an empty result. | | `filters_json` | | text | Any filter narrowing the scope, as JSON. | | `rate_limit_json` | | text | What the source imposed, as JSON. | | `enabled` | | bool | Defaults to `True`. | ## sync_runs One execution of a feeder against one scope, with what it covered. File: `sync_runs.csv`. Natural key: `source_system`, `scope_key`, `started_at`. | Column | Required | Kind | Meaning | |---|---|---|---| | `source_system` | **yes** | text | | | `scope_key` | **yes** | text | The scope this run read. Load source_scopes first. | | `started_at` | **yes** | date | When the run began. Part of the key, because a scope is synchronized repeatedly and each run is its own record. | | `status` | | text | One of: `running`, `succeeded`, `partial`, `failed`, `cancelled`. Defaults to `running`. | | `cursor` | | text | Where the feeder got to, for resuming. | | `watermark` | | text | The high water mark it read up to. | | `ended_at` | | date | | | `counts_json` | | text | What it saw and wrote, as JSON. | | `coverage_status` | | text | Whether the run covered the scope, in the feeder's own terms. | | `partial_failures_json` | | text | What failed inside a run that otherwise worked, as JSON. A 'partial' status with nothing here is a run nobody can act on. | ## source_snapshots A hashed record that a source looked a particular way at a particular time. File: `source_snapshots.csv`. Natural key: `source_system`, `captured_at`, `content_hash`. | Column | Required | Kind | Meaning | |---|---|---|---| | `source_system` | **yes** | text | | | `captured_at` | **yes** | date | | | `content_hash` | **yes** | text | Hash of what was captured. What makes the snapshot evidence rather than an assertion that a capture happened. | | `scope_key` | | text | The scope captured, if the snapshot belongs to one. | | `sync_started_at` | | date | The run that captured it, named by its started_at. Needs scope_key too, since that is how a run is identified. | | `object_count` | | number | | | `uri` | | text | Where the captured content is kept, if it was kept. | ## coverage_gaps Something a feeder could not see, recorded so that absence is not read as evidence. File: `coverage_gaps.csv`. Natural key: `source_system`, `scope_key`, `reason`, `detected_at`. | Column | Required | Kind | Meaning | |---|---|---|---| | `source_system` | **yes** | text | | | `scope_key` | **yes** | text | | | `reason` | **yes** | text | Why it could not be seen: permission denied, rate limited, timed out, unsupported object type. | | `detected_at` | **yes** | date | Part of the key, so the same gap recurring is a history rather than one row that keeps being rewritten. | | `sync_started_at` | | date | The run that hit the gap, named by its started_at. | | `detail` | | text | What specifically was missed. | | `remediation` | | text | What would fix it, if that is known. | | `resolved_at` | | date | Set when the gap closes. A resolved gap stays as history rather than being deleted. | ## org_units The accountability hierarchy. Division is the sub legal entity, organization is director level, team is where products are usually owned. File: `org_units.csv`. Natural key: `name`, `level`. | Column | Required | Kind | Meaning | |---|---|---|---| | `name` | **yes** | text | Unique within its level. | | `level` | **yes** | text | One of: `company`, `division`, `organization`, `team`. | | `parent_name` | | text | Name of the unit one level up. Empty for the top. | | `email` | | text | Distribution list or contact address. | ## parties People, teams, and service principals that can own or be accountable for things. File: `parties.csv`. Natural key: `display_name`. | Column | Required | Kind | Meaning | |---|---|---|---| | `display_name` | **yes** | text | | | `party_type` | **yes** | text | One of: `person`, `team`, `service`, `organization`. | | `email` | | text | | | `external_ref` | | text | Identifier in the source directory. | | `status` | | text | One of: `active`, `archived`. Defaults to `active`. | ## products A reviewable shipping or distributed software unit. File: `products.csv`. Natural key: `slug`. | Column | Required | Kind | Meaning | |---|---|---|---| | `slug` | **yes** | text | Stable local identifier, unique per tenant. | | `canonical_name` | **yes** | text | | | `lifecycle` | | text | Known values: `development`, `released`, `maintenance`, `eol`, `withdrawn`, `unknown`, `active`, `retired`, `deprecated`. Others are accepted and recorded as a finding for a person to look at. Defaults to `unknown`. | | `description` | | text | | | `implementation` | | text | How the product is made, which decides what a build is required of. code_bearing: built from source, so the product must reach a build. logical_bundle: an offering, SKU or rollup made of other products; build requirements apply to what it contains. externally_built: a supplier builds it; no build of ours is expected. manual_process: assembled by hand; no build is expected. Leave empty when nobody knows: an unclassified product is listed for review rather than reported as missing a pipeline. Recorded as a manual product.implementation claim, which a person's decision outranks. One of: `code_bearing`, `logical_bundle`, `externally_built`, `manual_process`. | | `source_system` | | text | Which system this row came from, for example 'project-catalog'. Load the same product from two exports with different values here and it becomes corroborated rather than single sourced. | | `external_id` | | text | This product's identifier in that system. What turns a link_template into a link back to the entry. | ## components A building block that may ship in several products. File: `components.csv`. Natural key: `slug`. | Column | Required | Kind | Meaning | |---|---|---|---| | `slug` | **yes** | text | | | `canonical_name` | **yes** | text | | | `lifecycle` | | text | Known values: `development`, `released`, `maintenance`, `eol`, `withdrawn`, `unknown`, `active`, `retired`, `deprecated`. Others are accepted and recorded as a finding for a person to look at. Defaults to `unknown`. | | `description` | | text | | | `is_open_source` | | bool | Set true for a third party component consumed from outside. | | `purl` | | text | Package URL, if the component is a published package. | | `source_system` | | text | Which system this row came from. | | `external_id` | | text | This component's identifier in that system. | ## build_workflows A build, release, packaging, signing, or publication definition. File: `build_workflows.csv`. Natural key: `slug`. | Column | Required | Kind | Meaning | |---|---|---|---| | `slug` | **yes** | text | | | `canonical_name` | **yes** | text | | | `source_system` | | text | Key of a source_system row, created if absent. | | `external_id` | | text | This pipeline's identifier in that system. Clicking through to the catalogue entry is how a failing pipeline gets fixed. | | `platform_type` | | text | What runs the build. Open vocabulary: a platform not listed here is accepted and shown as given. Known values: `github_actions`, `gitlab_ci`, `jenkins`, `azure_pipelines`, `aws_codebuild`, `google_cloud_build`, `bitbucket_pipelines`, `teamcity`, `bamboo`, `circleci`, `buildkite`, `tekton`, `argo_workflows`, `drone`, `concourse`, `travis_ci`, `make_or_script`, `manual`, `vendor_external`, `unknown`. Others are accepted and recorded as a finding for a person to look at. | | `platform_locator` | | text | Where the definition lives: a Jenkins job URL, a workflow file path, an Azure DevOps pipeline id. | | `environment` | | text | What the build is for. A product with four pipelines and no production one is a different situation from one with none. Known values: `production`, `staging`, `testing`, `development`, `unknown`. Others are accepted and recorded as a finding for a person to look at. | | `last_run_status` | | text | Result of the most recent run. Overwritten each time, and never on its own a reason a compliance score moves. One of: `success`, `unstable`, `failed`, `cancelled`, `running`, `never_run`. | | `last_run_at` | | date | When that run finished. | | `last_run_url` | | text | Link to the run, so a red build is one click from the log that explains it. | | `lifecycle` | | text | Known values: `development`, `released`, `maintenance`, `eol`, `withdrawn`, `unknown`, `active`, `retired`, `deprecated`. Others are accepted and recorded as a finding for a person to look at. Defaults to `unknown`. | | `description` | | text | | ## source_assets A repository, package, service record, catalog entry, image, or binary. File: `source_assets.csv`. Natural key: `canonical_locator`. | Column | Required | Kind | Meaning | |---|---|---|---| | `canonical_locator` | **yes** | text | URL, purl, or path. Unique. | | `asset_type` | **yes** | text | Known values: `repository`, `package`, `service`, `catalog_entry`, `container_image`, `binary_artifact`, `release_asset`, `package_feed`. Others are accepted and recorded as a finding for a person to look at. | | `display_name` | | text | | | `visibility` | | text | One of: `public`, `internal`, `restricted`, `unknown`. Defaults to `unknown`. | ## identities The same thing, as known by another system. A subject can hold several identities in one system, and status retires one. File: `identities.csv`. Natural key: `subject_type`, `subject_slug`, `source_system`, `object_type`, `external_id`. | Column | Required | Kind | Meaning | |---|---|---|---| | `subject_type` | **yes** | text | One of: `product`, `component`, `build_workflow`, `source_asset`. | | `subject_slug` | **yes** | text | The subject's slug here. | | `source_system` | **yes** | text | Which system knows it. | | `external_id` | **yes** | text | Its identifier there. | | `external_key` | | text | A second identifier, such as a job name or path, for templates that use {key}. | | `display_name` | | text | What it is called there, which is often not what it is called here. | | `locator` | | text | Full URL for this one object. Wins over the system's link template. | | `object_type` | | text | What kind of object it is there. Defaults to the subject type, and is part of the natural key either way. | | `status` | | text | What became of it. Setting this is how a feeder retires an identity, and it is the only way: a row absent from a later feed means nothing was said about it, never that it is gone. One of: `active`, `missing`, `inaccessible`, `deleted`, `unknown`. Defaults to `active`. | ## external_properties Typed metadata another system holds about a thing, attached to the identity it holds it under. File: `external_properties.csv`. Natural key: `source_system`, `object_type`, `external_id`, `property_name`, `captured_at`. | Column | Required | Kind | Meaning | |---|---|---|---| | `source_system` | **yes** | text | Which system this came from. | | `object_type` | **yes** | text | The object type of the identity to attach to, as on identities. | | `external_id` | **yes** | text | The identifier of the identity to attach to, as on identities. | | `property_name` | **yes** | text | What the property is called there. Kept verbatim, because renaming it loses the ability to ask the source about it. | | `value_type` | **yes** | text | Which typed column the value lands in. One of: `string`, `number`, `boolean`, `date`, `json`. | | `value` | **yes** | text | The value itself, written as text and coerced by value_type. A 'json' value must parse. | | `captured_at` | **yes** | date | When the source held this value. Part of the key, so a property that changes over time keeps its history rather than overwriting itself. | | `scope_key` | | text | With sync_started_at, the run that observed this value. Both are needed, because that pair is how a run is identified. | | `sync_started_at` | | date | The observing run's started_at. Optional: a value a person recorded by hand did not come from a run. | ## product_components Which components ship in which products. File: `product_components.csv`. Natural key: `product_slug`, `component_slug`, `relationship_type`. | Column | Required | Kind | Meaning | |---|---|---|---| | `product_slug` | **yes** | text | | | `component_slug` | **yes** | text | | | `relationship_type` | | text | Known values: `contains`, `bundles`. Others are accepted and recorded as a finding for a person to look at. Defaults to `contains`. | | `confidence` | | number | 0.0 to 1.0. | | `source_kind` | | text | One of: `imported`, `inferred`, `manual`, `approved`. Defaults to `imported`. | | `review_status` | | text | approved means somebody confirmed the relationship is true of the organization, not just asserted by a source. One of: `unreviewed`, `approved`. Defaults to `unreviewed`. | | `effective_to` | | date | When the relationship stopped holding. Set this rather than omitting the row from a later feed: an absent row means nothing was said, never that the relationship ended. An ended relationship leaves the current graph and stays in the record, and travels in a bundle exported with history. | | `dependency_depth` | | number | 1 for a direct dependency, deeper for what an SBOM pulled in behind it. Empty reads as direct. | | `note` | | text | | ## component_workflows Which build workflows produce which components. File: `component_workflows.csv`. Natural key: `component_slug`, `workflow_slug`, `relationship_type`. | Column | Required | Kind | Meaning | |---|---|---|---| | `component_slug` | **yes** | text | | | `workflow_slug` | **yes** | text | | | `relationship_type` | | text | Known values: `builds`, `signs`, `publishes`. Others are accepted and recorded as a finding for a person to look at. Defaults to `builds`. | | `confidence` | | number | | | `source_kind` | | text | One of: `imported`, `inferred`, `manual`, `approved`. Defaults to `imported`. | | `review_status` | | text | One of: `unreviewed`, `approved`. Defaults to `unreviewed`. | | `effective_to` | | date | When the relationship stopped holding. Set this rather than omitting the row from a later feed: an absent row means nothing was said, never that the relationship ended. An ended relationship leaves the current graph and stays in the record, and travels in a bundle exported with history. | | `note` | | text | | ## product_relationships Product to product: containment, bundling, succession. File: `product_relationships.csv`. Natural key: `from_product_slug`, `to_product_slug`, `relationship_type`. | Column | Required | Kind | Meaning | |---|---|---|---| | `from_product_slug` | **yes** | text | | | `to_product_slug` | **yes** | text | | | `relationship_type` | **yes** | text | Known values: `contains`, `bundles`, `succeeds`, `distributes_with`. Others are accepted and recorded as a finding for a person to look at. | | `confidence` | | number | | | `source_kind` | | text | One of: `imported`, `inferred`, `manual`, `approved`. Defaults to `imported`. | | `effective_to` | | date | When the relationship stopped holding. Set this rather than omitting the row from a later feed: an absent row means nothing was said, never that the relationship ended. An ended relationship leaves the current graph and stays in the record, and travels in a bundle exported with history. | | `note` | | text | | ## workflow_assets What a build workflow reads and what it produces. File: `workflow_assets.csv`. Natural key: `workflow_slug`, `asset_locator`, `relationship_type`. | Column | Required | Kind | Meaning | |---|---|---|---| | `workflow_slug` | **yes** | text | | | `asset_locator` | **yes** | text | | | `relationship_type` | **yes** | text | Known values: `source`, `template`, `output`, `dependency`. Others are accepted and recorded as a finding for a person to look at. | | `source_kind` | | text | One of: `imported`, `inferred`, `manual`, `approved`. Defaults to `imported`. | | `effective_to` | | date | When the relationship stopped holding. Set this rather than omitting the row from a later feed: an absent row means nothing was said, never that the relationship ended. An ended relationship leaves the current graph and stays in the record, and travels in a bundle exported with history. | | `note` | | text | | ## subject_assets Which source asset a product or component actually lives in, builds from, or is catalogued as. File: `subject_assets.csv`. Natural key: `subject_type`, `subject_slug`, `asset_locator`, `relationship_type`, `source_system`. | Column | Required | Kind | Meaning | |---|---|---|---| | `subject_type` | **yes** | text | One of: `product`, `component`, `build_workflow`, `source_asset`. | | `subject_slug` | **yes** | text | The subject's slug here. | | `asset_locator` | **yes** | text | The asset's canonical locator, from source_assets. | | `relationship_type` | **yes** | text | What the subject does with the asset, in the describing system's own words. Known values: `source`, `builds`, `output`, `contains`, `depends_on`, `implements`, `catalogued_as`. Others are accepted and recorded as a finding for a person to look at. | | `source_system` | | text | Which system asserts this. Part of the key, so two systems disagreeing are two rows rather than one overwriting the other. | | `source_kind` | | text | One of: `imported`, `inferred`, `manual`, `approved`. Defaults to `imported`. | | `confidence` | | number | 0 to 1, when the asserting system expresses one. | | `review_status` | | text | One of: `unreviewed`, `approved`, `rejected`, `superseded`, `conflicted`. Defaults to `unreviewed`. | | `effective_from` | | date | | | `effective_to` | | date | When the relationship stopped holding. Set this rather than omitting the row from a later feed: a missing row is not a deletion. | | `note` | | text | | ## workflow_dependencies A build that depends on another build. This is the fan in and fan out case. File: `workflow_dependencies.csv`. Natural key: `downstream_slug`, `upstream_ref`. | Column | Required | Kind | Meaning | |---|---|---|---| | `downstream_slug` | **yes** | text | | | `upstream_slug` | | text | Leave empty if the upstream build is not tracked. | | `upstream_ref` | **yes** | text | The name the source system uses, kept even when unresolved. | | `source_kind` | | text | One of: `imported`, `inferred`, `manual`, `approved`. Defaults to `imported`. | | `note` | | text | | ## responsibilities Who is accountable for what. File: `responsibilities.csv`. Natural key: `subject_type`, `subject_slug`, `party`, `role`. | Column | Required | Kind | Meaning | |---|---|---|---| | `subject_type` | **yes** | text | One of: `product`, `component`, `build_workflow`, `source_asset`. | | `subject_slug` | **yes** | text | | | `party` | **yes** | text | Matches parties.display_name or org_units.name. | | `role` | **yes** | text | Known values: `accountable`, `maintainer`, `security_contact`, `owning_organization`. Others are accepted and recorded as a finding for a person to look at. | | `source_kind` | | text | One of: `imported`, `inferred`, `manual`, `approved`. Defaults to `imported`. | | `review_status` | | text | One of: `unreviewed`, `approved`. Defaults to `unreviewed`. | ## aliases Other names a subject is known by, so search and matching find it. File: `aliases.csv`. Natural key: `subject_type`, `subject_slug`, `alias`, `alias_type`. | Column | Required | Kind | Meaning | |---|---|---|---| | `subject_type` | **yes** | text | One of: `product`, `component`, `build_workflow`, `source_asset`. | | `subject_slug` | **yes** | text | | | `alias` | **yes** | text | | | `alias_type` | | text | Known values: `display`, `legacy`, `source_label`, `acronym`, `name`. Others are accepted and recorded as a finding for a person to look at. Defaults to `display`. | ## scope Whether a subject is in scope, under one named definition. File: `scope.csv`. Natural key: `subject_type`, `subject_slug`, `lens`. | Column | Required | Kind | Meaning | |---|---|---|---| | `subject_type` | **yes** | text | One of: `product`, `component`, `build_workflow`, `source_asset`. | | `subject_slug` | **yes** | text | | | `lens` | **yes** | text | cra.regulatory, commercial, shiproom, legal.oss, or your own. | | `status` | **yes** | text | One of: `in_scope`, `out_of_scope`, `undecided`, `not_applicable`, `disputed`. | | `rationale` | | text | | | `source_kind` | | text | One of: `observed`, `normalized`, `derived`, `manual`, `approved`. Defaults to `manual`. | | `decided_by` | | text | Required when source_kind is approved. | | `valid_from` | | date | | ## build_topology Where code lives and where the build runs. Two different questions. File: `build_topology.csv`. Natural key: `subject_type`, `subject_slug`. | Column | Required | Kind | Meaning | |---|---|---|---| | `subject_type` | **yes** | text | One of: `product`, `component`, `build_workflow`, `source_asset`. | | `subject_slug` | **yes** | text | | | `code_hosting` | | text | One of: `internal_git`, `public_host_public`, `public_host_private`, `vendor`, `none`. | | `build_location` | | text | One of: `internal_pipeline`, `public_host_runner`, `vendor_external`, `manual`, `none`. | | `is_signed` | | bool | Whether the build signs what it produces. | | `source_kind` | | text | One of: `observed`, `normalized`, `derived`, `manual`, `approved`. Defaults to `observed`. | ## distribution Where something enters and leaves the organization. File: `distribution.csv`. Natural key: `subject_type`, `subject_slug`, `direction`, `channel_type`, `channel_locator`. | Column | Required | Kind | Meaning | |---|---|---|---| | `subject_type` | **yes** | text | One of: `product`, `component`, `build_workflow`, `source_asset`. | | `subject_slug` | **yes** | text | | | `direction` | **yes** | text | outbound if we make it available, inbound if we consume it. One of: `outbound`, `inbound`. | | `channel_type` | **yes** | text | Known values: `source_repository`, `release_asset`, `nuget`, `npm`, `pypi`, `maven`, `container_registry`, `internal_feed`, `direct_download`, `oem_partner`, `embedded_in_device`, `not_distributed`. Others are accepted and recorded as a finding for a person to look at. | | `channel_locator` | | text | A purl where one exists, else a registry URL. | | `visibility` | | text | One of: `public`, `customer`, `internal`, `unknown`. Defaults to `unknown`. | | `is_signed` | | bool | Leave empty when unknown. Empty is not the same as false. | | `source_kind` | | text | One of: `imported`, `inferred`, `manual`, `approved`. Defaults to `imported`. | | `note` | | text | | ## claims Anything else known about a subject: a declared support end date, a disclosure contact, a licence. The general purpose dataset for facts that are not relationships. File: `claims.csv`. Natural key: `subject_type`, `subject_slug`, `claim_kind`, `lens`, `variant`. Leaving `lens`, `variant` blank is a value rather than an absence: two rows that both leave it out name the same thing and the second is refused as a duplicate. Repeating another column's value there is a different identity, not the same one written out. | Column | Required | Kind | Meaning | |---|---|---|---| | `subject_type` | **yes** | text | One of: `product`, `component`, `build_workflow`, `source_asset`. | | `subject_slug` | **yes** | text | | | `claim_kind` | **yes** | text | A registered claim kind, or your own namespaced one. Known values: `support.end_date`, `vuln.contact`, `license.declared`, `origin.open_source`, `lifecycle.stage`, `artifact.purl`, `sbom.status`, `distribution.external`, `code.hosting`, `build.location`, `build.signed`, `product.implementation`. Others are accepted and recorded as a finding for a person to look at. | | `value` | **yes** | text | | | `value_type` | | text | One of: `string`, `number`, `boolean`, `date`, `json`. Defaults to `string`. | | `lens` | | text | Only for claims that mean something under one definition. | | `variant` | | text | What tells two claims of one kind about one subject apart, such as the profile a conformance verdict is about. Leave blank where the kind can only mean one thing: blank is its own value, not a shorthand for anything. | | `source_kind` | | text | One of: `observed`, `normalized`, `derived`, `manual`, `approved`. Defaults to `manual`. | | `observed_at` | | date | When the source was read. | | `valid_from` | | date | | | `valid_to` | | date | | ## scope_rules One scope judgement covering every subject that matches its criteria. File: `scope_rules.csv`. Natural key: `subject_type`, `lens`, `org_unit`, `source_system`, `lifecycle`, `origin`, `no_build`. | Column | Required | Kind | Meaning | |---|---|---|---| | `subject_type` | **yes** | text | One of: `product`, `component`. | | `lens` | **yes** | text | cra.regulatory, commercial, shiproom, legal.oss, or your own. | | `status` | **yes** | text | One of: `in_scope`, `out_of_scope`, `undecided`, `not_applicable`, `disputed`. | | `rationale` | **yes** | text | Has to be true of every subject the rule matches, now and later. | | `decided_by` | **yes** | text | Who answers for it. | | `expires_on` | | date | YYYY-MM-DD. Omitted means permanent, which is worth asking for. | | `org_unit` | | text | A division, organization or team name. | | `source_system` | | text | | | `lifecycle` | | text | Known values: `development`, `released`, `maintenance`, `eol`, `withdrawn`, `unknown`, `active`, `retired`, `deprecated`. Others are accepted and recorded as a finding for a person to look at. | | `origin` | | text | One of: `third_party`, `internal`, `unclassified`. | | `no_build` | | bool | Components nothing builds. | | `unresolved` | | bool | Set by export when the criteria no longer resolve. | ## evidence Pointers to evidence. The file itself is never copied into the database. File: `evidence.csv`. Natural key: `subject_type`, `subject_slug`, `uri`. | Column | Required | Kind | Meaning | |---|---|---|---| | `subject_type` | **yes** | text | One of: `product`, `component`, `build_workflow`, `source_asset`. | | `subject_slug` | **yes** | text | | | `kind` | **yes** | text | Known values: `sbom.spdx`, `sbom.cyclonedx`, `sbom.manifest`, `attestation`, `report`, `document`, `closure_record`. Others are accepted and recorded as a finding for a person to look at. | | `uri` | **yes** | text | | | `content_hash` | | text | sha256, if known. | | `media_type` | | text | | | `captured_at` | | date | | | `state` | | text | One of: `discovered`, `retrieved`, `parsed`, `validated`, `current`, `inaccessible`, `invalid`, `stale`, `superseded`, `deleted`. Defaults to `discovered`. | ## exceptions Queues and checks somebody has decided do not apply to a subject. File: `exceptions.csv`. Natural key: `subject_type`, `subject_slug`, `queue_key`, `check_key`. | Column | Required | Kind | Meaning | |---|---|---|---| | `subject_type` | **yes** | text | One of: `product`, `component`, `build_workflow`, `source_asset`. | | `subject_slug` | **yes** | text | | | `queue_key` | | text | An attention queue key. One of queue_key or check_key, never both. | | `check_key` | | text | A check inside an obligation policy. | | `reason` | **yes** | text | At least eight characters. The sentence somebody reads in a year when they ask why this was excused. | | `granted_by` | | text | Who granted it. Recorded on the decision. | | `expires_on` | | date | Empty means permanent, which is how a portfolio drifts while the dashboard stays green; prefer a date. | ## findings Something wrong with a subject, and where it got to. File: `findings.csv`. Natural key: `subject_type`, `subject_slug`, `code`, `variant`. Leaving `variant` blank is a value rather than an absence: two rows that both leave it out name the same thing and the second is refused as a duplicate. Repeating another column's value there is a different identity, not the same one written out. | Column | Required | Kind | Meaning | |---|---|---|---| | `subject_type` | **yes** | text | One of: `product`, `component`, `build_workflow`, `source_asset`. | | `subject_slug` | **yes** | text | | | `code` | **yes** | text | What kind of problem it is. Stable, so the same problem on two subjects is countable. | | `variant` | | text | What tells two findings of one code on one subject apart, such as the profile a conformance check measured against. Leave blank where the code can only mean one thing. Blank is its own identity, not a shorthand for anything: a row that repeats the code here is a different finding from one that leaves it out. | | `title` | **yes** | text | What is wrong, in one line. | | `severity` | | text | One of: `info`, `low`, `medium`, `high`, `critical`. Defaults to `medium`. | | `status` | | text | Where it got to. A migration brings findings that were triaged or closed years ago, and importing those as open would reopen work somebody already did. One of: `open`, `triaged`, `assigned`, `in_progress`, `resolved`, `verified`, `closed`, `blocked`, `accepted`, `duplicate`, `not_applicable`. Defaults to `open`. | | `detail` | | text | | | `detected_at` | | date | Defaults to now if not supplied. | | `closed_at` | | date | | | `confidence` | | number | 0 to 1, when the source expresses one. | ## decisions A recorded human judgement: what was decided, by whose authority, and why. File: `decisions.csv`. Natural key: `subject_type`, `subject_slug`, `decision_type`, `recorded_at`. | Column | Required | Kind | Meaning | |---|---|---|---| | `subject_type` | **yes** | text | One of: `product`, `component`, `build_workflow`, `source_asset`. | | `subject_slug` | **yes** | text | | | `decision_type` | **yes** | text | What kind of judgement: scope, mapping, risk acceptance, exception. Known values: `scope`, `mapping`, `risk_acceptance`, `exception`, `lifecycle`. Others are accepted and recorded as a finding for a person to look at. | | `recorded_at` | **yes** | date | When the decision was recorded. Part of the key, because a subject is decided about repeatedly and superseding is a new row rather than an edit to the old one. | | `rationale` | **yes** | text | Why. Required by the schema, not by convention: a decision nobody can explain is one nobody can defend. | | `status` | | text | One of: `proposed`, `pending_authority`, `approved`, `rejected`, `superseded`, `expired`. Defaults to `proposed`. | | `authority` | | text | The party whose authority this carries, by display name. Required when the status is 'approved'. | | `citation` | | text | Where the authority is recorded: a ticket, a minute, an email. | | `lens` | | text | Which definition this decision is made under, for a scope decision. | | `proposed_value_json` | | text | What is being decided, as JSON. | | `effective_from` | | date | When it started to apply, which is a different question from when it was recorded and is often unknown. | | `effective_to` | | date | | | `approved_at` | | date | | ## actions Work somebody owes, with an owner and a due date. File: `actions.csv`. Natural key: `subject_type`, `subject_slug`, `title`. | Column | Required | Kind | Meaning | |---|---|---|---| | `subject_type` | **yes** | text | One of: `product`, `component`, `build_workflow`, `source_asset`. | | `subject_slug` | **yes** | text | | | `title` | **yes** | text | What has to be done, in one line. | | `status` | | text | One of: `open`, `accepted`, `in_progress`, `blocked`, `completed`, `verified`. Defaults to `open`. | | `owner` | | text | Who owes it, by party display name. | | `due_date` | | date | | | `description` | | text | | | `blocker_reason` | | text | Why it cannot proceed. A 'blocked' action without one is a status nobody can act on. | | `completed_at` | | date | | | `finding_code` | | text | The finding this answers, by code on the same subject. | | `finding_variant` | | text | Which one, where that subject carries the same code more than once. Matches the finding's variant column. | ## action_dependencies An action that cannot start until another finishes. File: `action_dependencies.csv`. Natural key: `subject_type`, `subject_slug`, `title`, `depends_on_subject_type`, `depends_on_subject_slug`, `depends_on_title`. | Column | Required | Kind | Meaning | |---|---|---|---| | `subject_type` | **yes** | text | One of: `product`, `component`, `build_workflow`, `source_asset`. | | `subject_slug` | **yes** | text | | | `title` | **yes** | text | The action that is waiting. | | `depends_on_subject_type` | **yes** | text | One of: `product`, `component`, `build_workflow`, `source_asset`. | | `depends_on_subject_slug` | **yes** | text | | | `depends_on_title` | **yes** | text | The action it waits for. | | `note` | | text | | ## state_changes A field on a subject changing value, with when and why. File: `state_changes.csv`. Natural key: `subject_type`, `subject_slug`, `field`, `occurred_at`. | Column | Required | Kind | Meaning | |---|---|---|---| | `subject_type` | **yes** | text | One of: `product`, `component`, `build_workflow`, `source_asset`. | | `subject_slug` | **yes** | text | | | `field` | **yes** | text | What changed, named as the source named it. | | `occurred_at` | **yes** | date | When it changed. Part of the key, because the same field changing twice is two events. | | `from_value` | | text | Empty means it had no value before. | | `to_value` | | text | Empty means it was cleared. | | `note` | | text | | ## verifications Somebody checked that a finding was really resolved, or an action really done. File: `verifications.csv`. Natural key: `target_kind`, `subject_type`, `subject_slug`, `target_ref`, `target_variant`, `verified_at`. Leaving `target_variant` blank is a value rather than an absence: two rows that both leave it out name the same thing and the second is refused as a duplicate. Repeating another column's value there is a different identity, not the same one written out. `target_variant` carries no meaning when `target_kind` is `action`, and is not what tells two such rows apart. | Column | Required | Kind | Meaning | |---|---|---|---| | `target_kind` | **yes** | text | What was checked. The schema allows only these two, because verifying a product means nothing: you verify a specific claim about it, and that claim is a finding or an action. One of: `finding`, `action`. | | `subject_type` | **yes** | text | The subject the finding or action is about. One of: `product`, `component`, `build_workflow`, `source_asset`. | | `subject_slug` | **yes** | text | | | `target_ref` | **yes** | text | The finding's code, or the action's title, on that subject. | | `target_variant` | | text | Which finding, where that subject carries the same code more than once. Leave blank rather than repeating target_ref: blank is its own identity, not a shorthand. Ignored when the target is an action. | | `verified_at` | **yes** | date | When it was checked. Part of the key, because the same thing is verified more than once and each check is its own record. | | `result` | **yes** | text | One of: `verified`, `not_verified`, `partial`. | | `verifier` | | text | Who checked, by party display name. | | `note` | | text | | | `evidence_uri` | | text | The evidence the verification rests on, by uri from the evidence dataset. | ## campaigns A bounded audit: one lens, one obligation, one date range, one frozen population. File: `campaigns.csv`. Natural key: `slug`. | Column | Required | Kind | Meaning | |---|---|---|---| | `slug` | **yes** | text | Short identifier, used in links. | | `name` | **yes** | text | | | `status` | | text | One of: `draft`, `scoped`, `collecting`, `assessing`, `remediation`, `verification`, `closed`, `paused`, `cancelled`. Defaults to `draft`. | | `lens` | | text | Which definition of 'product' the campaign counts under. | | `starts_on` | | date | | | `ends_on` | | date | | | `scope_frozen_at` | | date | When the population stopped moving. The whole point of a campaign: a percentage means nothing if its denominator grew underneath it. | | `scope_query_json` | | text | How the population was chosen, as JSON. | | `owner` | | text | Who is accountable for the campaign, by party name. | ## campaign_subjects Which subjects a campaign counts, and which it deliberately does not. File: `campaign_subjects.csv`. Natural key: `campaign_slug`, `subject_type`, `subject_slug`. | Column | Required | Kind | Meaning | |---|---|---|---| | `campaign_slug` | **yes** | text | | | `subject_type` | **yes** | text | One of: `product`, `component`, `build_workflow`, `source_asset`. | | `subject_slug` | **yes** | text | | | `included` | | bool | False records a deliberate exclusion, which is not the same as absence: one is a decision, the other is an oversight. Defaults to `True`. | | `exclusion_reason` | | text | Required in practice when included is false, because an unexplained exclusion is indistinguishable from a mistake. | | `collection_status` | | text | One of: `not_started`, `collecting`, `ready`, `assessed`, `blocked`. Defaults to `not_started`. | ============================================================================== PAGE: Command reference (reference/cli.md) ============================================================================== # Command reference Generated from the argument parser, so it cannot drift from what the commands accept. It lists *what* each flag is; `docs/interfaces.md` explains what the commands are **for**, and is the page to read first. `crane --help` and `crane --help` print the same thing at the terminal. ## Global options | Option | What it does | |---|---| | `--version` | show program's version number and exit | | `--database` `PATH` | SQLite file to use. Defaults to $CRANE_DB or ./data/crane.db | ## Commands ### `crane init` ### `crane migrate` | Option | What it does | |---|---| | `--accept-id-scheme` | carry on with a database whose identifiers were derived under the old colon-joined recipe, accepting that its existing rows keep them | | `--dry-run` | list what would be applied and stop | ### `crane status` ### `crane lens` ### `crane lens list` | Option | What it does | |---|---| | `--database` `DATABASE` | — | | `--json` | — | ### `crane import` ### `crane import adapter` Discovers importers installed under crane.adapters.; run with no name to list what this deployment has. Adapters are deliberately absent from published images, because each one describes somebody else's system: a deployment that needs one installs its own. Sources are opened read only, and every source row is accounted for in the reconciliation report. | Option | What it does | |---|---| | `--option` `KEY=VALUE` | an adapter specific option, repeatable. Each adapter documents its own | | `--dry-run` | read, resolve, and report, then roll back without writing | | `--report` `PATH` | write the reconciliation report here | | `--json` | print the report as JSON | | `--fail-on-unresolved` | exit 3 if any source row could not be placed | ### `crane import spec` The contract for supplied data. Read this before writing files. --json gives the machine readable form. | Option | What it does | |---|---| | `--json` | print as JSON | ### `crane import template` ### `crane import data` Loads a directory of CSVs, a single CSV, or a JSON bundle. Only the datasets present are touched. Validation runs over the whole batch before anything is written, so the result is a clean load or a clean failure. Exit code 4 means the input did not validate. | Option | What it does | |---|---| | `--dry-run` | validate and report, write nothing | | `--json` | print the report as JSON | ### `crane import csv` Loads a directory of CSVs, a single CSV, or a JSON bundle. Only the datasets present are touched. Validation runs over the whole batch before anything is written, so the result is a clean load or a clean failure. Exit code 4 means the input did not validate. | Option | What it does | |---|---| | `--dry-run` | validate and report, write nothing | | `--json` | print the report as JSON | ### `crane import json` Loads a directory of CSVs, a single CSV, or a JSON bundle. Only the datasets present are touched. Validation runs over the whole batch before anything is written, so the result is a clean load or a clean failure. Exit code 4 means the input did not validate. | Option | What it does | |---|---| | `--dry-run` | validate and report, write nothing | | `--json` | print the report as JSON | ### `crane import sbom` The wizard's loop from a shell, calling the same function: identify SPDX or CycloneDX by content, store by hash so a re-registration is recognised, attach to a product, turn the packages into components with dependency depth unless --no-components, and queue validation with a rescore behind it. Exit 4 if any document was refused. | Option | What it does | |---|---| | `--subject` `REF` | where to attach: a product slug, or component:SLUG, workflow:SLUG, repo:LOCATOR for an SBOM produced at that level. Defaults to what each document says about itself, as a product | | `--no-create` | refuse documents for products that do not exist instead of creating them | | `--no-validate` | register only; queue no validation and no rescore | | `--no-components` | register a product-level document as evidence of the product without turning its packages into components. For a portfolio whose component model is curated and an SBOM's package list would bury it | | `--requested-by` `REQUESTED_BY` | who asked, recorded on the queued jobs | | `--json` | — | ### `crane export` Writes the migration bundle: every dataset as one JSON object, importable into an empty database to reproduce the portfolio, including scope decisions, exceptions, identity mappings and review states. Evidence is named by uri and hash rather than embedded. Deliberately not carried: finding states, decision records beyond scope and exceptions, settled conflicts, and job or score history -- the bundle is the portfolio, not the ledger, and the database file itself is the full dump. The same bundle is GET /api/v1/export. | Option | What it does | |---|---| | `--out` `PATH` | write here instead of stdout | | `--history` | include relationships that have ended, with the date they ended. The default bundle is the portfolio as it is now, so an ended relationship is simply absent; with this it is present and dated, and a question about a past month is answerable from the bundle rather than only from the database file | ### `crane collect` ### `crane collect github` Reads what the repositories already publish -- workflows, runs, releases, SBOM assets, CODEOWNERS, packages -- and imports it as observed, never approved. Repositories become source assets, not products: attaching them to products is a decision this tool records, not one a scanner makes. The worker reads CRANE_GITHUB_TOKEN from its environment; without it, public organizations scan inside GitHub's anonymous rate limit. The same operation is POST /api/v1/collect/github. | Option | What it does | |---|---| | `--dry-run` | scan one batch and report what it would create, writing nothing | | `--full` | re-read everything, ignoring the last completed scan's watermark. Needed periodically: an incremental scan reads what changed, so it never notices something that was deleted | | `--check` | read one object and report what came back, then stop. Verifies the credential and the connection without queueing a scan or writing anything | | `--json` | — | ### `crane collect gitlab` Reads what the projects already publish -- the pipeline configuration and its latest run, releases, SBOM release assets, CODEOWNERS -- and imports it as observed, never approved. Projects become source assets, not products: attaching them to products is a decision this tool records, not one a scanner makes. A project holds one pipeline configuration rather than many workflow files, so it contributes at most one build workflow. An archived project is a retired pipeline rather than an absent one. The worker reads CRANE_GITLAB_TOKEN from its environment; without it, public projects scan inside GitLab's unauthenticated rate limit. The same operation is POST /api/v1/collect/gitlab. | Option | What it does | |---|---| | `--dry-run` | scan one batch and report what it would create, writing nothing | | `--full` | re-read everything, ignoring the last completed scan's watermark. Needed periodically: an incremental scan reads what changed, so it never notices something that was deleted | | `--check` | read one object and report what came back, then stop. Verifies the credential and the connection without queueing a scan or writing anything | | `--json` | — | ### `crane collect ado` Reads what a project's repositories and pipelines already publish and imports it as observed, never approved. Give an organization, or 'organization/project' to scan one project. A pipeline costs an extra call: the listing does not name the repository it builds, only the per-pipeline detail does, and without it every Azure repository would be unattached. A pipeline building a repository outside Azure -- a GitHub one, commonly -- has that recorded by locator rather than resolved to an Azure repository that does not exist. The worker reads CRANE_ADO_TOKEN from its environment. The same operation is POST /api/v1/collect/ado. | Option | What it does | |---|---| | `--dry-run` | scan one batch and report what it would create, writing nothing | | `--full` | re-read everything, ignoring the last completed scan's watermark. Needed periodically: an incremental scan reads what changed, so it never notices something that was deleted | | `--check` | read one object and report what came back, then stop. Verifies the credential and the connection without queueing a scan or writing anything | | `--json` | — | ### `crane resolve` ### `crane resolve sources` One job per distinct purl, idempotent on the purl forever. Needs the purl2src executable on the worker; set CRANE_PURL2SRC_BIN when it lives in a virtualenv. The same operation is POST /api/v1/resolve/sources. | Option | What it does | |---|---| | `--subject` `SUBJECT` | limit to one component slug | | `--database` `DATABASE` | — | | `--json` | — | ### `crane measure` ### `crane measure health` Idempotent per purl per month, because health goes stale. A host the tool cannot speak ends unmeasured, never failed. Needs the suphm executable on the worker (CRANE_SUPHM_BIN); suphm is AGPL-3.0 and runs at arm's length as a separate process, never bundled. The same operation is POST /api/v1/measure/health. | Option | What it does | |---|---| | `--subject` `SUBJECT` | limit to one component slug | | `--database` `DATABASE` | — | | `--json` | — | ### `crane interpret` ### `crane interpret run` A stored prompt and the exact input text, read by an agent CLI in its non-interactive mode on whichever machine runs this definition -- a runner by default, this server where the definition has been set back to local. Queued either way; this does not run it. Never a verdict: the answer lands as an AGENT-REVIEW finding. The same operation is POST /api/v1/interpret. | Option | What it does | |---|---| | `--input` `INPUT` | file holding the text to read; stdin otherwise | | `--database` `DATABASE` | — | | `--json` | — | ### `crane validate` ### `crane validate sbom` Queues one job per SBOM artifact per profile and returns immediately. A worker executes them. With no --profile, validates against every profile an active obligation policy asks for. | Option | What it does | |---|---| | `--profile` `PROFILE` | ossbomer profile id, repeatable. Defaults to what the policies require | | `--subject` `SUBJECT` | limit to one product or component slug | | `--requested-by` `REQUESTED_BY` | who asked, recorded on the job | | `--json` | — | ### `crane validate profiles` | Option | What it does | |---|---| | `--json` | — | ### `crane assess` Evaluates every check in an obligation policy and stores the score with the per check explanation behind it. Nothing is approved by scoring. | Option | What it does | |---|---| | `--subject` `SUBJECT` | score one product by slug | | `--detail` | print every check for every product | | `--dry-run` | compute without storing | | `--queue` | queue the rescore as a job for a worker instead of scoring here. What POST /api/v1/assess does, and the shape to use next to a running server | | `--json` | — | ### `crane demo` Eight invented products chosen to show the situations this exists for: a shared component, one with no build, SBOMs that pass and fail, a document that cannot be read, and a product nobody has scoped. | Option | What it does | |---|---| | `--force` | seed even if data is present | | `--json` | — | ### `crane policy` ### `crane policy list` | Option | What it does | |---|---| | `--json` | — | ### `crane policy set-rollup` Cut a new version of an obligation that counts the relationship types you name. A new version rather than an edit, because the version and its hash are how a score from before is told from a score from after -- so numbers already reported keep citing the rules they were computed under. | Option | What it does | |---|---| | `--edge` `product_component` \| `product_relationship` | product_component is what the product is made of; product_relationship is what a parent product ships | | `--types` `TYPES` | comma separated relationship types, or '' for none at all | | `--unset` | say nothing about this edge, which is not the same as saying none | | `--check` `CHECK` | configure one check rather than the whole obligation | | `--database` `DATABASE` | — | | `--json` | — | ### `crane job` ### `crane job list` | Option | What it does | |---|---| | `--status` `STATUS` | pending, running, succeeded, failed, cancelled | | `--limit` `LIMIT` | — | | `--json` | — | ### `crane job prune` Deletes the working copy and the logs of finished runs, keeping failures longer than successes because those are the ones somebody opens. Then the ledger rows for runs nothing still points at, and the stored objects nothing claims, and a VACUUM so the file actually shrinks. A run id that is provenance for a claim, an evidence artefact or a state change is kept however old it is. Safe to run on a schedule. | Option | What it does | |---|---| | `--dry-run` | list what would go and remove nothing | | `--database` `DATABASE` | — | | `--json` | — | ### `crane job show` | Option | What it does | |---|---| | `--json` | — | ### `crane job cancel` ### `crane job retry` Only for jobs that ended badly. The old run stays as the record of the failure. The same operation is POST /api/v1/jobs/{id}/retry. | Option | What it does | |---|---| | `--database` `DATABASE` | — | | `--json` | — | ### `crane worker` Runs queued jobs. The --once form drains the queue and exits, which is what belongs in cron. ### `crane worker run` | Option | What it does | |---|---| | `--once` | drain the queue and exit. The default, and the shape to put in cron | | `--follow` | keep polling instead of exiting when the queue is empty | | `--max-jobs` `MAX_JOBS` | stop after this many jobs | | `--poll-seconds` `POLL_SECONDS` | — | | `--quiet` | — | | `--json` | — | | `--only` `DEFINITION` | claim only these job definitions, repeatable. The shape for a worker on a machine that has one tool installed and not the others. For a tool on a machine that should not hold the database at all, use a runner instead | | `--skip` `DEFINITION` | claim everything except these, repeatable. Leaves those jobs pending for a worker that has the tool, rather than failing them here | ### `crane scope` ### `crane scope list` | Option | What it does | |---|---| | `--json` | — | ### `crane scope set` A governed act, not a field update. It writes an approved decision carrying who decided and why, and only then a claim citing it. An import that disagrees is not deleted; it is kept as a conflict. | Option | What it does | |---|---| | `--rationale` `RATIONALE` | why. Required, and at least eight characters: this is the sentence somebody reads in a year | | `--by` `BY` | who decided. Required: an approved decision with no name against it is not one. Matched against known parties, and recorded either way, as a name rather than a verified identity | | `--database` `DATABASE` | — | | `--json` | — | ### `crane scope rule` One judgement covering many subjects. A scope decision is per subject on purpose -- it carries a rationale and an authority, and one sentence pasted across twenty of them is twenty assertions nobody made -- and that does not scale to thousands. A rule changes what the decision is about: the rationale is true of the rule, so it is true of everything the rule matches, however many that is. It is re-evaluated rather than stamped, so a subject that stops matching stops being covered. ### `crane scope rule new` | Option | What it does | |---|---| | `--org-unit` `ORG_UNIT` | division, organization or team name, or 'team:Edge Team' to disambiguate | | `--source-system` `SOURCE_SYSTEM` | the system subjects came from | | `--lifecycle` `LIFECYCLE` | — | | `--no-build` | components with no build workflow at all | | `--origin` `third_party` \| `internal` \| `unclassified` | — | | `--rationale` `RATIONALE` | why, and it has to be true of everything the rule matches | | `--by` `BY` | who decided | | `--expires-on` `EXPIRES_ON` | ISO date. Omitted means permanent, which is worth asking for | | `--apply` | apply it now rather than leaving it to the next run | | `--database` `DATABASE` | — | | `--json` | — | ### `crane scope rule list` | Option | What it does | |---|---| | `--database` `DATABASE` | — | | `--json` | — | ### `crane scope rule apply` | Option | What it does | |---|---| | `--database` `DATABASE` | — | | `--json` | — | ### `crane scope rule retract` | Option | What it does | |---|---| | `--reason` `REASON` | — | | `--database` `DATABASE` | — | | `--json` | — | ### `crane assessor` An assessor runs where it likes and submits check results over the API. Registering one and issuing its credential are decisions carrying a name; everything after that happens over HTTP. See ADR 0003. ### `crane assessor register` Registers the namespace its check keys sit under. An interpretive assessor is one whose results are a model's reading: those may only be review_needed or source_inaccessible, and a person confirming is what turns one into anything else. | Option | What it does | |---|---| | `--kind` `tool` \| `interpretive` | — | | `--description` `DESCRIPTION` | — | | `--by` `BY` | who decided to accept results from it | | `--database` `DATABASE` | — | | `--json` | — | ### `crane assessor list` | Option | What it does | |---|---| | `--database` `DATABASE` | — | | `--json` | — | ### `crane assessor token` ### `crane assessor token issue` The secret is printed once and stored only as a hash. It carries which source systems' subjects this token may write about; a token that names none can write about nothing. | Option | What it does | |---|---| | `--source-system` `SOURCE_SYSTEMS` | a source system key it may write about. Repeat for several, or pass '*' for every one | | `--expires` `EXPIRES_AT` | YYYY-MM-DD | | `--by` `BY` | who issued it | | `--database` `DATABASE` | — | | `--json` | — | ### `crane assessor token list` | Option | What it does | |---|---| | `--database` `DATABASE` | — | | `--json` | — | ### `crane assessor token revoke` Submissions presenting it are refused from that moment. What it wrote stays: the results are the assessor's, not the token's. | Option | What it does | |---|---| | `--by` `BY` | — | | `--database` `DATABASE` | — | | `--json` | — | ### `crane assessor runs` | Option | What it does | |---|---| | `--limit` `LIMIT` | — | | `--database` `DATABASE` | — | | `--json` | — | ### `crane runner` A runner asks the API for work it can execute, runs it, and posts the output back. CRAne never calls the runner, so the machine needs no inbound port and this deployment holds no credential to it. See ADR 0003. ### `crane runner token` ### `crane runner token issue` It names the job definitions that machine may run. Capabilities the runner declares when it claims narrow that further; they never widen it. | Option | What it does | |---|---| | `--definition` `DEFINITIONS` | a job definition key it may claim. Repeat for several, or pass '*' for every externally executed one | | `--description` `DESCRIPTION` | — | | `--expires` `EXPIRES_AT` | YYYY-MM-DD | | `--by` `BY` | who issued it | | `--database` `DATABASE` | — | | `--json` | — | ### `crane runner token list` | Option | What it does | |---|---| | `--database` `DATABASE` | — | | `--json` | — | ### `crane runner token revoke` Claims presenting it are refused from that moment. A job it is still holding keeps its lease until that expires and then returns to the queue, because revoking a credential does not stop a program that is already running. | Option | What it does | |---|---| | `--by` `BY` | — | | `--database` `DATABASE` | — | | `--json` | — | ### `crane manage` Everything the web interface can change, from a terminal. The same functions, so the two cannot disagree about what a decision is. Governed acts take a reason and a name; reference data does not. ### `crane manage create` | Option | What it does | |---|---| | `--slug` `SLUG` | derived from the name unless given; set it only to match the key a source system already uses | | `--locator` `LOCATOR` | source assets only, and required for them: the repository, package or image locator, the key an import matches on | | `--asset-type` `ASSET_TYPE` | source assets only, e.g. repository, package, service | | `--visibility` `VISIBILITY` | source assets only: internal, public, restricted or unknown | | `--lifecycle` `LIFECYCLE` | — | | `--description` `DESCRIPTION` | — | | `--platform` `PLATFORM` | pipelines only | | `--environment` `ENVIRONMENT` | pipelines only | | `--by` `BY` | who is making this change | | `--database` `DATABASE` | — | | `--json` | — | ### `crane manage edit` | Option | What it does | |---|---| | `--name` `NAME` | — | | `--lifecycle` `LIFECYCLE` | — | | `--description` `DESCRIPTION` | — | | `--platform` `PLATFORM` | — | | `--environment` `ENVIRONMENT` | — | | `--asset-type` `ASSET_TYPE` | source assets only | | `--visibility` `VISIBILITY` | source assets only | | `--last-run` `LAST_RUN` | — | | `--last-run-url` `LAST_RUN_URL` | — | | `--by` `BY` | who is making this change | | `--database` `DATABASE` | — | | `--json` | — | ### `crane manage connect` This product contains that component, this build produces it, this repository is its source. Lands as manual and unreviewed; an import asserting the same edge later lands on the same row. Undrawing is 'manage review' with a rejection. The same operation is POST /api/v1/connect. | Option | What it does | |---|---| | `--relationship` `RELATIONSHIP` | how they relate; each edge has a default | | `--note` `NOTE` | — | | `--create-missing` | create a named product or component that does not exist yet, slug derived, then connect it | | `--by` `BY` | who is making this change | | `--database` `DATABASE` | — | | `--json` | — | ### `crane manage adopt` Creates the component and connects every build workflow whose source is the repository. The scanner deliberately never does this; this is the human half of that bargain. The same operation is POST /api/v1/assets/adopt. | Option | What it does | |---|---| | `--slug` `SLUG` | component slug, defaults from the asset's name | | `--name` `NAME` | component name, defaults to the asset's | | `--by` `BY` | who is making this change | | `--database` `DATABASE` | — | | `--json` | — | ### `crane manage evidence` A governed act, because evidence leaving the pool moves scores. The artifact and its validation history stay; what goes is its standing. The same operation is POST /api/v1/evidence/{id}/state. | Option | What it does | |---|---| | `--state` `stale` \| `superseded` \| `deleted` | — | | `--reason` `REASON` | — | | `--by` `BY` | who is making this change | | `--database` `DATABASE` | — | | `--json` | — | ### `crane manage evidence-repair` A validation job does not fetch a remote URI, and it used to record that refusal as 'inaccessible' -- which means it tried and could not see. That is a learned state nothing overwrites, so the mark outlived the bug. This clears only the ones that bug could have caused: state 'inaccessible', a URI scheme the reader never fetches, and no validation ever recorded against it. They go back to 'discovered'. Run it with --dry-run first; it prints what it would touch and changes nothing. | Option | What it does | |---|---| | `--dry-run` | list what would change and stop | | `--by` `BY` | who is making this change | | `--database` `DATABASE` | — | | `--json` | — | ### `crane manage evidence-fetch` A validation job refuses a remote URI on purpose: reaching the network from a job is a surprise on the machine it runs on. Its refusal says to fetch the document first and register where it landed, and this is the command that does that, run by you under your own network policy. SBOMs registered at an http or https URI are fetched, identified by content, stored under their hash in the uploads directory exactly as an upload is; the artifact keeps its URI and gains the hash, which is how the validator finds the copy. No credentials are sent. A fetch that fails marks the document 'inaccessible', which here means what it says: somebody tried and could not see, and the reason is recorded. Run it with --dry-run first. Then 'crane validate sbom'. | Option | What it does | |---|---| | `--dry-run` | list what would be fetched and fetch nothing | | `--timeout` `SECONDS` | how long to wait for each document (default 60) | | `--by` `BY` | who is making this change | | `--database` `DATABASE` | — | | `--json` | — | ### `crane manage repair-finding-variants` A finding written before 0.10.1 did not store the value that distinguishes it from another finding of the same code on the same subject -- the profile, for a conformance check. Without it a portfolio measured against more than one profile exports a bundle that will not import, because those rows share one natural key. This recovers the value by recomputing each finding's derived id over the candidates the installation actually has and keeping only the one that reproduces the id the row already carries, so a wrong answer cannot be written. A finding nothing reproduces is left alone. Run it with --dry-run first. | Option | What it does | |---|---| | `--dry-run` | list what would be recovered and stop | | `--by` `BY` | who is making this change | | `--database` `DATABASE` | — | | `--json` | — | ### `crane manage repair-claim-variants` A claim written before 0.10.2 did not store the value that distinguishes it from another claim of the same kind about the same subject -- the profile, for a conformance verdict. Without it a product measured against three profiles exports one verdict instead of three, and the bundle imports cleanly while holding less than the portfolio did. This reads the profile from the claim's own recorded value and accepts it only when it reproduces the dedupe key the row was written under, so a claim whose value and identity disagree is left alone. It also retires the conflicts left standing over the group those claims moved out of, including ones an earlier run of this command left behind, so it is worth running again on a database it has already repaired. Run it with --dry-run first. | Option | What it does | |---|---| | `--dry-run` | list what would be recovered and stop | | `--by` `BY` | who is making this change | | `--database` `DATABASE` | — | | `--json` | — | ### `crane manage claim` Two CRA checks read claims directly: support.period.declared wants a support.end_date claim and vuln.contact.published wants a vuln.contact claim. Lands as manual; the same fact arriving later in an import lands on the same row. The same operation is POST /api/v1/claims. | Option | What it does | |---|---| | `--value-type` `string` \| `number` \| `boolean` \| `date` | — | | `--lens` `LENS` | only for facts under one scope definition | | `--valid-from` `VALID_FROM` | — | | `--valid-to` `VALID_TO` | — | | `--by` `BY` | who is making this change | | `--database` `DATABASE` | — | | `--json` | — | ### `crane manage classify` code_bearing: built from source, so the product must reach a build. logical_bundle: an offering, SKU or rollup; build requirements apply to the code-bearing products and components it contains. externally_built and manual_process: no build of ours is expected. Recorded as an approved decision that outranks an import. Rescore for build.attributed to follow. The same operation is POST /api/v1/products/{id_or_slug}/implementation. | Option | What it does | |---|---| | `--rationale` `RATIONALE` | why: for a bundle, what it bundles; for an externally built product, who builds it | | `--by` `BY` | who is making this change | | `--database` `DATABASE` | — | | `--json` | — | ### `crane manage retract` With --valid-to the fact was true and stopped being; without it the claim is retracted and stays visible as retracted rather than deleted. The same operation is POST /api/v1/claims/{id}/end. | Option | What it does | |---|---| | `--reason` `REASON` | — | | `--valid-to` `VALID_TO` | — | | `--by` `BY` | who is making this change | | `--database` `DATABASE` | — | | `--json` | — | ### `crane manage distribute` Published to a registry, consumed from one, shipped inside a device. Outbound is what places a product on the market, and not_distributed is a real channel: recording it outbound says 'not distributed' explicitly. Lands as manual; the same row in a distribution.csv lands on the same fact. The same operation is POST /api/v1/distribution. | Option | What it does | |---|---| | `--locator` `LOCATOR` | a purl or registry URL | | `--visibility` `public` \| `customer` \| `internal` \| `unknown` | — | | `--signed` `yes` \| `no` | leave off when nothing established it; that is not 'no' | | `--note` `NOTE` | — | | `--by` `BY` | who is making this change | | `--database` `DATABASE` | — | | `--json` | — | ### `crane manage depend` The upstream reference is kept verbatim even when the build is not tracked here, and the unresolved case raises MIG-004 like an import: a pipeline we depend on and cannot see is a coverage hole worth showing. The same operation is POST /api/v1/dependencies. | Option | What it does | |---|---| | `--slug` `SLUG` | the upstream build's slug, when it is tracked here | | `--note` `NOTE` | — | | `--by` `BY` | who is making this change | | `--database` `DATABASE` | — | | `--json` | — | ### `crane manage alias` An alias is what makes search find the thing under the name people actually use. Lands exactly like a row in aliases.csv. The same operation is POST /api/v1/aliases. | Option | What it does | |---|---| | `--type` `TYPE` | display, legacy, source_label, acronym or name | | `--by` `BY` | who is making this change | | `--database` `DATABASE` | — | | `--json` | — | ### `crane manage unalias` The row stays, marked removed: the record that the name was once used. What goes is its standing in search and matching. The same operation is POST /api/v1/aliases/{alias_id}/remove. | Option | What it does | |---|---| | `--reason` `REASON` | — | | `--by` `BY` | who is making this change | | `--database` `DATABASE` | — | | `--json` | — | ### `crane manage agent` codex, claude or copilot, installed and authenticated on this machine. Read only where this server runs the agent itself: by default interpret.agent is executed by a runner, the CLI lives there and is named in that runner's [commands], and this is not consulted -- the named consent is recorded against the runner's token instead. A recorded choice either way, because non-interactive execution bypasses the tool's own approval prompts. The same operation is POST /api/v1/settings/agent. | Option | What it does | |---|---| | `--by` `BY` | who is making this change | | `--database` `DATABASE` | — | | `--json` | — | ### `crane manage own` | Option | What it does | |---|---| | `--role` `accountable` \| `owning_organization` \| `maintainer` \| `security_contact` \| `reviewer` | only accountable counts for the no owner queue | | `--party-type` `person` \| `team` \| `organization` \| `service` | — | | `--email` `EMAIL` | — | | `--by` `BY` | who is making this change | | `--database` `DATABASE` | — | | `--json` | — | ### `crane manage party` | Option | What it does | |---|---| | `--name` `NAME` | the new display name. Import files matching on the old name will recreate it as a twin; update them too | | `--email` `EMAIL` | an empty value clears it | | `--type` `person` \| `team` \| `service` \| `organization` | — | | `--by` `BY` | who is making this change | | `--database` `DATABASE` | — | | `--json` | — | ### `crane manage merge` Everything the duplicate holds moves to the keeper, an assignment the keeper already has supersedes the duplicate's copy, and the duplicate is archived rather than deleted: its row is the record of the merge. | Option | What it does | |---|---| | `--reason` `REASON` | why these two are one party. The decision carries it | | `--by` `BY` | who is making this change | | `--database` `DATABASE` | — | | `--json` | — | ### `crane manage unit` The same rows an org_units.csv import writes, with the same derived identity, so neither makes twins of the other. | Option | What it does | |---|---| | `--parent` `PARENT` | the parent unit's name | | `--by` `BY` | who is making this change | | `--database` `DATABASE` | — | | `--json` | — | ### `crane manage program` Off means: absent from the selector, not scored, its checks raise no work, its validation profiles stop being required. History stays — the policy is retired, never deleted, and scores already computed remain queryable. Inventory confidence refuses to turn off. See 'crane policy list' for the programs and their state. | Option | What it does | |---|---| | `--reason` `REASON` | why. Turning a program off changes every denominator at once | | `--by` `BY` | who is making this change | | `--database` `DATABASE` | — | | `--json` | — | ### `crane manage resolve` Writes an approved decision and a claim citing it, which outranks both sides and keeps winning across reimports. The sources keep disagreeing and the conflict stays visible as settled, which is the truth. Conflict ids: /api/v1/conflicts, or the scope page. | Option | What it does | |---|---| | `--reason` `REASON` | why this side is right | | `--by` `BY` | who is making this change | | `--database` `DATABASE` | — | | `--json` | — | ### `crane manage review` | Option | What it does | |---|---| | `--note` `NOTE` | required to reject | | `--by` `BY` | who is making this change | | `--database` `DATABASE` | — | | `--json` | — | ### `crane manage except` | Option | What it does | |---|---| | `--queue` `QUEUE` | an attention queue key | | `--check` `CHECK` | a check inside an obligation policy | | `--reason` `REASON` | at least eight characters | | `--expires` `EXPIRES` | YYYY-MM-DD. Defaults to a year from today | | `--permanent` | no expiry. Deliberately awkward to ask for | | `--by` `BY` | who is making this change | | `--database` `DATABASE` | — | | `--json` | — | ### `crane manage revoke` | Option | What it does | |---|---| | `--reason` `REASON` | — | | `--by` `BY` | who is making this change | | `--database` `DATABASE` | — | | `--json` | — | ### `crane manage finding` Until this existed a finding could only ever be open, and the only way to make one stop appearing was to excuse it. Accepting a finding or calling it not applicable is a judgement and writes a decision; resolving one is an observation and does not. | Option | What it does | |---|---| | `--note` `NOTE` | why. Required for accepted and not_applicable | | `--by` `BY` | who is making this change | | `--database` `DATABASE` | — | | `--json` | — | ### `crane manage exceptions` | Option | What it does | |---|---| | `--status` `active` \| `expired` \| `revoked` | — | | `--by` `BY` | who is making this change | | `--database` `DATABASE` | — | | `--json` | — | ### `crane manage identity` | Option | What it does | |---|---| | `--external-key` `EXTERNAL_KEY` | — | | `--locator` `LOCATOR` | a full URL for this one object | | `--object-type` `OBJECT_TYPE` | what the source system calls this kind of thing, e.g. Service, Product, Project. It chooses the route a link is built from, so a catalogue that addresses several types alike needs it. Defaults to the subject type, which is a guess | | `--by` `BY` | who is making this change | | `--database` `DATABASE` | — | | `--json` | — | ### `crane manage define` A relationship type this deployment's catalogue has and CRAne has no term for, a claim kind a feed carries. Until this existed the registry was reachable only from an adapter, so a deployment could load the value and not explain it -- and the path that does check registration refused saying a migration had to declare it first. Registering restricts nothing: these columns are open vocabularies and stay open, so an unregistered value still loads. What it gains is a name and a meaning, which is what lets a page label it and what answers 'what was this supposed to mean' a year later. The same operation is POST /api/v1/vocabulary. | Option | What it does | |---|---| | `--name` `DISPLAY_NAME` | what to call it on screen | | `--description` `DESCRIPTION` | what it means in the source system | | `--namespace` `NAMESPACE` | defaults to 'local'. 'crane.core' is reserved for what ships | | `--retire` | stop offering the key while keeping the row, so data already carrying it stays explainable | | `--by` `BY` | who is making this change | | `--database` `DATABASE` | — | | `--json` | — | ### `crane manage definitions` Relationship types, claim kinds and the rest are open vocabularies, so what a key means lives only in the registry. The same listing is GET /api/v1/vocabulary. | Option | What it does | |---|---| | `--class` `asset_type` \| `channel_type` \| `check_key` \| `claim_kind` \| `decision_type` \| `environment` \| `evidence_kind` \| `obligation` \| `org_level` \| `platform_type` \| `relationship_type` \| `subject_type` | — | | `--namespace` `NAMESPACE` | — | | `--include-retired` | — | | `--by` `BY` | who is making this change | | `--database` `DATABASE` | — | | `--json` | — | ### `crane manage identities` The read half of identity mapping. `retype-identity` and `unidentify` are addressed by the mapping's id, which is derived from internal values and is not in an export, so this is where one comes from. Each row also carries the address the mapping currently resolves to, which is how a link pointing at the wrong kind of page is spotted without opening a browser. --generic lists only the mappings whose external type is just the subject type, which is the fallback applied when nothing named one. The same listing is GET /api/v1/identities. | Option | What it does | |---|---| | `--system` `SYSTEM` | restrict to one source system, by its key | | `--subject-type` `product` \| `component` \| `build_workflow` \| `source_asset` \| `party` | — | | `--object-type` `OBJECT_TYPE` | restrict to one kind of thing there, e.g. Service | | `--generic` | only the mappings carrying the subject type as their external type, which is the population that needs correcting | | `--limit` `LIMIT` | — | | `--offset` `OFFSET` | — | | `--by` `BY` | who is making this change | | `--database` `DATABASE` | — | | `--json` | — | ### `crane manage retype-identity` A catalogue that exposes several entity types behind one address shape -- a service, a product and a project, same host, same identifier format, one path segment apart -- needs each mapping to carry the type it actually is, because that is what chooses the route. A mapping recorded without one carries the subject type instead, which is a guess. This is an operation rather than an edit because the identity's id derives from the type: correcting the column in place leaves the row at an id no import will find, and the next one inserts a second mapping for the same object. The row moves, its recorded observations move with it, and a destination that already exists is merged into rather than duplicated. The same operation is POST /api/v1/identities/{id}/retype. | Option | What it does | |---|---| | `--by` `BY` | who is making this change | | `--database` `DATABASE` | — | | `--json` | — | ### `crane manage unidentify` Marked deleted rather than removed, so which systems keep asserting things that turned out to be wrong stays answerable. The same operation is POST /api/v1/identities/{id}/remove. | Option | What it does | |---|---| | `--by` `BY` | who is making this change | | `--database` `DATABASE` | — | | `--json` | — | ### `crane manage link` | Option | What it does | |---|---| | `--template` `TEMPLATE` | containing {id}, {key} or {type}. Omit to clear | | `--label` `LABEL` | — | | `--object-type` `OBJECT_TYPE` | which kind the source system itself calls this, e.g. Service, Product, Project. A catalogue addressing several kinds one path segment apart needs a route for each, and they all arrive as one subject type here. Omit for the pattern that covers any kind, which is what a system addressing everything alike needs | | `--by` `BY` | who is making this change | | `--database` `DATABASE` | — | | `--json` | — | ### `crane report` Three reports, because a steering committee, an auditor, and the people doing the work want different documents rather than different sections of one. Run 'crane report list' to see them. | Option | What it does | |---|---| | `--param` `NAME=VALUE` | a parameter for a template, repeatable | | `--obligation` `OBLIGATION` | obligation key, defaults to cra. See 'crane policy list' | | `--format` `md` \| `html` \| `json` | md for a file, html for something to send, json for a machine | | `--out` `PATH` | write here instead of stdout | | `--note` `NOTE` | a note recorded in an evidence package manifest | | `--json` | machine readable summary | ### `crane serve` Serves the interface. Binds to localhost by default: there is no authentication, so exposing it to a network is a decision to make deliberately. | Option | What it does | |---|---| | `--host` `HOST` | — | | `--port` `PORT` | — | | `--worker` | run a worker inside the web process. Required in a container nobody can shell into, since otherwise queued validations never execute |