Predicate Schema: stella.ops/federatedConsent@v1
Overview
This contract is for engineers integrating with Stella Ops federated telemetry. The stella.ops/federatedConsent@v1 predicate represents a consent attestation for federated telemetry participation: a valid consent proof must exist before any telemetry data can be aggregated and shared with federation peers. The companion bundle contract is federated-telemetry-v1.md(stella.ops/federatedTelemetry@v1).
The consent surface is implemented in StellaOps.Telemetry.Federation (Consent/ConsentManager.cs, Consent/IConsentManager.cs) and exposed over HTTP by the Platform WebService (Endpoints/FederationTelemetryEndpoints.cs). The predicate type string is configurable via FederatedTelemetryOptions.ConsentPredicateType (FederatedTelemetry:ConsentPredicateType) and defaults to stella.ops/federatedConsent@v1.
Implementation note: consent state is held in-memory by the singleton
ConsentManager(aConcurrentDictionarykeyed by tenant ID). There is currently no PostgreSQL persistence or migration for federated consent — state does not survive a Platform WebService restart. This is the documented MVP behaviour as of this contract version.
Predicate Type
stella.ops/federatedConsent@v1
The sibling telemetry-bundle predicate is stella.ops/federatedTelemetry@v1 (FederatedTelemetryOptions.BundlePredicateType); it is out of scope for this contract.
Schema
This is the DSSE payload document — the JSON object that is base64-encoded into the payload field of the signed DSSE envelope. It is emitted by ConsentManager.ConsentPayloadDocument and serialized with System.Text.Json (camelCase property names as written below).
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"required": ["tenantId", "grantedBy", "grantedAt", "type"],
"properties": {
"tenantId": {
"type": "string",
"description": "Identifier of the tenant granting consent. On the HTTP API this is taken from the authenticated tenant context, not the request body."
},
"grantedBy": {
"type": "string",
"description": "Free-form identifier of the actor who granted consent (e.g. an email, service-account name, or operator handle). The code treats it as an opaque string and does not validate it as an email."
},
"grantedAt": {
"type": "string",
"format": "date-time",
"description": "UTC timestamp when consent was granted. Set server-side to the current time from the injected TimeProvider."
},
"expiresAt": {
"type": ["string", "null"],
"format": "date-time",
"description": "Optional expiry timestamp; null means consent has no TTL. Computed as grantedAt + ttl when a TTL is supplied."
},
"type": {
"type": "string",
"const": "stella.ops/federatedConsent@v1",
"description": "Predicate type. Matches FederatedTelemetryOptions.ConsentPredicateType; the const value is the default."
}
}
}
Consent Lifecycle States
The lifecycle is modelled by ConsentManager. Note that “Expired” and “Revoked” are not persisted as distinct records: an expired entry is removed lazily on the next read (GetConsentStateAsync), and a revoke removes the entry outright (RevokeConsentAsync). In both cases GetConsentStateAsync subsequently reports granted: false — i.e. the tenant returns to the Not Granted state. The states below are therefore observable conditions rather than stored status values.
| State | Description | Transition |
|---|---|---|
| Not Granted | Default state; no federation data shared | -> Granted (via POST /api/v1/telemetry/federation/participation/consent/grant) |
| Granted | Active consent; federation data flows | -> Revoked (via POST /api/v1/telemetry/federation/participation/consent/revoke) or -> Expired (TTL) |
| Expired | TTL elapsed; entry dropped on next read, reverts to Not Granted | -> Granted (re-grant) |
| Revoked | Explicit revocation; entry removed, reverts to Not Granted | -> Granted (re-grant) |
Validation Rules
The following describe how ConsentManager.VerifyProofAsync actually validates a ConsentProof, plus a note on what is not checked.
tenantIdis bound to the authenticated tenant context by the HTTP grant endpoint (the request body has notenantIdfield), so it is enforced by construction rather than compared.VerifyProofAsyncadditionally requires the envelope payload’stenantIdto equalproof.TenantId.- The envelope’s
payloadTypemust equal the configuredConsentPredicateType, and the payload’stype,grantedBy,grantedAt, andexpiresAtfields must all match the correspondingConsentProofvalues exactly. - If
proof.SignerKeyIdis set, it must match the verified envelope’s signer key ID. - The proof must not be expired at verification time: if
expiresAtis set andnow >= expiresAt, verification fails. - The consent proof is DSSE-signed; the envelope digest (computed over the full DSSE envelope bytes, not the payload alone) is recorded and surfaced as
sha256:<lowercase-hex>(HmacFederationDsseEnvelopeService.ComputeDigest).VerifyProofAsyncrecomputes the envelope digest and rejects the proof if it does not matchproof.DsseDigest.
Not enforced: there is no check that
grantedAtis “not in the future” and no standaloneexpiresAt > grantedAtvalidation.grantedAtis always set server-side to the current time, andexpiresAt, when present, is computed asgrantedAt + ttl, so these conditions hold by construction rather than being validated.
Example Payload
{
"tenantId": "org-acme-production",
"grantedBy": "admin@acme.com",
"grantedAt": "2026-02-20T10:00:00Z",
"expiresAt": "2026-03-20T10:00:00Z",
"type": "stella.ops/federatedConsent@v1"
}
DSSE Envelope and Signing
The payload above is wrapped in a DSSE envelope, not signed directly. HmacFederationDsseEnvelopeService produces an envelope of the form:
{
"payloadType": "stella.ops/federatedConsent@v1",
"payload": "<base64 of the JSON payload>",
"signatures": [ { "keyid": "<DsseSignerKeyId>", "sig": "<base64 signature>" } ]
}
- Algorithm: the signature is an HMAC-SHA256 computed over the DSSEv1 Pre-Authentication Encoding (
PAE(payloadType, payload)), routed throughICryptoHmac.ComputeHmacForPurpose(..., HmacPurpose.Signing). This is a symmetric MAC, not an asymmetric signature. - Key material: signing uses
FederatedTelemetryOptions.DsseSignerKeyId+DsseSignerSecret. Verification trustsDsseTrustedKeys(key ID -> secret) plus the local signer key. - Unsigned fallback: when no key material is configured and
AllowUnsignedDsseFallbackis true, the service emits an unsigned envelope (emptysignatures) tagged with the key IDoffline-unsigned-fallback; otherwise signing throwsFederationSignatureException(federation.dsse.signer_unavailable). - Verification errors surface as machine-readable codes such as
federation.dsse.payload_type_mismatch,federation.dsse.signature_missing, andfederation.dsse.signature_invalid_or_untrusted.
HTTP API
The consent surface is served by Platform under /api/v1/telemetry/federation/participation. All participation endpoints require an authenticated tenant; actor identity is derived from authentication and is never accepted from request JSON.
| Method | Route | Scope | Description |
|---|---|---|---|
| GET | /api/v1/telemetry/federation/participation/consent | telemetry:participation:read | Get current consent state for the authenticated tenant. |
| POST | /api/v1/telemetry/federation/participation/consent/grant | telemetry:participation:write | Grant federation telemetry consent. |
| POST | /api/v1/telemetry/federation/participation/consent/revoke | telemetry:participation:write | Revoke federation telemetry consent. |
Scope policies map as follows (PlatformScopes / PlatformPolicies): the FederationRead policy requires platform:federation:read (StellaOpsScopes.PlatformFederationRead) and the FederationManage policy requires platform:federation:write (StellaOpsScopes.PlatformFederationWrite). The same route group also serves read-only federation endpoints (/status, /bundles, /bundles/{id}, /intelligence, /privacy-budget) and a POST /trigger aggregation endpoint, which are outside the scope of this consent contract.
Grant request
FederationGrantConsentRequest:
{ "ttlHours": 720 }
ttlHours(integer, optional): consent lifetime in hours. When omitted, consent has no expiry.
Grant response
FederationConsentProofResponse:
{
"tenantId": "org-acme-production",
"grantedBy": "admin@acme.com",
"grantedAt": "2026-02-20T10:00:00Z",
"expiresAt": "2026-03-20T10:00:00Z",
"dsseDigest": "sha256:<hex>"
}
The HTTP response intentionally omits the raw DSSE
envelopebytes andsignerKeyId; those are carried on the internalConsentProofrecord (IConsentManager) but are not returned over the API.
Consent state response
FederationConsentStateResponse:
{
"granted": true,
"grantedBy": "admin@acme.com",
"grantedAt": "2026-02-20T10:00:00Z",
"expiresAt": "2026-03-20T10:00:00Z",
"dsseDigest": "sha256:<hex>"
}
When no consent is recorded (or it has expired), all nullable fields are null and granted is false.
Revoke request
FederationRevokeConsentRequest:
{ "revokedBy": "admin@acme.com" }
Returns { "revoked": true } on success. revokedBy is accepted but, in the current implementation, is not persisted (revocation simply removes the in-memory entry).
Configuration
Bound from the FederatedTelemetry section (FederatedTelemetryOptions):
| Key | Default | Notes |
|---|---|---|
FederatedTelemetry:ConsentPredicateType | stella.ops/federatedConsent@v1 | DSSE predicate type for consent. |
FederatedTelemetry:BundlePredicateType | stella.ops/federatedTelemetry@v1 | DSSE predicate type for telemetry bundles. |
FederatedTelemetry:SiteId | default | This site’s identifier in the federation mesh. |
FederatedTelemetry:SealedModeEnabled | false | When true, no outbound federation traffic. |
FederatedTelemetry:DsseSignerKeyId | (empty) | Key ID used when signing envelopes. |
FederatedTelemetry:DsseSignerSecret | (empty) | HMAC secret for the default signer. |
FederatedTelemetry:DsseTrustedKeys | (empty map) | Trusted signer secrets keyed by key ID, for verification. |
FederatedTelemetry:AllowUnsignedDsseFallback | false | When true, missing key material yields unsigned envelopes tagged offline-unsigned-fallback. |
