---
title: "Developer"
manual: "nr-vault"
version: "1.0"
permalink: "https://docs.typo3.org/permalink/netresearch/nr-vault:developer@1.0"
source: "Developer/Index.rst"
rendered: "2026-09-18T07:37:50+00:00"
---

# Developer {#developer-1}

-   [API](https://docs.typo3.org/permalink/netresearch/nr-vault:api@1.0)
-   [CLI commands](https://docs.typo3.org/permalink/netresearch/nr-vault:cli-commands@1.0)
-   [TCA integration](https://docs.typo3.org/permalink/netresearch/nr-vault:tca-integration-1@1.0)
-   [Secure Outbound](https://docs.typo3.org/permalink/netresearch/nr-vault:secure-outbound@1.0)
-   [Technical actor context](https://docs.typo3.org/permalink/netresearch/nr-vault:technical-actor-context@1.0)
-   [Architecture decision records](https://docs.typo3.org/permalink/netresearch/nr-vault:architecture-decision-records@1.0)
    -   [ADR-001: UUID v7 for secret identifiers](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-001-uuid-v7-for-secret-identifiers@1.0)
    -   [ADR-002: Envelope encryption](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-002-envelope-encryption-1@1.0)
    -   [ADR-003: Master key management](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-003-master-key-management-1@1.0)
    -   [ADR-004: TCA integration](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-004-tca-integration-1@1.0)
    -   [ADR-005: Access control](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-005-access-control-1@1.0)
    -   [ADR-006: Audit logging](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-006-audit-logging-1@1.0)
    -   [ADR-007: Secret metadata](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-007-secret-metadata-1@1.0)
    -   [ADR-008: HTTP client](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-008-http-client-1@1.0)
    -   [ADR-009: Extension configuration secrets](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-009-extension-configuration-secrets-1@1.0)
    -   [ADR-010: Secure Outbound inside nr-vault](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-010-secure-outbound-inside-nr-vault@1.0)
    -   [ADR-011: Credential Sets data model](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-011-credential-sets-data-model@1.0)
    -   [ADR-012: SecureHttpClient API and transports](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-012-securehttpclient-api-and-transports@1.0)
    -   [ADR-013: Rust FFI preload-only mode](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-013-rust-ffi-preload-only-mode@1.0)
    -   [ADR-014: Packaging native artifacts](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-014-packaging-native-artifacts@1.0)
    -   [ADR-015: HTTP/3 feature flag](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-015-http-3-feature-flag@1.0)
    -   [ADR-016: Sidecar daemon option](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-016-sidecar-daemon-option@1.0)
    -   [ADR-017: Audit metadata retention](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-017-audit-metadata-retention-1@1.0)
    -   [ADR-018: FlexForm secret lifecycle management](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-018-flexform-secret-lifecycle-management@1.0)
    -   [ADR-019: Configurable audit read logging](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-019-configurable-audit-read-logging-1@1.0)
    -   [ADR-020: Master key request-lifetime caching](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-020-master-key-request-lifetime-caching-1@1.0)
    -   [ADR-021: Batch secret loading](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-021-batch-secret-loading-1@1.0)
    -   [ADR-022: Dedicated OAuth exception](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-022-dedicated-oauth-exception-1@1.0)
    -   [ADR-023: Audit hash chain HMAC consideration](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-023-audit-hash-chain-hmac-consideration@1.0)
    -   [ADR-024: Audit hash payload covers forensic fields](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-024-audit-hash-payload-covers-forensic-fields@1.0)
    -   [ADR-025: Secret entity is a readonly value object](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-025-secret-entity-is-a-readonly-value-object@1.0)
    -   [ADR-026: DNS-rebinding defence via CURLOPT_RESOLVE](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-026-dns-rebinding-defence-via-curlopt-resolve@1.0)
    -   [ADR-027: OAuth token requests use the secure HTTP client](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-027-oauth-token-requests-use-the-secure-http-client@1.0)
    -   [ADR-028: PHPat architectural lock for HTTP client construction](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-028-phpat-architectural-lock-for-http-client-construction@1.0)
    -   [ADR-029: Scoped technical-actor identity for headless use](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-029-scoped-technical-actor-identity-for-headless-use@1.0)
    -   [ADR-030: Read-time resolution of site-configuration vault references](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-030-read-time-resolution-of-site-configuration-vault-references@1.0)
    -   [ADR-031: One shared catalogue of secret shapes](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-031-one-shared-catalogue-of-secret-shapes@1.0)
    -   [ADR-032: A portable envelope codec for consumer-owned payloads](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-032-a-portable-envelope-codec-for-consumer-owned-payloads@1.0)
    -   [ADR-033: Master-key rotation reaches consumer-owned envelopes](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-033-master-key-rotation-reaches-consumer-owned-envelopes@1.0)
    -   [ADR-034: Audit chain tip anchor](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-034-audit-chain-tip-anchor-1@1.0)
    -   [ADR-035: Per-request allow-set of frontend-resolvable identifiers](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-035-per-request-allow-set-of-frontend-resolvable-identifiers@1.0)
    -   [ADR-036: Mutation and audit are all-or-nothing](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-036-mutation-and-audit-are-all-or-nothing@1.0)
    -   [ADR-037: A cancellable send is a method, not an exported handle](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-037-a-cancellable-send-is-a-method-not-an-exported-handle@1.0)
    -   [ADR-038: A host we cannot resolve is refused, not handed to curl](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-038-a-host-we-cannot-resolve-is-refused-not-handed-to-curl@1.0)

## Architecture overview {#architecture-overview}

nr-vault follows clean architecture principles with these main components:

-   **Service layer**

    `VaultService` \- Main facade for all vault operations.

-   **Crypto layer**

    `EncryptionService` \- Envelope encryption implementation.
    `MasterKeyProvider` \- Master key retrieval abstraction.

-   **Storage layer**

    `SecretRepository` \- Database persistence.
    `VaultAdapterInterface` \- Storage backend abstraction.

-   **Security layer**

    `AccessControlService` \- Permission checks.
    `AuditLogService` \- Operation logging.

## Extending nr-vault {#extending-nr-vault}

### Custom storage adapters {#custom-storage-adapters}

> [!NOTE]
> nr-vault currently includes only the **local database adapter**. External
> vault adapters (HashiCorp Vault, AWS Secrets Manager, Azure Key Vault) are
> planned for future releases. The adapter architecture below allows you to
> implement your own custom adapters in the meantime.

Implement `VaultAdapterInterface` to add new storage backends:

**EXT:my_extension/Classes/Adapter/CustomAdapter.php**

```php
namespace MyVendor\MyExtension\Adapter;

use Netresearch\NrVault\Adapter\VaultAdapterInterface;
use Netresearch\NrVault\Domain\Model\Secret;

final class CustomAdapter implements VaultAdapterInterface
{
    public function getIdentifier(): string
    {
        return 'custom';
    }

    public function isAvailable(): bool
    {
        // Check if your backend is configured and reachable
    }

    public function store(Secret $secret, bool $persistGroupRelations = true): Secret
    {
        // Store secret in your backend and return the stored instance —
        // on INSERT it must carry the freshly assigned UID (see ADR-025).
        //
        // $persistGroupRelations = false means: leave the record's two
        // group tiers untouched, MM rows and count columns alike. The
        // FormEngine completion path passes false so it does not overwrite
        // ACL relations DataHandler has already written.
    }

    public function retrieve(string $identifier): ?Secret
    {
        // Retrieve secret from your backend
    }

    public function delete(string $identifier): void
    {
        // Delete from your backend
    }

    public function exists(string $identifier): bool
    {
        // Check if secret exists
    }

    public function list(?\Netresearch\NrVault\Domain\Dto\SecretFilters $filters = null): array
    {
        // List secret identifiers
    }

    public function listSecrets(?\Netresearch\NrVault\Domain\Dto\SecretFilters $filters = null): array
    {
        // List whole Secret objects, not just identifiers
    }

    public function getMetadata(string $identifier): ?array
    {
        // Get secret metadata
    }

    public function updateMetadata(string $identifier, array $metadata): void
    {
        // Update metadata
    }

    public function incrementReadCount(int $uid): void
    {
        // Increment read counter atomically
    }
}
```

Register in `Services.yaml` by overriding the interface alias. Adapter
selection is **not** pluggable through a tag: nothing consumes a
`nr_vault.adapter` tag, and `VaultAdapterInterface` is a plain alias to
`LocalEncryptionAdapter`. Repointing that alias is what swaps the
adapter, and it swaps it for the whole installation.

**EXT:my_extension/Configuration/Services.yaml**

```yaml
Netresearch\NrVault\Adapter\VaultAdapterInterface:
  alias: MyVendor\MyExtension\Adapter\CustomAdapter
```

> [!NOTE]
> The three tags nr-vault does consume are `nr_vault.audit_sink`,
> `nr_vault.readiness_check` and `nr_vault.master_key_provider`; all
> three are collected as tagged iterators.

### Custom master key providers {#custom-master-key-providers}

> [!NOTE]
> nr-vault includes four built-in master key providers: **typo3** (derives
> from TYPO3's encryption key), **file** (reads from filesystem), **env**
> (reads from environment variable) and **transit** (unwraps the master key
> through HashiCorp Vault's transit engine — see the `hashicorp.transit*`
> settings). A provider another extension supplies is selected in exactly the
> same way, by putting its identifier in
> [masterKeyProvider](https://docs.typo3.org/permalink/netresearch/nr-vault:confval-ext-nrvault-masterkeyprovider@1.0).

Registering one takes two things: a class implementing
`MasterKeyProviderInterface`, and the `nr_vault.master_key_provider`
tag on its service.
`MasterKeyProviderRegistry` collects every tagged service and indexes it
under the identifier the provider returns from `getIdentifier()` — that
method, not the service id and not a tag attribute, is what
`masterKeyProvider` names.

**EXT:my_extension/Classes/Crypto/KmsKeyProvider.php**

```php
namespace MyVendor\MyExtension\Crypto;

use Netresearch\NrVault\Crypto\AbstractMasterKeyProvider;

final class KmsKeyProvider extends AbstractMasterKeyProvider
{
    // Pick an identifier no other provider uses. 'typo3', 'file', 'env'
    // and 'transit' are taken by the built-in providers, and a collision
    // is refused rather than resolved — see the contract below.
    public function getIdentifier(): string
    {
        return 'acme_kms';
    }

    public function isAvailable(): bool
    {
        // Configuration completeness and local preconditions. No network
        // call: this is consulted on hot paths.
    }

    public function storeMasterKey(#[\SensitiveParameter] string $key): void
    {
        // Write the key to the KMS, or throw
        // MasterKeyException::cannotStore() when the source is read-only.
    }

    public function generateMasterKey(): string
    {
        return random_bytes(32);
    }

    protected function loadRawKey(): string
    {
        // Fetch the 32 raw bytes from the KMS. Called at most once per
        // request; the base class caches and wipes the result (ADR-020).
    }
}
```

**EXT:my_extension/Configuration/Services.yaml**

```yaml
MyVendor\MyExtension\Crypto\KmsKeyProvider:
  tags: ['nr_vault.master_key_provider']
```

**Extension configuration**

```none
masterKeyProvider = acme_kms
```

#### What a custom provider must hold to {#what-a-custom-provider-must-hold-to}

Extending `AbstractMasterKeyProvider` is the supported route. What the
base class supplies is the [ADR-020](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-020-master-key-request-lifetime-caching@1.0) request-lifetime contract — the
key is loaded at most once per request, cached in a slot keyed by your class,
and wiped with `sodium_memzero()` — which it implements once as a final
`getMasterKey()` plus the static `clearCachedKey()`. In exchange you
implement `loadRawKey()`, its single abstract method.

The remaining `MasterKeyProviderInterface` methods stay with the provider
either way: `getIdentifier()`, `isAvailable()`,
`storeMasterKey()` and `generateMasterKey()` — the example above
writes all four. Implementing the interface directly is allowed; then the
caching and `clearCachedKey()` are yours to get right as well.

The rest is the contract every provider is held to:

-   **Identifier**

    Distinct and non-blank. Two providers claiming one identifier is refused
    with exception code `1789430001`, a blank identifier with
    `1789430002`. An identifier decides which key source protects the vault,
    so the registry will not settle a collision by load order — and while one
    exists it refuses *every* lookup, not only the colliding name.

-   **Constructor**

    Cheap. It runs while the registry builds its index, on the first vault
    operation of a request. Reaching the key source belongs in
    `isAvailable()` and `loadRawKey()`.

-   **`isAvailable()`**

    Configuration completeness and local preconditions, with no network call.
    It is consulted on hot paths, so an outage at your key source must not turn
    into a per-request timeout. The built-in `transit` provider is the worked
    example.

-   **`getMasterKey()`**

    Exactly 32 bytes. Never log the key and never put it in an exception
    message — that includes the path or URL it came from, which the built-in
    providers route to the PSR-3 log instead.

-   **Rotation**

    `storeMasterKey()` is what `vault:rotate-master-key` calls. A source
    that cannot be written to should throw
    `MasterKeyException::cannotStore()` with a message saying how to
    rotate out of band, as the `env` provider does.

-   **Security profile**

    A custom provider is permitted in the hardened profile. That profile
    refuses `typo3` by name, because its demand is that the key lives outside
    `config/system/settings.php`, and an external provider is the case it
    asks for. It cannot police what your provider then does — deriving a key
    from the TYPO3 encryption key under another name would pass — so the
    installation is trusting the extension it installed.

Auto-detection never reaches a custom provider. When the configured provider
is unavailable, the standard profile falls back through `typo3`, `env` and
`file` only: quietly adopting an external key custody nobody configured
would be worse than the misconfiguration it papers over.

## Events {#events}

nr-vault dispatches PSR-14 events for extensibility:

-   **SecretAccessedEvent**

    Dispatched when a secret is read.

-   **SecretCreatedEvent**

    Dispatched when a new secret is created.

-   **SecretRotatedEvent**

    Dispatched when a secret is rotated with a new value.

-   **SecretUpdatedEvent**

    Dispatched when a secret value is updated (without rotation).

-   **SecretDeletedEvent**

    Dispatched when a secret is deleted.

-   **MasterKeyRotatedEvent**

    Dispatched after master key rotation commits, carrying the number of secrets
    and of consumer-owned envelopes re-encrypted. This is a notification, not a
    participation hook: a listener cannot re-wrap anything, because the master
    keys are gone by the time it runs. To have your own envelopes rotated,
    implement `ForeignEnvelopeRotatorInterface`
    ([ADR-033](https://docs.typo3.org/permalink/netresearch/nr-vault:adr-033-foreign-envelope-rotation@1.0)).

-   **AuditIntegrityAlertEvent**

    Dispatched when audit verification produces a finding. Carries the alert,
    its stable reason code, and `isTamperEvidence()` so a listener can
    page on tampering without also paging on a failed sink delivery.

-   **BreakGlassActivatedEvent**

    Dispatched when a break-glass window opens, carrying the actor uid and
    username, the mandatory justification, and the expiry.

-   **BreakGlassDeactivatedEvent**

    Dispatched when a window is closed deliberately. Note that a window which
    merely *expires* dispatches nothing — nothing runs at the moment it lapses.

Example listener:

**EXT:my_extension/Classes/EventListener/SecretAccessLogger.php**

```php
namespace MyVendor\MyExtension\EventListener;

use Netresearch\NrVault\Event\SecretAccessedEvent;

final class SecretAccessLogger
{
    public function __invoke(SecretAccessedEvent $event): void
    {
        // Custom logging or alerting
        $identifier = $event->getIdentifier();
        $actorUid = $event->getActorUid();
    }
}
```

## Testing {#testing}

### Development setup {#development-setup}

Use DDEV for local development:

**Start DDEV environment**

```bash
ddev start
ddev install-v14
ddev exec vendor/bin/typo3 vault:init
```

### Running tests {#running-tests}

**Run test suites**

```bash
# Unit tests
Build/Scripts/runTests.sh -s unit

# Functional tests
Build/Scripts/runTests.sh -s functional
```

### Code quality {#code-quality}

**Run code quality tools**

```bash
# Code style (PHP-CS-Fixer)
Build/Scripts/runTests.sh -s cgl

# Static analysis (PHPStan)
Build/Scripts/runTests.sh -s phpstan
```

### Mutation testing (Infection) {#mutation-testing-infection}

Mutation testing validates the **strength** of the unit suite: Infection
rewrites operators, return values, and array/ternary constructs in the
production code and checks whether the test suite detects each mutation.
A test suite that still passes after a mutation = a missing assertion.

**Run mutation tests locally**

```bash
# Full run (initial tests must be green)
composer ci:test:php:mutation

# or via make
make test-mutation

# Inspect reports
$BROWSER .Build/infection/infection.html
```

The current baseline and top escape concentrations are tracked in
`Documentation/Developer/mutation-baseline.md` (Markdown — developer
artifact, not rendered in public docs).

#### Interpreting MSI {#interpreting-msi}

-   *MSI (Mutation Score Indicator):*

    % of all generated mutants that were detected (killed) by the test suite.
    Raw indicator of assertion density across the whole codebase.

-   *Covered Code MSI:*

    % of mutants **in code reachable by tests** that were killed. Removes
    noise from intentionally untested code (e.g. interfaces, enums).

-   *Mutation Code Coverage:*

    % of source lines that carry at least one mutant with a test. Closely
    tracks line coverage.

#### CI thresholds {#ci-thresholds}

Thresholds live in `infection.json5`. They follow a **ratchet** strategy,
so the committed values track the currently measured MSI rather than the
long-term target:

**infection.json5**

```json5
{
    "minMsi": 77,
    "minCoveredMsi": 77
}
```

A run that falls below either threshold fails CI. Ratchet these numbers
upward as test coverage improves; avoid ratcheting them downward (use a
brief TODO with a ticket instead). The measurement the floors were set from,
and the next target, are kept next to the values in `infection.json5`.

#### Badge generation {#badge-generation}

After a successful Infection run, emit a shields.io-compatible badge:

**Generate MSI badge JSON**

```bash
./Build/Scripts/check-msi.sh > .Build/infection/badge.json
```

The output matches the shields.io endpoint schema and can be served from
any HTTPS endpoint (GitHub Pages, CDN, …) and referenced from the README.

### Release evidence bundle {#release-evidence-bundle}

Tagged releases publish a bundle that records *what was actually verified* at
the tagged commit — test results, coverage, mutation score, dependency audit,
and the reference `vault:doctor` posture — together with pointers to the
signed release artifacts. It is assembled by
`Build/Scripts/collect-evidence.php` and published by the
`.github/workflows/release-evidence.yml` workflow.

The release is gated on it: `.github/workflows/release.yml` first runs the
fleet `release-gate.yml`, which waits for the evidence run and for
`ci.yml` to succeed at the tagged commit before anything is built, signed
or published.
A candidate is vetted before tagging with a manual run against its commit,
which publishes nothing:

**Pre-release check of a commit**

```bash
gh workflow run release-evidence.yml -f ref=<commit-sha>
```

**Build a bundle locally**

```bash
# Produce inputs first (any subset — a missing producer is recorded, not fatal)
composer ci:test:php:coverage
composer ci:test:php:mutation

# The path figure comes from PHPUnit's text report, which the command above
# writes to stdout; add a target for it to feed the bundle:
#   --coverage-text=.Build/coverage/coverage-text.txt --only-summary-for-coverage-text

# Assemble into .Build/evidence/
composer ci:evidence -- --tag=v1.2.3
```

In CI the producing jobs run on separate runners, so they upload their reports
into one flat drop-zone that the bundling job passes with `--parts`:

**Assemble from a CI drop-zone**

```bash
php Build/Scripts/collect-evidence.php --parts=parts --tag=v1.2.3
```

Recognised names under `--parts` are `junit-unit.xml`,
`junit-fuzz.xml`, `junit-functional.xml`, `clover.xml`,
`coverage-text.txt`, `infection.json`, `infection-security.json`,
`infection-summary.log`, `composer-audit.json` and
`doctor.json`. Precedence per input is: an explicit flag, then the
drop-zone, then the in-tree default location, then absent. Run
`php Build/Scripts/collect-evidence.php --help` for the full flag list.

The bundle contains `evidence-manifest.json` (machine-readable),
`EVIDENCE.md` (the same data rendered for a human reader), and an
`artifacts/` directory holding a verbatim, SHA-256-listed copy of every
input that was found.

#### Manifest schema {#manifest-schema}

**evidence-manifest.json (schemaVersion 1)**

```json
{
  "schemaVersion": 1,
  "extension": "nr_vault",
  "version": "0.13.0",
  "commit": "9267b6abba37cbe3b7cbdb856b0dc5a00beb2e07",
  "builtAt": "2026-07-31T20:15:00+00:00",
  "checks": [
    {
      "id": "coverage-line",
      "status": "pass",
      "summary": "line 95.65% (11454/11974 statements), branch 87.12% (6579/7551), path 5.02% (2411/47969) — bar 93.00%",
      "source": "clover.xml + coverage-text.txt"
    }
  ],
  "artifacts": [
    {"name": "clover.xml", "path": "artifacts/clover.xml", "sha256": "…"},
    {"name": "nr-vault-0.13.0.zip", "url": "https://github.com/…"}
  ]
}
```

The `checks` array is emitted in a fixed order with stable ids:
`release-identity`, `tests`, `coverage-line`, `coverage-security-dirs`,
`coverage-branch`, `mutation-msi`, `mutation-msi-security`,
`static-analysis`, `dependency-audit`, `vault-doctor`.
`coverage-branch` was added without a schema version change: it is a new
entry in the `checks` array, and no existing field changed shape.

-   *status:* One of `pass`, `warn`, `fail` or `absent`.
-   *absent:*

    The producing step did not run in this build. This is recorded, never
    hidden, and never fails the collector.

-   *tests:*

    Aggregates every suite that left a JUnit log, keeping the per-suite counts in
    the summary. A suite that did not run is not listed — it is never counted as
    passing.

-   *coverage-line:*

    Line coverage against its bar, with branch and path coverage reported
    beside it. Branch and path figures exist only when the run collected them
    (Xdebug `--path-coverage`); otherwise they read `n/a`. Path coverage is
    reported but not gated: its denominator grows combinatorially with nested
    conditions, so a bar on it would punish readable branching rather than
    missing tests.

-   *coverage-branch:*

    Branch coverage overall and per security directory, each against its own
    bar. A coverage report without branch data is a `warn`, never a `pass`:
    the report exists, but it cannot show the bar was met.

-   *mutation-msi-security:*

    The same mutation analysis narrowed to `Classes/Crypto`,
    `Classes/Security`, `Classes/Audit` and `Classes/Http`, held to the
    stricter thresholds in `infection-security.json5`.

-   *vault-doctor:*

    Read from `highestSeverity` (`pass`/`warning`/`critical`), falling
    back to `exitCode` (`0`/`1`/`2`). Two subtleties matter, because
    `vault:doctor` overloads exit code `2`:

    -   A run that could not start emits `{"error": …, "exitCode": …}` with **no**
        `findings` key — an unusable `--profile` value or an internal crash.
        That is recorded as `fail`: the gate did not run, and an ungated release
        is not a clean one.
    -   `VaultDoctorService` contains a crashing check by turning it into a
        `check.crashed` **critical** finding, so an unreachable database looks
        exactly like bad posture. When every critical is a `check.crashed`, the
        check is downgraded to `warn` and the summary says `INCOMPLETE`, naming
        the checks from `details.check`. A real critical alongside a crash still
        fails, so a crash can never mask an actual control failure.

    An override is recorded too: `--profile=hardened` on a standard install
    renders as `profile hardened (configured: standard)`, so the evidence never
    implies the live profile was the one evaluated.

Artifacts carry either a bundle-relative `path` plus `sha256` (copied into
the bundle) or a `url` (produced and signed by the release workflow, living on
the GitHub Release — the manifest references those, it does not reproduce them).

#### Graceful degradation {#graceful-degradation}

The collector exits `0` whenever it can describe the release honestly,
including when producers are missing; a check status never changes the exit
code. It exits `1` only when an artifact is *present but unparseable*, because
then the bundle would misrepresent a check that really ran.

The schema, the per-producer degradation, and the malformed-artifact contract
are pinned by a fixture-driven self-check that runs as part of
`composer ci`:

**Verify the collector**

```bash
composer ci:test:evidence
```

### Security scans {#security-scans}

**Run ad-hoc security scans**

```bash
# Composer dependency audit (locked + strict abandoned-package policy)
composer ci:audit

# Semgrep crypto-hygiene ruleset (advisory; not wired into CI)
semgrep --config=semgrep.yml Classes/
```

`semgrep.yml` targets nr-vault-specific concerns such as
non-constant-time secret equality, missing `sodium_memzero()`, and
debug dumps of secret-shaped variables.

## Contributing {#contributing}

See `CONTRIBUTING.md` for contribution guidelines.

1.  Fork the repository.
1.  Create a feature branch.
1.  Write tests for your changes.
1.  Ensure all tests pass.
1.  Submit a pull request.

## API reference {#api-reference}

The authoritative API reference — every interface, signature, exception and
event, kept in one place so it cannot drift against a second copy — lives in
[API](https://docs.typo3.org/permalink/netresearch/nr-vault:developer-api@1.0).
