A Go-first, multi-service compliance mapping platform that compares compliance standards, maps relationships between requirements, and stores the complete graph with full traceability.
CrossCodex delivers composable microservices, provider-agnostic LLM integration, and multi-tenant security with defense-in-depth.
LLM WARNING: This project was written with LLM (AI) assistance.
CrossCodex is in early development. All foundational, domain, and service packages are implemented and tested. The CLI provides approximately 22 commands across project, catalog, run, results, prompt, version, and completion groups with daemon connectivity and embedded single-node mode. See Development below to build from source and run tests.
Bounded multi-generator candidates (#259):
aggregate_scoreinrequiresprovenance is now a fused ranking value, the sum of weight / (60 + rank) over the generators that found the target, not a weighted average of scores. Compare it only within one source's candidates.- Each candidate's
requiresprovenance lists every generator that contributed, with itsrankin that generator's list for the source (tied scores share the mean position). - A relationship pair's
similarity_scoreis always the embedding cosine of the pair, whichever generators found it. - Candidates are capped per source by the new
analysis.candidates.max_per_source(default 10, at least 1), so keyword and level generators no longer send every match to the pair analyzers; keyword and level give every match the same score, so when they are the only generators that find a source's targets, the targets kept undermax_per_sourceare the first by target ID, not the most relevant. A config where an enabled semantic or lexicaltop_k(default 10 when unset) exceeds it fails to load; raisemax_per_sourceor lowertop_k. - New
lexicalgenerator (BM25 over control text), disabled by default.
The graph storage hardening in #148 makes these breaking changes:
- Tenant IDs are limited to 52 characters (3-52 lowercase letters, digits and hyphens). Existing longer tenants stop working and must be re-provisioned under a new ID; see the "Tenant IDs longer than 52 characters" note linked below for the detection query and steps.
- Derived edge ID formats changed, so existing graphs do not deduplicate against newly written edges.
- Temporal graph properties (
valid_from,valid_to,analyzed_at) use a new fixed-width encoding. - Node and edge properties that use a reserved key (such as
idorvalid_from) are rejected. BulkCreateEdgesrejects requests with more thangraph.max_bulk_edgesedges (default 1000).CreateEdgeandBulkCreateEdgesreject atemporal.confidenceoutside 0 to 1.- The never-populated
BulkCreateEdgesResponse.errorsfield (field 3) is removed and reserved;BulkCreateEdgesreports failures only as the RPC status. - The unused
tenants.allowed_tenantsconfig key is removed; delete it. A config file that still sets it fails to load (see unknown config keys below);CROSSCODEX_TENANTS_ALLOWED_TENANTSis ignored.
Graphs written before this change must be rebuilt. Follow the "Upgrade note (#148)" in docs/dev/design-principles.md.
The pre-release schema migrations are collapsed into one baseline, 001_initial_schema. A database migrated by an earlier build cannot be upgraded; crosscodexd refuses it as newer than itself. Drop it (for the compose deployment, task deploy:down VOLUMES=1) and recreate it with crosscodexd migrate.
Unknown config keys:
-
A config file (system, user, drop-in, profile, project or
--config) or CLI flag override that sets a key that is not a configuration field now fails to load instead of being silently ignored. This catches misspellings (pipeline.stage_timout) and keys that were removed (analysis.relationship.top_k,tenants.allowed_tenants). One error lists every unknown key in every file layer and CLI flag override, so all of them can be fixed in one pass, each with its dotted path,file:line:column, a "did you mean" suggestion when a valid key is close, and the valid keys at that level; a removed key says what replaced it. Example:unknown configuration key "pipeline.stage_timout" at /etc/crosscodex/config.yaml:2:3: did you mean "stage_timeout"? (valid keys under pipeline: addr, endpoint, max_concurrent_jobs, stage_timeout).tenant_overridesentries are checked the same way, and a global-only key set in one names the keys that can be overridden. Environment variables are not checked: aCROSSCODEX_*variable that matches no key is still ignored. -
Loading now reports every problem at once instead of stopping at the first: every invalid value in every section, values of the wrong type (text for a number, a value where a section belongs), and the invalid values alongside any unknown keys. A value of the wrong type is reported against the file, line and column (or CLI flag, or environment variable) that set it, such as
pipeline.max_concurrent_jobs at /etc/crosscodex/config.yaml:4:24: cannot unmarshal !!str `many` into int, instead of a line in the merged configuration. A float setting set to.nanis rejected. The CLI prints the problems one per line under a single summary line. Each error keeps its message (with(set in <file>)where the value came from a file, now also foranalysis.engine,analysis.requires,analysis.artifacts,analysis.artifact_adjudication,analysis.candidates,worker,pipelineandretention), joined one per line. A setting whose rule involves another, such astls.mode mutualneedingtls.cert,tls.keyandtls.ca, is reported once, under the setting that imposes the rule. -
A top-level key that only holds a YAML anchor, such as the compose idiom
x-common: &common, is an unknown key and is rejected. Define the anchor on the first place that uses it, inside a real section, and refer to it from the others; anchors do not reach across files:tls: targets: llm: &mtls mode: mutual ca: /etc/crosscodex/ca.pem nats: *mtls db: <<: *mtls cert: /etc/crosscodex/db.pem
-
<<merge keys and aliases now take effect over the compiled defaults and earlier layers. Before,logging: {<<: {level: debug}}loaded with the default levelwarn, because the default's explicitlevelbeat the merged one. Within one mapping, a key set explicitly still wins over a merged one, and in<<: [*a, *b]the earlier entry wins, as YAML specifies. -
crosscodex config set <key> <value>and the newcrosscodex config unset <key>edit only the user config, and write it only if the configuration loads with the change, every other layer and--profileincluded (only that profile; others are not checked). A misspelled key, an invalid value, a key that names a section or list, a path through an alias (<path> is an alias to another section), or a change to a section or value whose anchor an alias in the file refers to (the error names the aliases), is refused with the reason, and the file is left byte-identical. When a change depends on another setting (such astls.mode mutualontls.cert,tls.keyandtls.ca), set that one first. The file is replaced atomically, keeping its permissions and owner (a new one is created0600; a config owned by another user is refused unless the command can give the new file that owner), and a symlinked user config is written through to its target; a dangling symlink is refused. -
When the configuration does not load, every command fails with the error, except the
crosscodex configcommands: they print it as a warning and still run. If the user config is the cause,config unsetremoves an unknown key andconfig setfixes a value, one problem per change: a change is written if it removes at least one problem and adds none (the remaining ones are printed as a warning), and refused if it adds a problem or replaces a bad value with another bad one. A rule spanning settings counts as one problem under the setting that imposes it, sotls.mode mutualstays a problem untiltls.cert,tls.keyandtls.caare all set: set those first, ortls.modeback tooff. If some problem has no setting path (a document whose root is not a mapping, such as a YAML list; a file that does not parse is refused outright, fix its syntax by hand), a change is written only if it makes the configuration load. If a problem's value comes from another layer (system config, drop-in, profile, environment variable), set and unset refuse and name it: fix that file first. A layer that only loads together with the user config (a drop-in setsllm.gateway_mode: true, the user config setsllm.gateway_url) does not count as broken. A map key a dotted path cannot hold (one containing.,[,]or") is written quoted, both in errors and inconfig set,getandunsetkeys, for examplecrosscodex config unset 'llm.tenant_overrides["a.b"]'.config showandconfig gethave no resolved configuration to print, so they report the error once instead.
Tenant administration (#31):
- The gateway refuses every non-health request, with
PermissionDenied, from a tenant whosetenantsrow is missing, suspended or deleted. The initial schema creates thetenant_adminrole thatcrosscodexd admin tenantconnects as, so tenants can only be provisioned aftercrosscodexd migrateruns. Once crosscodexd has migrated, set the role's password andCROSSCODEX_DATABASE_TENANT_ADMIN_DSN, then runcrosscodexd admin tenant createfor each authenticated tenant before sending it traffic; requests from that tenant are refused until then. Rolesallandgatewayrefuse to start untilcrosscodexd migratehas provisionedtenants.default_tenant. See Tenant administration. - The gateway refuses, with
PermissionDenied, a request whosetenant_contextnames a tenant other than the one its client certificate authenticates. Before, the gateway replaced the named tenant with the certificate's and answered for that one, socrosscodex catalog list --tenant acmewith a certificate fordefaultlisteddefault's catalogs, andrun start --tenant acmestarted a job fordefault. Omit--tenant(it defaults to the certificate's tenant), or connect with a certificate issued for the tenant you name.
Analysis stage limits (#237):
pipeline.stage_timeoutnow caps every analyzer stage. Its default is 24h (was 30m, unused), and0is rejected: a config that setsstage_timeout: 0or more than168hfails to load. Raise it if a catalog's stage legitimately runs longer than 24h.analysis.engine.task_timeoutchanged meaning. Before, it was one deadline for a stage's whole batch of LLM tasks, so any real catalog failed aftertask_timeout. Now it is an idle timeout across the whole worker pool: a stage fails only when no worker anywhere reports progress for that long. Total time per stage is capped bypipeline.stage_timeoutinstead (default 24h).- Split deployments: each LLM worker remembers an abandon for its own host's
pipeline.stage_timeout, andworker.max_abandoned_collectionsis likewise per worker host. Setpipeline.stage_timeouton worker hosts to the same value as on pipeline hosts, or larger. A smaller value makes workers forget abandons early and run tasks nobody reads. - Mixed versions: workers from before this release publish no progress ticks, so a new pipeline with old workers still fails a stage queued behind another stage at
task_timeout. Upgrade workers with, or before, pipelines. - Analyzer
enabledsettings now take effect. Before, every job registered all five analyzers whateveranalysis.<analyzer>.enabledsaid, and each ran with whatever models it had: classification and embedding ran even when disabled; artifacts, requires and relationship ran whenever theirmodelswere set, so a disabled analyzer with models still billed its LLM calls, and with the defaults (enabled: false,models: []) they ran with zero LLM tasks, so jobs completed with no mappings and no sign that analysis was off. Now a disabled analyzer gets no stage and makes no calls. What changes for you:- Classification and embedding default to enabled, so a default configuration still runs them; one that set them
falseno longer runs them. - Artifacts no longer runs unless
analysis.artifacts.enabled: true. A configuration that setanalysis.artifacts.modelswithout enabling it used to extract artifacts and now does not. - Relationship and requires likewise run only when enabled. Both default to off, so a stock deployment produces no mappings until you enable
analysis.relationship(see Analyzerenabledsettings). - A configuration that enables an analyzer without what it needs no longer loads: embedding without classification, or requires or relationship without embedding or without an enabled
analysis.candidates.generatorsentry. - A job left running across the upgrade, or across a change to these settings, fails when it resumes if its analyzers differ from the enabled ones, naming each one. Start a new job.
- Classification and embedding default to enabled, so a default configuration still runs them; one that set them
crosscodex run cancelnow records the cancellation. Before, the cancelled job's context was also used to write its final state, so every write failed: the job stayedRUNNINGand resumed at the next daemon start. Stages running at the cancel end with the new stage statuscancelled. A daemon shutdown still leaves its jobsRUNNINGfor resume, and no longer publishes acancelledjob state for them.- When an analyzer stage times out, fails to dispatch, or its job is cancelled, LLM workers drop that stage's still-queued tasks instead of calling the model for results nobody reads; a resumed or retried job's new run is unaffected. Only workers and pipelines from this release do this: an older worker runs every task (and sends no progress ticks, see above), and a newer worker runs tasks from an older pipeline normally. See Control Messages: Abandoned Collections.
SDK and prompt changes:
natsbus.ClientgainedPublishWorkerProgressandSubscribeWorkerProgress. Implementations outside this repo (pkg/natsbusis public) must add them.pkg/natsbussubject builders reject job IDs longer than 128 bytes withErrInvalidJobID.- The shipped
requiresprompt is now 1.1.0 and renders| Control Domain: "${source_ancestor}";${source_ancestor}is the bare domain name, no longer a pre-formatted suffix. A user or project prompt override ofrequiresstill usingLevel: ${source_level}${source_ancestor}renders e.g.Level: tacticalAccess Control. Prompt overrides are not version-checked, so update them by hand.
Attestation fails closed (#240):
- An attested pipeline job now completes only once its whole attestation is stored: a signed layout, a link per analyzer, synthesis and graph, a chain that verifies, and the bundle. Before, every attestation failure was logged and ignored, so jobs completed with a partial attestation or none. A job that cannot be attested now fails with an error that names the step and object and says what to do. See Cryptographic Attestation.
attestation.enablednow takes effect, per tenant throughattestation.tenant_overrides. When it istrue(the default) for any tenant, rolesallandpipelinerefuse to start unless that scope'sprivate_key_pathandpublic_key_pathare both set, load, and are one key pair; withtls.fips.enabledthe key must be on a FIPS-approved curve. Before, the daemon refused only empty global paths, even with attestation disabled, and a key that did not load started the daemon and then left every job unattested. Thedeploy/stack andtest/e2ealready supply keys. A configuration of your own that does not: generate a key pair (task deploy:certswrites one todeploy/certs/) and set both paths, or setattestation.enabled: falseto run jobs unattested, which the daemon logs at startup.attestation.include_byproducts, the per-tenantexpiry_durationand key paths, and FIPS mode now apply to the pipeline's attestations; they were ignored.- Analyzer link digests are now SHA-256 of each analyzer's stored result. Before, every production analyzer's link recorded
sha256of the analyzer's name, so the chain attested nothing about results. Bundles stored before this change still verify their signatures but not against results. - A job left running across the upgrade resumes as before; if its synthesis or graph stage completed without a stored link, it fails and says to start a new job.
- New settings
attestation.storage_retries(default 3) andattestation.storage_retry_delay(default 2s) bound how long an attestation write is retried before the job fails. - Every layout step and link is bound to its job: it records
job:<tenant>/<job-id>with the digest of the job's stored config, and verification refuses a link or bundle from another job. Before, nothing signed named the job, so with the shared global key one job's attestation verified as another's. crosscodex results verifynow verifies a job's whole stored bundle and requires--public-key,--tenantand--job-id; it prints the job and its config digest. Before, it verified a single link and reportedStatus: Structure validwithout a key; given a bundle it failed.- New command
crosscodex run retry <job-id>(gateway RPCRetryJob,POST /v1/jobs/{job_id}/retry):--from-failureresumes a failed or cancelled job in place, otherwise a new job starts with the same config. Before, retrying needed the pipeline's own RPC, which rolealldoes not serve. A failed or cancelled job with no failed or cancelled stage (one whose attestation could not be stored) now resumes instead of being refused, and a job whose failure a retry cannot fix (an attestation that does not verify, changed analyzers, no catalog) is refused withFailedPreconditionand left unchanged. jobsgains anerror_classcolumn, folded into the pre-release baseline001_initial_schema. A database created by an earlier build lacks it;crosscodexdnow refuses to start on such a database, saying to recreate it (task deploy:down VOLUMES=1, thencrosscodexd migrate).- Cancelling and retrying: a retry of a job whose cancelled run is still stopping is refused (
FailedPrecondition, "still stopping") untilcrosscodex run statusshows it is no longer running, so a retry never runs beside the cancelled run; concurrent retries of one job start it once. Embedded mode now refusesrun cancel, since the job is run, and can only be stopped, by the daemon.JobProgressgainscancelled_steps, andrun statusprints the completed, failed and cancelled stage counts. gateway.PipelineBackendgainsRetryJob. Implementations outside this repo must add it.attestation.Generator.VerifyChaintakes the expected subject and returns a*VerifiedChain, andVerifiedLayout.KeyIDslists only the keys whose signatures verified.pipeline.Newtakes apipeline.Attestors(built bypipeline.NewAttestors) in place of anattestation.Generatorandconfig.AttestationConfig.attestation.Generator.VerifyChainnow requires the layout and one link per layout step (ErrMissingLink,ErrUnexpectedLink) and verifies every signature; callers that passed a nil layout or a partial chain get an error.
LLM result reuse (#243):
- The pre-release baseline
001_initial_schemachanged again. It adds the tablesllm_result_cache,job_cache_entriesandjob_cache_stats, a(job_id, tenant_id)unique key onjobs, and the columnjob_stages.cache_attempt.crosscodexdrefuses to start on a database created by an earlier build and names the missing column. Recreate it:task deploy:down VOLUMES=1, start the stack again, then runcrosscodexd migrate. - New API fields, all additive:
JobConfig.no_cache,StartAnalysisRequest.no_cacheandPipelineJob.cache_summaries(GetJobonly), with the enumsResultCacheModeandResultCacheAttestation. Existing clients are unaffected.run status --jsongains acachekey. - New
jobs.error_classvalues:result_cache_unavailableandllm_unavailable(both retryable) andresult_cache_corrupt.run retryrefuses aresult_cache_corruptjob in both forms, except a new job from a job that already had--no-cache. The remedy is a new job with--no-cache. - Each analyzer's attestation link now pins the material
<analyzer>.cache_manifest, and the job's layout requires it. The link also records the byproductsresult_cache.mode,result_cache.reasonandresult_cache.attempt. - Changing the attestation key pair, globally or through a tenant override, ends reuse of results from earlier jobs. Those results are recomputed, and nothing is flagged as corrupt.
- A tenant whose jobs are not attested (
attestation.enabled: false, globally or for the tenant) gets no reuse, not even of its own earlier attempts. Startup logs this for each such scope. - If the pipeline and worker processes run with different
llm.default_modelorllm.embedding_modelvalues, the cache stays correct but reuse drops. A request with no model alias is keyed by the pipeline's default, while the worker answers with its own, and a result is stored only when the model that answered is the one the job probed. GetJobandListJobsnow setPipelineJob.erroron a failed job, sorun statusprints anError:line andrun status --jsonanerrorobject (one line, control characters removed, at most 2048 bytes); before, both were always empty. The message is written for the job's owner: a failed stage's own message; the full error when the job's analyzers changed, its config names no catalog or its target catalog has no control with a statement; otherwisejob <id> failed because of an internal error (error class <class>), the commands that run it again as its class allows (resume it withrun retry --from-failure, or start a new job, with--no-cacheforresult_cache_corrupt), and the trace ID (or, with tracing off, the job ID) to give your operator, who finds the full error in the daemon'sjob failedlog line. Each stage's error (JobStage.error) follows the same rule, and its full error is in thestage failedorjob failedlog line. A job that failed before this change keeps the error it recorded, which can include internal detail such as a database error, andGetJobandListJobsnow return it to the job's owner and to admins.
Identity lifecycle (#195):
- Tenants and SAME_AS verdicts can no longer be deleted by any role short of the database owner or a superuser deliberately disabling triggers (see Identity Lifecycle). Retire a tenant or reviewer with the soft-delete commands under Tenant administration.
adjudicate artifacts deciderequires a registered reviewer; register reviewers withcrosscodexd admin reviewer add.
Cross-catalog analysis (#210) makes these breaking changes:
crosscodex run startrequires--target <catalog-id>;run start --source Xalone is refused before connecting. Pass--target Xfor the previous behaviour (analysis within one catalog), or another catalog's ID to map X's controls onto that catalog's.--bidirectionalwith--targetequal to--sourceis refused. A job that failed before this change recordedcrosscodex run start --source X(no--target) in its error, which the new CLI refuses;run statusstill shows the error as recorded, and arun retryrefusal quotes it, then ends with a command built from the job's config (--source X --target X). See 8. Start an analysis.StartAnalysisandCreateJobreject a request withouttarget_catalog_id, or withbidirectionalwhen the target equals the source, withInvalidArgument. Both refuse a source or target that is not one of the caller's tenant's imported catalogs withNotFound, before any job is stored.- A job created before the upgrade has no target in its stored config. It keeps running, resumes and retries within its one catalog.
CreateJobrecords each catalog's name and version in the job's config (JobConfig.source_catalog/target_catalog), overwriting whatever the caller sent, sorun statusandresults exportkeep showing the labels the job was created under after a catalog is re-imported under another name. A job created before the upgrade has none: both commands show the catalog's current name, and say so.ListJobMappingsresponses gain ananalysisfield (JobAnalysis: the job's source and target catalogs and direction).results exportJSON output gains ananalysiskey, andresults export --format csvappends nine job-level columns afteris_viable:job_source_catalog_id,job_source_catalog_name,job_source_catalog_version,job_target_catalog_id,job_target_catalog_name,job_target_catalog_version,job_bidirectional,job_source_catalog_snapshotandjob_target_catalog_snapshot. A*_snapshotcolumn istruewhen that side's name and version were recorded when the job was created, andfalsefor a job created before this change, whose labels are the catalog's current ones; JSON carries the same assnapshoton each catalog. Scripts that read CSV columns by position must account for them. A CSV cell taken from a catalog (control and catalog IDs, names, versions) that would start with=,+,-or@now gets a leading', so a spreadsheet does not run it as a formula; strip it if you compare IDs.run statusprintsSource catalog,Target catalogandDirectionlines, and its--jsonoutput ananalysisobject.- Import records a catalog's OSCAL
metadata.version. Catalogs imported earlier show the version as "not recorded" until they are imported again. - A crosswalk job whose target catalog has no non-section control with a statement fails before any analyzer runs, with the new
jobs.error_classvalueempty_target_catalog.run retryrefuses it (FailedPrecondition) with or without--from-failure, since a retry or a new job with the same config reads the same catalog; start a new job with another target (re-importing the target with statements gives it a new catalog ID). - The shipped
requires(1.2.0),relationship(1.1.0),artifacts(1.1.0) andclassify(1.1.0) prompts treat catalog values as untrusted data. Each control's ID, framework, type, level and control domain are one field per line inside<source_metadata>/<target_metadata>(<control_metadata>forartifacts), with no quotes around the domain.${source_framework}/${target_framework}no longer appear in the system message. The system message says that tag contents are data, not instructions. Every prompt value, catalog values and theartifact_same_as(now 1.2.0) artifact names and samples, is NFKC-normalized, so fullwidth and small-form look-alikes such as<fold to ASCII, and is cleaned and has&,<and>entity-escaped. The limits apply to the escaped text: statements keep their character limits, and short values get at most 200 bytes. Prompt overrides are not version-checked, so update them by hand. Results can shift against earlier runs.
Mapping on in the compose stack:
deploy/config.yamlnow enablesanalysis.relationshipandanalysis.requireson the gateway'sdefaultalias, with thesemanticcandidate generator. Before, both were off, so a job on the compose stack completed with zero mappings. A job now makes pair calls as well: about source controls × 10 × 6 (relationship 3 samples + requires 3), so it takes longer and costs more (see deploy/README.md). Setenabled: falsethere to go back. If you renamed thedefaultalias in the gateway config, rename it in bothmodelslists.- The compiled defaults are unchanged: a
crosscodexdwith no config file still runs no pair analyzers.
Mapping confidence:
- A relationship mapping's confidence is now the panel's agreement times the mean
analysis.relationship.confidence_weightsweight of the winning votes' reportedCONFIDENCE(see "Confidence" under Read a job's mappings). Before, it was the agreement alone, so a one-sample panel gave every mapping 1.000 and--min-confidenceandsynthesis.confidence_thresholdfiltered nothing. Jobs run after the upgrade report lower confidences than earlier jobs on the same catalogs, and with the default weights and threshold a one-sample panel's LOW mappings are no longer viable. Compare runs from the same build only. Set all three weights to 1 to get the old values back. - The agreement alone is a new
agreement_fractionproperty on eachSEMANTIC_MATCHgraph edge and in the relationship analyzer's stored results. - Cached LLM results are still reused: the prompts and requests are unchanged, and confidence is computed from the cached responses when a job aggregates them. Changing
samples_per_modeldoes change the requests (a panel of more than one sample usessampling_temperatureinstead of 0), so the compose stack's switch to 3 samples sends its relationship and requires requests to the model again once. - New config key
analysis.relationship.confidence_weights(high,medium,low). A config that sets a weight outside (0, 1], or a lower level above a higher one, fails to load.
Consensus thresholds:
- The compiled default
consensus_thresholdofanalysis.requiresandanalysis.artifact_adjudicationis now 0.66 (was 0.67). A panel decides when votes for the majority / valid votes ≥ the threshold, and 2/3 is 0.666…, so at 0.67 a three-sample panel decided only when unanimous: a two-of-three requires vote wrote no REQUIRES edge, and a two-of-three adjudication left the pair undecided. Now two of three decide. Expect more REQUIRES edges and fewer undecided artifact pairs; pairs already closed as undecided stay undecided. A config that sets 0.67 explicitly keeps the old behavior.
Analyzer-stage export:
results export --stage analyzerno longer reports a viability, because that stage has none. JSON mappings omitviabilityScoreandisViable(before:0andfalse), and the CSVviability_scoreandis_viablecells are empty (before:0.000andfalse). The columns stay where they were. Thesynthesizedstage is unchanged.
Certificate generation:
- The deploy PKI generator moved from
deploy/certs/gen.gotodeploy/certgen/gen.go, because a regeneration now replaces the wholedeploy/certs/directory. Keep nothing else indeploy/certs/(or in a customCERTS_DIR). deploy/certgen/gen.goandinternal/testcerts/gen.gokeep a set that verifies instead of always regenerating, and they verify more: key/certificate pairs, the required SANs, the fingerprint, the attestation keypair (deploy) and at least a day of validity left. A set that fails any check is replaced on the nexttask deploy:certsortask test:integration:generate-certs; you no longer deletedeploy/certs/.fingerprintby hand to pick up a new SAN.- The embedded daemon's
$XDG_STATE_HOME/crosscodex/pki/is generated the same way. Apki/directory holding certificates without a.fingerprint(not generated by the CLI) is no longer overwritten when invalid; the CLI stops with the reason instead. Remove the directory to get a generated set.
Model answers cut off at max_tokens:
- A model call that returns no answer because the model stopped at the analyzer's
max_tokensnow fails its stage with error classllm_output_truncated. Reasoning models do this when thinking uses the whole budget, and classification's budget is 20 tokens. Before, the empty answer was dropped and the job completed with those controls' results missing. Any such task fails the stage, even when others answered, so a job no longer completes with silent gaps. The task is not retried, because the same request gets the same reply. - The job's message names the setting to raise (
analysis.classification.max_tokensand so on) and points to turning the model's reasoning off in the gateway instead (see "llama.cpp, vLLM or another OpenAI-compatible server" in deploy/README.md). The class is retryable: after either fix (restartcrosscodexdafter changing its config),crosscodex run retry --from-failure <job-id>resumes the job. - Artifact adjudication records such a reply as the pair's failure (
model gave no answer within max_tokens), and the LLM-assisted catalog structurer refuses it. A partial answer cut off atmax_tokensis unchanged: the analyzer's parser judges it.
Prerequisites: Go >= 1.23, Task (taskfile.dev), container engine (podman or docker)
task buildtask dev:upThis starts PostgreSQL with AGE graph extension and pgvector.
The dev database requires mutual TLS. Copy the full DSN that task dev:up prints:
<!-- secretlint-disable-next-line @secretlint/secretlint-rule-database-connection-string -- documentation example with dev-only credentials (user: postgres, password: integration, host: localhost) -->
crosscodex config set database.dsn "postgres://postgres:integration@localhost:15432/crosscodex_test?sslmode=verify-full&sslrootcert=.test-output/certs/ca.pem&sslcert=.test-output/certs/client.pem&sslkey=.test-output/certs/client-key.pem"task fetch:oscal-docsThis downloads official NIST OSCAL catalogs to the catalogs/ directory.
crosscodex project initcrosscodex catalog import catalogs/NIST_SP-800-53_rev5_catalog.jsonImport stores and parses the catalog and prints its catalog ID. It does not start analysis.
# List all imported catalogs
crosscodex catalog list
# Inspect a specific catalog (use the ID from the list output)
crosscodex catalog inspect <catalog-id>An analysis maps the controls of a source catalog onto a target catalog (a crosswalk, for example CSF 2.0 onto SP 800-53):
crosscodex run start --source <catalog-id> --target <catalog-id>
crosscodex run status <job-id>- Direction. Pairs run source to target: each source control is compared with its most similar target controls. NIST IR 8477 relationship types are directional ("A is a subset of B" is not "B is a subset of A").
--bidirectionalalso maps the target's controls onto the source's. For target to source only, swap--sourceand--target. --targetis required. To analyse within one catalog (for example to find overlaps and REQUIRES dependencies in a catalog that carries no links of its own), pass its ID to both:crosscodex run start --source <id> --target <id>. Within one catalog, controls are already paired in both directions, so--bidirectionalis refused there.- What runs. The job classifies, embeds and extracts artifacts for the controls of both catalogs, then runs the pair analyzers (
requires,relationship) over the candidate pairs. Each pair prompt names both requirements' frameworks, taken from the catalog names. A crosswalk whose target has no non-section, non-withdrawn control with a statement fails before any analyzer runs. - Cost. Sections and withdrawn controls (OSCAL
statuspropwithdrawn, stored as the control propstatus; theirincorporated-into/moved-totargets are kept too) make no LLM calls, andembeddingalso skips controls without a statement. For every other control of both catalogs (a job within one catalog counts it once), each enabled per-control analyzer makes:classify1 call,embedding1 per model inanalysis.embedding.models,artifactsmodels×samples_per_model. Pair calls ≤ source controls ×max_per_source(analysis.candidates.max_per_source, default 10) × panel size (models×samples_per_model), summed over the enabled pair analyzers (analysis.requires,analysis.relationship).--bidirectionaladds the reverse direction, whose sources are the target's controls, so pair calls become ≤ (source + target controls) ×max_per_source× panel size, summed over the same analyzers: mapping CSF (about 100 controls) onto SP 800-53 (about 1,000) costs about 11 times the one-way pair calls, not twice.crosscodex catalog listandcatalog inspectshow each catalog's control count (sections and withdrawn controls included, so an upper bound; SP 800-53 rev 5 has about 200 withdrawn) before you start;run statusshows both sides' while the job runs. - Reuse. LLM results are reused across your tenant's jobs; other tenants never see them. A request identical to one an earlier job sent is answered from the cache instead of the model. Identical means the same rendered prompt, model alias, temperature, token limit and sample, answered by the same model and gateway deployment at the same
llm.model_revisionsrevision.- Which model. Before analysis, each job makes one probe call per distinct model alias and kind (a completion with
max_tokens: 1, or one short embedding) to learn which model the gateway serves. The model name the gateway returns and its deployment ID (LiteLLM'sx-litellm-model-idresponse header, which changes when the model behind the alias changes) are part of every cache key, so a model change is a miss. A result is stored only when its call reports the same model and deployment as the probe. So an alias load-balanced across several gateway deployments is cached only for the deployment the job's probe reached: results from the others are sent to the model and not stored. To cache every deployment, give each its own alias. An alias is not cached in that job when its probe returns no model name, or returns only the alias itself and no deployment ID: a change of the model behind it could not be detected. To cache such an alias, setllm.model_revisions.<alias>and change it whenever the model behind the alias changes. - Trust. A result is reused only through a signed cache checkpoint. When a stage stops, the daemon signs one checkpoint over everything that stage run stored and reused, with the job's attestation key. Before reusing a result, the stage verifies the signature of the producing run and checks that its records still match what was signed. A result edited after it was stored, even by a database superuser, is never used: the stage fails as
result_cache_corrupt. A run with no checkpoint supplies nothing, so its requests are sent to the model again. That covers a crashed run, a tenant without attestation, and a checkpoint signed by a key the daemon no longer trusts. - Cost. An unchanged re-run sends no analysis call. It costs:
- one probe per distinct alias and kind, per run or resume
- one signature and one object-storage write each time a stage of a cached analyzer stops
- for each distinct earlier job whose results a stage reuses, once per stage: one object-storage listing, plus one object read and signature check per checkpoint stored for that job and analyzer (one per stopped stage run)
- in each lookup batch (
analysis.result_cache.lookup_batch_sizekeys) that reads results of such a job, one database re-read of the records of its signed attempts, which the batch checks against their checkpoints - on each resume or
retry --from-failure: one object-storage listing per analyzer that kept no cache records - each
run status(the only command that reads cache summaries): at most one signed object read per analyzer (a checkpoint, or the analyzer's attestation link), plus one read of the stored results when an analyzer kept no cache records
- Seeing reuse.
run statusshows, per analyzer, the results reused against the calls sent, and the model behind each alias. It names the earlier jobs you created that supplied results, and only counts other users' jobs. It shows only figures a signed record vouches for. An analyzer without one shows as not attested, or as in progress while the job runs; one that completed in an earlier run that recorded no cache attempt showscache use not recorded for this stage (signed as unrecorded). If object storage cannot be read,run statusstill shows the job, with the affected analyzers not attested. In embedded mode,run statusprintsResult cache: shown only when connected to crosscodexdinstead of the lines. - Fresh runs and the kill switch.
--no-cachesends every call to the model for a fresh, reproducible run, and still stores its results. The job's attested config records the flag.- Operators can turn reuse off with
analysis.result_cache.enabled: false, per tenant throughtenant_overrides. - A model update that the gateway's model name and deployment ID cannot show, such as an Ollama re-pull of the same tag, does not cause misses by itself. Change
llm.model_revisionsto force them.
- Failures. Each message ends with the command to run and a trace ID for your operator.
- The cache cannot be read: the stage stops before any LLM call (
result_cache_unavailable, retryable). - A cached result fails its integrity check: it is never used and the stage stops (
result_cache_corrupt). Start a new job with--no-cache, which replaces the cached results. - The model gateway cannot be reached for the probe: the job stops before analysis (
llm_unavailable, retryable).
- The cache cannot be read: the stage stops before any LLM call (
- Retention. Cached results are purged with the job that produced them, and with either catalog whose text they carried.
- Which model. Before analysis, each job makes one probe call per distinct model alias and kind (a completion with
- Seeing what ran.
crosscodex run status <job-id>shows the source and target catalogs (name, version, ID and control count) and the direction.crosscodex results exportcarries the catalogs (ID, name, version) and direction: JSON underanalysis, CSV in thejob_*columns, which describe the job, not the row (in a bidirectional job a row's source control can come from the job's target catalog). Names and versions are the ones recorded when the job was created; for a job created before #210 they are the catalog's current ones, whichrun statusmarks "(current name; not recorded when the job was created)" and export marks withsnapshot: false/job_*_catalog_snapshotfalse. A version shows as "not recorded" when import stored none. A CSV cell taken from a catalog that would start with=,+,-or@gets a leading'so a spreadsheet does not run it as a formula. JSON key names differ between the two commands:run status --jsonuses snake_case (source_catalog), whileresults exportrendersanalysisand each mapping as proto JSON, in camelCase (sourceCatalog,mappingId), as mappings were before #210; only its top-leveljob_idis snake_case.
run start prints the job ID. Analysis runs in the crosscodexd daemon; embedded mode refuses it (see Deployment Modes).
crosscodex run cancel <job-id> asks for confirmation. When stdin is not a terminal (a script, pipe or CI job) it refuses and cancels nothing, even if y is piped in; pass --yes to cancel without the prompt. Embedded mode refuses it: only the daemon running the job can stop it.
A cancelled job ends with status CANCELLED, and the stages that were running end cancelled; it is not resumed automatically. crosscodex run retry --from-failure <job-id> resumes a cancelled job from its first cancelled stage, as it resumes a failed job from its first failed one, keeping the stages that completed. A failed or cancelled job with no failed or cancelled stage, such as one whose attestation bundle could not be stored, resumes from where it stopped. A job whose failure a retry cannot fix (its attestation does not verify, its analyzers changed, its config names no catalog, its target catalog has no control with a statement, a cached result it reused failed its integrity check) is refused, unchanged, with the reason, the error as the job recorded it, and the command that starts a new job. Without --from-failure, run retry starts a new job with the same config and prints its ID, except for a job whose target catalog has no control with a statement or whose cached result failed its integrity check, which it refuses because the new job would fail the same way. The remedy for the second is a new job with --no-cache, which run retry does start for a job that already had it. A job stored as bidirectional within one catalog runs without the flag, whether resumed or retried as a new job. run retry asks for no confirmation: it deletes nothing and re-runs only stages that did not complete. A cancelled job keeps writing its cancellation for a moment after run cancel returns; until it stops, run retry is refused, saying to retry once crosscodex run status <job-id> shows it is no longer running. Embedded mode refuses it, as it refuses run start. A job is COMPLETED only once its attestation bundle is stored, so a cancel during that last step still cancels it; once the job is being recorded as completed, run cancel is refused (FailedPrecondition) and says to check crosscodex run status. A job interrupted by a crosscodexd shutdown is not cancelled: it stays RUNNING and resumes from its completed stages when the daemon starts again, including one stopped while its bundle was being stored.
Once a job has completed, read its control mappings. Both commands take the job ID and the same filters:
# Count mappings by relationship type
crosscodex results summary <job-id>
# Export viable synthesized mappings as proto JSON (enum names, all fields)
crosscodex results export <job-id> --output mappings.json
# Export one control's high-confidence EQUIVALENT mappings as CSV
crosscodex results export <job-id> --format csv --control <control-id> --type equivalent --min-confidence 0.8| Flag | Applies to | Meaning |
|---|---|---|
--stage synthesized|analyzer |
both | synthesized (default) is the output after synthesis's confidence threshold and per-control cap, with a viability score per mapping. analyzer is the raw relationship analyzer consensus, with no viability: export omits viabilityScore and isViable from JSON and leaves the viability_score and is_viable CSV cells empty. |
--min-confidence <0-1> |
both | Only mappings at or above this confidence. |
--type <type> |
both | Only this relationship type; repeat for several. Case-insensitive. NO_RELATIONSHIP is excluded unless named here. |
--control <id> |
both | Only mappings whose source or target is this control. |
--include-non-viable |
both | Also return pairs synthesis filtered out (synthesized stage only). By default only viable mappings are returned. |
--format json|csv |
export |
Output format (default json). |
--output <file> |
export |
Write to a file instead of stdout. |
--json |
summary |
Structured output. |
A job that is still pending or running returns an error saying mappings are available once it completes. A failed or cancelled job never produces mappings; start a new job. An unknown job ID returns not found. mapping_id equals the ID of the mapping's SEMANTIC_MATCH graph edge. REQUIRES is not a mapping and is not returned.
Confidence. A mapping's confidence (0 to 1) is the relationship panel's agreement times how sure the agreeing votes said they were: (votes for the winning type / valid votes) × the mean of analysis.relationship.confidence_weights over the winning votes' reported CONFIDENCE (HIGH 1.0, MEDIUM 0.67, LOW 0.33 by default; a vote that reports none counts as LOW). Three samples that agree, all HIGH, give 1.0; two of three, HIGH and MEDIUM, give 0.557; a one-sample panel gives exactly its vote's weight. The winning type is still chosen by vote count alone. --min-confidence and synthesis.confidence_threshold (default 0.5) filter on this value, so with the default weights a one-sample panel's LOW mappings fall below the threshold. The agreement alone is kept as the agreement_fraction property of each SEMANTIC_MATCH graph edge.
Run crosscodex --help to see all available commands, or crosscodex <command> --help for command-specific usage.
The target architecture consists of seven core services that can run embedded in a single process or distributed across multiple hosts. Today the monorepo provides implemented infrastructure (pkg/config, pkg/db, pkg/storage, pkg/natsbus, pkg/tlsconfig, pkg/authn, pkg/tenant, pkg/telemetry, pkg/llmclient) and implemented domain packages (pkg/analyzer, pkg/attestation, pkg/oscal, pkg/graphdb, pkg/vectordb, pkg/prompt). Implemented services include internal/worker (LLM task execution), internal/synthesis (viability ranking and quality diagnostics), internal/graph (compliance relationship graph), internal/analysis (analysis engine), internal/catalog (OSCAL catalog management), internal/gateway (API gateway), internal/pipeline (job orchestration), and the analyzer plugins (internal/analyzer/classify, internal/analyzer/embedding, internal/analyzer/relationship).
%%{init: {'theme': 'base', 'themeVariables': {
'primaryColor': '#2f6dab',
'primaryTextColor': '#1e1e1e',
'primaryBorderColor': '#7c8ba1',
'lineColor': '#7c8ba1',
'edgeLabelBackground': '#eef2f8',
'tertiaryColor': 'transparent',
'tertiaryTextColor': '#7c8ba1',
'tertiaryBorderColor': '#7c8ba1',
'clusterBkg': 'transparent',
'clusterBorder': '#7c8ba1',
'titleColor': '#7c8ba1',
'noteBkgColor': '#eef2f8',
'noteTextColor': '#1e1e1e',
'fontFamily': 'system-ui, sans-serif'
}, 'themeCSS': '.node .nodeLabel{color:#ffffff!important;fill:#ffffff!important;}'}}%%
flowchart TD
subgraph external ["External Sources"]
docs["Documents<br/>PDF, DOCX, HTML"]
end
subgraph processing ["Processing Pipeline"]
ingestion["Ingestion Service<br/>Python + Docling"]
catalog["Catalog Service<br/>Go + OSCAL"]
analysis["Analysis Engine<br/>Go + Plugins"]
llm["LLM Workers<br/>Go + AI Models"]
end
subgraph orchestration ["Orchestration"]
synthesis["Synthesis<br/>Quality Ranking"]
pipeline["Pipeline<br/>Go + NATS"]
end
subgraph datalayer ["Data Layer"]
graphdb["Graph Store<br/>PostgreSQL + AGE"]
vectordb["Vector Store<br/>pgvector"]
end
subgraph infra ["Infrastructure"]
tenant["Tenant Isolation"]
tls["TLS / mTLS"]
authn["Authentication"]
telemetry["Telemetry<br/>OpenTelemetry"]
attestation["Attestation<br/>in-toto"]
end
docs --> ingestion --> catalog --> analysis --> llm
llm --> synthesis --> graphdb
pipeline --> analysis
pipeline --> synthesis
graphdb --> pipeline
analysis --> vectordb
infra -.-> processing
infra -.-> orchestration
infra -.-> datalayer
classDef sysA fill:#2f6dab,color:#ffffff,stroke:#7c8ba1
classDef sysB fill:#1d7848,color:#ffffff,stroke:#7c8ba1
classDef sysC fill:#7457b8,color:#ffffff,stroke:#7c8ba1
classDef sysD fill:#2d747e,color:#ffffff,stroke:#7c8ba1
classDef sysF fill:#5c6a82,color:#ffffff,stroke:#7c8ba1
class ingestion,catalog,analysis,llm sysA
class synthesis,pipeline sysB
class graphdb,vectordb sysC
class tenant,tls,authn,telemetry,attestation sysD
class docs sysF
| Service | Purpose | Technology |
|---|---|---|
| Ingestion | Multi-format document conversion via Docling | Python gRPC service |
| Catalog | OSCAL parsing, document structuring, validation | Go |
| Analysis Engine | Host for analyzer plugins, DAG execution | Go |
| LLM Workers | Horizontally scalable LLM task execution | Go |
| Synthesis | Ranking, viability weighting, quality metrics | Go |
| Graph | openCypher queries via Apache AGE on PostgreSQL | Go |
| Pipeline | Job orchestration, state tracking, retry logic | Go |
The Ingestion service lives in its own repository, complytime-labs/crosscodex-ingestion. The gateway does not call it yet: it stores uploads unchanged, and the Catalog service imports only OSCAL JSON. Wiring Docling conversion into imports is tracked in issue #214.
- Embedded -- All services in one process with auto-bootstrapped mTLS. Requires PostgreSQL with AGE and pgvector extensions (start with
task dev:up). Local filesystem for object storage. Catalog import, list, and inspect work for OSCAL JSON documents. Analysis does not run in embedded mode:crosscodex run startfails with an error that says to startcrosscodexdand setcli.endpoint, and no job is stored. Runcrosscodexd migratebefore startingcrosscodexdagainst a database.crosscodexd(default--role=all) bootstraps a DB pool and NATS client and runs the graph, worker, and gateway/pipeline roles together in one process; analysis result persistence is durable end-to-end at the library layer (internal/pipeline,pkg/analyzer). - Production (compose) -- Single-host production stack with full mutual TLS across all services (PostgreSQL, NATS, the LLM gateway, CrossCodex gateway). One-command bring-up via
task deploy:upafter populatingdeploy/.envand generating certificates withtask deploy:certs. The default LiteLLM gateway runs behind a stunnel sidecar to provide end-to-end mTLS;LLM_GATEWAY=praxisselects Praxis AI instead (spike). See deploy/README.md for setup, first-use CLI commands, and operations. - Distributed -- Services scale independently with external PostgreSQL cluster (AGE + pgvector), NATS cluster with JetStream, and S3-compatible object storage. Multi-host deployment and FIPS 140 container images tracked in issue #17.
See Service Runtime Guide for the --role flag, role precedence, health-check wiring, the shared nats.url every split role must set, and gateway startup preconditions.
CrossCodex follows XDG Base Directory conventions:
$XDG_CONFIG_HOME/crosscodex/
config.yaml # User-level defaults
profiles/
local.yaml # Single-node overrides
distributed.yaml # Cluster overrides
credentials/ # API keys, certificates (mode 0600)
tenants/ # Per-tenant configuration
Project directory:
.crosscodex/
config.yaml # Project-specific overrides
prompts/ # Custom prompt templates
Additional XDG directories:
$XDG_DATA_HOME/crosscodex/
prompts/ # User prompt template layers
$XDG_STATE_HOME/crosscodex/
pki/ # Embedded daemon auto-generated TLS certificates
daemon.pid # Embedded daemon PID and port
The embedded daemon generates pki/ on first use and replaces it whole when it is invalid or within a day of expiry; concurrent CLI invocations share one set. To use your own certificates instead, put ca.pem, ca-key.pem, server.pem (with a localhost SAN), server-key.pem, client.pem and client-key.pem in pki/ without a .fingerprint file. The CLI then uses them as long as they verify and never replaces them; if they don't, it stops with the reason. Remove the directory to go back to a generated set.
Values merge in ascending priority (last wins):
- Compiled defaults
- System config (
/etc/crosscodex/config.yaml) - System drop-ins (
/etc/crosscodex/conf.d/*.yaml) - User config (
$XDG_CONFIG_HOME/crosscodex/config.yaml) - User drop-ins (
$XDG_CONFIG_HOME/crosscodex/conf.d/*.yaml) - Profile (
--profile local) - Project config (
.crosscodex/config.yaml) - Environment variables (
CROSSCODEX_*) - CLI flags (highest priority)
# LLM Gateway
llm:
gateway_url: "http://localhost:4000" # roles pipeline, worker and all refuse to start if this doesn't answer
default_model: "qwen3:8b"
timeout: 30 # seconds per LLM request; 0 means the 30s default. Raise it for a self-hosted server that loads models on demand or reasons before answering (see "Tuning a self-hosted model server" in deploy/README.md)
probe_timeout: 0 # seconds for the per-job model probe, the first call to reach the model; 0 means llm.timeout. Raise it to cover a cold model load without stretching every analysis call's timeout (global-only)
model_revisions: # alias → revision label, part of every result-cache key (global-only); change it to force cache misses. Required to cache an alias the gateway names only by itself, with no deployment ID
default: "2026-11-01"
# Storage
storage:
objects:
backend: local # local | s3
# Database
database:
dsn: "${DATABASE_DSN}" # app_user, e.g. postgres://app_user:example-password@localhost:5432/crosscodex
migration_dsn: "${MIGRATION_DSN}" # schema owner, used only by `crosscodexd migrate`, e.g. postgres://owner:example-password@localhost:5432/crosscodex
extensions: [age, vector]
# TLS (Global Default)
tls:
mode: "mutual" # off | server-only | mutual
ca: /etc/crosscodex/tls/ca.crt
cert: /etc/crosscodex/tls/server.crt
key: /etc/crosscodex/tls/server.key
# Attestation (roles all and pipeline refuse to start if an enabled scope's keys are missing or do not load)
attestation:
enabled: true # false: jobs complete unattested (logged at startup)
private_key_path: /etc/crosscodex/attestation-key.pem # ECDSA (SEC1 "EC PRIVATE KEY")
public_key_path: /etc/crosscodex/attestation-pub.pem # PKIX "PUBLIC KEY" of the same key
expiry_duration: 8760h # layout validity; `results verify` rejects an expired layout
storage_retries: 3 # retries of a failed attestation write, [0, 10]; global-only
storage_retry_delay: 2s # wait before each retry, [0, 1m]; global-only
tenant_overrides:
sandbox:
enabled: false # this tenant's jobs run unattested
# Multi-tenant X.509 certificate-to-tenant mapping
tenants:
enabled: true
auth:
x509_mappings:
- match:
organization: "Acme*"
org_unit: "Engineering"
tenant: acme-engineering
roles: [admin, writer]
- match:
san_email: "*@partner.com"
tenant: partner-org
roles: [reader]
# Analysis
analysis:
engine:
task_timeout: 5m # (0, 30m]; fail a stage when neither its own results nor any worker progress arrive for this long
max_retries: 3 # Redispatches of a failed task
retry_backoff: 1s # Base delay before a redispatch
classification:
enabled: true # Runs the classify analyzer; embedding needs it
model: "" # Inherits from llm.default_model
max_text_length: 2000
temperature: 0.0
max_tokens: 20
embedding:
enabled: true # Runs the embedding analyzer; needs classification; requires and relationship need it
models: ["snowflake-arctic-embed2"] # Embedding model names; storage is model-agnostic (see docs/dev/embeddings.md), switching models needs no schema migration
max_chars: 1500 # Max runes before truncation
batch_size: 50 # Controls per batch call
relationship: # The analyzer whose results are a job's mappings
enabled: false # Off in the compiled defaults (deploy/config.yaml turns it on); needs embedding and an enabled candidates generator
models: [] # Panel models; required when enabled
confidence_weights: # Weight of each vote's reported CONFIDENCE in a mapping's confidence; each (0, 1], high >= medium >= low (global-only)
high: 1.0
medium: 0.67
low: 0.33 # Also used for a vote that reports no CONFIDENCE
requires:
enabled: false # Off in the compiled defaults (deploy/config.yaml turns it on); needs embedding and an enabled candidates generator; finds REQUIRES edges, which are not mappings
models: [] # Panel models; required when enabled
samples_per_model: 1 # Votes per model per pair; odd unless allow_even_samples
allow_even_samples: false # Allow an even samples_per_model
sampling_temperature: 0.3 # [0, 2]
max_tokens: 300 # Max tokens per LLM reply
consensus_threshold: 0.66 # [0.5, 1]; minimum confidence fraction
max_error_rate: 0.34 # [0, 1]; maximum fraction of votes that can fail
max_source_chars: 1500 # Max runes of source control text
max_target_chars: 800 # Max runes of target control text
candidates:
min_embedding_coverage: 0.8 # Minimum fraction of a job's non-section controls that have a statement and must have an embedding before candidate generation runs; below this, the candidate_generation stage fails the job (fail-closed)
embed_model: "snowflake-arctic-embed2" # Embedding model candidate generation reads; must be one of analysis.embedding.models
max_per_source: 10 # Candidate pairs kept per source control after rank fusion, and so the pairs each pair analyzer (requires, relationship) analyses per source; positive integer (default 10); each enabled semantic or lexical top_k must not exceed it
generators: # Candidate generators; none by default, and with none enabled no candidate pairs are produced. Enabled generators are fused per source: each ranks its targets by score (best is 1; tied scores share the mean of the positions they span), a target's fused score is the sum of weight / (60 + rank) over the generators that found it, and the top max_per_source by fused score are kept (ties broken by target ID, ascending)
- name: semantic # Most-similar controls by embedding
enabled: true
weight: 1.0 # [0, 1] in rank fusion; 0 or unset means 1.0
config: # Optional; unknown keys are rejected at load
top_k: 10 # Most-similar targets kept per source control; positive integer (default 10)
min_similarity: 0 # Drop targets below this cosine similarity x 100; [0, 100] (default 0)
- name: lexical # Targets ranked by BM25 over control text
enabled: false
weight: 1.0
config:
top_k: 10 # Most-similar targets kept per source control; positive integer (default 10)
- name: keyword # Targets whose text contains a keyword (case-insensitive substring)
enabled: false
weight: 1.0
config:
keywords: [policy, procedure] # Non-empty list of non-empty strings; omit to use the built-in list below
# Built-in list: policy, procedure, plan, program, framework,
# establish, define, designate, inventory, baseline, register, catalogue, catalog, architecture,
# strategy, governance, charter
- name: level # Targets at a higher abstraction level; takes no config keys
enabled: false
weight: 1.0
artifact_adjudication: # `crosscodexd admin adjudicate artifacts`
enabled: false # The command refuses to run until true
models: [] # Panel models; required when enabled
samples_per_model: 3 # Votes per model per pair; odd unless allow_even_samples
allow_even_samples: false # Allow an even samples_per_model
sampling_temperature: 0.3 # [0, 2]; 0 is used with one sample per model
max_tokens: 200 # Max tokens per panel reply
consensus_threshold: 0.66 # [0.5, 1]; below it (or on a split) the pair is undecided
max_error_rate: 0.34 # [0, 1]; more failed votes than this fails the panel (0 disables)
max_attempts: 5 # Failed panels before a pair is abandoned
lease_duration: 15m # How long a run holds each leased pair, and each verdict it or `decide` projects; must be positive even when enabled is false
lease_batch: 10 # Pairs leased per round trip
panel_concurrency: 4 # Completions in flight per pair
retry_base_delay: 1m # Wait after a pair's first failed panel, doubling per failure
retry_max_delay: 24h # Cap on that wait; at least retry_base_delay
result_cache: # LLM result reuse across a tenant's jobs (#243); needs attestation for the tenant
enabled: true # Kill switch; per tenant via tenant_overrides. run start --no-cache skips lookups for one job
lookup_batch_size: 1000 # [1, 10000]; cache keys per lookup query
tenant_overrides: {} # tenant ID → {enabled, lookup_batch_size}
worker:
max_abandoned_collections: 10000 # [1, 100000]; abandoned stages each worker remembers so it can drop their queued LLM tasks; raise it if more stages are abandoned within one stage_timeout; per worker host (global-only)
max_in_flight: 1 # [1, 256]; tasks each worker runs at once; match the model server's parallel slots, and keep llm.timeout covering the time a request queues there; cuts wall time, not LLM calls; per worker host (global-only)
pipeline:
stage_timeout: 24h # (0, 168h]; hard cap on each analyzer stage, which worker progress never extends; fails a stage whose task was lost; worker hosts must set the same or a larger value (they keep abandons that long)
graph:
max_bulk_edges: 1000 # Most edges per BulkCreateEdges request, range [1, 10000]; larger requests are rejected (global-only)
synthesis:
confidence_threshold: 0.5 # Minimum confidence for viable mappings (see "Confidence" under "Read a job's mappings")
max_mappings_per_control: 10 # Maximum mappings per source control
viability:
type_mismatch_factor: 0.8 # Penalty for different classification types
skip_level_factor: 0.7 # Penalty for non-adjacent abstraction levels
integral_to_factor: 1.1 # Boost for INTEGRAL_TO contribution type
assessment:
iqr_good: 20.0 # IQR threshold for good embedding spread
iqr_poor: 10.0 # IQR threshold for poor embedding spread
no_rel_high: 0.97 # NO_RELATIONSHIP rate upper bound
no_rel_low: 0.80 # NO_RELATIONSHIP rate lower bound
contested_warn: 0.20 # Contested pairs warning threshold
actionable_warn: 0.30 # Actionable coverage warning thresholdanalysis.classification.enabled, analysis.embedding.enabled, analysis.artifacts.enabled, analysis.requires.enabled and analysis.relationship.enabled decide which analyzers a job runs. A disabled analyzer gets no job stage, never runs and makes no LLM calls. The defaults enable classification and embedding only, so a stock deployment classifies and embeds controls but produces no mappings: mappings come from the relationship analyzer, which is off until you set analysis.relationship.enabled: true, its models, and at least one enabled analysis.candidates.generators entry. The compose stack's deploy/config.yaml turns mapping on: it enables relationship (3 samples per pair) and requires on the gateway's default alias, with the semantic generator (see deploy/README.md). crosscodex run status <job-id> lists the analyzers a job runs and says when it has no relationship analyzer.
The configuration does not load if an enabled analyzer cannot run: embedding without classification, requires or relationship without embedding, or requires or relationship with no enabled candidate generator (it would analyse zero pairs). Each error names the setting to change.
A job runs the analyzers enabled when it was created. If they change before the job is resumed (after a daemon restart), the job fails before any analyzer runs, and no stage is left running. The error names each analyzer whose setting differs from when the job was created. A failed job is not resumed, so start a new job with crosscodex run start --source <catalog-id> --target <catalog-id>; to give it the analyzers the old job had, first change the settings the error names.
The CLI recognizes these environment variables:
| Variable | Purpose | Default |
|---|---|---|
CROSSCODEX_ENDPOINT |
daemon address | localhost:50051 |
CROSSCODEX_COLOR |
Force color output (1) or disable (0) |
Auto-detect (isatty) |
CROSSCODEX_LOGLEVEL |
Log level (debug/info/warn/error) |
warn |
NO_COLOR |
Disable color output (standard convention) | — |
Persistent flags override the configured log level for a single invocation:
-v,--verbose— set log level toinfo(repeat-vvfordebug)--debug— set log level todebug(implies--verbose; wins if combined)
Precedence (highest first): --debug/-vv → -v → logging.level
(config file or CROSSCODEX_LOGLEVEL) → default warn. The resolved level
applies to both the CLI and the auto-started embedded daemon.
CrossCodex uses a Go monorepo with separate repositories for Python ingestion and TypeScript UI:
crosscodex/ # Main monorepo
api/proto/ # Protobuf definitions
pkg/ # Public SDK packages
cmd/ # CLI and daemon binaries
internal/ # Service implementations
deploy/ # Deployment manifests (compose stack, see deploy/README.md)
- Go >= 1.26 -- see
go.modfor exact version - Task (taskfile.dev) -- install via
go install github.com/go-task/task/v3/cmd/task@latest - Buf (buf.build) -- for protobuf code generation
- Container engine (podman or docker) -- for integration tests only
# Build all binaries
task build
# Run all tests
task test
# Run unit tests only
task test:unit
# Lint
task lint
# Generate protobuf code
task generateRun task --list for all available commands including integration tests and development utilities.
| Test Type | Framework | Status |
|---|---|---|
| Unit | Ginkgo/Gomega (BDD) | Available (task test:unit) |
| Integration | Go testing + containers | Available (task test:integration:all) |
| E2E | Venom | Available (task test:e2e) |
| Fuzz | Go native fuzzing | Available (task test:fuzz) |
task test:fuzz fuzzes every Fuzz* target for FUZZ_TIME each (default 10s). It runs one worker per CPU core, so expect it to occupy the machine. Narrow it with PKGS, for example task test:fuzz PKGS=./pkg/backup FUZZ_TIME=1m.
task test already replays every target's seed corpus and committed testdata/fuzz/ inputs, so known crashers are regression-tested on every run. CI does not fuzz pull requests or pushes, because fuzzing takes too long for a PR workflow. The nightly Fuzz (nightly) workflow fuzzes every target for 2m each, uploads any new crash inputs as the fuzz-crashers artifact, and (scheduled runs only) opens or comments on a "Nightly fuzz run failed" issue, which is closed by hand once a run passes. GitHub disables scheduled workflows in public repositories after 60 days without repository activity; re-enable it from the Actions tab.
Gateway stress tests verify concurrent upload handling, resource limits, data integrity, and throughput under load.
# Run in-process stress tests
task test:stress # alias for test:stress:unit
task test:stress:unit
# Run throughput benchmark
task test:stress:bench
# Run full-stack round-trip integration suite
task test:stress:e2eBuild tag: Stress tests compile only under -tags stress and are excluded from the default task test / go build ./... path.
E2E requirements: task test:stress:e2e requires a podman runtime (rootless mode). The taskfile sets TMPDIR=/workspace/.test-output/buildtmp for build scratch. The container storage driver is taken from your podman configuration (storage.conf), matching the rest of the integration suite; if your environment has no overlay support, select vfs via storage.conf or STORAGE_DRIVER=vfs rather than relying on the taskfile.
Embedded daemon end-to-end tests exercise startup (PKI, migrations, tenant provisioning, mTLS server), a full catalog round-trip over the mTLS gateway, graceful shutdown, and error paths.
# Run the embedded daemon e2e suite (starts a PostgreSQL container)
task test:integration:embeddedBuild tag: Embedded e2e tests compile only under -tags integration and require TEST_DATABASE_DSN; the suite skips cleanly when it is unset. The podman TMPDIR / storage-driver notes under Stress Tests apply here too (environment-only, not baked into the Taskfile).
Venom E2E suites exercise the crosscodexd daemon as a black box: they start the daemon and its PostgreSQL container, drive inline OSCAL through the mTLS gateway, and assert the resulting AGE graph.
# Run the hermetic E2E suite (Tier A, no LLM required)
task test:e2e # alias for test:e2e:venom
task test:e2e:venom
# Run the opt-in suite that adds real LLM analysis (Tier B)
task test:e2e:venom:llmTiers: test:e2e:venom (Tier A) is hermetic and asserts the OSCAL-to-AGE structural surface without an LLM. test:e2e:venom:llm (Tier B) is opt-in and additionally spins up Ollama and LiteLLM to exercise real LLM analysis.
Requirements: both suites need venom, psql, and pg_isready on the host plus a podman (or docker) runtime; the LLM tier also needs jq. The podman TMPDIR / storage-driver notes under Stress Tests apply here too.
Venom suites in test/tooling/ check the repo's own scripts and Taskfile behaviour: the e2e daemon is stopped before its database is torn down, and the deploy tasks fail with an actionable message when deploy/.env is missing. They need only venom and task, start no containers, and never touch your deploy/.env. CI runs them in the e2e-venom job.
task test:toolingSee CONTRIBUTING.md for development workflow, PR process, and coding standards.
All GitHub Actions workflows follow least-privilege principles:
- Default to no permissions (
permissions: {}) with explicit per-job grants - MegaLinter runs actionlint and other YAML-aware linters for workflow validation
Every layer enforces tenant isolation independently:
| Layer | Mechanism | Purpose |
|---|---|---|
| Gateway | mTLS client certificates, JWT sessions, RBAC | Identity verification |
| Services | Request metadata validation | Context propagation |
| NATS | Tenant-scoped subjects and ACLs | Message isolation |
| PostgreSQL | Row-Level Security policies | Data isolation |
| Object Store | Tenant-prefixed paths, bucket policies | Artifact isolation |
| Graph (AGE) | Separate graph per tenant | Traversal isolation |
| Method | Use Case | How It Works |
|---|---|---|
| X.509 (mTLS) | CLI, service-to-service, automation | Client certificate during TLS handshake |
| GSSAPI (Kerberos) | Enterprise SSO, Active Directory | Kerberos ticket via SPNEGO |
| SAML | Web UI, browser SSO | SAML assertion from IdP |
CrossCodex supports dual builds (standard and FIPS) from the same source:
- FIPS build: BoringCrypto, approved cipher suites only (container image planned — Red Hat UBI base)
- Standard build: Go stdlib crypto (container image planned — distroless base)
- Runtime enforcement:
tls.fips.enabled: truevalidates FIPS compliance
When attestation is enabled for a job's tenant (attestation.enabled, attestation.tenant_overrides), the pipeline records an in-toto attestation of the job in object storage under attestations/<tenant>/<job-id>/:
- Layout (
layout.json): signed before any analyzer runs; one step per enabled analyzer, thensynthesisandgraph. - Links (
links/<step>.json): one signed link per step. Every link records the job,job:<tenant>/<job-id>, with the SHA-256 of the job's stored config, and every layout step expects it, so an attestation cannot be passed off as another job's. An analyzer's link records the SHA-256 of its stored result and of the results it consumed; synthesis records every analyzer's result and its own output (the SHA-256 of the JSON of the result it computed, not of rows an auditor can read back from the database); graph records the synthesis output.candidate_generationhas no link. - Bundle (
bundle.json): the layout and every link, stored only after the chain verifies. The job isCOMPLETEDonly once it is stored.
The job fails instead of completing unattested. Every error names the step and object:
| Failure | Job | What to do |
|---|---|---|
| Layout or a link cannot be signed | Fails (the step's stage too, for synthesis and graph); a layout failure fails it before any analyzer runs | Fix the key pair and restart crosscodexd, then crosscodex run retry --from-failure <job-id> or start a new job. If you fixed it by rotating to a new key pair, start a new job: the links the job already stored were signed with the old key and no longer verify, so a retry fails |
Object storage refuses a write after attestation.storage_retries retries |
Fails | Fix storage, then crosscodex run retry --from-failure <job-id> or start a new job |
| A stored link is missing, does not verify, or is for another job on resume, or the chain does not verify | Fails, with every stored object left as it was; run retry --from-failure refuses it |
Start a new job; check whether objects under attestations/ were changed |
Publishing the bundle to the audit stream is best effort: a failure is logged, since the stored bundle is the record. When attestation is disabled, no signer is built, no attestation object is written, and the daemon logs at startup that jobs are unattested.
To verify a job offline, copy its bundle.json out of object storage and run:
crosscodex results verify bundle.json --public-key deploy/certs/attestation-pub.pem --tenant <tenant> --job-id <job-id>It checks that the bundle is bound to that job, the layout's signature and expiry, every link's signature, one link per layout step, and every digest one step passes to another, and prints the job's config digest the links record. It does not read the database.
See Cryptographic Attestation Guide for the attestation model, trace correlation, and how the pipeline builds the chain.
PostgreSQL with extensions handles all data:
| Store | Extension | Purpose |
|---|---|---|
| Relational | PostgreSQL | Job metadata, catalogs, classifications, tenant config |
| Graph | Apache AGE | Relationship graph, openCypher queries, temporal attributes |
| Vector | pgvector | Embedding similarity search |
Additional storage:
| Store | Technology | Purpose |
|---|---|---|
| Object Store | Local FS / S3 | Documents, embeddings, attestation bundles |
| Message Bus | NATS JetStream | Audit trails, work distribution, service communication |
Built-in observability with OTLP export:
- Traces: Span per stage, span per LLM call, cross-service correlation
- Metrics: Job duration, LLM latency, worker utilization, queue depth
- Logs: Structured logging correlated to trace IDs.
crosscodexdwrites logs to stderr atlogging.level(defaultwarn) inlogging.format(textorjson, defaulttext)
See Telemetry Guide for configuration, Jaeger setup, metrics reference, and instrumentation status.
JetStream provides persistent audit streams:
| Stream | Retention | Content |
|---|---|---|
| Decisions | Indefinite | Final compliance determinations |
| LLM Calls | 90 days | Full prompts, responses, model versions |
| Events | 30 days | Pipeline lifecycle, debugging |
See Audit Streams Guide for provenance headers, message inspection, and trace correlation.
CrossCodex enforces per-data-class retention policy: expired data is archived to
a secondary store and then purged from the primary store, unless a legal hold
covers it. Every operation is scoped to a single tenant. Completed jobs and
catalogs are purged as aggregates with their dependents: an expired catalog is
archived as {catalog, classifications, controls} and then purged together with
its classifications, controls, and embeddings. The archive deliberately
omits embeddings (derived/regenerable, so purge-only); their lifetime is bound
to the parent catalog's retention rather than an independent
retention.tiers.embeddings tier. For how the engine works internally, see the
Retention Lifecycle guide.
All admin commands take --tenant, which must match the tenant the caller is
authenticated for; the server authorizes every request against the caller's
tenant.
# Scan and enforce the retention lifecycle for a tenant.
crosscodex admin retention scan --tenant acme
# Preview without mutating any data.
crosscodex admin retention scan --tenant acme --dry-run
# Show retention-eligible counts and the active-hold total.
crosscodex admin retention stats --tenant acmeA legal hold prevents matching data from being purged for as long as it is
active. An empty scope field is a wildcard (matches everything), so a hold with
no --job/--created-before covers the whole tenant.
# Create a hold. --name is required. The server forces the hold's tenant to the
# caller's tenant, so a hold can never be planted for another tenant.
crosscodex admin hold create --tenant acme --name "litigation-2026-014"
# Narrow a hold to one job and/or only data created before a cutoff (RFC3339),
# and optionally auto-release it at a future timestamp.
crosscodex admin hold create --tenant acme --name "audit-hold" \
--job job-abc123 \
--created-before 2026-01-01T00:00:00Z \
--expires-at 2026-12-31T00:00:00Z
# List active holds (alias: ls).
crosscodex admin hold list --tenant acme
# Release a hold by name. Released holds remain visible in `list` as an audit
# trail but no longer block purges.
crosscodex admin hold release --tenant acme --name "litigation-2026-014"crosscodexd never migrates. Before the first start, and after every upgrade, run:
crosscodexd migrateIt connects as the schema owner (database.migration_dsn, env CROSSCODEX_DATABASE_MIGRATION_DSN), applies pending migrations, sets the passwords of app_user, graph_user, purge_user, tenant_admin and backup_user from the passwords in database.dsn, database.graph_dsn, database.purge_dsn, database.tenant_admin_dsn and backup.dsn, and provisions tenants.default_tenant. It is idempotent. The daemon and the crosscodexd admin commands that open the application database (retention scan, reconcile artifacts, and adjudicate artifacts including review, show and decide) refuse a superuser, BYPASSRLS or table-owner connection, and a schema that is not at the latest migration. admin tenant, admin identity (database.tenant_admin_dsn) and admin backup (backup.dsn) do not run these checks. See Database Migrations. The embedded crosscodex CLI is unchanged: it migrates and provisions with the DSN it is given.
For on-host operation (e.g. an external scheduler running next to the daemon),
crosscodexd exposes in-process one-shot commands. Like healthcheck and
version, they are unauthenticated by design. They run locally against the
daemon's configured resources and select their tenant with --tenant, or run
for every active tenant with --all-tenants (retention scan --all-tenants
covers every tenant, deleted and suspended included, because a deleted tenant's
job data still follows retention):
crosscodexd admin retention scan --tenant acme
crosscodexd admin retention scan --tenant acme --dry-run
crosscodexd admin retention scan --all-tenants
crosscodexd admin reconcile artifacts --tenant acme
crosscodexd admin reconcile artifacts --tenant acme --dry-run --max-edges 10000
crosscodexd admin reconcile artifacts --all-tenants --dry-run
crosscodexd admin adjudicate artifacts --tenant acme
crosscodexd admin adjudicate artifacts --tenant acme --dry-run
crosscodexd admin adjudicate artifacts --tenant acme --reproject
crosscodexd admin adjudicate artifacts --all-tenants --max-pairs 500
crosscodexd admin adjudicate artifacts review --tenant acme
crosscodexd admin adjudicate artifacts show --tenant acme --pair 3f9c0a1b2d4e5f60
crosscodexd admin adjudicate artifacts decide --tenant acme --pair 3f9c0a1b2d4e5f60 --verdict rejected --reviewer jdoe --expect <token>retention scan and an adjudicate artifacts run without --dry-run or
--reproject publish audit events, so they attach to the daemon's NATS server. They refuse to start
when nats.url is empty, because embedded mode would start a second JetStream
server on the daemon's store directory. They also refuse a server whose audit
streams do not exist yet, and never create or update a stream, so a one-shot
cannot change the daemon's stream retention. Start the daemon first.
--all-tenants and --tenant are mutually exclusive, and adjudicate artifacts review and decide take --tenant only. With --all-tenants:
- Tenant list: the command lists tenants through
database.tenant_admin_dsnand skips suspended ones. It refuses to run when that DSN is unset. - Order and output: tenants run one at a time in ID order, on one set of
connections opened for the whole run. Each report on stdout is preceded by a
tenant: <id>line. - Failures: a failing tenant does not stop the rest. Its error goes to
stderr, and once every tenant has run the command prints
<n> of <m> tenants failed: <ids>and exits1. If the command's connections or configuration cannot be opened at all (for example the database, graph or NATS is unreachable, or the adjudicator's prompts or LLM client cannot be set up), the command prints that one error and exits1without running any tenant. - No active tenants (
retention scan: no tenants): the command says so on stderr and exits0.
reconcile artifacts links equivalent artifacts across controls and catalogs:
- Exact duplicates: artifacts with the same type and normalized name join one
ArtifactGroupnode throughMEMBER_OFedges. - Overlapping names: groups of one type whose names overlap (token-set overlap of
0.6 or more) get a scored
SAME_ASedge.
It never merges or deletes nodes. Every run is a full pass, and re-running it over
an unchanged graph writes no graph elements (it re-queues open pairs for
adjudication, which leaves already-queued pairs unchanged), so it is safe to
schedule (cron, Kubernetes CronJob) once per tenant or once with --all-tenants.
- Overlapping runs: do not run two reconciles for the same tenant at the same time.
--max-edges: defaults to 50000. A run that would add moreSAME_ASedges fails before writing anything; this usually means an over-broad artifact name.- Exit codes: 0 on success, 1 on failure, 2 on usage errors.
--dry-run: thenew_*counts report what the run would have written.
reconcile artifacts also queues every overlapping-name pair for
adjudication in PostgreSQL (artifact_pair_verdicts). It skips pairs that are
closed, meaning confirmed or rejected, and reports them in its closed row. It
therefore needs the database as well as the graph.
adjudicate artifacts asks an LLM panel whether each queued pair really
denotes one artifact. Token overlap over-matches containment: "Policy" scores
1.0 against "Password Policy".
- Confirmed: the panel says yes. The scored edge is superseded by a
SAME_ASedge withdetermination_typellm_panel. - Rejected: the panel says no. The scored edge is superseded and nothing replaces it.
- Undecided: the panel split or fell below
analysis.artifact_adjudication.consensus_threshold. Automation does not retry the pair; only a human verdict can change it. - Failed: too many calls failed or replies did not parse. The pair is
retried after a backoff (
retry_base_delay, doubling per failure up toretry_max_delay; 1 minute and 24 hours by default) and is abandoned aftermax_attemptsfailures. - LLM unavailable: if every call for a pair fails because the LLM gateway
is unavailable or not configured, the rate limit is hit, the credentials
cannot be resolved or are rejected, or the model is not allowed, the
tenant's run stops, the command exits
1, and the pair is not charged a failure. An outage or a wrong key therefore cannot abandon the queue; fix the cause and run the command again. With--all-tenants, an outage stops each tenant's run in turn: every tenant fails fast on its first pair, no attempts are charged, and the command exits1naming them. - Human verdicts win: automation never overwrites a pair a human decided
with
decide(see Human verdicts). If one lands while the panel is running, the panel's opinion is stored as dissent and the graph keeps the human verdict.
Other behavior:
- Scheduling: run
reconcile artifactsfirst, thenadjudicate artifacts, each on its own schedule. Concurrent adjudicators are safe: each pair is leased to one run at a time, and a pair whose lease expired before its panel started is skipped and retried by a later run. --max-pairs: the most pairs sent to the panel per tenant per run (default 200), which bounds LLM spend. Every pair costslen(models) × samples_per_modelcompletions.- Report:
reset(with--reproject: verdicts marked for re-projection),recovered(verdicts from earlier runs written to the graph now),orphaned(confirmations whose groups are gone from the graph),leased,confirmed,rejected,undecided,failed,abandoned,dissent(a human decided the pair meanwhile),stale(another run decided it meanwhile),expired(lease expired before the panel started),fenced(a newer verdict replaced the one being written to the graph, or another run took over the write; the newer verdict is written next) andbusy(another run is writing the pair's verdict to the graph and finishes it). - Audit: every panel call publishes an LLM audit event to the
AUDIT_LLMstream (see Audit Trails), so a real run also attaches to the daemon's NATS server (see above). A tenant run's events share the job IDartifact-adjudication-<uuid>, logged asrun.id. --dry-run: reportspending,dueandunprojectedcounts without leasing, calling the LLM or writing verdicts or graph elements. It opens no LLM client and no NATS connection, so it runs on a host without an LLM gateway or NATS (the configuration check below still applies). Like every command that opens the database, it does not migrate: it refuses an outdated schema and points atcrosscodexd migrate.--reproject: with--tenantonly. Marks the tenant's projected confirmed and rejected verdicts, LLM-panel and human alike, unprojected and writes them back to the graph (resetcounts them), then stops: it leases no pairs and makes no LLM calls, so like--dry-runit needs no LLM gateway or NATS. Use it after rebuilding the tenant's graph, then run without--reprojectto adjudicate due pairs. It cannot be combined with--dry-run,--max-pairsor--all-tenants.- Configuration: the command refuses to run until
analysis.artifact_adjudication.enabledistrueandmodelsis set (see Key Configuration Examples).reviewanddecidedo not. - Prompt: the panel uses the
artifact_same_asprompt. Like any other prompt, a YAML file with thatnamein$XDG_DATA_HOME/crosscodex/prompts/or in a directory listed inprompt.layer_pathsoverrides it. Artifact names and samples come from analyzed catalogs, so they are flattened to one line, stripped of invisible characters, capped and escaped before they reach the prompt. - Exit codes: 0 on success, 1 on failure, 2 on usage errors.
- Clocks: leases, and the claims that reserve a verdict while it is
written to the graph, are timed by the clock of the host running the
command (
adjudicateordecide). Keep every such host in sync (NTP); otherwise a skewed host can take a pair whose lease or claim has not yet expired. A host whose clock runs far ahead holds its claims that far into the future: until then every run skips the pair (--dry-runstill counts it asunprojected) and its verdict does not reach the graph.--reprojectdoes not clear a claim; clearing one early takes a manual database update of that pair's row.
After rebuilding a tenant's graph, re-run the reconciler, then run
crosscodexd admin adjudicate artifacts --tenant <tenant> --reproject to write
the tenant's verdicts back to the graph, human verdicts included.
crosscodexd admin backup captures, lists, verifies and restores PostgreSQL (WAL-G base backups plus WAL archiving for PITR), tenant object stores, and the JetStream audit streams:
crosscodexd admin backup run
crosscodexd admin backup list
crosscodexd admin backup verify [--point ID]
crosscodexd admin backup restore --point IDbackup run— captures Postgres, every tenant's objects and the audit streams into a new backup point.backup list— lists complete backup points (and incomplete ones separately).backup verify— checks blob/snapshot checksums, WAL-G base backup presence, WAL continuity and staleness.backup restore— restores objects and audit streams from a backup point; Postgres is restored separately withcrosscodex-db-restore.
Exit codes: 0 ok, 1 failure or stale, 2 usage error. See deploy/README.md, Backups for setup, the run/verify/restore procedures, and PITR.
adjudicate artifacts review, show and decide let a person settle the
SAME_AS pairs the panel could not, or overrule a panel verdict. The workflow
has three steps: list, read, decide.
crosscodexd admin adjudicate artifacts review --tenant acme --limit 20
crosscodexd admin adjudicate artifacts show --tenant acme --pair 3f9c0a1b2d4e5f60
crosscodexd admin adjudicate artifacts decide --tenant acme --pair 3f9c0a1b2d4e5f60 --verdict confirmed --reviewer jdoe --expect <token>1. review lists up to --limit pairs (default 100), oldest first, that
need a person:
undecidedandabandonedpairs;- human verdicts that a later panel run disagreed with (its opinion is stored as dissent).
Its columns are PAIR (the pair ref), STATUS, BY (who decided it:
llm_panel, human, or token_overlap for an abandoned pair), VOTES,
TYPE, LOW and HIGH (the two artifact groups' names), SCORE (token
overlap) and DISSENT (the dissenting verdict, or -). VOTES is the
current panel verdict's split:
2/3 same: two of three valid votes said the groups are the same;2/3 same (1 failed): the same, with one more vote that failed and did not count;unreadable: the stored votes cannot be decoded;showprints the error;-: no panel verdict (an abandoned or human-decided pair).
Names come from analyzed catalogs, so they are flattened to one line,
stripped of control and invisible characters and capped before they are
printed. With nothing to review it prints no pairs need review.
2. show prints everything a reviewer needs to decide one pair, so the
decision rests on the panel's reasoning and not only its vote count:
PAIR 3f9c0a1b2d4e5f60
TYPE policy
LOW Access Control Policy
HIGH Access Policy
SCORE 0.71
STATUS undecided
BY llm_panel / artifact-adjudicator
CONFIDENCE 0.50
DECIDED 2026-10-13T09:14:02.51Z
PROMPT artifact_same_as v1.1.0
VOTES (1 same, 1 distinct, 1 failed; 2 valid of 3)
voter-a same high
Both titles name the organization's access control policy ...
voter-b distinct medium
The second document scopes only remote access ...
voter-c failed -
PANEL DISSENT
none
RELATED PAIRS (showing 1 of 1 that share a group)
7d21e0c4b9a85f36 confirmed llm_panel Access Policy | Access Control Standard
EXPECT 9be41c07d3a2f685
To decide: crosscodexd admin adjudicate artifacts decide --tenant acme --pair 3f9c0a1b2d4e5f60 --verdict <confirmed|rejected> --reviewer <id> --expect 9be41c07d3a2f685
The example is illustrative; values vary. Its sections:
- Header: the pair, its artifact groups, the token-overlap
SCORE, the currentSTATUS, who decided it (BYis determination type / decider),CONFIDENCE,DECIDEDtime and the prompt that produced the panel verdict. VOTES: the split, then each vote's voter, decision (same,distinctorfailed), confidence and justification.PANEL DISSENT: on a human verdict, the panel's opinion, labelledoverridden by the human verdict(the panel verdict the person replaced),given after the human verdict (disputed)(a later panel run disagreed; the pair is inreview) orgiven after the human verdict (agrees). A dissent that cannot be decoded is labelledunreadable. Otherwisenone.LAST ERROR: shown only when the last panel attempt failed.RELATED PAIRS: other pairs that share a group with this one, with their status and decider.--related <n>sets how many to list (default 20, must be positive); the heading says how many exist in total.EXPECT: the tokendecideneeds, and a ready-to-editdecidecommand.
Everything printed from stored data is cleaned the same way as review.
show changes nothing. If the stored votes cannot be decoded, it prints the
pair with an unreadable VOTES section and the EXPECT token anyway, then
exits 1 with the error; the pair can still be decided.
3. decide records the verdict, using the EXPECT token from show
(see the --expect item below).
To change a verdict the panel already reached, find the pair with --name:
crosscodexd admin adjudicate artifacts review --tenant acme --name "access policy"
crosscodexd admin adjudicate artifacts decide --tenant acme --pair 7d21e0c4b9a85f36 --verdict rejected --reviewer jdoe --expect <token>review --name <text> lists, instead of the review queue, up to --limit
pairs in any status (pending, confirmed, rejected, undecided, abandoned),
whoever decided them, whose LOW or HIGH name contains the text. Matching
ignores case, and %, _ and \ match only themselves. The text must be
1–200 bytes of plain text (no control or invisible characters). Same columns,
oldest first; on a pair a person decided, DISSENT shows the panel's
opinion, such as the verdict the person overrode. With no match it prints no pairs match "<text>".
decide records --verdict confirmed or --verdict rejected on one pair:
- Pair ref:
--pairis thePAIRcolumn ofreview, 16 lowercase hex characters derived from the pair's internal key. It is opaque and safe to paste; the key itself embeds artifact names and is never printed. A ref that matches no pair of the tenant records nothing.reviewlists only pairs that need a person; usereview --nameto find the ref of any other pair, such as one the panel confirmed or rejected. --expect: required. TheEXPECTvalueshowprinted for this pair. It identifies the state you read: the verdict, who decided it, its confidence, decision time, score, votes and dissent. If another person or a panel run changed the pair since,deciderecords nothing and sayspair <ref> changed since you viewed it: it is now .... Runshowagain, read the new reasoning, and decide with the new token. Queue bookkeeping (retries, leases, projection) does not change the token. Repeating a verdict the pair already holds from a person is accepted with any valid token. Without--expect,decideexits 2 and names theshowcommand to run.--reviewer: required. The ID of the person recording the verdict, stored asdetermined_byon the verdict and on a confirmation's graph edge (at most 256 bytes, no control or invisible characters). It must be a registered, non-deleted reviewer of the tenant (crosscodexd admin reviewer add, see Tenant administration); otherwisedeciderecords nothing and names the command to run. It is not authenticated: like everycrosscodexd admincommand,decidetrusts whoever can run it on the host.- Authority: a human verdict outranks the panel, and automation never
changes it. Any pair can be decided, including one the panel decided or
another person decided; a panel verdict it overrides is kept as dissent.
Repeating a pair's current human verdict changes nothing and prints
unchanged(the originaldetermined_byis kept), unless a later panel run disagreed with it (the pair is inreview). Thendecidewith the same verdict re-affirms it: it records--reviewerand the new decision time, writes a fresh graph edge for a confirmation (the earlier human edge ends at the new decision time), printsre-affirmed over the panel's later dissent, and the pair leavesreview. The panel's opinion stays as dissent. - Graph:
decidewrites the verdict to the graph at once. A confirmation adds aSAME_ASedge withdetermination_typehumanand confidence 1; either verdict supersedes every other currentSAME_ASedge between the two groups (the token-overlap candidate, a panel edge, an earlier human edge). It then prints the projection report:recovered(verdicts written to the graph),orphaned(confirmations whose groups are gone from the graph),fencedandbusy(as foradjudicate artifacts). - Delays: if an
adjudicaterun is writing the pair's previous verdict to the graph at that moment,deciderecords the verdict, prints that it reaches the graph when that run finishes or by re-runningdecideafter a printed time, and leaves the pair to that run. If its own graph write fails, it says the verdict is recorded and when to re-run (up tolease_durationlater). Re-runningdecidewith the same arguments never records the verdict twice; it finishes the graph write. - Requirements: neither command needs
analysis.artifact_adjudication.enabled, an LLM gateway or NATS; they open only the database and the graph.lease_durationbounds how long one write may hold a pair, so it is validated (must be positive) even when adjudication is disabled, and a non-positive value fails config load. - Rebuilds:
adjudicate artifacts --reprojectwrites human verdicts back to a rebuilt graph along with the panel's. - Exit codes: 0 on success, 1 on failure, 2 on usage errors.
crosscodexd admin tenant create --tenant acme --display-name "Acme Corp"
crosscodexd admin tenant list
crosscodexd admin tenant inspect --tenant acme
crosscodexd admin tenant suspend --tenant acme
crosscodexd admin tenant resume --tenant acme
crosscodexd admin tenant import --file tenants.yamlThese commands connect as the least-privilege tenant_admin role, never the
app or owner credential. Set database.tenant_admin_dsn (or
CROSSCODEX_DATABASE_TENANT_ADMIN_DSN) to a DSN for that role.
crosscodexd migrate creates the role; set its password yourself. The commands
refuse to run while the DSN is empty.
-
create: creates the tenant and its graph, or renames an existing one without changing its status. Printscreatedorupdated. Display names must not be blank or contain control characters. -
suspend/resume: while suspended, the gateway refuses every request from the tenant except health checks, untilresume. Queued pipeline work still drains. Printssuspendedoralready suspended, andresumedoralready active. Both refuse a deleted tenant; runtenant restorefirst. -
import: applies a YAML file in one transaction, so either every tenant is created or renamed, or none is. Renaming a deleted tenant is refused, as withcreate:tenants: - tenant_id: acme display_name: Acme Corp
Tenants and reviewers are never removed, because their judgements are permanent. They are soft-deleted, and a deleted one can be hidden behind a pseudonym:
crosscodexd admin tenant delete --tenant acme --by alice --reason "contract ended"
crosscodexd admin tenant restore --tenant acme --by alice --reason "contract renewed"
crosscodexd admin tenant hide --tenant acme --by alice --reason "customer requested"
crosscodexd admin tenant unhide --tenant acme --by alice --reason "audit follow-up"
crosscodexd admin reviewer add --tenant acme --reviewer jdoe --by alice --reason "joined review team"
crosscodexd admin reviewer list --tenant acme
crosscodexd admin reviewer delete --tenant acme --reviewer jdoe --by alice --reason "left the team"
crosscodexd admin reviewer restore --tenant acme --reviewer jdoe --by alice --reason "rejoined"
crosscodexd admin reviewer hide --tenant acme --reviewer jdoe --by alice --reason "privacy request"
crosscodexd admin reviewer unhide --tenant acme --reviewer jdoe --by alice --reason "audit follow-up"
crosscodexd admin identity trace --pseudonym hidden-reviewer-0123456789abcdef --by alice --reason "audit query"
crosscodexd admin identity export --out /secure/crosscodex-audit-2026-10-22.jsonlThese also connect as tenant_admin. Every command except reviewer list
and identity export changes or reveals an identity, so it requires --by (who is acting, 1-256
bytes) and --reason (1-500 bytes), is signed with the attestation keys
(attestation.private_key_path and attestation.public_key_path) and is
recorded in the database's audit log. Without the keys or
database.tenant_admin_dsn the commands refuse to run and change nothing.
tenant delete: the tenant's data and verdicts are kept, but the gateway and every service refuse it. Retention still applies to its job data.createcannot rename a deleted tenant.tenant restore: brings a deleted tenant back assuspended; runcrosscodexd admin tenant resumeto reactivate it. A hidden tenant must be unhidden first.reviewer add: registers a reviewer soadjudicate artifacts decideaccepts them. Printsadded, oralready registered. A deleted reviewer is restored withreviewer restore, not added again.reviewer delete/restore: a deleted reviewer's verdicts stay, butdeciderefuses new ones from them. Restoring makes them active again. On a deleted tenant,reviewer addandrestoreare refused;delete,hideandunhidestill work.hide/unhide: only a deleted tenant or reviewer can be hidden.hideprints the pseudonym (hidden-tenant-…orhidden-reviewer-…), which then replaces the ID intenant list,reviewer list,retention scan --all-tenantsandadjudicate artifacts show. The pseudonym stays the same across unhide and hide again. Hiding is not erasure: the real ID remains in stored verdicts, graph edges and audit records. Tenant and reviewer IDs starting withhidden-are reserved.identity trace: prints the kind, tenant, identity, status and hidden flag behind a pseudonym. The lookup itself is recorded.identity export: writes the whole audit log to--outas JSON Lines, one event per line inevent_idorder, and printsexported <n> identity events to <path>. Each line hasevent_id,occurred_at(RFC 3339, UTC),action,kind,tenant_id,identity_id,actor,db_user,reason,requestandattestation(base64 of the stored bytes, so their SHA-256 can be recomputed) andattestation_sha256. The file is sensitive: it holds the real IDs of hidden tenants and reviewers, so store it securely. It is created with mode0600and never overwrites an existing file. It is written as<path>.partialfirst and moved into place only when complete; a failed export removes the partial file. It reads only, so it needs no--by,--reasonor attestation keys. Run it before decommissioning a database (see Decommissioning).- Exit codes: 0 on success, 1 on failure, 2 on usage errors.
retention:
# Default retention per data class. Each value is "indefinite", "<n>y",
# "<n>d", or any Go duration (e.g. "720h"). An empty value means "keep".
defaults:
attestation: indefinite
job_results: 90d
catalogs: 2y
embeddings: 90d
# Audit-class tiers are accepted for config completeness but do NOT drive
# engine purges — the retention engine never purges audit data. Audit
# retention is enforced by JetStream stream MaxAge via
# nats.streams.audit_llm_retention / audit_events_retention
# (see docs/dev/audit-streams.md).
audit_decisions: indefinite
audit_llm: 90d
audit_events: 30d
# Optional cron expression for the in-daemon scheduler. Empty = disabled
# (drive scans externally via the one-shot instead; see below).
scan_schedule: ""
# Archive backend for data moved out of the primary store before purge.
archive:
backend: local # local | s3 | "" (disabled)
local:
path: /var/lib/crosscodex/archive
s3:
bucket: crosscodex-archive
storage_class: GLACIER
# Per-tenant overrides. Non-empty fields replace the matching default;
# empty fields fall back to the default.
tenants:
acme:
job_results: 1y
database:
# Purge runs under a dedicated, RLS-scoped purge_user role that the
# delete-path immutability triggers accept. When purge_dsn is unset, scans
# still run but purges fail closed rather than deleting under the wrong role.
purge_dsn: "${PURGE_DATABASE_DSN}" # e.g. postgres://purge_user:...@localhost:5432/crosscodex
backup:
# postgres:// URL for backup_user (LOGIN REPLICATION).
# Required when destination.backend is set. Never logged.
# CROSSCODEX_BACKUP_DSN sets this field from the environment.
dsn: ""
destination:
backend: "" # local | s3 | "" (backup disabled; fails closed)
local:
path: "" # absolute; must not overlap storage.objects.base_path
s3:
bucket: ""
region: ""
endpoint: "" # S3-compatible stores; enables path-style addressing
storage_class: ""
max_age:
# `admin backup verify` reports a store stale once its newest backup
# point is older than this. Each value must be greater than zero.
postgres: 26h
objects: 26h
nats: 26hWhen retention.scan_schedule is left empty, drive scans from an external
scheduler using the daemon one-shot. Example crontab entry running a nightly
scan of every tenant at 02:00:
0 2 * * * crosscodexd admin retention scan --all-tenants >> /var/log/crosscodex/retention.log 2>&1To scan only one tenant, use --tenant acme in place of --all-tenants. Do not
schedule both at the same time: non-dry-run scans share one global lock, so one
of them would fail every night (see Backups).
Artifact deduplication runs the same way. Reconcile first, then adjudicate:
0 3 * * * crosscodexd admin reconcile artifacts --all-tenants >> /var/log/crosscodex/reconcile.log 2>&1
30 3 * * * crosscodexd admin adjudicate artifacts --all-tenants >> /var/log/crosscodex/adjudicate.log 2>&1To fully remove CrossCodex:
-
Remove the binary (location depends on your install method):
rm $(which crosscodex) -
Remove configuration:
rm -rf "${XDG_CONFIG_HOME:-$HOME/.config}/crosscodex" -
Remove data (prompt layers):
rm -rf "${XDG_DATA_HOME:-$HOME/.local/share}/crosscodex" -
Remove state (daemon PID, embedded TLS certificates):
rm -rf "${XDG_STATE_HOME:-$HOME/.local/state}/crosscodex" -
Remove project-level configuration (run in each project directory):
rm -rf .crosscodex/
-
Remove shell completions (if installed):
rm -f ~/.bash_completion.d/crosscodex
- Issues: github.com/complytime-labs/crosscodex/issues
- Discussions: github.com/complytime-labs/crosscodex/discussions
- License: Apache 2.0
- CrossCodex Ingestion - Python document conversion service
- CrossCodex UI - React web interface
- Docling - Document extraction library
- Apache AGE - Graph extension for PostgreSQL
- NATS - Cloud native messaging system
- in-toto - Supply chain attestation framework