Imposed rule: Work of this type or tasks of this type on this component must also be applied everywhere else it should be applied.
Pack Signing & RBAC Controls
This document defines the signing, verification, and authorization requirements for Stella Ops Task Packs across the CLI, Packs Registry, Task Runner, and Offline Kit. It is written for operators who publish and approve packs and for engineers building pack tooling. It aligns with Authority sprint tasks (AUTH-PACKS-41-001, AUTH-PACKS-43-001) and security-guild expectations. For the canonical scope catalogue and role model, see authority-scopes.md and scopes-and-roles.md.
Implementation status (reconciled 2026-05-30). Several controls in this document are the target posture; the shipped implementation is narrower. Where they diverge, an explicit Status note is given. Ground truth:
StellaOpsScopes.cs(scope catalog),StellaOps.PacksRegistry.*(registry service),PasswordGrantHandlers.cs/StellaOpsScopeAuthorizationHandler.cs(Authority approval-token enforcement), andStellaOps.Cli(PackClient.cs,CommandFactory.cs).Key gaps to be aware of before relying on this page:
- The Packs Registry service signature verifier for uploaded packs is
SimpleSignatureVerifier(SHA-256 digest match, accepts unsigned) orRsaSignatureVerifier(RSA-PKCS1 over the digest string). Cosign/Fulcio and DSSE verification of uploaded pack bundles are not implemented — those rows are roadmap.- The OFFLINE-SEED EXPORT is a separate path and it IS signed (SPRINT_20260722_012 JOB-13, F-JOB13-1; producer/verifier interop and key precedence fixed by SPRINT_20260914_001 JCR-1).
POST /api/v1/export/offline-seedwritescatalog-manifest.json.dsse.jsonbeside the manifest when an export signing key is configured (PacksRegistry:Export:Signing:*, ES256, runtime-supplied — see §7.1). The envelope binds the manifest by SHA-256, so a verifier must recompute that digest and check the signature — binding is not authenticity. With no key the export stays complete and unsigned, and the missing envelope is what says so. Verify withtools/scripts/packs/verify_offline_seed_archive.py --public-key <pem>.- The Packs Registry WebService authorizes with a single static
X-API-Key(PacksRegistry:Auth:ApiKey); when no key is configured, auth is disabled. It does not enforce thepacks.*OAuth scopes — scope enforcement lives in the CLI token requests and the shared Authority resource-server policies (StellaOpsResourceServerPolicies), applied when the registry is fronted by the gateway/router.
1 · Threat Model Highlights
| Threat | Mitigation |
|---|---|
| Unsigned or tampered pack uploaded to registry | Digest is recomputed (SHA-256) and the supplied signature is verified on upload (PackService.UploadAsync). Status: today the verifier is SimpleSignatureVerifier (digest match; unsigned accepted unless Policy.RequireSignature=true) or RsaSignatureVerifier. Mandatory cosign/DSSE verification is roadmap (not implemented). |
| Unauthorized user publishing or promoting packs | Authority scope packs.write is requested by the CLI and enforced by the shared resource-server policy when the registry is gateway-fronted. Status: the standalone Packs Registry WebService gates on a static X-API-Key, not on the packs.* scopes. |
| Privilege escalation during approvals | Approval tokens require packs.approve and the pack_run_id / pack_gate_id / pack_plan_hash parameters at the token endpoint, plus fresh-auth (≤ 5 minutes); enforced in PasswordGrantHandlers (issuance) and StellaOpsScopeAuthorizationHandler (resource layer). All three are recorded as sensitive audit properties (pack.run_id, pack.gate_id, pack.plan_hash). |
| Secret exfiltration via pack steps | Secrets injection with redaction, sealed-mode network guardrails, evidence review. Status: the CLI exposes secret injection (PackClient.InjectSecretAsync); the broader sandbox/redaction guarantees are roadmap. |
| Replay of old approval tokens | Approval tokens bind pack_plan_hash (validated as a 32–128 char hex digest) and require fresh authentication; stale auth_time is rejected at the resource layer. |
| Malicious pack in Offline Kit | Mirror/offline-seed export carries provenance and digests, and the export’s catalog manifest is DSSE-signed when a key is configured (JOB-13). Status: the PRODUCE side is implemented; DSSE verification on IMPORT remains roadmap, so a consumer must still verify the archive itself before trusting it. |
2 · Signing Requirements
2.1 Implemented today
- On upload, the registry recomputes the bundle digest as
sha256:<hex>(PackService.ComputeSha256) and validates the supplied signature via the configuredIPackSignatureVerifier:SimpleSignatureVerifier(Development/Testing fallback when no public key is configured): with no signature it checks integrity only (the content must hash to the declared digest); any supplied signature is rejected, because it holds no key to verify one. Outside Development and Testing the host refuses to start without a key.RsaSignatureVerifier(selected whenPacksRegistry:Verification:PublicKeyPemis set, directly or read from the filePacksRegistry:Verification:PublicKeyPemFilenames): verifies a base64 RSA-PKCS1 signature over the UTF-8 digest text using SHA-256. The release bundle generates a packs-signing RSA key pair per installation, mounts the public half at/app/etc/jobengine/keys/packs-signing.pub.pemthroughPublicKeyPemFile, and keeps the private half insecrets/packsregistry/packs-signing.pem; sign the packs you upload with that private key.
- Signature, digest, and (optional) provenance digest are stored alongside the bundle in registry storage (
PackRecord). - A signature can be required by policy:
PacksRegistry:Policy:RequireSignature(default false). Whentrue, upload with an empty signature is rejected ("Signature is required by policy."). - Signatures can be rotated post-upload via
POST /api/v1/packs/{packId}/signature(re-verified against the stored content; emits asignature.rotatedaudit event). - CLI: there is no
stella pack …group any more — it was deleted on 2026-09-14 (SPRINT_20260914_003 JEF-4) because every path it called (/api/v1/packs/plan|run|push|pull|verify|runs/*|secrets) was unmapped by any service. Bundles reach the registry throughPOST /api/v1/packsdirectly; the signature and--force-style semantics live on that endpoint, not in a CLI flag, and there is no--insecure-publishflag anywhere.
2.2 Roadmap (NOT IMPLEMENTED in the registry service)
The following describe the intended target posture and are not present in StellaOps.PacksRegistry:
- Cosign signatures (keyless/Fulcio OIDC, KMS/HSM-backed, offline-vault keys).
- DSSE attestations embedding manifest digest, build metadata (repo, commit, CI run), and CLI version (
stella/pack). - Trust-policy verification modes:
| Policy | Description | Status |
|---|---|---|
anyOf | Accepts any key in configured trust store. | Roadmap |
keyRef | Accepts specific key ID (kid). | Roadmap |
oidcIssuer | Accepts Fulcio certificates from allowed issuers (e.g., https://fulcio.sigstore.dev). | Roadmap |
threshold | Requires N-of-M signatures. | Roadmap |
3 · RBAC & Scopes
Authority exposes exactly four pack-related scopes (canonical source: StellaOpsScopes.cs):
| Scope | Const | Description |
|---|---|---|
packs.read | StellaOpsScopes.PacksRead | Read-only access to Task Packs catalogues and manifests. |
packs.write | StellaOpsScopes.PacksWrite | Publish or update Task Packs in the registry. |
packs.run | StellaOpsScopes.PacksRun | Execute Task Packs via CLI or Task Runner. |
packs.approve | StellaOpsScopes.PacksApprove | Fulfil Task Pack approval gates. |
All four are exposed as resource-server policies in StellaOpsResourceServerPolicies and seeded into the authority.permissions catalog by S002_seed_full_scope_permissions.sql. Tenant assignment is mandatory for any pack scope under the client-credentials grant (ClientCredentialsHandlers rejects with "Pack scopes require a tenant assignment.").
Note. The CLI
PackClientadditionally requests two scopes —packs.secrets(secret injection) andpacks.cache(cache management) — that are not registered inStellaOpsScopes.cs. They are therefore unknown scopes and will not be issued in an OpTok; treat the corresponding CLI surfaces (InjectSecretAsync,ManageCacheAsync) as not yet scope-backed.
3.1 Role Mapping (illustrative)
These role bundles are guidance only — they are not defined as named roles in the codebase. Operators assemble custom roles from the four scopes above via the Console Admin role editor.
| Role (suggested) | Scopes | Use Cases |
|---|---|---|
pack.viewer | packs.read | Inspect packs, plan runs. |
pack.publisher | packs.read, packs.write | Publish new versions, manage channels. |
pack.operator | packs.read, packs.run | Execute packs, monitor runs. |
pack.approver | packs.read, packs.approve | Fulfil approvals, authorize promotions. |
pack.admin | all four | Full lifecycle management (rare). |
Roles are tenant-scoped; cross-tenant access requires explicit addition.
3.2 CLI Enforcement
- The
stella pack …verb group was deleted on 2026-09-14 (SPRINT_20260914_003 JEF-4, owner ruling reversing the 012 record-only decision). Its fifteen verbs (plan,run,push,pull,verify,runs list|show|cancel|pause|resume|logs,secrets inject,cache list|add|prune) called/api/v1/packs/plan,/run,/push,/pull,/verify,/runs/*,/secretsand/runs/{id}/artifacts/*— none mapped by any service — and requestedpacks.cacheandpacks.secrets, which are not in the scope catalog. No test covered them. The remaining CLI caller of the registry isstella crypto verification-key reenvelope(POST /api/v1/packs/re-envelope, scopepacks.write);stella assurance packandstella deltasig packare unrelated local verbs that keep the word. - There was never a
pack approveverb under that group. - Approval tokens (
packs.approve) are obtained directly from the Authority token endpoint, not via a dedicated CLI subcommand. Whenpacks.approveis requested, the/tokenendpoint requires thepack_run_id,pack_gate_id, andpack_plan_hashrequest parameters (PasswordGrantHandlers). Validation rules:pack_run_id,pack_gate_id: non-empty, ≤ 128 characters.pack_plan_hash: a 32–128 character lowercase hexadecimal digest.- Authority rejects grants that omit or truncate any field and records the values as sensitive audit properties
pack.run_id,pack.gate_id,pack.plan_hash.
4 · Approvals & Fresh Auth
- Approval commands require recent fresh-auth (< 5 minutes). When the
packs.approvescope is requested,/tokendemandspack_run_id,pack_gate_id, andpack_plan_hash(PasswordGrantHandlers), and the resource-layer scope handler (StellaOpsScopeAuthorizationHandler) re-verifies that the corresponding claims are present plus thatauthentication_timefalls within the five-minute window before authorizing the request. - Failure surfaces:
- At issuance, a missing/invalid metadata field rejects the grant and records
packs.approveunder the auditInvalidScopeproperty (AuditInvalidScopeProperty). - The
authority.pack_scope_violationactivity tag is emitted specifically by the client-credentials grant when pack scopes are requested without a tenant assignment (ClientCredentialsHandlers). - At the resource layer, missing metadata or stale auth fails authorization with reasons such as
"packs.approve tokens require pack_run_id claim."or"packs.approve tokens require fresh authentication.", and stampspack.fresh_auth_satisfied(and, when present,pack.auth_time) audit properties.
- At issuance, a missing/invalid metadata field rejects the grant and records
- Approval token metadata (as carried / audited):
pack_run_id/pack.run_idpack_gate_id/pack.gate_idpack_plan_hash/pack.plan_hash(validated 32–128 char hex)
- The plan hash binds the approval to a specific plan to prevent rerouting; the run pause/resume/approval flow is driven through the Packs Registry / Task Runner endpoints (
runs:pause,runs:resume).
5 · Secret Management
Status: largely roadmap / partially wired. The CLI exposes a secret-injection call (
PackClient.InjectSecretAsync→POST /api/v1/packs/runs/{runId}/secrets, requesting the unregisteredpacks.secretsscope), but the documented secret audit events below (secretRequested/secretDelivered) are not emitted by the Packs Registry service. Treat this section as the intended design.
- Secrets defined in a pack manifest are intended to map to Authority secret providers (e.g., HSM, Vault).
- Task Runner is intended to obtain secrets using a service account with scoped access; the CLI may prompt or read from profile.
- Intended secret audit trail (not yet implemented in the registry):
secretRequestedevent with reason, pack, step.secretDelivereddeliberately omitted (only aggregate metrics) to avoid leakage.- Evidence bundle includes hashed secret metadata (no values).
Sealed mode is intended to require secrets to originate from a sealed vault, with external endpoints blocked.
6 · Audit & Evidence
- The Packs Registry emits append-only audit records (
IAuditRepository.AppendAsync) plus structured audit events (IAuditEventEmitter). - Audit events actually emitted by the registry (source:
PackService,LifecycleService,ParityService,MirrorService,AttestationService,ExportService,Program.cs):pack.uploaded(on publish/upload)signature.rotatedlifecycle.updated(deprecation and other lifecycle transitions)parity.updatedattestation.uploadedmirror.upserted,mirror.syncoffline.seed.exported
- The richer event taxonomy previously listed here (
pack.version.published,pack.version.promoted,pack.run.started/completed,pack.approval.requested/granted,pack.secret.requested) is not implemented — it is a target design, not the current emitter set. - Dashboards can track the
authority.pack_scope_violationactivity tag (emitted byClientCredentialsHandlerson tenant-missing pack-scope grants) alongsideauthority.aoc_scope_violationto highlight mis-scoped automation clients. Break down counts byauthority.client_idto surface stale automation/registry configurations quickly. - Evidence Locker retention of attestations and run bundles for 90 days (configurable) is a target — verify against the Evidence Locker configuration before relying on the exact window.
- No
stella pack …CLI group exists (deleted 2026-09-14, JEF-4; apack auditverb never existed). The shipped registry audit surface is the append-only audit log behind the registry API; there is no run-history endpoint in the registry at all (no/api/v1/packs/runs/*route is mapped).
7 · Offline / Air-Gap Policies
Status: partly roadmap. Offline-seed export is implemented (
POST /api/v1/export/offline-seed→offline.seed.exported); the manual offline-signing approval workflow (.approval-request.json,trust-bundle.pem) is a target design and is not wired in the registry/CLI today.
- Offline Kit is intended to include:
- Pack bundles + signatures.
- Trusted key store (
trust-bundle.pem). - Approval workflow instructions for manual signing.
- Air-gapped approvals (target design):
- CLI generates an approval request file (
.approval-request.json). - Approver uses offline CLI to sign with an offline key.
- Response imported to the Task Runner.
- CLI generates an approval request file (
- The CLI defines the relevant pack error codes (
StellaOps.Cli.Output.CliError):ERR_PACK_SIGNATURE_INVALID,ERR_PACK_VERIFY_FAILED,ERR_PACK_PULL_FAILED,ERR_PACK_APPROVAL_REQUIRED,ERR_PACK_OFFLINE_VIOLATION. A signature/verification failure during pull or verify surfacesERR_PACK_SIGNATURE_INVALID/ERR_PACK_VERIFY_FAILED; offline-mode violations surfaceERR_PACK_OFFLINE_VIOLATION.
7.1 · Offline-seed export signing operations
Implemented (SPRINT_20260722_012 JOB-13; corrected by SPRINT_20260914_001 JCR-1/JCR-2). jobengine-web signs the export’s catalog-manifest.json in process: the manifest is hashed as it streams into the archive and a small DSSE subject document (entryName, manifestSha256, tenantId, declaredPackCount) is signed with ES256 and written as the 7th root entry, catalog-manifest.json.dsse.json. The envelope uses the DSSE specification’s field names (payloadType, payload, signatures[].keyid, signatures[].sig) and standard padded base64.
Key supply (.memory-independent summary of KEY_MATERIAL.md’s rule: runtime only, the operator’s source wins):
| Setting | Environment alias | Role |
|---|---|---|
PacksRegistry:Export:Signing:Es256PrivateKeyPemFile | STELLAOPS_PACKSREGISTRY_EXPORT_ES256_PRIVATE_KEY_PEM_FILE | Authoritative once set. Path to a mounted EC P-256 private key PEM. A configured path that is missing, unreadable or empty fails the host at startup naming this variable — never a silent fallback. |
PacksRegistry:Export:Signing:Es256PrivateKeyPkcs8Base64 | STELLAOPS_PACKSREGISTRY_EXPORT_ES256_PRIVATE_KEY_PKCS8_B64 | Inline PKCS#8, consulted only when no file is configured. |
PacksRegistry:Export:Signing:KeyId | STELLAOPS_PACKSREGISTRY_EXPORT_KEY_ID | The keyid written into the envelope. Empty (the compose default) means derived from the key: sha256-<lowercase hex SHA-256 of the public half's DER SubjectPublicKeyInfo>, the delimiter-safe fingerprint form the verification-key rotation runbook uses for introduced roots. Set it only to pin an operator-chosen id. |
Neither source configured → the export is complete and visibly unsigned (no .dsse.json entry; one Information log line per export). A configured signer that fails at signing time also ships unsigned, but logs at Error naming the tenant (owner ruling D-JCR-1). The compose estate mounts devops/etc/jobengine/ read-only at /app/etc/jobengine and points the file variable at packsregistry-export-dev.pem, which devops/compose/scripts/ensure-dev-certs.sh generates per lab (gitignored, never committed) together with its public half packsregistry-export-dev.pub.pem. Run that script before docker compose up. A release-bundle install gets the same pair from install.sh / install.ps1, generated once into the bundle’s config/etc/jobengine/keys/ and never overwritten on a re-run.
Why the key id is derived. A fixed label is independent of the key it names, so the producer can keep signing with one key under a new label, or keep one label across a key change. Platform refuses to publish key material that duplicates an existing root under a different id, including a revoked root. That refusal is what makes revocation final, and it means a relabelled key can never be trusted again. A fingerprint id cannot drift from its key: rotating the key changes the id, and the same key always presents the same id. Compute it from the public half alone:
printf 'sha256-%s
' "$(openssl pkey -pubin -in packsregistry-export-dev.pub.pem -outform DER | openssl dgst -sha256 -r | cut -d' ' -f1)"
Rotating the export signing key (ADR-028; first run on the lab estate 2026-09-25, SPRINT_20260924_005). Use this when the current key is compromised, when its root has been revoked, or on a scheduled rotation. It never needs a change to the distinctness rule, and a revoked root stays revoked.
Generate the new key at runtime. The script moves the current pair out of the directory the container mounts (into the git-ignored
.local-secrets/retired/jobengine-export/<UTC>/), writes a fresh EC P-256 pair at the same paths, proves the pair with a sign/verify round trip, and prints the new key id. It prints no private material.bash devops/compose/scripts/rotate-jobengine-export-key.sh --confirmPublish the public half as a new
pack-export-public-keyroot. This is consequences-gated: the previously active root moves toretiringand stays trusted for the grace window.stella crypto verification-key introduce --profile world --material-type pack-export-public-key --key-id sha256-<fingerprint> --public-material-file devops/etc/jobengine/keys/packsregistry-export-dev.pub.pem --confirm --i-understand-old-material-will-be-invalidatedRecreate
jobengine-webso the host reloads the key and pulls the updated trust set. An estate that adopts the published trust set must clear any localSTELLAOPS_PACKSREGISTRY_EXPORT_VERIFICATION_PUBLIC_KEY_PEM_FILEin the same recreate, because the host refuses to start with both configured (section 7.2).Verify a fresh export against the published document, then confirm the negative case. An archive signed under a revoked root must still be refused with
422 signature-key-untrusted.
The CLI authenticates as the operator when STELLAOPS_AUTHORITY_USERNAME and STELLAOPS_AUTHORITY_PASSWORD are set, and uses the password grant with the tenant from STELLAOPS_AUTHORITY_TENANT. Without a username it falls back to client credentials. It sends no token at all unless STELLAOPS_AUTHORITY_URL is set, and Platform then answers 403. list needs crypto:read. introduce needs crypto:read and crypto:profile:admin together, because the read policy guards every verification-key route and the admin policy is added on top; the CLI requests both.
Verifying an archive. The public key is the SPKI PEM beside the private one; for any other key derive it with openssl ec -in <private.pem> -pubout. Then:
python3 tools/scripts/packs/verify_offline_seed_archive.py --archive export.zip --public-key packsregistry-export-dev.pub.pem
# optional determinism check against a second export of the unchanged catalog:
python3 tools/scripts/packs/verify_offline_seed_archive.py --archive a.zip --compare b.zip --public-key <pem>
In product (SPRINT_20260914_003 JEF-5). The same checks, codes and verdicts ship as the CLI verb stella verify offline-seed, backed by the jobengine family’s closed consumer SDK StellaOps.PacksRegistry.OfflineSeed.Contracts (the producer takes its payload type, schema pin and base64 rule from the same SDK, so the two sides cannot drift; the committed fixture pins both verifiers to the real producer):
stella verify offline-seed --archive export.zip --public-key packsregistry-export-dev.pub.pem
stella verify offline-seed --archive export.zip --trust-set trust-set.json --compare second.zip --output json
--public-key is the SPKI PEM; --trust-set is a stellaops.packsregistry.verification-trust-set/v1 document whose active and retiring roots are trusted. The CLI’s exit policy is stricter than the script’s (D-JEF-6): ABSENT and BOUND_UNVERIFIED exit 1 unless --integrity-only says integrity was all that was asked. Verdicts are identical.
| Verdict | Meaning | stella verify offline-seed exit |
|---|---|---|
VERIFIED | Envelope binds this manifest and the signature verifies under the supplied key. | 0 |
BOUND_UNVERIFIED | Envelope binds this manifest; no --public-key given (or cryptography not installed), so authenticity was not checked. Binding is not authenticity. | 1 (0 with --integrity-only) |
INVALID_SIGNATURE | Bound, but no signature verifies under the supplied key. | 1 |
INVALID_BINDING | The envelope names a different manifest, entry or pack count — the dangerous case, checked first. | 1 |
ABSENT | No envelope: the export ran unsigned. | 1 (0 with --integrity-only) |
UNREADABLE | The envelope could not be parsed. | 1 |
The verifier accepts both base64 alphabets and both field spellings (DSSE parsers must), and a fixture produced by the real service — tools/scripts/packs/fixtures/offline-seed-signed/ — is verified to VERIFIED by its test module, so producer and verifier cannot drift apart unnoticed again.
7.2 · Importing an offline-seed archive
Implemented (SPRINT_20260914_006 OSI-1). POST /api/v1/import/offline-seed takes the archive as an application/zip body under the same packs.write scope the export uses. It verifies with the same SDK as the CLI verb and then replays the accepted packs through the ordinary upload path, so an import cannot acquire weaker rules than an upload.
Trust supply — a NEW key family, deliberately separate from PacksRegistry:Verification:* (that one is the RSA root for pack signatures; this is the ES256 key that signs the archive):
| Setting | Role |
|---|---|
PacksRegistry:Export:Verification:TrustSetJson | A verification-trust-set/v1 document. Its active and retiring roots are trusted, which is the ADR-028 grace window: during a rotation an archive exported under the outgoing key still imports. |
PacksRegistry:Export:Verification:TrustEpoch | Optional. When present it must equal sha256: of <regionProfile>\n<canonical-json>, so a truncated or re-ordered pull fails closed instead of narrowing trust silently. |
PacksRegistry:Export:Verification:PublicKeyPem | A single SPKI PEM, for an estate with one export key and no rotation ledger. Consulted only when no trust set is configured. |
A trust set must carry exactly one active root and at most one retiring one; archived and revoked roots are ledger history, never live trust. A misconfigured root fails the host at startup rather than degrading to “no trust”. With nothing configured the endpoint answers 503 export_trust_not_configured — it refuses rather than verifying against nothing.
Verdict to response. The vocabulary is the CLI verb’s, so an operator reads the same words from either surface:
| Verdict | Response | Catalog effect |
|---|---|---|
VERIFIED, no findings | 200 with importedPacks / alreadyPresentPacks and the signing keyid | Packs written; offline.seed.import.completed audit row |
INVALID_BINDING | 422 with the finding codes | Nothing written; offline.seed.import.refused audit row |
INVALID_SIGNATURE | 422 with the finding codes | Nothing written; refusal audit row |
BOUND_UNVERIFIED | 422 | Nothing written; refusal audit row |
ABSENT | 422 | Nothing written; refusal audit row |
UNREADABLE | 422 | Nothing written; refusal audit row |
ABSENT and BOUND_UNVERIFIED are refusals here with no --integrity-only equivalent. The CLI can offer that escape because it reports to a human; an ingest gate admitting content into a catalog cannot, and integrity is not authenticity.
Tenancy. Packs land in the caller’s validated stellaops:tenant claim. The tenantId inside catalog-manifest.json is read for verification and otherwise ignored, so a cross-estate import is never a cross-tenant write and an archive cannot name its own destination.
7.3 · Anchoring an admitted import in the transparency log
Implemented (SPRINT_20260914_006 OSI-1c, posture D-OSI-4). Every admitted import is anchored in the local transparency log: the archive’s own catalog-manifest.json.dsse.json is submitted to the Attestor’s POST /api/evidence/v1/rekor/entries under the artifact kind offline-seed-import, with the manifest digest as meta.artifact.sha256 and the accepting keyid in the subject.
| Setting | Role |
|---|---|
PacksRegistry:Import:Anchoring:AttestorUrl | Base URL of the Attestor. Blank means no transparency log, which is a supported air-gapped deployment, not a degraded one. |
PacksRegistry:Import:Anchoring:TimeoutSeconds | How long one submission may take before it counts as unreachable (default 10). The dedicated identity’s token request is bounded by the same value. |
PacksRegistry:Import:Anchoring:RetryIntervalSeconds | How often the backlog is drained once a log is configured (default 300). |
PacksRegistry:Import:Anchoring:AuthScope | Scope requested for the submission token (default attest:create). The route sits behind the attestor:write policy, which is satisfied by this scope - the two spellings differ, and requesting the policy name mints a token the policy rejects. |
PacksRegistry:Import:Anchoring:Authority:TokenEndpoint / :ClientId / :ClientSecret | The dedicated anchoring identity. When all three are set the sink mints its own client-credentials token per tenant, sending the tenant as the tenant request parameter so the issued token carries the stellaops:tenant claim the Evidence host reads. Authority answers 400 Tenant selection is required to a client-credentials request that omits it. When they are absent the sink falls back to the host’s shared IStellaOpsTokenClient, if one is registered. |
Each setting also resolves from its flat STELLAOPS_PACKSREGISTRY_IMPORT_ANCHORING_* alias (..._AUTH_SCOPE, ..._AUTHORITY_TOKEN_ENDPOINT, ..._AUTHORITY_CLIENT_ID, ..._AUTHORITY_CLIENT_SECRET). The reference registration is stellaops-jobengine-anchor in devops/etc/authority/plugins/standard.yaml, holding only attest:create; its secret reaches the Authority container through JOBENGINE_ANCHOR_AUTHORITY_CLIENT_SECRET, because the seeder resolves clientSecretEnvironmentVariable in Authority’s own environment, not in the jobengine’s.
The Evidence host must know the artifact kind. offline-seed-import was added to the Attestor’s closed artifact-kind whitelist in the same change that introduced anchoring. An estate whose running Evidence image predates it answers every submission 400 artifact_kind_invalid; the import is still admitted and the entry sits at pending with that exact answer on its packs.import_anchors row, which is the tell to look for before suspecting the sink. Measured on the lab estate 2026-09-20 (SPRINT_20260914_006 OSI-1e): twelve one-minute attempts recorded that answer, and the drainer anchored the row seventeen seconds after the rebuilt Evidence host came up.
A verified import that cannot be anchored is admitted and recorded, never refused. This estate is air-gap-first, so making the log an availability dependency of ingest would break the primary deployment shape; recording “unanchored” and moving on would leave an evidence gap nothing ever closes. The offline.seed.import.completed audit row therefore carries an anchor= field:
anchor= | Meaning | Retried |
|---|---|---|
anchored | The log accepted the entry; the row also carries logIndex=<n>. | Terminal |
pending | A log is configured but did not accept the entry - unreachable, refusing, or rejecting the token alike. The import stands and the entry is queued durably in packs.import_anchors with the log’s answer (or the transport failure) on the row. | Yes, until it lands |
not-configured | No transparency log on this estate. | Never |
Why a deferred anchor is not a lost one. The manifest digest, the accepting key id and the DSSE envelope itself are captured at admission time and retained on the backlog row, so anchoring later proves exactly the same fact it would have proven then — deferral costs evidence timeliness, not evidence. Storing only a digest would have made pending unrecoverable, because the uploaded archive is gone by the time a retry runs.
The log de-duplicates on the canonical bundle hash: importing the same archive bytes twice anchors both admissions to the same logIndex, and a backlog row drained after an identical archive was anchored inline settles to that existing index rather than a new one.
Entries are never discarded on an attempt ceiling: a backlog that will not drain is an operator signal, and dropping it is the evidence loss the queue exists to prevent. Settled rows are retained with their log_index rather than deleted, so pending is the absence of an anchor rather than the presence of a row.
Per-pack signatures still apply. The archive’s ES256 signature attests the catalog; each pack’s own signature attests that pack. A pack whose signature this estate does not trust is refused by the upload path with 422 pack_rejected_on_import. An importing estate must trust both, which is the intended property, not an obstacle to work around.
8 · Incident Response
- Compromised pack signature:
- Revoke key via Authority trust store.
- Deprecate affected versions by transitioning lifecycle state to
deprecated(POST /api/v1/packs/{packId}/lifecycle, statedeprecated). There is noregistry deprecateCLI command; deprecation is via the lifecycle API. - Notify consumers via Notifier. Status: a dedicated
pack.security.alertnotification event is not defined in source today — use a general Notifier rule. - Forensically review run evidence for impacted tenants.
- Unauthorized approval:
- Review audit log for
packs.approveissuance failures (InvalidScope = packs.approve) and pack approval metadata (pack.run_id,pack.gate_id,pack.plan_hash). - Status (verified 2026-09-14, JEF-4): there is no run pause/freeze mechanism in the registry — no
/api/v1/packs/runs/*route is mapped, and theruns pauseverb of the deleted group that pointed at one went with it. Estate-doctor pack execution is gated by the Approvals layer (StellaOps.JobEngine.Approvals) and stopped by disabling the schedule. - Rotate approver credentials and require fresh-auth (the five-minute window forces re-authentication on the next approval token).
- Review audit log for
- Secret leak suspicion:
- Quarantine evidence bundles.
- Rotate secrets referenced by pack.
- Run sealed-mode audit script to confirm guardrails.
9 · Compliance Checklist
- [ ] Signing requirements (cosign/DSSE, trust policies) documented.
- [ ] Authority scope mapping and CLI enforcement captured.
- [ ] Approval workflow + fresh-auth expectations defined.
- [ ] Secret lifecycle (request, injection, audit) described.
- [ ] Audit/evidence integration noted (timeline, Evidence Locker).
- [ ] Offline/air-gap controls outlined.
- [ ] Incident response playbook provided.
- [ ] Imposed rule reminder retained at top.
Last updated: 2025-10-27 (Sprint 43). Reconciled against implementation 2026-05-30 — see the “Implementation status” note at the top; sections 2, 5, 6, 7, and 8 distinguish shipped behavior from roadmap.
