Facet Quota Configuration Guide

Audience: policy authors, scanner operators, and supply-chain engineers configuring image-drift controls. Purpose: Configure and operate per-facet drift quotas in Stella Ops — controlling how much each part of a container image may change between builds, and what happens (warn, block, or require VEX) when a quota is exceeded.

Version: 2.1.0
Sprint: SPRINT_20260105_002_003_FACET (gate, QTA-011/016/018/019) + SPRINT_20260105_002_004_CLI (CLI surface)
Last Updated: 2026-05-30

This guide covers configuration and operation of per-facet drift quotas in Stella Ops.

Reconciliation note (2026-05-30): This guide was rewritten to match the shipped implementation in StellaOps.Facet, StellaOps.Policy (FacetQuotaGate), and the StellaOps.Cli seal / drift / vex gen commands. Several constructs described in the original 1.0.0 draft (/etc/stellaops/facet-quotas.yaml, a profile: loader, auto-vex action, maxAddedFiles/maxRemovedFiles, stella facet … / stella vex draft … subcommands, the metrics/Grafana surface, and the excititor.vex.facetDriftEnabled key) are not implemented and have been removed or flagged below.

The 2.1.0 pass additionally established that: (1) the FacetQuotaGate and its PolicyGates:FacetQuota config section are library-onlyAddPolicyEngine never registers the gate and PolicyGateEvaluator never reads the section, so it is unenforced in the shipped Policy Engine (see Policy Integration); (2) stella vex gen --format csaf is accepted but not honoured (always emits OpenVEX); and (3) the prior claim of “no references to facet drift under StellaOps.Excititor.Core” was imprecise — a distinct AutoVex/DriftGateIntegration.cs exists there, but it does not consume facet-drift drafts.


Overview

Facet quotas control how much change is acceptable in different parts of a container image between builds. When drift exceeds a quota, the facet quota gate produces one of three verdicts:

The action is selected by the QuotaExceededAction enum on each quota: Warn, Block, or RequireVex (source: StellaOps.Facet/QuotaExceededAction.cs). There is no auto-vex action.

What are Facets?

Facets are logical groupings of files in a container image, defined by glob selectors. The built-in facet set is declared in StellaOps.Facet/BuiltInFacets.cs. Each facet has a granular facet ID (it is the facet ID — not the category name — that is used as the quota key). The shipped built-in facets are:

Category (FacetCategory)Facet IDsExample selectors
OsPackagesos-packages-dpkg, os-packages-rpm, os-packages-apk, os-packages-pacman/var/lib/dpkg/status, /var/lib/rpm/**, /lib/apk/db/**
Interpretersinterpreters-python, interpreters-node, interpreters-ruby, interpreters-perl/usr/bin/python*, /usr/bin/node*
LanguageDependencieslang-deps-npm, lang-deps-pip, lang-deps-nuget, lang-deps-maven, lang-deps-cargo, lang-deps-go, lang-deps-gem**/node_modules/**/package.json, **/site-packages/**/*.dist-info/METADATA
Certificatescerts-system/etc/ssl/certs/**, /etc/pki/**
Binariesbinaries-usr, binaries-lib/usr/bin/*, /usr/lib/**/*.so*
Configurationconfig-etc/etc/**/*.conf, /etc/**/*.yaml, /etc/**/*.json
Custom(user-defined)declared via FacetDefinition / FacetExtractionOptions.Facets

Note: A single file may match more than one facet (the extractor does not stop at the first match — GlobFacetExtractor). There is no built-in data facet for “static data files”; the original draft’s five-row table (binaries / lang-deps / os-packages / configs / data) did not match the code.

Why Per-Facet Quotas?

Different file types have different risk profiles:

Per-facet quotas let you apply appropriate controls to each category by keying overrides on the facet ID.


How Quotas Are Evaluated

Drift is computed by FacetDriftDetector (StellaOps.Facet/FacetDriftDetector.cs) by comparing a current FacetExtractionResult/FacetSeal against a baseline FacetSeal:

The separate DriftScore (0–100) weights changes as additions=1.0, removals=1.0, modifications=0.5 and is informational; the quota verdict is driven by ChurnPercent and MaxChangedFiles, not by DriftScore.


Quota Model (FacetQuota)

Each facet quota is a FacetQuota record (StellaOps.Facet/FacetQuota.cs):

ParameterTypeDefaultDescription
MaxChurnPercentdecimal10Maximum percentage of files in the facet that may change
MaxChangedFilesint50Maximum absolute number of changed files (added + removed + modified)
AllowlistGlobsstring[][]Glob patterns whose files are excluded from drift calculation
ActionQuotaExceededActionWarnAction when the quota is exceeded: Warn, Block, or RequireVex

There are no maxAddedFiles / maxRemovedFiles parameters in the implementation — only the combined MaxChangedFiles count plus MaxChurnPercent.

Built-in presets

FacetQuota exposes three static presets in code (these are C# constants, not YAML-selectable profiles):

PresetMaxChurnPercentMaxChangedFilesAction
FacetQuota.Default1050Warn
FacetQuota.Strict510Block
FacetQuota.Permissive25200Warn

There is no moderate preset and no profile: configuration loader. To get “auto-VEX on review” behaviour, set a facet’s Action to RequireVex explicitly.


Policy Integration

The facet quota gate is FacetQuotaGate in StellaOps.Policy (Gates/FacetQuotaGate.cs). It exposes DI helpers AddFacetQuotaGate(...) and RegisterFacetQuotaGate(gateName = "facet-quota") (FacetQuotaGateServiceCollectionExtensions). A matching configuration POCO FacetQuotaGateOptions hangs off PolicyGateOptions.FacetQuota (StellaOps.Policy.Engine/Gates/PolicyGateOptions.cs), which binds the PolicyGates configuration section (PolicyGateOptions.SectionName = "PolicyGates").

Not wired into the running Policy Engine (library-only as of 2026-05-30). The gate and its AddFacetQuotaGate/RegisterFacetQuotaGate helpers are referenced only by tests (StellaOps.Policy.Tests, StellaOps.Policy.Engine.Tests). PolicyEngineServiceCollectionExtensions.AddPolicyEngine registers the Execution-Evidence and Beacon-Rate context gates but does not call AddFacetQuotaGate/RegisterFacetQuotaGate, and PolicyGateEvaluator evaluates only the Evidence / Lattice / VexTrust / Uncertainty / Confidence gates — it never reads PolicyGateOptions.FacetQuota. So although the PolicyGates:FacetQuota section deserializes into a POCO, nothing in production consumes it for facet enforcement today. To run the gate you must wire it yourself (services.AddFacetQuotaGate(...) + registry.RegisterFacetQuotaGate()) or invoke FacetDriftDetector directly (as the CLI does). The YAML below is therefore the intended binding shape, not a section the shipped Policy Engine acts on.

The standalone file /etc/stellaops/facet-quotas.yaml and the environment variable STELLAOPS_FACET_QUOTAS_PATH referenced in the original draft do not exist.

Policy Engine options (PolicyGates:FacetQuota)

The Policy-Engine-facing option set (PolicyGateOptions.FacetQuotaFacetQuotaGateOptions) is:

PolicyGates:
  FacetQuota:
    Enabled: false              # default: disabled (Policy Engine FacetQuotaGateOptions.Enabled)
    DefaultAction: Warn         # Warn | Block | RequireVex
    DefaultMaxChurnPercent: 10.0
    DefaultMaxChangedFiles: 50
    SkipIfNoBaseline: true      # skip the check when no baseline seal exists
    VexReviewSlaDays: 7         # SLA used when Action == RequireVex
    FacetOverrides:
      binaries-usr:
        MaxChurnPercent: 5
        MaxChangedFiles: 10
        Action: Block
        AllowlistGlobs: []
      lang-deps-npm:
        MaxChurnPercent: 40
        Action: RequireVex

Two FacetQuotaGateOptions types exist: the Policy-Engine binding type above (StellaOps.Policy.Engine/Gates/PolicyGateOptions.cs, with DefaultAction / FacetOverrides / SkipIfNoBaseline / VexReviewSlaDays) and the gate’s own runtime options type (StellaOps.Policy/Gates/FacetQuotaGate.cs, with Enabled, NoSealAction, DefaultQuota, FacetQuotas). Keep this in mind when reading the source.

No-baseline behaviour

FacetQuotaGate reads a precomputed FacetDriftReport from PolicyGateContext.Metadata["FacetDriftReport"]. If no report is present, the gate falls back to its NoSealAction setting (gate-level FacetQuotaGateOptions.NoSealAction):

NoSealActionGate result
Pass (default)passes — treated as a first scan
Warnpasses, reason no_baseline_seal, action=warn
Blockfails, reason no_baseline_seal, action=block

The Policy-Engine binding type expresses the equivalent intent via SkipIfNoBaseline (default true).

Seal Management

Quotas compare current state against a “sealed” baseline produced by the CLI:

# Seal the current image root as a baseline (stores to the seal API by default)
stella seal <image-reference-or-digest>

# Seal only specific facets, write JSON to a file, skip remote storage
stella seal <image> --facets binaries-usr,binaries-lib --output seal.json --store false

# Analyze drift against the latest stored seal, failing CI on a breach
stella drift <image> --baseline <combinedMerkleRoot> --fail-on-breach

stella seal flags (source: Commands/SealCommandGroup.cs): --output/-o, --store (default true), --sign (default true), --key/-k, --facets/-f, --format (json|yaml|compact).

stella drift flags (source: Commands/DriftCommandGroup.cs): --baseline/-b (defaults to the latest seal for the image), --format (table|json|yaml), --verbose-files, --fail-on-breach, --output/-o. With --fail-on-breach, the exit code is 2 for Blocked, 3 for RequiresVex, and 0 otherwise.

The original draft’s stella facet seal --image … --tenant …, stella facet seals list, and stella facet drift --image … forms do not exist; the commands are top-level stella seal <image> and stella drift <image>, and neither takes a --tenant flag.


VEX Generation Workflow

When a facet’s verdict is RequiresVex, a VEX statement/draft can be produced for human review. Two code paths exist:

  1. CLI: stella vex gen --from-drift --image <image> (source: Commands/VexGenCommandGroup.cs) computes drift against the latest/--baseline seal and emits an OpenVEX document to stdout (or --output). Flags: --from-drift (required), --image/-i (required), --baseline/-b, --output/-o, --format (openvex default, csaf), --status (under_investigation default, not_affected, affected), --link-evidence (default true), --evidence-threshold (default 0.8), --show-evidence-uri (default false).

    Note: --format csaf is accepted as an option value but not honoured — the handler always calls GenerateOpenVex(...) and emits OpenVEX regardless of --format. CSAF output is not implemented.

  2. Library emitter: FacetDriftVexEmitter (StellaOps.Facet/FacetDriftVexEmitter.cs) produces FacetDriftVexDraft records for facets with verdict RequiresVex, and IFacetDriftVexDraftStore (StellaOps.Facet/IFacetDriftVexDraftStore.cs) persists/queries them with review status tracking (PendingApproved/Rejected/Expired). Only an in-memory store implementation ships (InMemoryFacetDriftVexDraftStore, registered via AddInMemoryFacetDriftVexDraftStore / AddFacetDriftVexServices).

Not implemented / forward-looking: there is no notification fan-out on draft creation, and there is currently no Excititor/Concelier wiring that ingests these facet-drift drafts into the VEX pipeline. No production source under src/Concelier/__Libraries/StellaOps.Excititor.Core references FacetDriftVexDraft, IFacetDriftVexDraftStore, or StellaOps.Facet. (Excititor does contain a AutoVex/DriftGateIntegration.cs, but that is a separate “auto-VEX downgrade” feature keyed on runtime hot-symbol detections — it does not consume facet-drift drafts.) Persistence of drafts beyond the in-memory store, draft-approval CLI/API surfaces, and notification delivery are roadmap items, not shipped behaviour.

What the CLI actually emits (OpenVEX)

stella vex gen --from-drift emits an OpenVEX document. One statement is generated per facet whose verdict is RequiresVex or Warning (VexGenCommandGroup.GenerateOpenVex). The statement shape:

{
  "@context": "https://openvex.dev/ns",
  "@id": "https://stellaops.io/vex/vex:drift:<sha256>",
  "author": "StellaOps CLI",
  "timestamp": "2026-05-30T00:00:00.0000000Z",
  "version": 1,
  "statements": [
    {
      "@id": "vex:drift-statement:<sha256>",
      "status": "under_investigation",
      "timestamp": "2026-05-30T00:00:00.0000000Z",
      "products": [
        { "@id": "<imageDigest>", "identifiers": { "facet": "binaries-usr" } }
      ],
      "justification": "Facet drift authorization for binaries-usr. Churn: 15.00% (3 added, 0 removed, 9 modified)",
      "action_statement": "Review required before deployment"
    }
  ]
}

The original draft’s VEX example used a synthetic FACET-DRIFT-2026-001234 vulnerability ID and a facet_drift_detected justification string — neither is produced by the implementation. The library emitter (FacetDriftVexDraft) uses the enums FacetDriftVexStatus (Accepted/Rejected/UnderReview) and FacetDriftVexJustification (IntentionalChange/SecurityFix/DependencyUpdate/ ConfigurationChange/Other); the CLI emits free-text OpenVEX status/justification instead.


Monitoring

Not implemented: The metrics and Grafana dashboard described in the original draft do not exist. There is no Meter/Counter/Histogram instrumentation in StellaOps.Facet, the metric names facet_drift_evaluations_total, facet_quota_exceeded_total, facet_vex_drafts_created_total, and facet_seal_operations_total are not emitted anywhere, and no devops/observability/grafana/facet-quota-metrics.json dashboard ships.

For now, observe facet quota outcomes through:

Adding OpenTelemetry metrics and a Grafana dashboard for facet drift is a roadmap item.


Troubleshooting

Quota Unexpectedly Blocking

  1. Inspect the live drift report and per-facet churn:
    stella drift <image> --format json
    
  2. Show detailed file-level changes for the breaching facet(s):
    stella drift <image> --verbose-files
    
  3. Reduce false positives by adding AllowlistGlobs to the relevant facet override under PolicyGates:FacetQuota:FacetOverrides:<facetId>:AllowlistGlobs (allowlisted paths are excluded from the added/removed/modified counts in FacetDriftDetector).

Missing Baseline Seal

# Create the initial baseline seal
stella seal <image>

If no baseline exists at gate time, behaviour is governed by SkipIfNoBaseline (Policy Engine binding, default true) / NoSealAction (gate runtime, default Pass).

There is no stella scan --skip-facet-quota flag. To skip the check, leave the gate disabled (PolicyGates:FacetQuota:Enabled: false, the default) or rely on SkipIfNoBaseline/NoSealAction.Pass.

VEX Statement Not Generated

  1. A facet only triggers RequiresVex when its Action is RequireVex and the quota is exceeded. Verify the override:
    PolicyGates:
      FacetQuota:
        FacetOverrides:
          binaries-usr:
            Action: RequireVex   # not Warn / Block
    
  2. stella vex gen --from-drift only emits statements for facets whose verdict is RequiresVex or Warning; if every facet is Ok it prints “No facets require VEX authorization.”

There is no excititor.vex.facetDriftEnabled configuration key to check — that key does not exist.


Best Practices

  1. Start permissive, then tighten. Begin with FacetQuota.Permissive-style thresholds, observe real drift via stella drift, then ratchet down per-facet MaxChurnPercent/MaxChangedFiles.
  2. Use Block for high-trust facets (binaries-usr, binaries-lib, certs-system) and RequireVex for facets that change for legitimate reasons (lang-deps-*).
  3. Allowlist expected churn (logs, caches, timestamped artefacts) with AllowlistGlobs rather than loosening the whole quota.
  4. Re-seal after each release with stella seal <image> to establish the new baseline.
  5. Wire a durable draft store before relying on RequireVex in production — only the in-memory store ships today.
  6. Wire the gate yourself if you need enforcement. The FacetQuotaGate is not registered by AddPolicyEngine and PolicyGateEvaluator does not read PolicyGates:FacetQuota (see Policy Integration). Today the only live enforcement path is the CLI (stella drift --fail-on-breach) against FacetDriftDetector; an in-process policy gate requires an explicit AddFacetQuotaGate(...) + RegisterFacetQuotaGate() registration in your host.

Source Map

ConcernSource
Quota model & presetssrc/__Libraries/StellaOps.Facet/FacetQuota.cs
Action / verdict enumssrc/__Libraries/StellaOps.Facet/QuotaExceededAction.cs
Drift detection & quota evaluationsrc/__Libraries/StellaOps.Facet/FacetDriftDetector.cs
Built-in facet definitionssrc/__Libraries/StellaOps.Facet/BuiltInFacets.cs, FacetCategory.cs
Seal model & sealersrc/__Libraries/StellaOps.Facet/FacetSeal.cs, FacetSealer.cs
VEX draft emitter / storesrc/__Libraries/StellaOps.Facet/FacetDriftVexEmitter.cs, IFacetDriftVexDraftStore.cs
Policy gatesrc/Policy/__Libraries/StellaOps.Policy/Gates/FacetQuotaGate.cs
Gate DI helpers (AddFacetQuotaGate/RegisterFacetQuotaGate)src/Policy/__Libraries/StellaOps.Policy/Gates/FacetQuotaGateServiceCollectionExtensions.cs
Policy Engine options binding (declared, not consumed for facet gate)src/Policy/StellaOps.Policy.Engine/Gates/PolicyGateOptions.cs
Policy Engine gate wiring (no FacetQuotaGate registration)src/Policy/StellaOps.Policy.Engine/DependencyInjection/PolicyEngineServiceCollectionExtensions.cs, Gates/PolicyGateEvaluator.cs
CLI: sealsrc/Cli/StellaOps.Cli/Commands/SealCommandGroup.cs
CLI: driftsrc/Cli/StellaOps.Cli/Commands/DriftCommandGroup.cs
CLI: vex gensrc/Cli/StellaOps.Cli/Commands/VexGenCommandGroup.cs

Revision History

VersionDateAuthorChanges
1.0.02026-01-07AgentInitial release
2.0.02026-05-30AgentDoc↔code reconciliation: corrected facet IDs, action enum (Warn/Block/RequireVex), quota params (removed non-existent maxAddedFiles/maxRemovedFiles), config surface (PolicyGates:FacetQuota, removed non-existent /etc/stellaops/facet-quotas.yaml + env var), CLI surface (stella seal/drift/vex gen), VEX output shape; flagged metrics, Grafana dashboard, Excititor wiring, --skip-facet-quota, excititor.vex.facetDriftEnabled, and the profile:/moderate loader as NOT IMPLEMENTED
2.1.02026-05-30AgentDeeper doc↔code pass: flagged FacetQuotaGate + PolicyGates:FacetQuota as library-only / not wired into AddPolicyEngine or PolicyGateEvaluator (unenforced in the shipped Policy Engine); noted --format csaf is accepted but always emits OpenVEX; corrected the OpenVEX statement key (id@id); corrected the imprecise “no facet-drift references in Excititor.Core” claim (separate AutoVex/DriftGateIntegration.cs exists but does not consume facet-drift drafts); added imageDigest/vexRequired to the gate Details key list; added Best Practice on self-wiring the gate; added DI-helper Source Map row