Curator workflow — waive, block, exclude

This guide is for operators acting in the curator role: making day-to-day decisions on artifacts that quarantine-by-default has held, or that the scanner has rejected, without escalating to a full admin session. It covers how the curator grant is made, the three decision flows (waive / block / exclude-finding), the audit trail produced, and the operational caveats — especially the finding-exclusion blast radius and the block-versions continue-on-error contract.

The quarantine state model, the quarantine-by-default posture, and the fail-closed release predicate are documented in ADR 0007 and security.md.


1. The curator role

Permission::Curate is the day-to-day decision role for security / release-team operators who triage artifacts in Quarantined or Rejected state. It is narrower than Permission::Admin by design:

AuthorityPermission::CuratePermission::Admin
Waive a Quarantined artifactyesyes
Waive a ScanIndeterminateno (admin-only)yes (broader source-state guard)
Block any non-terminal artifactyesyes
Bulk-block via explicit version listyesyes
Exclude / unexclude a CVE findingyesyes
All other admin endpointsnoyes

The narrower source-state guard on waive is intentional. ScanIndeterminate means the scanner could not complete (a terminal scan failure, not a clean result); releasing such an artifact requires the broader deliberation that admin_release represents (the broader role, the emergency escalation context). Curator-waive serves the common case: "the scan was clean, advisory-lag residual risk is acceptable for this specific artifact, release it before the window completes."

The role is claim-based (see operate/claim-based-rbac.md). A Permission::Curate grant rides the existing audited ApplyConfigUseCase apply path; there is no admin REST endpoint, no migration backfill, no direct DB insert. The grant flow is identical to every other claim-based permission in the system.

1.1 Granting the curator role

Curator authority is granted via a gitops PermissionGrant envelope loaded from $HORT_CONFIG_DIR at hort-server boot (see docs/architecture/how-to/declare-gitops-config.md for the full operator flow). Two grant subjects are supported (the GrantSubject taxonomy is deliberately closed at two):

Grant to a claim set (the recommended pattern for SSO-backed operator identities):

apiVersion: project-hort.de/v1
kind: PermissionGrant
metadata:
  name: curator-security-team
spec:
  subject:
    kind: claims
    required:
      - org:security-team       # set of claim names the principal must hold
  permission: curate            # the literal DB ENUM value
  # repository omitted = global authority across all repositories

Grant to a service account / individual user (for an hort_svc_* token or a specific human):

apiVersion: project-hort.de/v1
kind: PermissionGrant
metadata:
  name: curator-ci-rotation-bot
spec:
  subject:
    kind: user
    userId: 9c1e3a2f-...         # user_id UUID (the SA's user row)
  permission: curate
  repository: npm-proxy          # optional — narrow to a single repo;
                                 # omit for a global grant

The apply emits a PermissionGrantApplied event with full actor attribution (who applied the grant; from which gitops commit); the linter rejects malformed grants before they hit the DB. Both paths run through the same audited surface — there is no grant back door.

Apply via the gitops boot path:

# 1. Write the YAML under $HORT_CONFIG_DIR, e.g.
#      $HORT_CONFIG_DIR/permissions/curator-security-team.yaml
# 2. Restart hort-server (or signal the boot apply if hot-reload is
#    wired in your deployment).
# The boot apply walks $HORT_CONFIG_DIR recursively and emits
#   hort_gitops_objects_total{kind=permission_grant,result=...}
# once per row (created | updated | unchanged | deleted).

There is no hort-cli admin apply subcommand; $HORT_CONFIG_DIR is the only loader path for declarable configuration. See docs/architecture/how-to/declare-gitops-config.md for the directory layout, validation behaviour, and idempotency contract.

1.2 What curator does NOT confer

Permission::Curate does not confer:

If an operator needs more authority than curator, the correct flow is to assume an admin session via hort-cli auth login --admin (see using-hort-cli-with-admin-ops.md), not to widen the curator grant.


2. The three decision flows

2.1 Waive — release a Quarantined artifact early

Use it when: the scan completed clean, the observation window is still running, and the cost of waiting (downstream consumers stuck on an older version with a known issue, a critical patch that fixes a deployed CVE, an urgent dependency-of-a-dependency unblock) outweighs the marginal risk-reduction of the remaining window.

Pre-flight check: look at the queue listing first.

hort-cli curation queue --status quarantined --repo npm-proxy
# Identify the artifact_id. Then look at its finding count and scan
# status. A waive is appropriate when finding_count = 0 (clean scan)
# or the findings are known false-positives you intend to follow up
# with --exclude-finding.

Issue the waive:

hort-cli curation waive <artifact_id> \
  --justification "lodash@4.17.21 fixes CVE-2024-XXXX (verified upstream
                    npm signature 2026-05-23); risk-accepted by
                    security@example.com"

The --justification is required (non-empty, ≤ 512 bytes — the CLI rejects empty / oversize before the round trip). The justification rides the ArtifactReleased event's justification field; it is the load-bearing audit anchor.

Event emitted:

ArtifactReleased {
  released_by: Curator,
  released_by_user_id: <your user_id>,
  justification: "<the text you supplied>",
  ...
}

The audit log distinguishes curator-waive from admin_release via the released_by discriminator; both populate released_by_user_id + justification, so a single query against the event stream reconstructs every human-authority release with its attribution.

Source-state guard reminder: if quarantine_status is ScanIndeterminate (not Quarantined), the waive returns 400 — that artifact requires admin_release. The queue listing carries the status; check it before issuing.

2.2 Block — reject a non-terminal artifact

Use it when: you've identified an artifact that should not be served — a shadow-IT upload, a supply-chain risk surfaced by external intelligence (out-of-band of the scanner DB), a deprecation you want to hard-pull rather than wait for clients to update.

Two shapes:

Single artifact:

hort-cli curation block artifact <artifact_id> \
  --justification "Shadow-IT upload — pulled per security review
                    SEC-2026-014; vendor-internal package leaked through
                    proxy fallback"

Bulk by version list (the operator already knows which (repo, package, versions) tuples are vulnerable):

hort-cli curation block versions \
  --repo npm-proxy \
  --package left-pad \
  --versions 1.0.0,1.0.1,1.0.2,1.0.3 \
  --justification "CVE-2026-XXXX confirmed across these versions per
                    GHSA-aaaa-bbbb-cccc; blocking pending upstream
                    backport"

The bulk variant caps at 100 versions per call (mirrors the queue's limit shape — bounded per-call work).

Source-state guard: None | Quarantined | Released → Rejected. Released → Rejected is the shadow-IT / retroactive-rejection case (mirrors reject_from_retroactive_curation). Already- Rejected artifacts are an idempotent no-op at the use-case layer (counted in BlockOutcome.already_rejected_ids; no re-appended event; avoids audit-log noise from accidental double-blocks).

Event emitted (per resolved-and-non-terminal artifact):

ArtifactRejected {
  rejected_by: Curator { curator_id: <your user_id> },
  reason: "<the justification text>",
  correlation_id: <shared across every event a single block call emits>,
  ...
}

Bulk-call result shape — operators MUST inspect this:

{
  "correlation_id": "...",
  "blocked_artifact_ids": ["..."],      // transitioned to Rejected on this call
  "already_rejected_ids": ["..."],      // idempotent no-op (no event appended)
  "not_found_versions": ["..."],        // not ingested yet — NOT auto-blocked
  "failed": [["...", "<error>"], ...]   // per-append failures (continue-on-error)
}

See §2.4 below for the continue-on-error contract — this is the load-bearing operational contract on block versions.

2.3 Exclude / unexclude a CVE finding

Use it when: the scanner is flagging a CVE that does not apply to your deployment (the vulnerable code path is unreachable in your configuration, the CVE was filed against a different ecosystem, the upstream advisory was retracted), and you want to silence it at policy level so all currently-quarantined artifacts whose only blocking finding is this CVE are released.

# Exclude a CVE for a specific policy
hort-cli curation exclude-finding \
  --policy <policy_id> \
  --cve CVE-2024-XXXX \
  --justification "Vulnerable code path not reachable in our config
                    (issue tracker SEC-2026-022); confirmed with vendor
                    advisory addendum"

# Reverse the decision (the CVE becomes blocking again)
hort-cli curation unexclude-finding \
  --policy <policy_id> \
  --cve CVE-2024-XXXX \
  --justification "Upstream advisory clarified — code path IS reachable
                    in certain build configurations; reinstating block"

Events emitted:

ExclusionAdded {                      # or ExclusionRemoved on unexclude
  policy_id, cve_id,
  ...
}
# followed by the re-evaluation cascade — see §3 below
ArtifactReleased { released_by: PolicyReEvaluation, ... }   // × N

The actor attribution rides the event envelope (PersistedEvent.actor), not the payload — ExclusionAdded / ExclusionRemoved payloads carry no actor field by design. The projector copies the envelope's actor Uuid into exclusion_projections.added_by_actor_id (the field the hort-cli curation exclusions listing surfaces).


3. The finding-exclusion blast-radius warning

Read this before issuing your first exclude-finding.

Excluding a CVE finding is the highest-blast-radius decision a curator can make. One exclusion can release N artifacts.

3.1 What happens under the hood

When you exclude a CVE:

  1. The ExclusionAdded event is appended to the policy's stream with your curator attribution.
  2. The re_evaluate_after_exclusion cascade fires immediately. It scans every artifact whose policy includes this exclusion and whose quarantine_status = 'rejected'.
  3. For each artifact, the cascade checks: are the artifact's only blocking findings now excluded? If yes: Rejected → Quarantined (the time hold still applies). immediately (the artifact becomes downloadable).
    • If quarantine_until is still in the future →
    • If quarantine_until has elapsed → Rejected → Released

So a single exclude-finding call can release multiple artifacts across multiple repositories in one cascade.

3.2 The audit chain

The cascade is fully reconstructable after the fact. The event stream carries:

[policy stream]    ExclusionAdded { actor=<your user_id>, cve_id, ... }
[artifact A]       ArtifactReleased { released_by: PolicyReEvaluation, ... }
[artifact B]       ArtifactReleased { released_by: PolicyReEvaluation, ... }
[artifact C]       ArtifactQuarantined { ... }     # window not elapsed
...

Your curator-side justification on ExclusionAdded is the single audit anchor for every artifact the cascade released — make it informative.

3.3 The pre-flight check

Before exclusion, run the queue listing scoped to Rejected to see how many artifacts your decision will affect:

# Approximate the blast radius: rejected artifacts whose latest scan
# findings include this CVE. (Exact resolution needs a join the queue
# listing does not expose today; this is an upper bound.)
hort-cli curation queue --status rejected --output json \
  | jq '.entries[] | select(.finding_count > 0)'

# If the count is small and you've reviewed each row, proceed.
# If the count is large, consider the per-policy `package_pattern` field
# on the exclusion to narrow scope before issuing.

3.4 Reverting an exclusion

unexclude-finding runs the same cascade in reverse: artifacts whose only re-evaluation-released path was this exclusion transition back to Rejected. The audit chain is symmetric (one ExclusionRemoved + N re-evaluations); the cascade emits Released → Rejected or Quarantined → Rejected per artifact.


4. admin_release vs curator-waive — when to escalate

The two paths are structurally distinct, not interchangeable authority levels.

Concerncurator-waiveadmin_release
Required permissionCurate or AdminAdmin
Source-state guardQuarantined onlyAny non-terminal state, incl. ScanIndeterminate
Justification cap (≤ 512 bytes)yes, requiredyes, required
released_by discriminatorReleaseReason::CuratorReleaseReason::Admin
released_by_user_id populatedcurator's user_idadmin's user_id
Use case"scan was clean, accept advisory lag""scanner failed, accept the broader risk"

Escalate to admin_release when:

Stay on curator-waive when:

The choice is recorded in the released_by discriminator on the event, so future auditors see whether each release went through the broader or the narrower path.


5. The quarantineDuration: 0 per-repo opt-out

For repositories where the quarantine posture itself is wrong (an internal hosted repo of first-party builds where every artifact is already vouched-for; a build-cache proxy whose contents the upstream already vetted), the right answer is not to curator-waive every ingest — it is to set quarantineDuration: 0s on the repo's ScanPolicy.

apiVersion: project-hort.de/v1
kind: ScanPolicy
metadata:
  name: internal-hosted-quarantine-opt-out
spec:
  scope:
    repository: internal-hosted   # `scope: global` is the cross-repo form
  severityThreshold: high         # critical | high | medium | low — required
  quarantineDuration: 0s          # permissive mode — no time gate
  requireApproval: false          # required
  provenanceMode: off             # optional: off | verify_if_present | required
  negligibleAction: ignore        # optional: ignore | warn | block — informational (unmaintained/unsound) advisory handling; default ignore
  scanBackends:                   # scan still runs; finding-rejection still applies
    - trivy

Permissive mode (quarantineDuration: 0) is a supported posture — the fail-closed scan gate (ADR 0007) still applies; only the time gate is collapsed. This is a per-repo posture choice, not a per-artifact one.

When to prefer this over curator-waive: if you're waiving every ingest from a specific repo, the posture is wrong for that repo — encode the decision once in policy, not N times in the audit log.


6. Changing a ScanPolicy re-judges the existing population — in both directions

Read this before you tighten or loosen a live ScanPolicy. A gate change is no longer point-in-time at ingest: it re-judges every artifact already decided under that policy.

Editing the gate-affecting fields of a ScanPolicyseverityThreshold, the blocked-class set, negligibleAction, or removing/adding a CVE finding-exclusion — does not only change how future ingests are judged. Hort re-derives each in-scope artifact's verdict under the new policy and transitions the artifact to match, in both directions:

6.1 What a tightening actually pulls — and what it cannot

A re-hold blocks future downloads through the status gate. It does not recall already-served bytes — anything a client pulled before the re-hold is already on disk somewhere. What the re-hold buys you is the audit record of the moment of non-compliance and the closed door going forward; closing the door forward is strictly better than leaving it open, but it is not a recall. If bytes already in the wild are the concern, the re-hold is necessary but not sufficient — treat it as a containment step, not a clean-up.

The timer window is not re-opened on a tighten. A re-held artifact is held on the verdict, independent of its original quarantine deadline.

6.2 The evidence model — re-evaluation reads stored findings, never rescans

Re-evaluation does not run a scanner. The per-finding scan results recorded at ingest (scan_findings) are the durable evidence; the policy is the interpretation. A gate change re-runs the same pure evaluator over the same stored findings under the new policy — so:

6.3 It runs asynchronously, off the request path

A gate-affecting policy mutation enqueues a re-evaluation pass and returns; the pass runs as a worker task over the whole in-scope population, fully paginated (no fixed cap), and is idempotent (re-running yields the same verdict). Expect the population to settle shortly after the change, not synchronously with the apply. Each transition appends its own audit event naming the policy change that drove it, and the pass surfaces an outcome breakdown (released / re_held / unchanged) plus a completeness signal — see hort_policy_reevaluation_artifacts_total / hort_policy_reevaluation_population in the metrics catalog — so a partial pass is observable, never silent.


7. hort-cli curation command reference

All decision subcommands require --justification (non-empty, ≤ 512 bytes); the CLI rejects malformed input before the HTTP round trip.

# Decision flows
hort-cli curation waive <artifact_id> --justification <text>
hort-cli curation block artifact <artifact_id> --justification <text>
hort-cli curation block versions \
  --repo <key> --package <name> --versions v1,v2,v3 \
  --justification <text>
hort-cli curation exclude-finding   --policy <id> --cve <id> --justification <text>
hort-cli curation unexclude-finding --policy <id> --cve <id> --justification <text>

# Read surfaces
hort-cli curation queue \
  [--repo <key>] \
  [--status <quarantined|rejected|scan_indeterminate>] \
  [--reason <scanner|curator|curation_retroactive>] \
  [--limit <n>] [--output json|table]

hort-cli curation decisions \
  [--type <waive|block|exclude_finding|unexclude_finding>] \
  [--actor <user_id>] \
  [--repo <key>] \
  [--package <name>] \
  [--since <iso-time>] \
  [--limit <n>] \
  [--by-correlation] \
  [--output json|table]

hort-cli curation exclusions \
  [--policy <id>] \
  [--cve <id>] \
  [--actor <user_id>] \
  [--limit <n>] \
  [--output json|table]

Notes:


8. block versions — the continue-on-error contract

This is the most operationally subtle curation surface and the one operators most often misread.

8.1 What the contract says

Event-sourcing rules out "all-or-nothing" — events are immutable, there is no rollback once appended. The use case picks continue-on-error:

The HTTP layer mirrors this: a block-versions call with a non-empty failed array returns 200 OK (with the full outcome body), NOT 5xx. Partial success is a successful outcome at the HTTP semantics level.

8.2 What operators MUST do

After every block versions call, inspect the response:

hort-cli curation block versions \
  --repo npm-proxy \
  --package left-pad \
  --versions 1.0.0,1.0.1,1.0.2,1.0.3 \
  --justification "..." \
  --output json | tee block-result.json

# Look at the four lists:
jq '{
  correlation_id,
  blocked: (.blocked_artifact_ids | length),
  already_rejected: (.already_rejected_ids | length),
  not_found: (.not_found_versions | length),
  failed: (.failed | length)
}' block-result.json

The CLI's table output highlights the failed column in red when it is non-empty; do not dismiss the highlight.

8.3 Retrying the failed subset

If failed is non-empty, the contract is:

  1. Identify the failed artifact_ids from the failed list.
  2. Map them back to versions (either from local context or by querying the queue / decisions listing for the affected artifacts).
  3. Re-issue block versions for the failed subset only — using the same justification text as the original call:
hort-cli curation block versions \
  --repo npm-proxy \
  --package left-pad \
  --versions 1.0.2 \
  --justification "<same justification text as original>"

The retry call mints its own correlation_id. The server's BlockVersionsUseCase generates a fresh correlation_id per invocation (Uuid::new_v4()); there is no caller-supplied --correlation-id seam on block versions today (a retry helper that reuses the original correlation_id is recognised, deferred future work). Reconstructing intent across the two calls. The operator correlates the original batch with the retry batch via the shared justification text + actor + close-in-time timestamps: ``sh hort-cli curation decisions --by-correlation \ --repo npm-proxy --package left-pad ` The two correlation_id groups surface as adjacent rows with identical actor / kind / justification`; visually grouping them as one logical decision is the operator step until a retry helper lands.

Why the same justification. The justification rides every event the call emits, and is the field that ties the original batch to the retry batch in the absence of a shared correlation_id. Re-issuing with a different justification would fragment the audit trail — two reasons for what is conceptually one decision. Re-issuing with the same text preserves the one-decision framing.

8.4 Why not stop-on-first-error

With a VersionList carrying tens of resolved artifact_ids, a stop-on-first-error contract forces the operator to recompute "what landed vs what was skipped" on every retry. Continue-on-error gives the operator a single complete result envelope they can act on (retry the failed subset only). The choice was made deliberately.

8.5 The not_found_versions caveat

Versions in the not_found_versions list are not auto-blocked on future ingest. If the version arrives in the proxy later, it goes through the normal scan + policy gate (it does not automatically inherit your curator-block intent). Stored curator block-rules (a forward-blocking layer keyed on (repo, package, version-range)) are deliberately not built — a recognised gap, deferred.

If you need to block versions that haven't been ingested yet:


9. What the system does NOT do automatically (and why)

These deliberate non-features are the same shape as the patch-release playbook (docs/architecture/how-to/quarantine-patch-release.md §6), extended for the curator surface:

The threat-model rationale is the same as in the patch-release playbook: automation here is exactly the attack pattern. xz's malicious 5.6.0 was framed as a fix-and-improvement release; event-stream's compromised 3.3.6 was published as a maintenance update by a takeover account. A self-service or rule-driven release path would have shipped both payloads to consumers within minutes of upload. Manual curator decisions with attribution are the threat-model-aligned answer.