Task — engineering-spec@1

"AUTH Lumi tenant-identity JWT shape — agent_persona + tenant_residency + lumi_org_tenant claims + persona-version stamping + cross-tenant sync identity"

doneTASK-AUTH-108
module auth · class product · priority p0 · created 2026-05-16 · shipped 2026-05-23
depends on TASK-AUTH-101 · blocks none

§1 — Description (BCP-14 normative)

The AUTH service MUST extend TASK-AUTH-004 JWT shape with Lumi-specific claims for cross-tenant sync identity. Each requirement:

  1. MUST add the following 5 claims to JWTs issued via the /v1/auth/lumi/issue endpoint (per DEC-420):
  1. MUST issue Lumi tokens with distinct iss: https://lumi.cyberos.world and aud: https://memory.cyberos.world/sync (per DEC-426 + DEC-427). Per-tenant tokens (TASK-AUTH-004) continue using iss: https://auth.<tenant>.cyberos.world and tenant-specific audiences. Mismatch on either → 401 + auth.lumi_token_rejected memory row.
  1. MUST enforce that agent_persona claim's persona-key ∈ TASK-AUTH-101 agent-persona-role-family enum (per DEC-421). Verifier parses prefix cuo- + key + @ + semver; invalid format → 401 agent_persona_malformed; unknown persona-key → 401 agent_persona_unknown.
  1. MUST verify persona_version against the live version registry from TASK-AI-014 (per DEC-424 + DEC-429). Verifier compares token's persona_version against cuo-<persona-key>@<live-semver> from the registry; if token's version is more than 2 minor versions behind → 401 persona_version_stale + emit auth.lumi_persona_version_stale memory row (sev-2 — signals refresh-token reuse beyond rotation window).
  1. MUST enforce sync_class_allowed is a closed-set array (per DEC-425). Allowed values: 'private', 'shareable', 'publishable', 'shared', 'client-visible'. Unknown values → 401 sync_class_unknown. Empty array → 401 sync_class_empty (token must permit at least one class).
  1. MUST verify tenant_residency claim matches the routing residency of the request (per DEC-422). The memory sync endpoint's host determines the expected residency (e.g. memory.vn-1.cyberos.world expects tenant_residency=vn-1); mismatch → 451 unavailable_for_legal_reasons + emit auth.lumi_token_rejected memory row with reason='residency_mismatch'.
  1. MUST pin Lumi JWT alg at RS256 (per DEC-431). Reject alg=none ALWAYS. Reject alg=HS256 ALWAYS (JWT-confusion attack: attacker uses JWKS public key as HS256 secret). Unknown alg → 401 unsupported_alg.
  1. MUST restrict Lumi token issuance to subjects whose roles claim contains agent-persona per TASK-AUTH-101 (per DEC-432). Human subjects (those without agent-persona role) attempting issuance → 403 human_cannot_issue_lumi. Delegation flow (a human authorising a Lumi issuance on their behalf) deferred to task-AUTH-2xx.
  1. MUST include anchor_chain_hash: TEXT claim (per DEC-433) — the source tenant's memory chain head at token issuance time. The memory sync endpoint validates this against the current chain head; if the source chain has diverged → 409 chain_diverged + emit auth.lumi_token_rejected with reason='chain_diverged'.
  1. MUST define lumi_token_issuance_log table: (id BIGSERIAL PRIMARY KEY, issuer_subject_id UUID NOT NULL REFERENCES auth.subjects(id), agent_persona TEXT NOT NULL, persona_version TEXT NOT NULL, source_tenant_id UUID NOT NULL, lumi_org_tenant TEXT NOT NULL, residency TEXT NOT NULL, sync_class_allowed TEXT[] NOT NULL, anchor_chain_hash TEXT NOT NULL, ttl_seconds INT NOT NULL DEFAULT 3600, issued_at TIMESTAMPTZ NOT NULL DEFAULT now(), token_jti TEXT NOT NULL UNIQUE). REVOKE UPDATE, DELETE FROM cyberos_app (per DEC-430).
  1. MUST enforce RLS with both USING and WITH CHECK on lumi_token_issuance_log. Policy: source_tenant_id = current_setting('auth.tenant_id')::uuid OR current_setting('auth.is_root_admin', true) = 'true' (root-admin can see all for audit).
  1. MUST ship POST /v1/auth/lumi/issue handler. Body: {agent_persona: "<key>", persona_version: "<semver>", lumi_org_tenant: "<slug>", sync_class_allowed: [...], anchor_chain_hash: "<hex>"}. Caller MUST have role agent-persona per TASK-AUTH-101. Validates all fields against closed sets + persona_version against registry. Returns {token: "<jwt>", expires_at: <iso8601>}. Emits auth.lumi_token_issued memory row.
  1. MUST ship GET /v1/auth/lumi/verify handler used by memory sync + downstream consumers. Header: Authorization: Bearer <token>. Returns {valid: bool, claims: <object>, reason: <text?>}. Internal-use only (not exposed externally); the verifier library is the canonical path.
  1. MUST emit 4 memory audit row kinds (per DEC-428):
  1. MUST PII-scrub lumi_org_tenant slug via TASK-MEMORY-111 before chain commit (treated as PII at the cross-tenant boundary).
  1. MUST complete Lumi token issuance in ≤ 100 ms p95 (signing key + JWKS already in process). Verification in ≤ 50 ms p95 (signature verify + claim parse + persona-version lookup from in-memory registry).
  1. MUST emit OTel span auth.lumi.{issue,verify} with attributes: tenant_id, agent_persona, persona_version, lumi_org_tenant, outcome (success | residency_mismatch | chain_diverged | persona_version_stale | aud_mismatch | iss_mismatch | unsupported_alg | sync_class_unknown | agent_persona_unknown | human_cannot_issue).
  1. MUST emit OTel metrics:
  1. MUST sign Lumi JWT with the SAME RS256 keypair used by TASK-AUTH-004 (issuer-distinct via the iss claim; key-distinct deferred to slice 2). The kid is the same; the iss differs — verifiers must check both.
  1. MUST include jti: TEXT claim (JWT ID — RFC 7519) as a UUIDv4. Stored in lumi_token_issuance_log.token_jti for revocation lookup. Revocation API ships in task-AUTH-2xx; this task ships the column.
  1. MUST ship the LumiClaims Rust struct with all 5 new claims + existing 8 TASK-AUTH-004 claims. Total 13 claims. Serialisation order: existing 8 first (preserves backward compat for any TASK-AUTH-004 verifier inspecting only the standard claims), then Lumi additions.
  1. MUST validate that lumi_org_tenant slug matches the pattern ^[a-z][a-z0-9-]{2,40}[a-z0-9]$ per TASK-TEN-001's slug regex. Malformed → 401 lumi_org_tenant_malformed.
  1. MUST support TTL configurable via env LUMI_TOKEN_TTL_SECONDS with default 3600 (per DEC-429). Operators MAY shorten for high-security tenants; values < 300 (5min) → reject env config at startup.
  1. MUST include anchor_chain_hash validation flow: the memory sync endpoint (out of scope here, ships in task-MEMORY-2xx) reads the claim + compares against the current chain head; this task ships the claim emission + verifier-side parsing.
  1. MUST validate sync_class_allowed ⊆ what the source tenant's policy permits. The per-tenant policy YAML (TASK-AI-005) declares per-tenant lumi_sync_max_classes. If requested array exceeds policy → 403 sync_class_exceeds_policy + emit auth.lumi_token_rejected with reason.

§2 — Why this design (rationale for humans)

Why distinct iss for Lumi tokens (DEC-426)? Per-tenant AUTH compromise should not let attackers forge cross-tenant Lumi tokens. Distinct iss means even if a tenant's AUTH cluster is breached, the attacker still needs to forge tokens with iss=https://lumi.cyberos.world — that's a separate trust anchor. Verifiers check iss + tenant_id mapping; mismatch fails closed.

Why distinct aud (DEC-427)? Audience-bound tokens (RFC 7519 §4.1.3) prevent token misuse: a Lumi token meant for memory.cyberos.world/sync cannot be replayed against api.cyberos.world/v1/.... Bounding audience to sync endpoints contains the blast radius of any compromise.

Why alg=HS256 rejected (DEC-431, §1 #7)? Classic JWT-confusion attack: attacker takes a JWKS public key, signs a token using HS256 with the public key as the HMAC secret, and the verifier (if it accepts HS256) validates because the "secret" matches. The defense is to pin alg=RS256 — rejecting HS256 always means the attack cannot succeed regardless of verifier bugs.

Why human subjects can't issue Lumi tokens at slice 1 (DEC-432, §1 #8)? Lumi is an AI persona; tokens represent AI-initiated cross-tenant operations. A human tenant-admin authenticating + then issuing a Lumi token would be claiming to be an AI agent — defeats the EU AI Act Art. 13 transparency requirement (which clearly distinguishes AI actors from human actors). Restricting issuance to agent-persona role subjects keeps the boundary clean. Delegation (human authorises a specific Lumi sync on their behalf) is a slice 3 ADR-gated flow.

Why persona_version staleness check at 2 minor versions (DEC-424, §1 #4)? Persona prompts evolve; a 2-minor-version-stale token suggests the verifier's persona registry has updated but the token-issuing process hasn't refreshed. Tolerance of 2 minors covers normal rotation lag (~hours); beyond that signals operational issue. Sev-2 alarm prompts ops investigation.

Why sync_class_allowed closed enum (DEC-425, §1 #5)? AGENTS.md §15 defines the closed sync-class set. Allowing arbitrary strings would let tokens claim privileges that don't exist (silently treated as "no privilege" or worse). Closed-set validation at issuance time catches typos at the boundary.

Why anchor_chain_hash claim (DEC-433, §1 #9)? Lumi sync writes to memory. If an attacker captures a Lumi token and replays it after the source tenant's chain has advanced (e.g. with newer rows), the replay would write at a stale head — silently corrupting the chain. The anchor_chain_hash binds the token to a specific chain state; mismatch rejects the replay.

Why same signing key as TASK-AUTH-004 at slice 1 (§1 #19)? Operationally simpler; one key rotation flow. Cryptographically the distinct iss + aud claims provide the security boundary (an attacker forging an TASK-AUTH-004 token can't get the iss accepted by the Lumi verifier). Distinct key per identity-class is a slice 2 hardening.

Why TTL 1 hour configurable (DEC-429, §1 #23)? Lumi sync is a periodic activity (typically every 5-15 minutes). 1-hour TTL gives reasonable refresh cadence without making compromised tokens long-lived. Operators with high-security needs can tighten via env; lower bound of 5 minutes prevents pathological config.

Why jti claim with log row (§1 #20)? Revocation requires looking up "is this jti revoked?". Storing every issued jti in the log enables this. The revocation API (task-AUTH-2xx) consults the log. Slice 1 ships the column + log; revocation lookup is slice 2.

Why tenant_residency check at verifier (§1 #6, DEC-422)? Decree 53/2022 + GDPR + DORA require data residency enforcement at every operation. If a Lumi token issued in vn-1 hits a sync endpoint in eu-1, that's a cross-residency operation — must be rejected even if the token is otherwise valid. The 451 status code (RFC 7725) is the spec-correct response for legal-reason rejection.

Why lumi_org_tenant is a separate claim from tid (DEC-423, §1 #1)? tid is the regular tenant id of the issuing AUTH; lumi_org_tenant is the slug of the org-tenant this Lumi persona represents. These can differ: a Lumi sync from a customer tenant pushing to the CyberSkill org-tenant has tid=<customer-uuid> and lumi_org_tenant=cyberskill. Two-field design makes the cross-tenant relationship explicit.

Why auth.lumi_token_verified sampled at 1% (§1 #14)? High-volume hot path; 100% sampling would flood memory. 1% gives statistical coverage; 100% on outcome != success ensures every failure is captured for debugging.

Why sync_class_allowed validated against per-tenant policy (§1 #25)? A tenant may restrict their data's max sync class (e.g. "we never publish externally — sync_class cap = 'shared'"). The issuance handler enforces; tokens cannot grant more than policy permits. Defense in depth — the memory sync endpoint also checks at write time, but rejecting at issuance time saves the round-trip.

Why slug pattern validation for lumi_org_tenant (§1 #22)? Slug is used in cross-tenant routing + memory audit paths; malformed values cause hard-to-debug failures downstream. Pattern validation at JWT issuance time + JWT verification time catches typos at the boundary.

Why total 13 claims (§1 #21)? Extending TASK-AUTH-004's 8 with 5 Lumi-specific claims is the minimum complete set. Adding fewer would force consumers to infer; adding more bloats the token. The serialisation order preserves TASK-AUTH-004-compatible read paths.

Why same kid for TASK-AUTH-004 + Lumi at slice 1 (§1 #19)? Simplification — JWKS verification just needs to find the kid; iss + aud provide the boundary. Per-identity-class kid (slice 2) provides defense-in-depth at the cost of dual-key rotation procedures.

Why sev-2 alarm on persona_version_stale > 5/h (§1 #14, §1 #18)? Normal rotation produces 0-1 stale tokens/hour; > 5/h sustained signals (a) version-registry update not propagated to issuer, (b) compromised token reuse beyond refresh window, (c) misconfigured caching. Sev-2 prompts operator investigation.

Why no Lumi token revocation handler at slice 1 (§1 #20)? Revocation is a separable concern — needs a CRL endpoint + propagation mechanism + cache invalidation. Slice 1 ships the jti column; task-AUTH-2xx ships the revocation flow. Until then, short TTL (1h) is the mitigation.


§3 — API contract

3.1 — Migration 0013

-- services/auth/migrations/0013_lumi_token_issuance_log.sql

BEGIN;

CREATE TABLE lumi_token_issuance_log (
    id                       BIGSERIAL    PRIMARY KEY,
    issuer_subject_id        UUID         NOT NULL REFERENCES auth.subjects(id) ON DELETE RESTRICT,
    agent_persona            TEXT         NOT NULL CHECK (agent_persona ~ '^cuo-[a-z][a-z0-9-]*@[0-9]+\.[0-9]+\.[0-9]+$'),
    persona_version          TEXT         NOT NULL,
    source_tenant_id         UUID         NOT NULL,
    lumi_org_tenant          TEXT         NOT NULL CHECK (lumi_org_tenant ~ '^[a-z][a-z0-9-]{2,40}[a-z0-9]$'),
    residency                TEXT         NOT NULL CHECK (residency IN ('vn-1','sg-1','eu-1','us-1')),
    sync_class_allowed       TEXT[]       NOT NULL CHECK (array_length(sync_class_allowed, 1) >= 1),
    anchor_chain_hash        TEXT         NOT NULL CHECK (anchor_chain_hash ~ '^[0-9a-f]{64}$'),
    ttl_seconds              INT          NOT NULL DEFAULT 3600 CHECK (ttl_seconds BETWEEN 300 AND 86400),
    issued_at                TIMESTAMPTZ  NOT NULL DEFAULT now(),
    token_jti                TEXT         NOT NULL UNIQUE
);

CREATE INDEX lumi_log_tenant_issued_idx ON lumi_token_issuance_log (source_tenant_id, issued_at DESC);
CREATE INDEX lumi_log_persona_idx ON lumi_token_issuance_log (agent_persona, persona_version);

ALTER TABLE lumi_token_issuance_log ENABLE ROW LEVEL SECURITY;
CREATE POLICY lumi_log_tenant_iso ON lumi_token_issuance_log
    USING (source_tenant_id = current_setting('auth.tenant_id')::uuid
           OR current_setting('auth.is_root_admin', true) = 'true')
    WITH CHECK (source_tenant_id = current_setting('auth.tenant_id')::uuid);

REVOKE UPDATE, DELETE ON lumi_token_issuance_log FROM cyberos_app;

COMMIT;

3.2 — Lumi claims struct

// services/auth/src/lumi/claims.rs
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::lumi::sync_class::SyncClass;

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LumiClaims {
    // TASK-AUTH-004 baseline claims
    pub sub: Uuid,
    pub tid: Uuid,
    pub iss: String,        // always "https://lumi.cyberos.world" for Lumi tokens
    pub aud: String,        // always "https://memory.cyberos.world/sync"
    pub iat: i64,
    pub exp: i64,
    pub nbf: i64,
    pub roles: Vec<String>,
    pub rbac_v: u32,
    pub jti: String,        // RFC 7519 JWT ID (UUIDv4)

    // Lumi-specific claims (DEC-420)
    pub agent_persona: String,         // "cuo-<key>@<semver>"
    pub tenant_residency: String,      // vn-1 | sg-1 | eu-1 | us-1
    pub lumi_org_tenant: String,       // slug
    pub persona_version: String,       // semver
    pub sync_class_allowed: Vec<SyncClass>,
    pub anchor_chain_hash: String,     // 64-hex
}

impl LumiClaims {
    pub fn validate_shape(&self) -> Result<(), LumiClaimError> {
        if self.iss != "https://lumi.cyberos.world" {
            return Err(LumiClaimError::IssMismatch);
        }
        if self.aud != "https://memory.cyberos.world/sync" {
            return Err(LumiClaimError::AudMismatch);
        }
        crate::lumi::persona_version::validate_format(&self.agent_persona)?;
        crate::lumi::sync_class::validate_array(&self.sync_class_allowed)?;
        if !slug_re().is_match(&self.lumi_org_tenant) {
            return Err(LumiClaimError::LumiOrgTenantMalformed);
        }
        if !chain_hash_re().is_match(&self.anchor_chain_hash) {
            return Err(LumiClaimError::AnchorChainHashMalformed);
        }
        Ok(())
    }
}

3.3 — Sync class enum

// services/auth/src/lumi/sync_class.rs
use serde::{Deserialize, Serialize};

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum SyncClass {
    Private,
    Shareable,
    Publishable,
    Shared,
    ClientVisible,
}

impl SyncClass {
    pub const ALL: &'static [SyncClass] = &[
        SyncClass::Private, SyncClass::Shareable, SyncClass::Publishable,
        SyncClass::Shared, SyncClass::ClientVisible,
    ];
}

pub fn validate_array(arr: &[SyncClass]) -> Result<(), LumiClaimError> {
    if arr.is_empty() {
        return Err(LumiClaimError::SyncClassEmpty);
    }
    Ok(())
}

3.4 — Verifier

// services/auth/src/lumi/verifier.rs
use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode_header, decode};
use crate::lumi::claims::LumiClaims;
use crate::lumi::persona_version::PersonaVersionRegistry;

const LUMI_ISS: &str = "https://lumi.cyberos.world";
const LUMI_AUD: &str = "https://memory.cyberos.world/sync";
const PERSONA_VERSION_STALE_THRESHOLD: u32 = 2; // minor versions behind

pub fn verify_lumi_token(
    token: &str,
    jwks: &crate::oidc::jwks::JwksCache,
    persona_registry: &PersonaVersionRegistry,
    expected_residency: &str,
) -> Result<LumiClaims, LumiVerifyError> {
    // 1. Header check — alg pinned at RS256 (DEC-431)
    let header = decode_header(token).map_err(|_| LumiVerifyError::TokenMalformed)?;
    if header.alg != Algorithm::RS256 {
        return Err(LumiVerifyError::UnsupportedAlg(format!("{:?}", header.alg)));
    }
    let kid = header.kid.as_deref().ok_or(LumiVerifyError::KidMissing)?;
    let jwk = jwks.get(kid).ok_or(LumiVerifyError::UnknownKid)?;

    // 2. Build decoder
    let key = DecodingKey::from_rsa_components(
        jwk.n.as_deref().ok_or(LumiVerifyError::JwkMalformed)?,
        jwk.e.as_deref().ok_or(LumiVerifyError::JwkMalformed)?,
    ).map_err(|_| LumiVerifyError::JwkMalformed)?;

    let mut validation = Validation::new(Algorithm::RS256);
    validation.set_issuer(&[LUMI_ISS]);
    validation.set_audience(&[LUMI_AUD]);
    validation.leeway = 60;

    // 3. Decode + validate signature/iss/aud/exp/nbf
    let decoded = decode::<LumiClaims>(token, &key, &validation)
        .map_err(|e| LumiVerifyError::TokenValidation(format!("{e:?}")))?;

    let claims = decoded.claims;
    claims.validate_shape().map_err(LumiVerifyError::Shape)?;

    // 4. Residency check (DEC-422)
    if claims.tenant_residency != expected_residency {
        return Err(LumiVerifyError::ResidencyMismatch {
            expected: expected_residency.into(), got: claims.tenant_residency.clone(),
        });
    }

    // 5. Persona-version staleness check (DEC-424)
    let live_version = persona_registry
        .get_live_version(&claims.agent_persona)
        .ok_or_else(|| LumiVerifyError::PersonaUnknown(claims.agent_persona.clone()))?;
    let behind = persona_registry.minor_versions_behind(&claims.persona_version, &live_version);
    if behind > PERSONA_VERSION_STALE_THRESHOLD {
        return Err(LumiVerifyError::PersonaVersionStale {
            token_version: claims.persona_version.clone(),
            live_version,
            minor_versions_behind: behind,
        });
    }

    Ok(claims)
}

3.5 — Issuer handler

// services/auth/src/handlers/lumi.rs
use axum::{Json, extract::State, http::StatusCode};
use cyberos_auth::rbac::Role;
use crate::lumi::{claims::LumiClaims, sync_class::SyncClass, repo, audit};

#[derive(Deserialize)]
pub struct IssueRequest {
    pub agent_persona: String,
    pub persona_version: String,
    pub lumi_org_tenant: String,
    pub sync_class_allowed: Vec<SyncClass>,
    pub anchor_chain_hash: String,
}

#[derive(Serialize)]
pub struct IssueResponse {
    pub token: String,
    pub expires_at: chrono::DateTime<chrono::Utc>,
    pub jti: String,
}

pub async fn issue_lumi_token(
    State(state): State<AppState>,
    claims: crate::jwt::Claims,
    Json(req): Json<IssueRequest>,
) -> Result<(StatusCode, Json<IssueResponse>), LumiIssueError> {
    // (1) Caller must have agent-persona role (DEC-432)
    if !claims.roles().contains(&Role::AgentPersona) {
        return Err(LumiIssueError::HumanCannotIssue);
    }

    // (2) Validate persona_version against registry
    state.persona_registry.validate_known(&req.agent_persona, &req.persona_version)?;

    // (3) Validate sync_class_allowed against per-tenant policy (TASK-AI-005)
    let policy = state.tenant_policy.load(claims.tenant_id()).await?;
    state.lumi.validate_sync_class_against_policy(&req.sync_class_allowed, &policy)?;

    // (4) Resolve residency
    let residency = state.residency.resolve(claims.tenant_id()).await?;

    // (5) Issue token
    let jti = uuid::Uuid::new_v4().to_string();
    let ttl = state.config.lumi_token_ttl_seconds;
    let exp = chrono::Utc::now() + chrono::Duration::seconds(ttl as i64);
    let lumi_claims = LumiClaims {
        sub: claims.subject_id(), tid: claims.tenant_id(),
        iss: "https://lumi.cyberos.world".into(),
        aud: "https://memory.cyberos.world/sync".into(),
        iat: chrono::Utc::now().timestamp(),
        exp: exp.timestamp(),
        nbf: chrono::Utc::now().timestamp(),
        roles: vec!["agent-persona".into()],
        rbac_v: state.rbac_matrix.snapshot().version,
        jti: jti.clone(),
        agent_persona: req.agent_persona.clone(),
        tenant_residency: residency.code.clone(),
        lumi_org_tenant: req.lumi_org_tenant.clone(),
        persona_version: req.persona_version.clone(),
        sync_class_allowed: req.sync_class_allowed.clone(),
        anchor_chain_hash: req.anchor_chain_hash.clone(),
    };
    let token = state.signer.sign_rs256(&lumi_claims).await?;

    // (6) Persist to log + emit memory audit
    let mut tx = state.db.begin().await?;
    repo::insert_issuance_log(&mut tx, &lumi_claims, claims.subject_id(), &jti, ttl).await?;
    audit::emit_lumi_token_issued(&mut tx, &lumi_claims, claims.subject_id()).await?;
    tx.commit().await?;

    Ok((StatusCode::CREATED, Json(IssueResponse { token, expires_at: exp, jti })))
}

§4 — Acceptance criteria

  1. iss locked at https://lumi.cyberos.world — token with different iss → 401 iss_mismatch.
  2. aud locked at https://memory.cyberos.world/sync — different aud → 401 aud_mismatch.
  3. alg=RS256 enforced — HS256 or none → 401 unsupported_alg.
  4. agent_persona format cuo-<key>@<semver> — malformed → 401 agent_persona_malformed.
  5. agent_persona unknown key → 401 agent_persona_unknown.
  6. tenant_residency in closed enum — outside set → 451 residency_mismatch.
  7. tenant_residency mismatch with route — 451 unavailable_for_legal_reasons.
  8. sync_class_allowed closed enum — unknown value → 401 sync_class_unknown.
  9. sync_class_allowed empty → 401 sync_class_empty.
  10. persona_version 2 minor versions behind → 401 persona_version_stale + sev-2 audit.
  11. persona_version > 2 behind → 401; counter increments.
  12. Human cannot issue Lumi token — caller without agent-persona role → 403 human_cannot_issue.
  13. anchor_chain_hash 64-hex format — malformed → 401 anchor_chain_hash_malformed.
  14. lumi_org_tenant slug pattern — malformed → 401 lumi_org_tenant_malformed.
  15. POST issue happy path — agent-persona role + valid request → 201 + token + log row + memory row.
  16. lumi_token_issuance_log append-only — UPDATE/DELETE blocked from cyberos_app.
  17. jti uniqueness enforced — duplicate jti at insert → 23505.
  18. TTL default 3600 — exp = iat + 3600.
  19. TTL configurable via env — LUMI_TOKEN_TTL_SECONDS=600 → token expires at 600s.
  20. TTL < 300 rejected at startup — service refuses to start.
  21. sync_class_allowed exceeds policy → 403 sync_class_exceeds_policy.
  22. OTel span emitted — auth.lumi.issue / verify with outcome attr.
  23. Counter auth_lumi_issuance_total{outcome=success} increments per successful issuance.
  24. Counter auth_lumi_persona_version_stale_total increments on stale-version rejection.
  25. Perf budget < 100ms p95 issue / 50ms p95 verify — perf tests assert.
  26. auth.lumi_token_issued memory row carries jti + persona + version + residency + sync_class + ttl.
  27. auth.lumi_token_rejected memory row carries reason — for every failure path.

§5 — Verification

// services/auth/tests/lumi_alg_confusion_test.rs
#[test]
fn hs256_token_rejected() {
    let token = mock_hs256_token();   // attacker forges using public key as HMAC secret
    let result = cyberos_auth::lumi::verifier::verify_lumi_token(
        &token, &mock_jwks(), &mock_registry(), "vn-1",
    );
    assert!(matches!(result, Err(LumiVerifyError::UnsupportedAlg(_))));
}

#[test]
fn alg_none_rejected() {
    let token = mock_none_alg_token();
    let result = cyberos_auth::lumi::verifier::verify_lumi_token(
        &token, &mock_jwks(), &mock_registry(), "vn-1",
    );
    assert!(matches!(result, Err(LumiVerifyError::UnsupportedAlg(_))));
}
// services/auth/tests/lumi_human_cannot_issue_test.rs
#[tokio::test]
async fn human_subject_cannot_issue_lumi(ctx: TestCtx) {
    let human_token = ctx.issue_tenant_admin_token().await;   // role=tenant-admin, no agent-persona
    let resp = ctx.post("/v1/auth/lumi/issue", &valid_body())
        .header("Authorization", format!("Bearer {human_token}"))
        .await;
    assert_eq!(resp.status(), 403);
    assert_eq!(resp.json::<serde_json::Value>().await.unwrap()["error"], "human_cannot_issue_lumi");
}

#[tokio::test]
async fn agent_persona_can_issue_lumi(ctx: TestCtx) {
    let agent_token = ctx.issue_agent_persona_token().await;
    let resp = ctx.post("/v1/auth/lumi/issue", &valid_body())
        .header("Authorization", format!("Bearer {agent_token}"))
        .await;
    assert_eq!(resp.status(), 201);
}
// services/auth/tests/lumi_persona_version_stale_test.rs
#[test]
fn version_3_minors_behind_rejected() {
    let live_version = "1.5.0";
    let token_version = "1.2.0";   // 3 minors behind
    let result = cyberos_auth::lumi::verifier::verify_lumi_token(
        &mock_token_with_version(token_version), &mock_jwks(),
        &mock_registry_with_live(live_version), "vn-1",
    );
    assert!(matches!(result, Err(LumiVerifyError::PersonaVersionStale { .. })));
}

#[test]
fn version_2_minors_behind_accepted() {
    let live_version = "1.5.0";
    let token_version = "1.3.0";   // 2 minors behind — at threshold
    let result = cyberos_auth::lumi::verifier::verify_lumi_token(
        &mock_token_with_version(token_version), &mock_jwks(),
        &mock_registry_with_live(live_version), "vn-1",
    );
    assert!(result.is_ok());
}
// services/auth/tests/admin_deny_list_test.rs
#[test]
fn vn1_token_at_eu1_endpoint_rejected() {
    let token = mock_token_with_residency("vn-1");
    let result = cyberos_auth::lumi::verifier::verify_lumi_token(
        &token, &mock_jwks(), &mock_registry(), "eu-1",
    );
    assert!(matches!(result, Err(LumiVerifyError::ResidencyMismatch { .. })));
}
// services/auth/tests/lumi_sync_class_closed_test.rs
#[test]
fn empty_sync_class_rejected() {
    let result = cyberos_auth::lumi::sync_class::validate_array(&[]);
    assert!(matches!(result, Err(LumiClaimError::SyncClassEmpty)));
}

#[test]
fn unknown_sync_class_rejected_at_deserialise() {
    let json = r#"["private", "made-up-class"]"#;
    let r: Result<Vec<cyberos_auth::lumi::sync_class::SyncClass>, _> = serde_json::from_str(json);
    assert!(r.is_err());
}

§6 — Implementation skeleton

(API contract above is the skeleton; 4 memory row builders follow the canonical pattern.)


§7 — Dependencies

Upstream:

Downstream: none at slice 1 (this task provides the JWT shape that downstream memory sync + task-AUTH-2xx revocation consume).

Cross-module:


§8 — Example payloads

8.1 — POST /v1/auth/lumi/issue request

{
  "agent_persona": "cuo-cpo@0.4.1",
  "persona_version": "0.4.1",
  "lumi_org_tenant": "cyberskill",
  "sync_class_allowed": ["shareable", "publishable"],
  "anchor_chain_hash": "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
}

8.2 — 201 CREATED response

{
  "token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjFhYjJjM2Q0IiwidHlwIjoiSldUIn0...",
  "expires_at": "2026-05-16T11:00:00Z",
  "jti": "01HG7V8B0K8M4Z8Z8M8M8M8M8M"
}

8.3 — Decoded Lumi JWT claims

{
  "sub": "9b1deb4d-3b7d-4bad-9bdd-2b0d7b3dcb6d",
  "tid": "5e8f1d2a-...",
  "iss": "https://lumi.cyberos.world",
  "aud": "https://memory.cyberos.world/sync",
  "iat": 1747920731,
  "exp": 1747924331,
  "nbf": 1747920731,
  "roles": ["agent-persona"],
  "rbac_v": 2,
  "jti": "01HG7V8B0K8M4Z8Z8M8M8M8M8M",
  "agent_persona": "cuo-cpo@0.4.1",
  "tenant_residency": "vn-1",
  "lumi_org_tenant": "cyberskill",
  "persona_version": "0.4.1",
  "sync_class_allowed": ["shareable", "publishable"],
  "anchor_chain_hash": "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"
}

8.4 — auth.lumi_token_issued memory row

{
  "kind": "auth.lumi_token_issued",
  "tenant_id": "5e8f1d2a-...",
  "issuer_subject_id_hash16": "9b1deb4d3b7d4bad",
  "agent_persona": "cuo-cpo@0.4.1",
  "persona_version": "0.4.1",
  "lumi_org_tenant_hash16": "abc123def4567890",
  "residency": "vn-1",
  "sync_class_allowed": ["shareable", "publishable"],
  "ttl_seconds": 3600,
  "token_jti": "01HG7V8B0K8M4Z8Z8M8M8M8M8M",
  "ts_ns": 1747920731000000000
}

8.5 — auth.lumi_persona_version_stale memory row (sev-2)

{
  "kind": "auth.lumi_persona_version_stale",
  "severity": "sev-2",
  "tenant_id": "5e8f1d2a-...",
  "agent_persona": "cuo-cpo",
  "token_version": "0.2.0",
  "live_version": "0.4.1",
  "minor_versions_behind": 2,
  "ts_ns": 1747920731000000000
}

§9 — Open questions

Deferred:

All other questions resolved.


§10 — Failure modes inventory

FailureDetectionOutcomeRecovery
HS256 alg in tokenheader check401 unsupported_algDesigned
alg=none in tokenheader check401 unsupported_algDesigned
iss mismatchvalidation401 iss_mismatchDesigned
aud mismatchvalidation401 aud_mismatchDesigned
Unknown kidJWKS lookup401 unknown_kidDesigned
Token expiredexp + leeway401 token_expiredRe-issue
persona_version > 2 behindregistry compare401 persona_version_stale + sev-2Refresh token
Unknown agent_persona keyregistry lookup401 agent_persona_unknownDesigned
Residency mismatchendpoint vs claim451 unavailable_for_legal_reasonsUse correct region
Empty sync_class_allowedvalidator401 sync_class_emptyDesigned
Unknown sync_class valueserde + validator401 sync_class_unknownDesigned
Human subject issuesrole check403 human_cannot_issue_lumiUse agent-persona subject
Slug malformedregex401 lumi_org_tenant_malformedFix slug
anchor_chain_hash malformedregex401 anchor_chain_hash_malformedFix hash
sync_class exceeds policypolicy lookup403 sync_class_exceeds_policyReduce array
jti collisionUNIQUE23505Should never happen (UUIDv4)
TTL < 300 in envstartup checkservice refusesFix env
persona registry unreachableregistry error500 + sev-2Investigate
KMS signing key disabledsigner error500 + sev-1Rotate key
Append-only log UPDATE from appSQL grantpermission deniedDesigned
RLS bypassUSING0 rowsDesigned
Cross-tenant log readRLS0 rows unless root-adminDesigned
Token forwarded with different residency in URLendpoint check451Designed
Refresh path missingTASK-AUTH-004 dependencyExisting pathDesigned
> 5/h stale-version sustainedOBS rulesev-2 alarmInvestigate
jsonwebtoken crate version drifttests pinCI failsPin version
concurrent issuance with same jtiUUID v4Collision astronomically improbableNone
Policy YAML unreachabletenant config500 + sev-3Restore config
Subject deleted while log refsFK RESTRICTDELETE failsSoft-delete subject first
chain_hash regex too permissiveunit testCI failsTighten regex
sub_claim leaked in memoryPII scrubPre-commitDesigned

§11 — Implementation notes


End of TASK-AUTH-108.