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 theStellaOps.Cliseal/drift/vex gencommands. Several constructs described in the original 1.0.0 draft (/etc/stellaops/facet-quotas.yaml, aprofile:loader,auto-vexaction,maxAddedFiles/maxRemovedFiles,stella facet …/stella vex draft …subcommands, the metrics/Grafana surface, and theexcititor.vex.facetDriftEnabledkey) are not implemented and have been removed or flagged below.The 2.1.0 pass additionally established that: (1) the
FacetQuotaGateand itsPolicyGates:FacetQuotaconfig section are library-only —AddPolicyEnginenever registers the gate andPolicyGateEvaluatornever reads the section, so it is unenforced in the shipped Policy Engine (see Policy Integration); (2)stella vex gen --format csafis accepted but not honoured (always emits OpenVEX); and (3) the prior claim of “no references to facet drift underStellaOps.Excititor.Core” was imprecise — a distinctAutoVex/DriftGateIntegration.csexists 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:
- Warn (
QuotaVerdict.Warning): the gate still passes; the breach is recorded in the gate resultDetails. - Block (
QuotaVerdict.Blocked): the gate fails (Passed = false, reasonquota_exceeded). - Require VEX (
QuotaVerdict.RequiresVex): the gate fails (Passed = false, reasonrequires_vex_authorization) and a VEX draft can be generated for human review.
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 IDs | Example selectors |
|---|---|---|
OsPackages | os-packages-dpkg, os-packages-rpm, os-packages-apk, os-packages-pacman | /var/lib/dpkg/status, /var/lib/rpm/**, /lib/apk/db/** |
Interpreters | interpreters-python, interpreters-node, interpreters-ruby, interpreters-perl | /usr/bin/python*, /usr/bin/node* |
LanguageDependencies | lang-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 |
Certificates | certs-system | /etc/ssl/certs/**, /etc/pki/** |
Binaries | binaries-usr, binaries-lib | /usr/bin/*, /usr/lib/**/*.so* |
Configuration | config-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-indatafacet 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:
- A single binary change might be intentional (rebuild) or might indicate compromise
- 100 new npm packages might be a normal dependency update
- Removing OS security packages is always suspicious
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:
- Churn percent =
(added + removed + modified) / baselineFileCount * 100(per facet). When the baseline facet has zero files, churn is100%if any files were added, otherwise0%. - A quota is exceeded when
churnPercent > MaxChurnPercentORtotalChanges > MaxChangedFiles(FacetDriftDetector.EvaluateQuota). - When a facet is removed entirely, or file-level detail is unavailable, the facet drift is treated as maximum drift (
DriftScore = 100) and the quotaActionis applied directly. - A brand-new facet (present in current, absent in baseline) is reported with verdict
Warningby default. - The report’s overall verdict is the worst per-facet verdict in the order
Blocked>RequiresVex>Warning>Ok(ComputeOverallVerdict).
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 byChurnPercentandMaxChangedFiles, not byDriftScore.
Quota Model (FacetQuota)
Each facet quota is a FacetQuota record (StellaOps.Facet/FacetQuota.cs):
| Parameter | Type | Default | Description |
|---|---|---|---|
MaxChurnPercent | decimal | 10 | Maximum percentage of files in the facet that may change |
MaxChangedFiles | int | 50 | Maximum absolute number of changed files (added + removed + modified) |
AllowlistGlobs | string[] | [] | Glob patterns whose files are excluded from drift calculation |
Action | QuotaExceededAction | Warn | Action when the quota is exceeded: Warn, Block, or RequireVex |
There are no
maxAddedFiles/maxRemovedFilesparameters in the implementation — only the combinedMaxChangedFilescount plusMaxChurnPercent.
Built-in presets
FacetQuota exposes three static presets in code (these are C# constants, not YAML-selectable profiles):
| Preset | MaxChurnPercent | MaxChangedFiles | Action |
|---|---|---|---|
FacetQuota.Default | 10 | 50 | Warn |
FacetQuota.Strict | 5 | 10 | Block |
FacetQuota.Permissive | 25 | 200 | Warn |
There is no
moderatepreset and noprofile:configuration loader. To get “auto-VEX on review” behaviour, set a facet’sActiontoRequireVexexplicitly.
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/RegisterFacetQuotaGatehelpers are referenced only by tests (StellaOps.Policy.Tests,StellaOps.Policy.Engine.Tests).PolicyEngineServiceCollectionExtensions.AddPolicyEngineregisters the Execution-Evidence and Beacon-Rate context gates but does not callAddFacetQuotaGate/RegisterFacetQuotaGate, andPolicyGateEvaluatorevaluates only the Evidence / Lattice / VexTrust / Uncertainty / Confidence gates — it never readsPolicyGateOptions.FacetQuota. So although thePolicyGates:FacetQuotasection 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 invokeFacetDriftDetectordirectly (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.yamland the environment variableSTELLAOPS_FACET_QUOTAS_PATHreferenced in the original draft do not exist.
Policy Engine options (PolicyGates:FacetQuota)
The Policy-Engine-facing option set (PolicyGateOptions.FacetQuota → FacetQuotaGateOptions) 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
FacetQuotaGateOptionstypes exist: the Policy-Engine binding type above (StellaOps.Policy.Engine/Gates/PolicyGateOptions.cs, withDefaultAction/FacetOverrides/SkipIfNoBaseline/VexReviewSlaDays) and the gate’s own runtime options type (StellaOps.Policy/Gates/FacetQuotaGate.cs, withEnabled,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):
NoSealAction | Gate result |
|---|---|
Pass (default) | passes — treated as a first scan |
Warn | passes, reason no_baseline_seal, action=warn |
Block | fails, 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, andstella facet drift --image …forms do not exist; the commands are top-levelstella seal <image>andstella drift <image>, and neither takes a--tenantflag.
VEX Generation Workflow
When a facet’s verdict is RequiresVex, a VEX statement/draft can be produced for human review. Two code paths exist:
CLI:
stella vex gen --from-drift --image <image>(source:Commands/VexGenCommandGroup.cs) computes drift against the latest/--baselineseal and emits an OpenVEX document to stdout (or--output). Flags:--from-drift(required),--image/-i(required),--baseline/-b,--output/-o,--format(openvexdefault,csaf),--status(under_investigationdefault,not_affected,affected),--link-evidence(default true),--evidence-threshold(default 0.8),--show-evidence-uri(default false).Note:
--format csafis accepted as an option value but not honoured — the handler always callsGenerateOpenVex(...)and emits OpenVEX regardless of--format. CSAF output is not implemented.Library emitter:
FacetDriftVexEmitter(StellaOps.Facet/FacetDriftVexEmitter.cs) producesFacetDriftVexDraftrecords for facets with verdictRequiresVex, andIFacetDriftVexDraftStore(StellaOps.Facet/IFacetDriftVexDraftStore.cs) persists/queries them with review status tracking (Pending→Approved/Rejected/Expired). Only an in-memory store implementation ships (InMemoryFacetDriftVexDraftStore, registered viaAddInMemoryFacetDriftVexDraftStore/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.CorereferencesFacetDriftVexDraft,IFacetDriftVexDraftStore, orStellaOps.Facet. (Excititor does contain aAutoVex/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-001234vulnerability ID and afacet_drift_detectedjustification string — neither is produced by the implementation. The library emitter (FacetDriftVexDraft) uses the enumsFacetDriftVexStatus(Accepted/Rejected/UnderReview) andFacetDriftVexJustification(IntentionalChange/SecurityFix/DependencyUpdate/ConfigurationChange/Other); the CLI emits free-text OpenVEXstatus/justificationinstead.
Monitoring
Not implemented: The metrics and Grafana dashboard described in the original draft do not exist. There is no
Meter/Counter/Histograminstrumentation inStellaOps.Facet, the metric namesfacet_drift_evaluations_total,facet_quota_exceeded_total,facet_vex_drafts_created_total, andfacet_seal_operations_totalare not emitted anywhere, and nodevops/observability/grafana/facet-quota-metrics.jsondashboard ships.
For now, observe facet quota outcomes through:
- The gate result
Detailswritten byFacetQuotaGate(keys includeoverallVerdict,breachedFacets,totalChangedFiles,imageDigest, a per-facetfacet:<id>object for each breached facet withverdict,churnPercent,added,removed,modified, and — when the verdict isRequiresVex— avexRequired: trueflag). - CLI exit codes from
stella drift --fail-on-breach(2= Blocked,3= RequiresVex). - Overdue/expired draft queries via
IFacetDriftVexDraftStore.GetOverdueAsync/PurgeExpiredAsyncwhen the VEX draft store is wired in.
Adding OpenTelemetry metrics and a Grafana dashboard for facet drift is a roadmap item.
Troubleshooting
Quota Unexpectedly Blocking
- Inspect the live drift report and per-facet churn:
stella drift <image> --format json - Show detailed file-level changes for the breaching facet(s):
stella drift <image> --verbose-files - Reduce false positives by adding
AllowlistGlobsto the relevant facet override underPolicyGates:FacetQuota:FacetOverrides:<facetId>:AllowlistGlobs(allowlisted paths are excluded from the added/removed/modified counts inFacetDriftDetector).
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-quotaflag. To skip the check, leave the gate disabled (PolicyGates:FacetQuota:Enabled: false, the default) or rely onSkipIfNoBaseline/NoSealAction.Pass.
VEX Statement Not Generated
- A facet only triggers
RequiresVexwhen itsActionisRequireVexand the quota is exceeded. Verify the override:PolicyGates: FacetQuota: FacetOverrides: binaries-usr: Action: RequireVex # not Warn / Block stella vex gen --from-driftonly emits statements for facets whose verdict isRequiresVexorWarning; if every facet isOkit prints “No facets require VEX authorization.”
There is no
excititor.vex.facetDriftEnabledconfiguration key to check — that key does not exist.
Best Practices
- Start permissive, then tighten. Begin with
FacetQuota.Permissive-style thresholds, observe real drift viastella drift, then ratchet down per-facetMaxChurnPercent/MaxChangedFiles. - Use
Blockfor high-trust facets (binaries-usr,binaries-lib,certs-system) andRequireVexfor facets that change for legitimate reasons (lang-deps-*). - Allowlist expected churn (logs, caches, timestamped artefacts) with
AllowlistGlobsrather than loosening the whole quota. - Re-seal after each release with
stella seal <image>to establish the new baseline. - Wire a durable draft store before relying on
RequireVexin production — only the in-memory store ships today. - Wire the gate yourself if you need enforcement. The
FacetQuotaGateis not registered byAddPolicyEngineandPolicyGateEvaluatordoes not readPolicyGates:FacetQuota(see Policy Integration). Today the only live enforcement path is the CLI (stella drift --fail-on-breach) againstFacetDriftDetector; an in-process policy gate requires an explicitAddFacetQuotaGate(...)+RegisterFacetQuotaGate()registration in your host.
Source Map
| Concern | Source |
|---|---|
| Quota model & presets | src/__Libraries/StellaOps.Facet/FacetQuota.cs |
| Action / verdict enums | src/__Libraries/StellaOps.Facet/QuotaExceededAction.cs |
| Drift detection & quota evaluation | src/__Libraries/StellaOps.Facet/FacetDriftDetector.cs |
| Built-in facet definitions | src/__Libraries/StellaOps.Facet/BuiltInFacets.cs, FacetCategory.cs |
| Seal model & sealer | src/__Libraries/StellaOps.Facet/FacetSeal.cs, FacetSealer.cs |
| VEX draft emitter / store | src/__Libraries/StellaOps.Facet/FacetDriftVexEmitter.cs, IFacetDriftVexDraftStore.cs |
| Policy gate | src/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: seal | src/Cli/StellaOps.Cli/Commands/SealCommandGroup.cs |
| CLI: drift | src/Cli/StellaOps.Cli/Commands/DriftCommandGroup.cs |
| CLI: vex gen | src/Cli/StellaOps.Cli/Commands/VexGenCommandGroup.cs |
Revision History
| Version | Date | Author | Changes |
|---|---|---|---|
| 1.0.0 | 2026-01-07 | Agent | Initial release |
| 2.0.0 | 2026-05-30 | Agent | Doc↔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.0 | 2026-05-30 | Agent | Deeper 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 |
