Interfaces

Every way data enters or leaves CRAne. This document is the contract. Code follows it, and when the contract changes it changes here first.

Status markers used throughout:

  • built works today
  • planned specified here, not implemented, the CLI exits 2 and says so

1. Command line

This section says what each command is for. It does not enumerate every flag, and a hand-written list of them would drift: the command reference is generated from the argument parser, so a flag that exists is listed and a flag that is listed exists. crane <command> --help prints the same thing.

Installed as crane, or run from a source checkout with PYTHONPATH=src python3 -m crane.

Global options:

Option Meaning
--database PATH SQLite file to use. Defaults to $CRANE_DB, then ./data/crane.db
--version Print version and exit

Environment: the two that decide where a command reads and writes are below. Every variable, including authentication and the upload and template directories, is in one table in docs/deployment.md, and a test fails if the code reads one that is not listed there. A second copy here would be a second thing to keep true, so this is deliberately not that copy.

Variable Default Meaning
CRANE_DB ./data/crane.db Database file
CRANE_MIGRATIONS searched Migration directory. Searched in order: this variable, the repository root, the installed package, the working directory

1.1 Database lifecycle

crane init (built)

Creates the database file and applies every migration. Safe to run repeatedly.

$ crane init
database: /path/to/CRAne/data/crane.db
state:    created
applied:  0001_initial
applied:  0002_seed_core_vocabulary

crane migrate [--dry-run] (built)

Applies pending migrations. --dry-run lists what would run and changes nothing.

Migrations are immutable once applied. If a file changes after it was applied the runner refuses to continue and names the file. Fix forward with a new migration.

crane status (built)

Prints the database location and size, applied and pending migrations, and row counts for the tables that matter. Exit code 1 if the database does not exist yet.

Use this as the first diagnostic for any “is my data there” question. Over HTTP the same answer is GET /api/v1/status.

1.2 Definitions

crane lens list (built)

Lists the definitional lenses, their descriptions, and which one is the reporting default. Four lenses are seeded: cra.regulatory, commercial, shiproom, legal.oss.

crane scope set (built)

Records a scope status for one subject under one lens. A governed act, not a field update.

crane scope set product acme-gateway cra.regulatory in_scope \
    --rationale "Sold on the Union market, ships firmware" \
    --by "Priya Raman"

status is one of in_scope, out_of_scope, undecided, not_applicable, disputed. --rationale is required and must be at least eight characters: it is the sentence somebody reads in a year when they ask why.

--by is required too. The claim this writes outranks every import, and a claim at the top of the authority ladder with nobody’s name against it is not a decision. The name is matched against known parties and recorded either way, as a name rather than a verified identity. The one path that does not require it is an unapproved note, which asserts no authority: approved: false over the API.

What it writes, in order: an approved decision carrying who decided and why, then a claim with claim_kind = 'scope.status', source_kind = 'approved' and decision_id citing it, then a second claim holding the rationale so a report can print why and not only what.

It never overwrites a claim of higher authority. Where a conflicting claim exists both are kept, the weaker is marked conflicted, and a reconciliation_conflict row records the disagreement. If the decision did not prevail the command says so on stderr rather than reporting success, because believing a question is settled when it is not is worse than the conflict.

Only the named lens moves. Deciding a product is in regulatory scope says nothing about whether it is commercially in scope, and writing both would be inventing the second decision.

The web form at POST /scope/set and POST /api/v1/scope call the same function. A second implementation behind a different entry point is how two entry points start disagreeing about what a decision is.

crane manage classify <product> <classification> --rationale --by (built)

How a product is made, which decides what a build is required of. A product.implementation claim with no lens, one of:

Classification A build is required of build.attributed Pipeline queues
code_bearing the product a missing build fails counted
logical_bundle what it contains judged on its own components and those of every code-bearing or nested bundle product it contains not counted; its contents are
externally_built nothing of ours not_applicable not counted
manual_process nothing not_applicable not counted
none undetermined insufficient_evidence unless everything already reaches a build in unclassified_products instead

A bundle is a commercial offering, SKU, feature or rollup: sold and reviewed, and built from nothing of its own. It stays in scope, ownership and compliance review. Its contents are found through the relationship types the policy rolls up for product_relationship (contains where the policy says nothing), descending through nested bundles only: what sits below a code-bearing, externally built or manual product is that product’s own business, not the bundle’s. A contained product nobody has classified holds the bundle at insufficient_evidence, unless something it contains already fails.

SBOM and other evidence about what ships are required whatever the classification: a supplier’s SBOM is still an SBOM. On the SBOM coverage table, a component of an externally built or manual process product is not counted as having “nothing that builds it”.

Recording one here is always an approved decision, which outranks an import. An import asserts one through the implementation column on products, or a product.implementation row in claims; both land on the same claim. The product list, its CSV and the API rows carry implementation and build_requirement, and implementation= filters by a classification or unclassified.

1.3 Data intake

crane import spec [--json] (built)

Prints the data contract: every dataset, every column, which values are accepted, and the rules the loader applies. --json gives the machine readable form.

Read this before writing files. It is generated from the code, so unlike a table in a document it cannot drift from what the loader actually accepts.

crane import template DIR (built)

Writes an empty CSV per dataset with the correct headers, plus a README describing each one. Fill in the files you have data for and delete the rest.

crane import data PATH (built)

Loads supplied data. Accepts a directory of CSVs, a single CSV, or a JSON bundle whose top level keys are dataset names. import csv and import json are aliases.

crane import data ./my-data --dry-run    # validate, write nothing
crane import data ./my-data              # load it
crane import data ./my-data --json       # machine readable report
Option Meaning
--dry-run Validate and report, then roll back without writing
--json Print the report as JSON, including every error as a record

Exit codes: 0 loaded, 4 the input was readable but did not validate, 1 something is wrong with the setup rather than the data.

How it behaves

  • All or nothing. Validation runs over the whole batch before the first write. A partly applied load is worse than a clean failure, because the caller cannot tell what state it left behind.
  • Only what you provide. Loading distribution.csv alone against an existing database is normal. Datasets you do not supply are not touched.
  • References resolve both ways. A row can point at something defined in the same batch or at something loaded weeks ago.
  • Unknown columns are an error. A typo in a header is never silent data loss.
  • Idempotent. The same input twice changes nothing the second time.
  • Nothing is approved by loading it. Rows land as observations needing review unless source_kind says otherwise and names an authority.

Errors are records, not prose. Each carries the dataset, the row number, the column, the offending value, and what was expected, so a caller can fix and retry without parsing text:

{"dataset": "products", "row": 2, "column": "canonical_name",
 "value": null, "message": "required value is missing"}

Datasets

File Loads What it is for
source_system_links.csv source_system_link 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.
definitions.csv extension_registration 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.csv source_system A system that holds metadata about the portfolio, and how to link back to it.
org_units.csv party The accountability hierarchy. Division is the sub legal entity, organization is director level, team is where products are usually owned.
parties.csv party People, teams, and service principals that can own or be accountable for things.
products.csv product A reviewable shipping or distributed software unit.
components.csv component A building block that may ship in several products.
build_workflows.csv build_workflow A build, release, packaging, signing, or publication definition.
identities.csv external_identity The same thing, as known by another system. Several per system are allowed, and status retires one explicitly.
external_properties.csv external_property Typed metadata another system holds about a thing, attached to the identity it holds it under.
source_scopes.csv source_scope What a feeder is allowed and configured to look at inside a source system. The denominator coverage is measured against.
sync_runs.csv sync_run One execution of a feeder against one scope, with status, cursor, watermark, counts and partial failures.
source_snapshots.csv source_snapshot A hashed record that a source looked a particular way at a particular time.
coverage_gaps.csv coverage_gap Something a feeder could not see, recorded so absence is not read as evidence.
findings.csv finding Something wrong with a subject, and where it got to. Status travels, so a migration does not reopen closed work.
decisions.csv decision A recorded human judgement: what was decided, by whose authority, and why. An approval without an authority is refused.
actions.csv action_item Work somebody owes, with an owner and a due date.
action_dependencies.csv action_dependency An action that cannot start until another finishes, possibly on another subject.
state_changes.csv state_change A field on a subject changing value, with when and why. The history that makes a current value defensible.
verifications.csv verification Somebody checked, and what they concluded, optionally resting on a named piece of evidence.
campaigns.csv audit_campaign A bounded audit: one lens, one date range, one frozen population. Recorded, not yet reflected in scoring.
campaign_subjects.csv campaign_subject Which subjects a campaign counts, and which it deliberately excludes and why.
source_assets.csv source_asset A repository, package, service record, catalog entry, image, or binary.
product_components.csv product_component Which components ship in which products.
component_workflows.csv component_workflow Which build workflows produce which components.
product_relationships.csv product_relationship Product to product: containment, bundling, succession.
workflow_assets.csv workflow_asset What a build workflow reads and what it produces.
subject_assets.csv subject_asset Which source asset a product or component lives in, builds from, or is catalogued as.
workflow_dependencies.csv workflow_dependency A build that depends on another build. This is the fan in and fan out case.
responsibilities.csv responsibility_assignment Who is accountable for what.
aliases.csv alias Other names a subject is known by, so search and matching find it.
scope.csv claim Whether a subject is in scope, under one named definition.
scope_rules.csv scope_rule One scope judgement covering every subject that matches its criteria. Exported instead of the per-subject decisions it produced, so that restoring it keeps it able to widen and withdraw.
build_topology.csv claim Where code lives and where the build runs. Two different questions.
distribution.csv distribution Where something enters and leaves the organization.
claims.csv claim 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.
evidence.csv evidence_artifact Pointers to evidence. The file itself is never copied into the database.
exceptions.csv exception_grant Queues and checks somebody has decided do not apply. A governed act: loading one writes the decision first.
exceptions.csv exception_grant Queues and checks somebody has decided do not apply to a subject.

A worked example covering the awkward cases, a component shared across products, two builds producing one component, a vendor built component delivered as binaries, a component consumed from a public registry and never built, and a build depending on something outside the inventory, is in examples/sample-portfolio/.

POST /api/v1/import/data takes the JSON bundle form in the request body, runs the same importer with the same all-or-nothing validation, and returns the same report; a batch that does not validate is a 422 listing every row to fix, and writes nothing. The import wizard at /import accepts the same bundle as a file, with the importer’s own rehearsal in the preview step: it says what would load, or lists what to fix, before anything is written.

crane export [--out PATH] [--history] (built)

The whole portfolio back out, as one JSON bundle in exactly the shape import data accepts. This is the migration bundle: importing it into an empty database reproduces the portfolio — scope decisions, exceptions, identity mappings, review states and dependency depth included — and a test holds export → import → export to be the identity. The same bundle is GET /api/v1/export, which is how an installation moves out of a container nobody can shell into: download there, upload to the wizard here.

Three properties, decided deliberately:

  • The prevailing view, by default. One row per natural key. A fact two sources disagree about exports as the value that prevails under the authority ladder; the full history is the database file itself, which is the full dump.

    --history, and GET /api/v1/export?history=true, also carries relationships that have ended. The default bundle answers what is the portfolio now, which is what a migration needs and what the interface and the scores agree with. A history bundle answers what has the portfolio been, so a question about March is still answerable from an export taken in July.

    This exists because retirement was otherwise unexportable. A feeder records that a component stopped shipping in a product by setting effective_to; the resolved graph views drop an edge whose end date has passed, correctly, since it is not true now — so the end date imported and then vanished, and the bundle claimed the relationship had never existed rather than that it had ended. History is not the default because every export would then grow forever.

    The flag decides which relationships are in the bundle and nothing else. effective_to travels in both modes, because it is not a column about the past: a relationship can be live today and announced to end in December, and a bundle that dropped that date reimported the announcement as ships indefinitely. A live relationship is also described identically either way — same review state, confidence, depth, source kind and end date — because each history view is its current view plus the relationships that have wholly ended, rather than the same query with the time filter taken out. That distinction is load-bearing rather than cosmetic: re-aggregating without the filter lets an assertion that has stopped applying contribute to a group the current view excluded it from, and the same live edge then exports as unreviewed in one mode and approved in the other, so reimporting the history bundle would approve something never approved. Migration 0027 has the reasoning, and a test holds the two modes to the same row.

  • Merge on import, never replace. Deterministic ids mean a reimport updates rather than duplicates, and nothing in this system deletes anything.
  • Evidence is named, not embedded. Rows carry the uri and content hash; the files travel separately (for uploads, the /data/uploads directory).

What deliberately does not travel. The bundle is the portfolio, not the ledger. A migration is trusted for exactly what it does, so the boundary is stated rather than discovered:

  • Finding states. A finding accepted or resolved through the interface does not travel. Findings are observations the data raises, and the new installation re-raises them from the data it now holds; carrying a closed state without the data behind it would close findings nobody there examined.
  • Decision records beyond scope and exceptions. Those two reproduce their decisions on import, because their datasets are governed acts. Every other decision — a conflict resolution, a finding acceptance — travels only as its effect: the approved claim wins on the new installation, but the who-and-why behind it stays in the source database. If the rationale matters, the database file is the migration.
  • Settled conflicts. Conflict rows are recomputed from the claims, so a settled conflict re-settles on the new installation only when the losing source reasserts itself there. Until it does, there is no disagreement to display, which is accurate.
  • Job history, score snapshots, run manifests. Operational record, not portfolio. Scores are recomputed by the first rescore; what a worker did on another machine is that machine’s history.

If one of these turns out to be needed rather than acceptable, it becomes a dataset the way exceptions did — a governed loader, not a bigger dump.

crane import adapter [NAME] [PATH] (built)

Imports a legacy inventory system through an installed adapter. Skip this section entirely if you have none. Nothing else in CRAne depends on adapters, they never ship in a published image — each one describes somebody else’s system — and the command is built for their absence: it says what is not installed and exits 2.

crane import adapter                     # list what this deployment has installed
crane import adapter <name> ./source.db \
    --option workflow_db=./workflow.db \
    --report exports/migration-report.md

An adapter is a module under crane.adapters.<name> implementing two functions: run_import(conn, path, *, options, dry_run) returning a report with as_dict() and unresolved, and render_report(report) returning the reconciliation report as text. Options are adapter specific, passed as repeated --option KEY=VALUE, and an adapter refuses one it does not understand rather than ignoring it.

Option Meaning
--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 full report as JSON
--fail-on-unresolved Exit 3 if any source row could not be placed

Rules, which are the same for any importer:

  • The source is opened read only and is never written to.
  • Everything lands as source_kind = 'imported' with review_status = 'unreviewed', except records carrying evidence of a human judgment, which land as approved with the original authority in decision.citation.
  • Derived values are not imported. They are recomputed under a CRAne policy version.
  • Every source row is accounted for, with one of six dispositions. The counts per source table land in import_reconciliation_summary, and any row that needed explaining – anything other than a clean imported – also lands in import_reconciliation with its own reason. Nothing is dropped silently. A clean row does not get a row of its own: that cost one ledger row per source row, and on a real database those rows, all saying the same thing and read by nothing, were most of the file.
  • Target ids are deterministic, so a second run updates in place rather than duplicating.

crane.adapters.example is a worked one, committed and exercised by the suite. It reads an invented source described in writing an adapter, which is the page to read before writing your own. An adapter written against a real system is tenant tooling: it exposes that system’s table names and by implication its data model, so it stays out of this repository and out of every image.

Findings any import can raise

An import is also the first audit. These codes describe the state of the imported portfolio rather than the source system, so they are shared by every importer:

Code Meaning
MIG-001 Source record has no canonical mapping
MIG-002 Scope is still marked for review and was never resolved
MIG-003 SBOM generation is disabled on a build that ships a component
MIG-004 Build depends on an upstream build that is not in the inventory
MIG-005 Relationship asserted with no supporting evidence
MIG-006 Action marked verified with no closure evidence
MIG-007 Component has no build workflow, excluding third party open source components
MIG-008 Component ships in several products with no accountable owner
MIG-009 Two sources disagree about scope under the same lens
MIG-010 Source flagged the record for human review

crane import sbom PATH [--subject SLUG] (built)

The wizard’s whole loop, from a shell, calling the same function as /import and POST /api/v1/import/sbom so the three surfaces cannot disagree about what a registration does. PATH is one document or a directory of .json documents.

crane import sbom ./sboms/gateway-2.1.spdx.json --subject acme-gateway
crane import sbom ./sboms/ --json          # a directory, machine readable outcomes

What each document becomes: identified by content (SPDX or CycloneDX, never by file name), stored under its SHA-256 so a re-registration is recognised as the same document, attached to the product (--subject, else what the document says about itself, created unless --no-create), and turned into inventory — a component per package deduplicated by versionless purl, an unreviewed product_component edge carrying dependency_depth (section 5.1), an artifact.purl identity claim, and an artifact.version claim per distinct version seen; two products shipping two versions of one library is a disagreement the conflict machinery holds rather than letting the last upload win. Validation and a rescore are queued behind it unless --no-validate.

--no-components registers a product-level document as evidence of the product and stops there: stored, identified and attached exactly as above, sbom.present written against the product so coverage counts it, and none of its packages turned into components. For a portfolio whose component model is curated, where an SBOM’s package list is larger than the inventory by orders of magnitude and importing it would bury the components somebody chose to manage. The outcome says how many packages it saw and that none became inventory, so a component count of zero is not mistaken for an empty document. The same switch is components=0 on POST /api/v1/import/sbom and the “Its packages” choice in the wizard.

Exit 4 if any document was refused, with the sentence saying why.

crane import sbom ./sboms/gateway-2.1.spdx.json --subject product:acme-gateway

The file is not copied into the database. The URI, hash, media type, and capture time are stored, per the evidence linked and not copied principle.

crane collect gitlab GROUP [--dry-run] [--full] (built)

The same contract against GitLab, and the same refusal to guess: projects become source assets, never products. Subgroups are included, because a group of any size nests and a scan stopping at the top level would report a portfolio far smaller than the one that exists. The same operation is POST /api/v1/collect/gitlab.

crane collect gitlab acme-group --dry-run          # scan one batch, report, write nothing
crane collect gitlab platform/backend              # a nested group is one group
crane worker run --once                            # a worker does the reading

Two differences from the GitHub mapping, both structural rather than cosmetic:

  • A project contributes at most one build workflow. A GitHub repository holds many workflow files, each its own definition with its own runs. A GitLab project holds one pipeline configuration — .gitlab-ci.yml, or wherever ci_config_path points — so the workflow’s identity is the project plus that path, and its status comes from the project’s newest pipeline. Treating a GitLab pipeline as a definition would record one build workflow per run.
  • An archived project is a retired pipeline, not an absent one. The listing is not filtered by archived, because hiding a dead project turns a retirement into an absence and an inventory that silently drops things is worse than one that shows them retired.

CRANE_GITLAB_TOKEN is read from the worker’s environment at the moment of use. Without one, public projects still scan inside GitLab’s unauthenticated rate limit, which is reported in RateLimit-* headers and turns into a scheduled resumption rather than a failure.

crane collect github ORG [--dry-run] [--full] (built)

The first collector: point it at a GitHub organization and the inventory builds itself from what the repositories already publish. The command queues a job and returns; a worker does the reading, because a large organization is thousands of API calls behind a rate limit, and the queue is where leases, retries and resumption already live. The same operation is POST /api/v1/collect/github and the form on /import.

crane collect github acme --dry-run     # scan one batch, report, write nothing
crane collect github acme               # queue the real scan
crane worker run --once                 # a worker does the reading

What GitHub publishes, mapped onto things the model has:

From GitHub Becomes
Repositories source_asset of type repository, plus an external_identity per repo
.github/workflows/* build_workflow with platform_type = github_actions
Latest workflow runs last_run_status, last_run_at, last_run_url
Releases outbound distribution, signed when signature assets are present
SBOM release assets evidence_artifact, state discovered, for the ordinary validator path
The catch-all CODEOWNERS rule responsibility_assignment as maintainer, unreviewed
Published packages outbound distribution per package, on the final batch

Two rules give the collector its shape:

  • Everything it writes is observed, never approved. A scanner sees what a repository looks like, not what the organization has decided. A CODEOWNERS entry is evidence about who touches the code, not a statement that somebody accepted accountability. Everything goes through the same importer the CSV files use, so the authority ladder, the dedupe keys and the conflict machinery apply unchanged, and a rescan is idempotent.
  • A repository is not a product. Scanning an org gives you repositories, and the mapping from repositories to products is the contested many-to-many question this tool exists to record. The collector creates repositories and pipelines confidently, components never, and products not at all. A hundred unattached repositories, visible as unattached, beat a hundred invented products nobody ships.

A large organization is a chain of batches: each job scans a bounded slice, imports it, and submits its own continuation, so every link fits inside a queue lease and a rate limit becomes available_at on the next link rather than a failure. The finished scan queues the rescore. The token, when private repositories need one, is CRANE_GITHUB_TOKEN in the worker’s environment — never a parameter, never a row, per the deployment guide’s rule on secrets.

1.4 Obligations, validation, and asynchronous work

crane policy list [--json] (built)

The obligations the portfolio is measured against, and the checks each one contains.

Scoring is per obligation, not per regulation the project happened to start with. Three ship: cra, fedramp, and ntia. Each is a row in assessment_policy holding a rules document with its checks, weights, blocking flags, bands, and reporting lens. A product carries one score per obligation.

Adding an obligation is a policy row. It needs no code change, and if it depends on a new SBOM profile, that profile is a file in your ossbomer profile directory.

A check may name a validator and a profile. That is the only link between an obligation and the tool that answers part of it:

cra      sbom.conformance -> ossbomer:eu-cra-annex-i
fedramp  sbom.conformance -> ossbomer:fedramp-sbom
ntia     sbom.conformance -> ossbomer:ntia-min-elements

crane validate profiles [--json] (built)

Lists the profiles the installed ossbomer offers, including your own from CRANE_OSSBOMER_PROFILE_PATH, and which of them an active policy requires. Exits 1 if a policy needs a profile this installation does not have, which makes it a useful check to run after changing either.

If ossbomer cannot be run it says so and reports what policy needs anyway. A missing tool is a coverage gap, never a verdict.

crane validate sbom [--profile ID] [--subject SLUG] (built)

Queues one validation per SBOM artifact per profile and returns immediately. The work happens in a worker.

crane validate sbom                              # every profile policy requires
crane validate sbom --profile eu-cra-annex-i     # just one
crane validate sbom --subject aurora-gateway     # just one product

With no --profile, it validates against exactly what the active obligation policies ask for, so what gets checked follows what you are measured against.

The idempotency key is the artifact’s content hash plus the profile. Re-running against unchanged evidence queues nothing, which is what makes this safe on a schedule.

The same queueing is POST /api/v1/validate/sbom, same function, for the deployment with no shell.

Only local files can be validated. Evidence registered with an https URI is refused with a message saying so, and the job is not retried, because fetching is a separate concern with its own network policy.

crane worker run [--once|--follow] (built)

Executes queued work. --once drains the queue and exits, and is the default.

crane worker run --once            # the cron shape
crane worker run --follow          # poll until interrupted
crane worker run --once --max-jobs 5

Exit code 5 if any job failed, so a cron wrapper can notice.

POST /api/v1/worker/run drains the same queue with the same function, in a background thread so the request returns immediately. If the serving process already runs a worker (crane serve --worker), the endpoint says so and starts nothing, because that worker will claim the work on its own.

crane job list|show|cancel|prune (built)

crane job list --status failed
crane job show <job-id>          # parameters, every event, artifacts, run directory
crane job cancel <job-id>
crane job prune [--dry-run]      # remove run directories, ledger rows and stored
                                # objects past their retention window

job show prints the run directory. Every execution leaves logs/stdout.txt, logs/stderr.txt, artifacts/, and run-manifest.json on disk, hashed, including when the command failed.

job prune is how those stop accumulating. Only finished runs are considered, so a job a worker is still writing to is never swept. Where a directory has already been pruned, job show says that rather than printing a path that is not there.

One policy, in one place, because an operator asking “how long does this keep things” should not have to find two answers:

What Kept for Why
Run directory, failed or timed out 90 days A failure is the one somebody opens
Run directory, succeeded or cancelled 14 days The artefacts were registered with their hashes when the run finished; what goes is the working copy
job_run, job_artifact, sync_run, import_reconciliation rows 365 days The row is the record that the run happened. A year is the window a compliance question is usually asked within
coverage_gap rows 180 days Re-detected on the next scan, so an old row is a stale observation rather than a record

A run row is only removed when nothing points at it. A run id is provenance: it is on the claim that run wrote, on the evidence it registered, on the state change it recorded. Age is necessary and not sufficient, so an installation that imports continuously keeps its ledger, and one that ran a batch of failed jobs reclaims it. job prune says how many rows it kept for this reason, so a ledger that does not shrink is explained rather than mysterious.

A pruned run takes its job_artifact rows with it, which is what stops a row naming a file inside a directory that is gone.

The file is compacted at the end, and only when rows were actually deleted. auto_vacuum is 0 on these databases, so a delete returns pages to the freelist and the file stays exactly as large as it was – without the VACUUM a purge frees nothing an operator can see. The command prints the before and after size rather than claiming to have compacted anything.

An artifact is stored once, under runs/objects, named by its own SHA-256. It is linked rather than copied, so one copy of the bytes carries two names: the object, and the path inside the run directory where somebody debugging that run expects to find it. A collector that fails and is retried produces the same bytes again, and the second copy is reclaimed as it is stored rather than kept – about a third of stored artifacts on a real installation were byte-identical copies of one another.

That is also what makes pruning safe. Removing a run directory drops one name; the object stays, because another run may have produced the same bytes and the row that proves it still points there. job artifact uris therefore survive the prune, where they used to be left naming a path that no longer existed. What removes an object is having no job_artifact row refer to it at all, which job prune sweeps after the directories – in that order, because removing a run is often what leaves an object unreferenced.

Note that an import is a job run too. It has no definition because nothing executed it from the queue, but it is the provenance record for every row it wrote.

The same queue over HTTP: GET /api/v1/jobs, GET /api/v1/jobs/{id}, and POST /api/v1/jobs/{id}/cancel, the last calling the same function as crane job cancel. Pruning stays on the command line: it removes directories from a disk, which is the machine’s business rather than the portfolio’s.

How the queue behaves

Property Why
Submitting and executing are separate A validation takes minutes and the caller is cron
An idempotency key makes submission repeatable A schedule that fires twice must not do the work twice
A claim is a lease, not a lock A worker that is killed cannot release a lock. The lease expires and the job is retried
Failures retry with backoff up to max_attempts Transient trouble is normal
Some failures do not retry at all A missing executable, an unknown job definition, or an unreadable document will be just as missing next time
Arguments are an argv list, never a shell string A file path containing a semicolon is a path, not a second command
The child gets an environment allowlist Tools log their environment when they crash

What a tool result becomes

A validation writes an evidence_validation row with the validator, its version, the profile, the outcome, and the per severity counts. A derived sbom.conformance claim names the validation it came from and the algorithm version. A failing profile raises a finding naming the rules that failed and which obligations are affected.

None of it is a decision. Tool output is untrusted input, and a profile result is an observation about a document, which is a long way from a statement that a product complies.

Recording the validator version matters: a score moving because ossbomer changed shows up as a change in evidence rather than as unexplained drift.

crane assess [OBLIGATION] [--subject SLUG] [--detail] [--json] (built)

Scores the portfolio against an obligation and stores the per check explanation behind every number.

crane assess cra                        # the whole in scope population
crane assess fedramp --subject gateway  # one product, one obligation
crane assess cra --detail               # every check for every product
crane assess cra --dry-run              # compute without storing
crane assess cra --queue                # hand the rescore to a worker instead

--queue submits an assess.portfolio job and returns its id rather than scoring in this process. POST /api/v1/assess queues the same job, which is how the score moves in a deployment where nobody can run this command: the import wizard queues one automatically after an upload, at a lower priority than the validations it queues, so the rescore runs after them and reads what they found. A rescore already waiting for the same obligation is returned rather than duplicated.

The same product usually scores differently under different obligations, because each has its own checks and its own reporting lens. That is the point of them being separate.

A terminal lifecycle leaves the parent’s aggregates, out loud. A component or pipeline marked deprecated, eol, retired or withdrawn stays in the inventory as record — visible, filterable, marked on the product page — and stops influencing its parent: it leaves composition and coverage denominators, does not count as a build path, and its SBOM remains registered history without counting as coverage of what ships now. The explanation states the exclusion (“2 component(s) excluded as deprecated”) rather than letting the denominator quietly shrink. The vocabulary is defined once, in TERMINAL_LIFECYCLES, and the queues, the checks and the pages all read it.

SBOM evidence counts wherever it is attached. SBOMs are produced per repository, per build, and per component; a product is an aggregation. When a product carries no SBOM of its own, sbom.present and sbom.conformance walk product → direct component → build workflow → asset and grade the aggregate: full coverage passes, partial coverage warns with the fraction and the uncovered components named, and one failing component document fails the product, because conformance is not an average. No SBOM anywhere is still a plain failure, and the explanation says the walk happened. The product page shows the same coverage, from the same walk, so the number and the picture cannot disagree.

The denominator is stated, not implied. Output separates products in scope, products whose scope nobody has decided, and products explicitly out of scope. The undecided ones are their own number: they are neither counted as passing nor as failing, because a portfolio where half the scope questions are open should not be able to report 100 percent.

How a score is reached. A pass earns its full weight, a warn earns half, everything else earns nothing, and the score is the percentage of the weight that was actually available. Two rules override the arithmetic:

  • A check waived by an approved exception is removed from the denominator rather than scored zero, so a granted waiver cannot look like a failure.
  • A blocking check that is not satisfied caps the band at red. The score still shows how far along the product is, but it cannot be called amber.

Outcomes, and why there are this many:

Outcome Means Earns
pass Satisfied full weight
warn Satisfied but not well, for example scope recorded but never approved half
fail Genuinely not satisfied nothing
insufficient_evidence Nobody has looked yet nothing, and it is not a failure
review_needed Inputs conflict, or a human has to decide nothing
source_inaccessible We tried and could not see. A coverage gap nothing
not_applicable Does not apply, or waived removed from the denominator

The distinction between fail and insufficient_evidence is the one that matters most. A product with no SBOM and a product nobody has checked are different problems and send people to different work.

Every score records the policy version and the hash of the exact policy document that produced it, so a score from three months ago can be explained by the rules that were in force then rather than the ones in force now.

1.5 Reports

Three built in reports, plus your own as Markdown templates. The audience is which report you run, not a section inside one: a steering committee, an auditor, and the people doing the work want different documents, and one report serving all three serves none of them.

crane report list (built)

Describes what is available, built in and otherwise.

crane report leadership|audit|worklist (built)

crane report leadership --obligation cra
crane report audit --format html --out ./audit.html
crane report worklist --format json
Report For Contains
leadership A steering committee or a board Position, what moved since the last assessment, which checks block the most products, who needs attention first, and a split by division
audit An auditor or internal reviewer The method, the denominator including what was excluded and why, then every assessed subject with every check and the evidence hash behind it. Long on purpose
worklist The people doing the work Every unsatisfied check as a task on a specific product, grouped by owner, split into fix, collect, and decide
Option Meaning
--obligation KEY Which obligation to measure against. Defaults to cra
--format md\|html\|json A file, something to send, or something to parse
--out PATH Write here instead of stdout

Every report carries the policy version and the hash of the exact policy document that produced its numbers, plus a content hash that is stable across runs over unchanged data. That last one is what lets a reader tell “nothing moved” from “nobody looked”.

Reports never write to the database, and a test enforces it. No filesystem path appears in a report either, since reports get forwarded.

crane report package (built)

The immutable evidence package from requirements section 15: all three reports in Markdown and JSON, the full policy document, the machine readable data, a manifest, and a SHA256SUMS covering every file including the manifest itself.

crane report package --obligation cra --out ./handover --note "Q3 review"

It records what was excluded and why, and it references evidence by URI and hash rather than copying it, so somebody can check the originals instead of trusting a copy.

crane report templates and crane report render (built)

Your own reports are Markdown files with TOML front matter, in $CRANE_REPORT_TEMPLATES (default ./report-templates).

crane report templates
crane report render division-readiness --param division="Aurora Division"
crane report render division-readiness --format html --out ./readiness.html

A template declares parameters in its front matter, which become --param flags on the CLI and form fields in the interface. The body can call products(), components(), scorecard(), checks(), findings(), sbom(), health(), movement() and the rest, with the same filters the inventory pages accept, plus query() for read only SQL when nothing else fits.

Templates run sandboxed: no imports, no filesystem, no attribute access into internals, and query() refuses anything that is not a SELECT or a WITH. A report reads.

The full contract is in report-templates/README.md, and two worked examples ship there.

1.7 Web UI

crane serve [--host HOST] [--port PORT] (built)

Runs the interface. Binds to 127.0.0.1 by default.

crane serve                       # http://127.0.0.1:8000
crane serve --port 9000 --worker

Authentication is one shared token in CRANE_API_TOKEN, described in ADR 0002 and in detail in docs/deployment.md. With no token set and a non local bind the command refuses to start rather than warning: that would put the whole inventory on the network. CRANE_ALLOW_UNAUTHENTICATED_BIND=1 is the one explicit way past it, for a container whose published port is already restricted.

--worker runs the job worker inside the web process, which is how a single container validates what it is given. Prefer a separate crane worker run --once on a schedule where that is available.

2. Web routes

Server rendered pages under /, JSON under /api/v1/. Both are built. The generated OpenAPI document at /api/docs is the current list; the tables below describe intent and are not regenerated, so where they disagree the document wins.

2.1 Conventions that every page follows

These are global rules, not per page features. A page that does not follow them is a bug.

Global search. A search box is present in the header of every page. It searches across all subject types at once: products, components, build workflows, source assets, parties, evidence artifacts, findings, and decisions. It matches canonical names, slugs, aliases, external identifiers, and source locators, so pasting a repository URL or a source system id finds the thing it maps to. Results are grouped by subject type and say which field matched. Backed by GET /api/v1/search?q=.

Scoped search. Every list page has its own search box that searches only that page’s type, with the page’s active filters still applied. The global box answers “where is this thing”, the scoped box answers “narrow what I am looking at”.

The inventory lists are filterable, sortable, and paginated. They take q, the filter keys below, sort, page and per_page, and state the total and the filtered count so the denominator is always visible. Ordering always ends with the id, so paging is stable and a row cannot swap pages and be missed. Filters are shown as removable chips and the active set is encoded in the URL, so a filtered view can be shared or bookmarked.

A scope changes the denominator, not just the rows. An organizational unit is not an ordinary filter: a lifecycle narrows a portfolio, an organization says which portfolio. So total is the population within the scope in view, portfolio_total is the wider figure it sits inside, and scope_label names the unit — null when nothing organizational is filtering, where total and portfolio_total are equal and the count line reads as it always did. The facet lists follow the scope too, so no filter value is offered that would return nothing. Assets and evidence carry no organizational dimension, so a scope does not narrow them and they do not claim it does.

Every list page follows these conventions now: owners, scope, findings and jobs are paginated and searchable like the inventory lists, products, components and workflows export the filtered view as CSV, and every page opens with either the score of its population or a stated reason it is not one (workflows and build health report coverage, findings and jobs report a count and an age, because a score there would be invented).

Filter dimensions available on the inventory lists:

Dimension Applies to
Lens and scope status under that lens products, components
Lifecycle stage products, components, workflows
Owner and ownership gap all subjects
Evidence state: present, missing, stale, inaccessible, failing products, components
SBOM conformance profile and outcome products, components
Readiness band and score range products, components
Relationship shape: no components, no workflow, shared across products, fan in, fan out products, components, workflows
Review status of contributing relationships all subjects
Source system and last sync age all subjects
Open findings by severity, blocked or overdue actions all subjects
Any recorded fact, by value or by absence workflows

The last row is not a fixed list. Whatever claim kinds an installation records against its build workflows become filters on /workflows under a claim. prefix. The kinds are discovered from the data, so a claim kind that arrives with a new source is filterable without a code change. See section 3.3.2.

The value has a two-word grammar, because claim values are the installation’s own and any of them could collide with a sentinel:

Filter Asks for
?claim.build.system=is:crestline recorded with exactly that value
?claim.build.system=crestline the same thing; the bare form is for URLs typed by hand
?claim.build.system=present recorded with any value
?claim.build.system=absent not recorded at all — usually the actionable half

present and absent are reserved words, not values. An installation that records either as a literal string writes it is:present / is:absent, and the two stay distinguishable; the dropdown always emits the prefixed form, so a generated filter can never collide with them.

present is the only usable value filter for a free-text kind — a note or a URL has as many distinct values as it has subjects, so no list of them is a filter, and “which pipelines have one at all” is the answerable question.

A value matches if any active claim of that kind holds it, not just the one the column shows. Where two equal-authority sources disagree the model keeps both assertions, the facet advertises both with a count each, and filtering by either finds the subject. Matching only the most recent would make the facet offer values that filter to nothing.

Every page opens with a scorecard for what is on the page. The header of every page carries the score for its own content, computed from the rows currently in view after filters are applied, not a fixed global figure.

  • On a detail page it is that subject’s score, band, and the one line reason for it.
  • On a list page it is the aggregate over the filtered set: how many pass, fail, need review, are unassessed, and are blocked, with the denominator stated as a count and the lens named.
  • Filtering the list recomputes the header. That is the point: filter to one business unit or one lens and the score at the top is that slice’s score.
  • The header states the policy version and the evaluation time, and links to the full explanation. A band with no explanation behind it is not displayed.
Route Purpose
GET / Overview. Active campaign, completion against a named denominator, what changed, what is blocked, stale sources, decisions awaiting authority
GET /products Inventory pane. Search by name and alias, filter by lens, scope status, lifecycle, owner, evidence state, and readiness band
GET /products/{slug} Identity and hierarchy, scope per lens with rationale, components, workflows, assets, owners, score with full explanation, evidence freshness and conflicts, open decisions and actions, history
GET /components Component inventory, including which products each one ships in
GET /components/{slug} Component detail and its fan out across products and workflows
GET /graph Relationship explorer
GET /attention Saved explainable queues, listed in section 3 below
GET /evidence Evidence artifacts, validation outcomes, freshness, coverage gaps
GET /decisions Decision register and action list
GET /sources Source systems, scopes, sync health, coverage gaps
GET /jobs Job runs, artifacts, and ingestion results
GET /assets/{id} One source asset: every subject that references it, the builds that read it, maintainers, distribution, evidence
GET /reports/leadership The leadership report rendered in the browser

JSON API:

Route Returns
GET /api/v1/search?q= Global search across every subject type, grouped by type, with the matched field named
GET /api/v1/products Paginated with page and per_page, filterable, sortable, stable ordering
GET /api/v1/products/{id} Product with extensions passthrough
GET /api/v1/scorecard?subject=&filter= The scorecard for one subject or for a filtered set, with denominator, lens, policy version, and explanation
GET /api/v1/graph.json {nodes, edges, root, depth, node_count, island_count, node_limit, oversized}. Every edge carries type, source kind, confidence and review status; a node carries island: true when nothing relates to it. oversized: true means the graph was larger than node_limit, and nodes and edges are then empty while node_count still reports the real size
GET /api/v1/scope?lens= Scope status per subject for one lens
GET /api/v1/report/leadership The report as data
GET /api/openapi.json Generated specification. Not under /api/v1/: the document describes every version rather than being one of them

Every assessment response carries its explanation and provenance links. Unknown namespaced extensions content round trips unchanged.


3. Attention queues

The saved queues, each one a documented query rather than a hardcoded dashboard tile:

  • products in scope under the reporting lens with no assessment
  • products where two lenses disagree about scope
  • products with no accountable owner
  • components that ship in more than one product and have no owner
  • components with no build workflow
  • build workflows producing more than one component, where attribution is unreviewed
  • SBOM missing, stale, or failing its profile
  • evidence that is inaccessible, distinct from evidence that is absent
  • unresolved identity or mapping conflicts
  • decisions awaiting authority
  • actions blocked or overdue
  • completed actions awaiting verification
  • products in scope that nobody has classified as code-bearing, a bundle, externally built or manual
  • code-bearing products in scope with no build pipeline
  • code-bearing products in scope whose pipelines are all non production
  • build pipelines whose last run failed
  • build pipelines that have never reported a run
  • build pipelines with no platform recorded

Deprecated, retired, withdrawn and end of life subjects are excluded from every one of these. They stay in the database as the record of what once built what, and they raise no work.

Which products a queue counts.

Five of these ask whether a product is in scope, and scope is a claim per lens rather than a column: a product can be in scope for the commercial catalogue and out of scope for the regulatory reading, and both are true at once. So “in scope” is only an answer once the lens is named.

A queue read under an obligation counts under that obligation’s reporting_lens — the same field the scorer scores against and the obligation cards count by. Read without one, as the API does by default, it counts a product in scope under any lens. Both are defensible; the page says which it used, because the harm is in leaving it unsaid.

The five are products_unassessed, scope_undecided, unclassified_products, no_pipeline and no_production_pipeline. The rest ask questions that have nothing to do with scope — who owns a thing, whether evidence can be read — and they state no population, rather than implying a filter they do not apply.

scope_undecided counts a product as decided when somebody recorded in_scope, out_of_scope or not_applicable under that lens. undecided and disputed are statuses a person can record, and both say the question is still open, so neither settles it. The listing filter agrees: scope=undecided includes disputed products, because a tile that counts them linking to a page that hides them is a number that changes when you click it.

products_unassessed asks whether this obligation has scored the product, not whether any obligation has. A product assessed for one programme and not another is unassessed for the second, which is the only reading under which its number and the page it opens can agree.

Each queue’s link carries the obligation it was counted under, because the listing it opens ranks scope claims by the lens of whatever obligation it is read under. A tile counted one way linking to a page reading another is a number that changes when you click it.

3.1 Saying an item is not work

Some queue items will never be resolved because there is nothing to resolve. A product assembled from purchased binaries will sit in the no pipeline queue forever. Without a way to say so, queues fill with items nobody will action and stop being read.

An exception is granted against one subject and one queue, through POST /exceptions/grant, POST /api/v1/exceptions, or the control on the subject’s own page. It costs:

  • a reason of at least eight characters,
  • a name, recorded whether or not it matches a known party,
  • an expiry, defaulting to a year. Permanent has to be asked for explicitly.

What it does and does not do:

  • It writes a decision first. exception_grant.decision_id is NOT NULL, so an exception cannot exist without the governed act that authorised it.
  • It changes no fact. The product still has no pipeline.
  • It never raises a score. An excepted check reports not_applicable, which leaves the denominator rather than counting as a pass.
  • Every queue reports what it is suppressing next to what it is holding, so an emptied queue and a solved problem never look the same.
  • Expiry is computed in v_exception_grant, not stored, so an exception stops applying the moment it lapses with nothing scheduled and nothing to forget.
  • Revoking leaves the grant in place. Deleting the record of a decision to ignore something is worse than the decision.

Queues that are not about a subject, such as failed jobs, cannot be excepted. You fix those or you delete them.

3.2 Confirming what an import asserted

An import can only record that a source claimed something. Whether it is true of the organization is a judgement, and until somebody makes it the relationship is unreviewed, which is not the same as wrong.

POST /review and POST /api/v1/review take a table, one or more edge ids, and a verdict of approved or rejected. Rejecting requires a note; confirming does not, because confirming agrees with what the data already claims while rejecting contradicts a source.

The unit reviewed is the relationship, not one source’s assertion of it. Two systems agreeing that a product contains a component produce two rows and one fact, and all the rows behind a relationship move together, or it stays half approved forever.

Rejecting does not delete. The assertion stays, marked rejected and out of the resolved v_ views, because which sources keep asserting things that turn out to be false is worth knowing.

3.2.0 Drawing the graph by hand

The counterpart of review: imports assert relationships, but a person can also just know one, and until connect existed the only way to record it was to write a CSV.

crane manage connect, POST /api/v1/connect, and a form on every subject’s page draw one edge: product_component (contains, bundles), component_workflow (builds, signs, publishes), workflow_asset (source, template, output, dependency), or product_relationship. The edge lands as manual and unreviewed — one person’s say-so, corroborated by nothing, exactly like a subject created by hand. Its dedupe key is the importer’s, deliberately: an edge drawn by hand and the same edge arriving later in a file are one fact and become one row. Drawing an edge that already exists returns that edge rather than a twin, and never rewrites it — an already-approved edge stays approved. Undrawing is a review: reject it with a reason, and it stays visible as rejected.

One composite exists because the scan makes it constant work: crane manage adopt / POST /api/v1/assets/adopt / the Adopt button on /assets turns a scanned repository into a component and connects every build workflow whose source it is, in one act. The scanner deliberately never creates components; this is the human half of that bargain, made one click. The component then joins products from its own page, which is the decision that makes scores start to mean something.

crane collect ado ORG[/PROJECT] [--dry-run] [--full] (built)

Azure DevOps, where an organization holds projects and a project holds both repositories and pipelines. Give an organization to scan all of its projects, or organization/project to scan one. The unit of resumption is the project.

Two things about Azure change what a scan costs, and both are visible in the mapping:

  • A pipeline costs an extra call. The definition listing does not say what a pipeline builds; only the per-pipeline detail carries configuration.repository. Without that call every Azure repository would be unattached, so it is made.
  • A pipeline may build a repository that is not in Azure. A definition observed on a real instance had repository.type: gitHub. That is recorded as the pipeline’s source by locator, rather than resolved to an Azure repository that does not exist. A repository type CRAne does not recognise is left unlinked, because a link nobody can follow is worse than an absent one.

A pipeline’s slug is ado:organization/project/id, carrying Azure’s own identity, slashes included: a pipeline id is unique only within its project.

One project is read per batch, which is a measured bound rather than caution: a real public project with 200 pipelines cost 402 requests, because each definition needs its detail and its newest run. A project with more definitions than one batch reads says so in the job’s notes rather than stopping quietly at the cap.

CRANE_ADO_TOKEN is a personal access token, sent as HTTP basic with an empty username. Azure answers an unauthenticated request for a private organization with a redirect to a sign-in page rather than a 401, so a missing token is reported as a refusal that says so — read as an empty organization, it would silently report a portfolio of nothing.

Campaigns, and why a percentage needs one

Every score runs live, over a portfolio that keeps changing. “62% ready” in one period and “70%” in the next are not comparable if the denominator moved underneath them, and it always does: products arrive, scope decisions are made, things retire.

A campaign fixes that by freezing a population for a period.

POST /api/v1/campaigns                      declare, and freeze what it measures
POST /api/v1/campaigns/{campaign}/assess    measure the frozen list
GET  /api/v1/campaigns/{campaign}           what it measured, read not recalculated

The population is written down, not remembered as a query. Every subject in scope on the day of declaration gets a row, and the freeze is dated in scope_frozen_at. Re-running the scope query at report time would answer what is in scope now, which is a different question than the report claims to answer — and it would answer it silently.

The period is any two dates. Monthly and quarterly are the same shape here; how often a campaign is declared is a policy an organization chooses, not something this model decides.

Measuring never disturbs the live scores. A campaign’s snapshots are stamped with it, and v_current_score selects only unstamped ones, so the pages keep answering “how are we doing now” while the campaign holds “how were we doing when this was measured”.

A subject that disappears is missing, not dropped. A denominator that shrinks when something vanishes is how a percentage improves without anything improving.

Declaring is a governed act with a rationale and a named authority, recorded as a decision, because it fixes what somebody will later report against. Four questions can therefore be answered months later without trusting a recomputation: when the denominator was frozen, which subjects were in it, who declared it, and what they asked for.

Changing several subjects at once

POST /api/v1/subjects/bulk sets descriptive fields across several named subjects. Three decisions shape it, and each could reasonably have gone the other way:

The selection is the rows named, never “everything matching a filter”. A caller sends ids. A filtered set can move between the moment somebody reads it and the moment they act, so what changed would not be what was seen — and at that size nobody notices until an audit.

One reason, recorded against every subject. The change writes a single sentence to each subject’s own state_change, so the audit trail has as many entries as there were changes. A shared reason is honest only when it is true of all of them, which is exactly what selecting them together asserts. Subjects already at the value are reported as unchanged rather than given a record that says nothing happened.

All of them or none. Every subject is checked before any is written. A partial bulk change leaves somebody to work out which half landed from a page that now shows a mixture.

What may be set is a classification, never an identity. The test a field has to pass is whether saying the same value about twenty subjects at once is an ordinary act. Twenty pipelines really can all be Jenkins, and twenty repositories really can all be internal.

Subject type Fields
product, component lifecycle, description
build_workflow lifecycle, description, platform_type, environment
source_asset lifecycle, visibility, asset_type

A field the subject type does not have is refused rather than ignored, so sending visibility for a product is an error with a sentence rather than a silent no-op. Sending several at once changes them together, and any field left out is left alone.

canonical_name, display_name and platform_locator are absent although each is editable per subject: a name identifies one subject and an address points at one thing, so setting twenty of them to one string is never what anybody meant. The last_run_* columns are absent for a different reason — they are observations a collector writes about one run, so setting them by hand across twenty subjects asserts something about builds that never happened.

Scope status and review status are not available either, even though both are per-subject and editable: each is a decision under its own rules — a named lens and an authority ladder for scope, the review flow for relationships — and one reason spread across twenty of them would be twenty assertions nobody made. A hundred subjects is the limit, and it is not about performance: above it, one reason stops being true of everything it is written against.

The values each field offers come from the import contract rather than from a list kept beside the form, because a value set here and a value loaded from a file land in the same column. A second list would drift, and the drift would surface as a pipeline whose platform the importer does not recognise.

In the interface, the products, components, pipelines and repositories lists carry a checkbox per row and the same change under the table, posting to /subjects/bulk. It calls the same manager, so every rule above holds identically: the same fields for the same subject types, the rows ticked rather than the filter, all of them or none, and one reason written against each. Every control offers “leave unchanged” and defaults to it, so setting one field never means restating the others. Two things the page adds because a person needs them and an API caller does not — it says how many are selected, and it refuses to submit over the limit rather than letting somebody write a reason and then be turned down. Select all on this page is offered and select all matching the filter is not, for the reason in the first rule: the ticked rows were on the screen, a filter’s contents were not.

Checking the connection before scanning anything

crane collect gitlab acme-group --check
crane collect ado acme-corp/platform --check

Reads one object and reports what came back, then stops. Nothing is queued and nothing is written. It runs the platform’s own collector with a batch of one, so the credential, the reachability, the organization’s existence, the refusals and the mapping are all exercised by the code a real scan runs — a check that asked a different question could pass while the scan it predicts fails.

First contact with a real instance is otherwise a full scan that fails somewhere around the four-hundredth request, with a rate limit and a half-written inventory in the way of reading the error.

Scanning only what changed

A scan is incremental by default. When the previous scan of the same organization finished, its start time becomes a watermark, and the next scan reads only what has moved since — minus an hour of overlap, because a project changed while a scan was running would otherwise fall between the watermark and the next filter.

Only a finished scan earns a watermark. One that stopped halfway read part of the portfolio and not the rest, so trusting its watermark would skip whatever it never reached, and skipping is invisible in a way repeating is not. The state lives in sync_run against a source_scope, which is what those tables were for: cursor while a chain of batches is in flight, watermark once it completes.

What it saves differs by platform, and the difference is worth knowing before judging a scan’s cost:

  Listing Per-object calls
GitLab Narrowed server-side by last_activity_after Not made for a project that did not move
GitHub Still paged in full Not made for a repository that was not pushed to

GitHub’s organization listing has no since, and the only way to narrow it server-side is to sort by pushed date — which would destroy the stable ordering the batch offset depends on, so a push landing mid-scan would reorder the pages and a batch would step over repositories it never read. Paging is the cheaper half; the per-repository calls are the bulk of a scan.

An incremental scan never notices a deletion, because something deleted simply stops appearing in a listing it was already filtered out of. --full, or "full": true on the API, ignores the watermark and re-reads everything. Run one periodically.

3.2.0.1 What the graph draws

How the things being certified interact: repositories feed components, components compose products, and products bundle other products. An asset is a node rather than an attribute of the things that use it, so “what else builds from this repository” is answerable on the one screen designed for that question.

Build pipelines are not nodes. They are how a portfolio is built rather than how it interacts, and drawing them answered no question this page is for. The relationships that existed only through a build are redrawn as derived edges labelled with the build they travel through, rather than dropped: dropping them would make hiding builds silently shrink the portfolio, and a reader cannot tell an absent relationship from a hidden one. An upstream build nobody tracks is still a gap worth seeing, and is reported on the pipeline’s own page.

A subject that no relationship in force names at all is drawn as an island, with a hollow ring. A repository nobody claims or a product with no components is a finding, not a reason to be left out. An island is not a package an SBOM named transitively: that one holds a dependency_depth > 1 row, so it is related, just not at the depth this graph draws.

?root= accepts an internal id, a slug, or an asset’s canonical locator, because those are what a person has to hand. Past node_limit nodes — 400 by default — the page reports its real size and asks for a narrower root instead of attempting the draw: the layout is all-pairs, so its cost grows with the square of the node count. Reported, never trimmed. A graph cut to fit would look like the whole answer.

Edges carry their type, how many sources asserted them, review status, provenance and confidence. Solid is approved, dashed is unreviewed, dotted is derived. A derived edge is never written back as a stored relationship — see section 5.2.

What places a subject in an organization

Membership is ownership. Attach a responsibility at the most specific level that is true and the organization and division follow from the party hierarchy, which is what the scope selector filters on.

A portfolio part way through recording ownership has a second case: a source system that knows which organization runs a product without knowing who owns it, so the fact arrives as a claim and the subject belongs to a unit while answering to nobody. Left alone, those subjects are absent from every organizational view — a division’s portfolio understated by however many its feeder has minted so far.

Naming the claim kinds whose value is a unit, per level, on /settings or through POST /api/v1/settings/org-membership-claims, brings them in.

Both records count, and neither overrides the other. A subject owned by two teams is in both teams’ portfolios — the model keeps disagreeing sources rather than settling them — so an owner placing a subject in one division and a label placing it in another means it is in both, and each page says which unit came from which record. Taking ownership as the winner would discard the label silently, and the unit it names would never learn the subject exists. Two labels naming two units work the same way, as do two parties that share a display name: a claim naming either is ambiguous, so the subject lands in both rather than in whichever row was read last.

A claimed team implies its organization and division the same way ownership does. A claimed name matching no known party fills only the level it names rather than inventing a parent. Only claims outside a lens count: a lens-scoped claim is one obligation’s reading of a subject, not portfolio-wide membership.

Rows a label placed are marked with which levels it placed them at. Separately, the count line says how many of the listed subjects have no recorded owner at all — a different fact from having been placed by a label, since a subject can have an accountable owner and still be in a unit only a label names. That keeps closing the gap visible work rather than letting it look finished.

Nothing is configured by default and nothing is inferred from the shape of a key. A claim kind ending in organization is not evidence that it means a business unit — a feeder can record a code host’s account name exactly that way — so which vocabulary carries membership is a statement an operator makes, recorded with a name against it like any other decision.

3.3.2 Which recorded facts a list shows

The facts worth knowing about a build pipeline are mostly claims: the build template it uses, the kind of software it produces, whether and how it emits an SBOM. Those are an installation’s own vocabulary, so CRAne discovers the claim kinds recorded against build workflows rather than naming any of them, and turns them into columns, badges, facets, filters and workflows.csv columns.

Which kinds earn a column is a choice, because a table has a width and a portfolio may record twenty kinds. The default is the kinds most of the portfolio carries, capped; an operator overrides it and the order on /settings, or through POST /api/v1/settings/claim-columns, and that choice is recorded with a name on it. Everything recorded stays filterable whether or not it has a column, and appears as a badge if it does not, so nothing a pipeline says about itself is visible only by opening it.

Display names come from the vocabulary registry, so an adapter’s own namespaced kind renders under the name that adapter registered. An unregistered kind shows as its own key, which is honest: a kind nobody declared is shown exactly as it was stored.

3.2.1 Settling what two sources cannot

A claim conflict means two systems assert different values for one fact. Picking a winner and rejecting the loser would not survive the next import — the losing system does not stop saying what it says — so resolution uses the mechanism scope decisions already use: an approved decision carrying who and why, then a claim citing it, which outranks both sides and keeps winning across every reimport. Nothing is suppressed and no import is taught to ignore a source.

crane manage resolve <conflict-id> released --reason "The catalog is authoritative" --by "Avery Stone"

The same act is POST /api/v1/conflicts/{id}, and a prefilled form on each conflict on the scope page. GET /api/v1/conflicts lists them with the competing values. For scope.status conflicts the resolution goes through the scope act itself, with all of its rules.

Three conflict states, because two would lie. resolved means the sources came to agree. settled means somebody decided and the sources still disagree — permanently, which is why it stays on the scope page instead of vanishing, while dropping out of the work queues. An identity or relationship conflict is not a value to choose: the refusal, and the page, route to the mapping or the review that actually corrects it.

CRAne consolidates and measures. It is not where product, pipeline and repository metadata is authored: somewhere there is a service catalogue that owns it. Once a page has said a pipeline is failing and has no accountable owner, the most useful thing it can offer is a link to the entry where that gets corrected. Without one, every finding ends in somebody guessing what the thing is called in the catalogue, which is the problem external_identity exists to solve.

Three pieces, two of which already existed:

Where What it holds
source_system.link_template A URL pattern for that system, stored once
external_identity.external_object_id This subject’s identifier in that system
external_identity.raw_locator A full URL for this one object, where a pattern will not do

Where the pattern is configured

Two places, and the interface is the one to use:

  • Settings, /settings. A pattern per system and per kind of thing: products, components, pipelines, repositories, people. A catalogue rarely addresses them alike, so source_system_link holds the pattern. Leaving one empty means no link for that kind of subject, which is a real answer rather than a gap.
  • The same page, per kind the source system itself distinguishes. A catalogue that exposes services, products and projects addresses them one path segment apart and with the same identifier shape, and all three arrive here as product — so one pattern sent two of the three to a page for the wrong kind of thing. The page offers a field for each kind a mapping has actually arrived carrying, because the taxonomy is the catalogue’s and nothing here can know it in advance. This is what external_object_type on a mapping buys, and why correcting one matters.
  • source_systems.csv. A single fallback pattern for a system that genuinely does address everything the same way, plus the display name.

Resolution order, most specific first: the identity’s own raw_locator, then the pattern for that kind of thing in that system, then the pattern for that kind of subject, then the system fallback, then nothing.

Blank means different things at the two levels, and deliberately. Blank on the subject-wide pattern means this system has no addressable page for that kind of subject. Blank on a per-kind row means inherit, because that field starts blank: saving a form somebody did not fill in must not suppress links they never asked to suppress. To say a catalogue has a page for services and none for projects, clear the subject-wide pattern and name a route for Service.

The label follows the pattern the address came from. A label on the subject-wide row describes that route, so a kind with a route of its own and no label of its own falls back to the system’s label rather than borrowing one that describes a different page.

{type} substitution still covers the narrow case where the path segment is exactly the type string and the rest of the URL is identical — #/ServiceModel/{type}/{id} — which is one row instead of one per kind. It cannot express a path that differs in more than that segment, and #/Repos/Repository/{id} beside #/ServiceModel/Service/{id} differs in two.

A pattern must contain {id}, {key} or {type}. Without one every subject would link to the same page, which is worse than no link because it looks like it worked.

Declare the system and its fallback in source_systems.csv:

key,display_name,link_template,link_label
metadataservice,Metadata service,https://metadataservice.internal/#/Service/{id},Open in the metadata service

Identifiers are text. A catalogue that uses UUIDs, or human readable references like SR-118, works exactly as one that uses integers; nothing parses them.

A subject is usually in more than one system: the catalogue that owns its metadata, the build server that runs it, the repository host. A row in build_workflows.csv can only name one origin, so the rest go in identities.csv:

subject_type,subject_slug,source_system,external_id,external_key,locator
build_workflow,gateway-build,metadataservice,7701,gateway-official,
product,aurora-gateway,shiproom,SR-118,,https://shiproom.internal/releases/SR-118

Loading a subject from several systems is also what makes corroboration measurable: the inventory confidence obligation counts how many independent systems agree a thing exists.

Substitutions: {id} is the external object id, {key} the external object key falling back to the id, {type} the external object type. Then give each row its identifier in that system with the external_id column, available on products.csv, components.csv and build_workflows.csv:

slug,canonical_name,source_system,external_id
gateway-build,gateway-official,metadataservice,4417

raw_locator wins where it is set, because a row carrying its own URL knows better than a pattern and not every catalogue has predictable addresses.

Resolution happens in v_external_link when the page renders, not when the row is imported, so changing a template fixes every link at once rather than requiring a reimport.

Only http and https are rendered. A locator arriving from an imported source becomes an href, and javascript: is a URL. The check lives in queries.safe_url, in one place, because there is one query layer and many templates.

A mapping with no resolvable address is still shown, flat rather than as a link. Knowing a subject is object 4417 in the catalogue is worth something even when nobody has configured a template, and hiding it would hide that the mapping exists.

3.3.1 Which programs are measured

/settings also decides which compliance programs this installation measures at all. Off means: absent from the obligation selector, not scored, its checks raise no work, and its validation profiles stop being required by crane validate sbom. On restores all of it, because off is retired, never deleted — scores already computed stay queryable, since “we used to measure this and stopped” is itself an audit question, and each toggle writes its own decision carrying who and why.

Two boundaries, both deliberate:

  • Inventory confidence cannot be turned off. It is not a regulation: it measures whether the portfolio is trustworthy enough for any of the others to mean anything.
  • Per installation, not per organizational unit. Divisions answering to different regulations is what scope lenses are for; the toggle is about what the installation measures at all.

Three surfaces, one function: the settings page, crane manage program <key> on|off --reason --by, and POST /api/v1/settings/programs (GET lists the programs and their state). crane policy list remains the full view including superseded versions.

3.4 Changing things through the interface

Everything below is also available under /api/v1/, taking the same token, and calls the same function the form does.

Operation What it writes Governed
Create a product, component or pipeline the row, plus an identity in the interface system no
Edit its descriptive fields the row, plus a state_change per field with the old value no
Assign or remove an owner responsibility_assignment as approved yes
Set scope under a lens a decision, then a claim citing it yes
Confirm or reject a relationship review_status, plus a state_change yes
Grant or revoke an exception a decision, then exception_grant yes
Resolve a finding status and closed_at, plus a state_change no
Accept a finding, or call it not applicable a decision, then the same yes
Add or remove an identity mapping external_identity no
Set a link pattern source_system_link no

The split is not arbitrary. A governed act answers a question somebody could be asked to defend: who decided this is out of scope, who accepted accountability, who said this mapping is correct. Those carry a rationale, an authority, and a decision row. The rest is reference data and configuration: it asserts nothing about the portfolio, moves no score, and getting it wrong produces a broken link rather than a wrong number.

Every write, governed or not, is attributed to a job_run with requested_by = 'interface', the same as an import. A change with no provenance would be the one row in the audit trail nobody can explain.

A created subject is not more real than an imported one. It is recorded against a source system called interface, so the inventory confidence obligation counts it as single sourced, which is what it is.

The slug is not editable. It is the natural key an import matches on and it appears in URLs. Changing it would fork the subject the next time a file using the old one is loaded.

Nothing is deleted. A removed owner is rejected, a removed mapping is deleted, a revoked exception keeps its grant. Who used to answer for a product, and when that stopped being true, is a question asked about the period before it stopped.

3.4.1 Loading data over HTTP in two steps

POST /api/v1/import/data loads a bundle in one request and takes a dry_run flag. It has never wanted a server filesystem path and never fetches a URL, so an integration has never needed shell or container access. For a bundle small enough to validate and load in one breath it remains the shorter path, and it is what the CLI and the wizard call.

The staged flow exists for the integration that has to be able to prove that what was loaded is what passed validation:

Route Purpose
GET /api/v1/import/spec The data contract, and a fingerprint identifying it
POST /api/v1/imports Validate a bundle, write nothing, keep it, return its id and SHA-256
GET /api/v1/imports Recent imports, including the refused ones
GET /api/v1/imports/{import_id} The persistent receipt
POST /api/v1/imports/{import_id}/apply Load the bundle that id names

The apply step accepts no bundle. The only thing named is which staged import to apply, so there is no second payload that could differ from the one that passed validation. That is stronger than comparing two uploads, which would leave a window. The stored bytes are digested again before anything is written, which catches the case the design does not: somebody altering the stored row.

The contract fingerprint is computed from the spec rather than maintained by hand, and is checked at both steps. A bundle whose contract moved in between is refused rather than reinterpreted, because a column that has changed meaning is not something row validation catches. crane import spec prints the same fingerprint.

Also: an Idempotency-Key header makes a retried upload one import rather than two and a different bundle under a used key is refused; byte and row limits are checked before the importer is asked to do anything, and are discoverable from the spec; the caller and source system are recorded on every import including the refused ones; and a bundle above the row threshold is applied by the worker, returning 202 with status applying.

Both surfaces run the same importer. There is deliberately no second validator that could disagree with the first.

Two things the endpoints do not do, so that nothing here reads as a promise: there are no scoped import permissions and no tenant isolation beyond the single tenant CRAne runs as. The security model is one shared token (section 3 of configure.md), so there is no scope to grant and no second tenant to isolate from.

3.5 OpenAPI

The document is at /api/openapi.json, with two renderers: /api/docs to try a call and /api/redoc to read. It is generated from the code, so it cannot describe an endpoint that does not exist, and four tests keep it worth reading:

  • every request body is a named model, so a client can be generated from it;
  • every endpoint has a summary and a tag;
  • every write documents both refusal shapes, 401 and 422;
  • the HTML pages are excluded. They were in the document, which made 59 paths out of about twenty real ones and buried the endpoints a caller can use.

The bulk import body is described dataset by dataset. POST /api/v1/import/data used to document its body as an object with a pointer to crane import spec, which is fine for somebody with a shell and useless for a generated client that cannot run a CLI. The document now carries a row schema per dataset — every column, its type, its enumerated values where the set is closed, which are required, and what each reference must resolve to — generated from the same registry the importer validates against, so the two cannot drift. data is an object with one property per dataset and additionalProperties: false, because an unknown dataset name is an error rather than something ignored.

Those schemas are documentation and not enforcement, deliberately. The importer stays the only validator, because it is the one that can say row 4, column severity and refuse a whole batch atomically, and that report is the contract an agent acts on. Binding the schemas to the request model would mean a malformed body came back as a framework validation error instead, and the row-level report would never be produced. A test asserts that an unknown column still returns the importer’s report rather than a framework error.

One refusal shape. A schema rejection and a rule refusal both return {"error": "<sentence>"} with 422. The distinction between them is ours, not the caller’s; both mean fix the request, and nothing was written either way.

Three surfaces, one implementation. Every management operation exists in web/manage.py, and the form, the API route and the CLI command all call it. A test enumerates them and fails if one surface gains an operation the others do not have, or if any surface reimplements one. Two entry points with two implementations is how they start disagreeing about what a decision is, and the disagreement shows up as two audit trails for the same act.

Operation CLI API
Create a subject crane manage create POST /api/v1/subjects
Edit its fields crane manage edit POST /api/v1/subjects/{subject_type}/{subject_id}
Assign an owner crane manage own POST /api/v1/owners
Edit a person or team crane manage party POST /api/v1/parties/{party_id}
Merge two parties into one crane manage merge POST /api/v1/parties/{keep_id}/merge
Create an organizational unit crane manage unit POST /api/v1/settings/org-units
Confirm or reject a relationship crane manage review POST /api/v1/review
Grant an exception crane manage except POST /api/v1/exceptions
Revoke one crane manage revoke POST /api/v1/exceptions/{grant_id}/revoke
Record an identity mapping crane manage identity POST /api/v1/identities
Remove an identity mapping crane manage unidentify POST /api/v1/identities/{identity_id}/remove
Set a link pattern crane manage link POST /api/v1/settings/links
Turn a program on or off crane manage program POST /api/v1/settings/programs
Decide scope crane scope set POST /api/v1/scope
Classify how a product is made crane manage classify POST /api/v1/products/{id_or_slug}/implementation
Move a finding crane manage finding POST /api/v1/findings/{finding_id}
Settle a conflict crane manage resolve POST /api/v1/conflicts/{conflict_id}
Draw an edge in the graph crane manage connect POST /api/v1/connect
Adopt a repository as a component crane manage adopt POST /api/v1/assets/adopt
Record a fact crane manage claim POST /api/v1/claims
Retire a document crane manage evidence POST /api/v1/evidence/{evidence_id}/state
Fetch remote SBOMs into local storage crane manage evidence-fetch POST /api/v1/evidence/fetch
End or retract a fact crane manage retract POST /api/v1/claims/{claim_id}/end
Record a distribution channel crane manage distribute POST /api/v1/distribution
Record a build dependency crane manage depend POST /api/v1/dependencies
Add an alias crane manage alias POST /api/v1/aliases
Remove an alias crane manage unalias POST /api/v1/aliases/{alias_id}/remove
Queue a rescore crane assess --queue POST /api/v1/assess
Queue SBOM validations crane validate sbom POST /api/v1/validate/sbom
Load a batch of rows crane import data POST /api/v1/import/data
Scan a GitHub organization crane collect github POST /api/v1/collect/github
Scan a GitLab group crane collect gitlab POST /api/v1/collect/gitlab
Scan an Azure DevOps organization crane collect ado POST /api/v1/collect/ado
Change one field across several subjects — POST /api/v1/subjects/bulk
Change lifecycle across selected rows — the four inventory lists
Declare a campaign, freezing its population — POST /api/v1/campaigns
Measure a campaign against what it froze — POST /api/v1/campaigns/{campaign}/assess
Measure upstream repository health crane measure health POST /api/v1/measure/health
Choose the interpretation agent this server invokes crane manage agent POST /api/v1/settings/agent
Queue an interpretive reading crane interpret run POST /api/v1/interpret
Resolve where component code lives crane resolve sources POST /api/v1/resolve/sources
Cancel a job crane job cancel POST /api/v1/jobs/{job_run_id}/cancel
Retry a failed job crane job retry POST /api/v1/jobs/{job_run_id}/retry
Run queued work once crane worker run --once POST /api/v1/worker/run

Every operation is reachable on all three surfaces. The reads travel too: jobs (GET /api/v1/jobs, /api/v1/jobs/{id}), migration state and row counts (GET /api/v1/status), the validator’s profile catalog against policy (GET /api/v1/validate/profiles), components (GET /api/v1/components, /api/v1/components/{slug}), and reports as the rendered document itself (GET /api/v1/reports/{kind}?format=md|html), which is what crane report --out writes to a file: saving the response body is the export.

3.6 Findings have a life

A finding used to be able to start and never end. The writer only ever produced open and nothing updated one, so the eleven states in the schema were unreachable and the only way to make an item stop appearing was to grant an exception. That made excusing work cheaper than recording that it was done, which is the wrong way round for a tool whose whole argument is that an emptied queue and a solved problem must not look the same.

crane manage finding <id> resolved --note "Published in the July release" --by "Priya Raman"
crane manage finding <id> accepted --note "Carried until the migration lands" --by "Priya Raman"

Where it can go: triaged, assigned, in_progress, blocked, resolved, duplicate, accepted, not_applicable. The last four close it and drop it out of the open queues.

Two of them are judgements rather than observations. accepted says the finding is real and the risk is being carried; not_applicable says it never applied here. Both improve a number without anything about the portfolio having changed, so both require a reason and a name and write a decision the finding then cites. resolved does not, for the same reason an import does not: it is a statement about the world, and the world is checkable.

Reopening by hand is refused. A finding comes back because the data says so again, and a state change with no cause behind it is the kind of entry that makes an audit trail worth less than no audit trail.

3.7 External assessors

A scanner that runs outside CRAne — in a pipeline, a container, a function, or on a workstation — and submits check results over the API. It needs no package installed here and no image rebuilt. Designed in ADR 0003.

Registering one and issuing its credential are decisions carrying a name:

crane assessor register acme "Acme metadata scanner" --by "Avery Stone"
crane assessor token issue acme nightly --source-system catalogue --by "Avery Stone"
# the secret is printed once and stored only as a hash
crane assessor list
crane assessor runs acme
crane assessor token revoke <prefix> --by "Avery Stone"

Everything after that is HTTP, with that token:

Endpoint What it does
GET /api/v1/assessors/{assessor}/schema The contract, generated from the validator
POST /api/v1/assessors/{assessor}/runs Open a run, idempotent on your own run key
POST /api/v1/assessors/{assessor}/runs/{run_key}/results A batch, with ?dry_run=true
POST /api/v1/assessors/{assessor}/runs/{run_key}/close complete, partial or failed
GET /api/v1/assessors/{assessor}/runs/{run_key} The receipt and its diagnostics

A result names the check key, the subject as a source system’s own identifier, one of the seven outcomes, an explanation, structured details, evidence references and when it was observed. What each outcome does:

Outcome Effect
fail, warn Raises a finding, owned by this assessor
pass Closes the finding this assessor raised for that check, subject and variant
not_applicable, insufficient_evidence, review_needed, source_inaccessible Recorded; no finding moves

Rules worth knowing before writing the integration:

  1. Silence closes nothing. A scan that stops halfway says nothing about what it did not reach. Only a pass closes, so a partial or crashed run is harmless rather than dangerous.
  2. A person outranks the scanner. A finding closed as accepted, not applicable or duplicate is never reopened by a repeat; the repeat is recorded as still reported. A finding the assessor’s own pass closed does come back when the failure does.
  3. not_applicable from a tool is not a decision. It is recorded and closes nothing — a tool deciding a check does not apply is a guess, and the same word from a person is a decision with a name against it.
  4. A bad row refuses the whole batch with 422 naming the row and the field. A subject that resolves to nothing is accepted and named in the receipt: that is a gap in the data, not a bug in the caller, and the fix is to load its identity.
  5. Ordering is by arrival, and an observed_at in the future is refused. A result observed earlier than the one already stored is kept as history and does not replace it.
  6. The token is the boundary. It may write only check keys under its own namespace, only about source systems it names, and only on these paths. The source system is checked before the identifier resolves. The shared administrative token reads here and cannot write.
  7. Results are findings and reporting signals. A result is not a verdict: whether an external check earns weight in a score is a policy’s decision, recorded in a versioned document, and is not part of this release.

/assessors shows who may report, what each run declared and covered, and which credentials are live. A subject’s own page shows what each assessor last said about it.

3.8 Runners

A runner executes queued work where that work can run — a workstation with a signed-in agent CLI, a machine in another network zone, an operating system this server does not run on. It needs no access to the database, which is what the two-worker arrangement in docs/deployment.md costs today.

The runner polls. CRAne never calls the runner. No inbound port on that machine, no fixed address for it, and no credential here pointing at it. A queued job is picked up on the next poll.

crane runner token issue build-box --definition interpret.agent --by "Avery Stone"
crane runner token list
crane runner token revoke <prefix> --by "Avery Stone"
Endpoint What it does
POST /api/v1/runner/claim A job you can execute, or 204 when there is nothing
POST /api/v1/runner/jobs/{id}/heartbeat Hold it longer while you work
POST /api/v1/runner/jobs/{id}/succeed Raw output, and the exit code
POST /api/v1/runner/jobs/{id}/fail The error; retried until the attempts run out

What decides whether you are offered a job:

  1. The definition is externally executed. Its runner begins external.. A worker in this process never claims one, so the two cannot race; and a runner is offered nothing else.
  2. Your token allows that definition. The token is the ceiling.
  3. You declared the capabilities it requires. Capabilities narrow what you are offered and never widen it, so declaring everything buys nothing. A job nobody can be shown to be able to run stays pending, which is visible, rather than being failed, which reads as an answer.

The lease is the proof. Each claim mints a lease token; heartbeat, succeed and fail present it. A report arriving after your lease expired answers 409 — the job went back to the queue and somebody else may hold it now. Claim again rather than retrying the report.

A runner asserts nothing. It hands back stdout, stderr and an exit code. What that output means is decided here, by the parser for that definition, so a compromised runner can misreport what a tool printed — which anybody controlling that machine can do anyway — and cannot write a claim, a finding or a score.

What happens to the output when it arrives

succeed is not the end of the job. The output is recorded against the run, then read by the parser the definition names — never one the report names, because a report has nowhere to name one — and the run finishes as whatever that reading produced. An externally executed job therefore writes the same claims as the same tool run in this process, from the same output, because both reach one reader rather than two kept in step.

Four outcomes, and they stay distinguishable because each answers a different question:

The run says What happened What to do about it
succeeded, wrote above zero The tool ran, the output was read, evidence was written Nothing
succeeded, wrote zero, with a reason Read, and it said nothing about this subject — an internal purl no public registry knows The subject is unmeasured, not failing
failed, unreadable … output The tool ran and printed something this parser does not recognise Check the tool’s version against parser_version; the raw output is on the run
failed, the runner’s own error The tool did not run Fix the machine it runs on

Collapsing any pair of those loses something real. Reporting the second as the third makes a healthy installation look broken; reporting the third as the second makes a broken one look measured.

A parser refusing is not retried — the same bytes are unreadable next time, and a retry would run the tool again to find that out. The raw output survives the refusal, because it is the evidence of why.

parser_version is recorded on the run, by both paths, so “which parser produced this result” is answerable from history rather than from what a definition happens to say today. It is NULL on a run nothing has read yet, and on every run that predates this.

Output above 256 KiB is refused, not trimmed. Nothing is kept: a trimmed log recorded against a run cannot be told apart from a complete one, and neither can a truncated document be told from a whole one by the parser that reads it. crane-runner refuses above the same number on its own side, so a well-behaved runner never reaches this.

Nothing ships with an external definition yet: this is the mechanism, and the tools that use it arrive with their own definitions. /assessors lists runner credentials, what each may run, and when each last claimed.


4. Agent and CLI job output contract

Requirements section 12. Any tool run under CRAne writes into a per run directory:

run-manifest.json
results.jsonl
artifacts/
logs/stdout.txt
logs/stderr.txt

run-manifest.json carries the run id, job definition version, command template identifier with no secrets, timestamps, exit status, input artifact ids and hashes, output paths and hashes, result schema version, and parser warnings.

Ingestion validates the manifest, registers outputs as job artifacts, ingests normalized claims and findings, preserves the raw output as untrusted evidence, and requires human approval before any proposal becomes an approved decision or an authoritative mapping.


5. Counting relationships

Edge tables store assertions, not relationships. Two source systems claiming the same product contains the same component produce two rows, each with its own provenance. Both are kept, because agreement between systems is evidence.

Every query that counts or lists relationships must therefore use the resolved views, not the tables:

View Collapses
v_product_component Product to component
v_component_workflow Component to build workflow
v_product_relationship Product to product
v_workflow_asset Build workflow to asset
v_subject_asset Any other subject to asset
v_workflow_dependency Build workflow to build workflow
v_product_flow The whole walk, product to component to workflow to asset

Each view gives one row per distinct relationship plus assertion_count, the strongest review_status any assertion reached, and source_kinds. An assertion_count above 1 is corroboration and is worth surfacing in the UI.

5.0 Asset relationships in both directions

Two tables carry “this thing lives in that asset”, because they are two different facts. workflow_asset says a pipeline reads an asset; subject_asset says a product, component, workflow or other asset is in one. Both are many-to-many on purpose: one monorepo is the source for a dozen components, and recording that must not require CRAne to choose a winner. An asset page lists every subject that references it, of every type, and never picks one.

A third relationship is real and deliberately not stored. A component whose pipeline reads a repository is related to that repository through the pipeline. It is computed for display and labelled with the workflow it travels through, on the page and on the graph. It is never written back as a direct row: a pipeline reading a repository is a weaker claim than a component living in one, and persisting the first as the second would forge an assertion nobody made.

Every subject’s page therefore shows three groups:

Group Means
Direct A source system asserted this about this subject
Reached through a pipeline Derived for display, labelled with the workflow, never persisted
No longer in force Stored and not active, labelled with which of rejected, superseded or ended it is

ended outranks the review status. A relationship somebody approved and that then stopped holding is history, and drawing it as approved would put an approval badge on a retired repository. The review status is still reported beside it, because it answers a different question. An end date in the future is not an end.

“Active” is one predicate, shared with the resolved views, so a page and its view cannot disagree about what is in force.

5.1 Direct and transitive containment

product_component.dependency_depth records how far from the product a contained component sits: 1 for a direct dependency, the BFS distance from the SBOM’s root for anything deeper, NULL where nobody said — which every hand-entered and tabular row is, and which reads as direct. v_product_component resolves it as the minimum across assertions, because if any source says direct, it is direct.

The rule every surface follows: transitive rows are recorded, marked, and kept out of the way. An SBOM names thousands of packages behind the fifty anybody manages, so the components page, the product page component count, the graph, the review queue and the shared-component queue all consider dependency_depth = 1 only. The components page takes ?transitive=1 to show everything, and the product page keeps the transitive packages behind a fold. Both questions stay answerable from one set of rows: “components we manage” is the direct set, “everything we ship” is all of it.

6. What never happens through these interfaces

  • An import never approves a mapping or a scope decision.
  • A validator result never becomes a product compliance verdict on its own.
  • A lower authority claim never overwrites a higher authority one. The conflict is kept.
  • A completed action never counts as closed without a verification record.
  • No interface writes authoritative state into Markdown, YAML, or a JSON sidecar.
  • An exception never raises a score, and never deletes the fact it excuses.
  • A manual edit is source_kind = 'manual'. It does not pretend to be observed.
  • An approval writes a decision first, and the claim cites it. A claim with source_kind = 'approved' and no decision behind it is one nobody can defend.
  • A refused write writes nothing at all, including the decision that would have authorised it.

Back to top

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

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