TramAI

Artifact Verification

Experimental

The FileSystemModelArtifactVerifier is tested but the safe-code reason codes and manifest format are still evolving. Verification settings may gain additional options in minor releases.

Artifact verification ensures that local model files loaded at build time match their registered digests. The sovereign builder verifies every local-model artifact before building the runtime, producing immutable verification receipts that can be included in evidence packs.

Package: dev.tramai.core.model (SPIs, manifests, receipts), dev.tramai.security.verification (FileSystemModelArtifactVerifier)


What

The verification system operates at four levels:

  1. ModelArtifactVerifier SPIsuspend fun verify(registeredModel: RegisteredModel): VerifiedLocalModelArtifact?
  2. FileSystemModelArtifactVerifier — verifies file-system artifacts against an immutable LocalModelArtifactManifestV1
  3. ModelArtifactVerificationSettings — controls whether verification is enabled and whether local models require a pinned digest
  4. VerifiedLocalModelArtifact — immutable receipt produced by successful verification

Verification Flow

SovereignTramai.Builder.build()
  → validateOfflineDeployment()
  → verifyLocalModelArtifacts()
       → For each (providerName, modelName) pair with LOCAL trust zone:
            → modelRegistry.findApprovedModel(providerName, modelName)
            → verifier.verify(registeredModel)
                 → manifest lookup by registryEntryId (null if not found)
                 → identity match check (registryEntryId, providerId, modelName, revision)
                 → aggregate digest check (if artifactDigest is pinned)
                 → per-file streaming SHA-256 verification
                 → symlink policy enforcement
            → collect VerifiedLocalModelArtifact receipt
  → store immutable receipt list

When to Use

Use artifact verification when:

  • You need build-time integrity guarantees for local model artifacts
  • You need to detect tampering — modified model files, substituted artifacts, symlink attacks
  • You need a verification receipt for audit or evidence pack generation
  • You run in offline/air-gapped deployments where artifact integrity cannot be verified at runtime

Do not use artifact verification for cloud-hosted model providers — verification is only applied to LOCAL trust zone providers (trust zone check happens before registry lookup).


Quickstart

Configuration

// 1. Define your artifact manifests
val manifests = mapOf(
    "ollama-llama3.2" to LocalModelArtifactManifestV1(
        schemaVersion = 1,
        registryEntryId = "ollama-llama3.2",
        providerId = "ollama",
        modelName = "llama3.2",
        revision = "1.0",
        artifacts = listOf(
            LocalModelArtifactFileV1(
                relativePath = "models/llama3.2/model.bin",
                sizeBytes = 4_123_456_789,
                digest = ModelArtifactDigest.of("sha256:abc123def456abc123def456abc123def456abc123def456abc123def456abcd"),
            ),
        ),
    ),
)

// 2. Create the verifier
val verifier = FileSystemModelArtifactVerifier(
    allowedRootDirectories = setOf(Paths.get("/opt/models")),
    manifests = manifests,
)

// 3. Build with verification enabled
val tramai = SovereignTramai.builder()
    .profile(localProfile)
    .modelRegistry(registry)
    .auditStore(auditStore)
    .provider(ollamaProvider, name = "ollama", default = true)
    .model("llama3.2", "ollama")
    .modelArtifactVerifier(verifier)
    .modelArtifactVerificationSettings(
        ModelArtifactVerificationSettings(
            enabled = true,
            requireDigestForLocalModels = true,
        ),
    )
    .build()

// 4. Access receipts
val receipts = tramai.verificationReceipts()
receipts.forEach { receipt ->
    println("Verified ${receipt.modelName}:")
    println("  Manifest digest: ${receipt.manifestDigest}")
    println("  Artifacts: ${receipt.artifactCount}")
    println("  Total size: ${receipt.totalSizeBytes}")
    println("  Verified at: ${receipt.verifiedAt}")
}

FileSystemModelArtifactVerifier

Constructor

class FileSystemModelArtifactVerifier(
    allowedRootDirectories: Set<Path>,
    manifests: Map<String, LocalModelArtifactManifestV1>,
    private val clock: Clock = Clock.systemUTC(),
) : ModelArtifactVerifier
ParameterDescription
allowedRootDirectoriesDirectories where artifact files are allowed (path traversal protection)
manifestsMap of registryEntryId to immutable manifest
clockClock for receipt timestamps (default: system UTC)

Verification Checks

For each registered model, the verifier:

  1. Manifest lookup — looks up the manifest by registryEntryId; returns null if not found (safe code: artifact-manifest-not-found)
  2. Identity check — verifies registryEntryId, providerId, modelName, and revision match between manifest and registry (safe code: artifact-manifest-identity-drift)
  3. Canonical digest computation — computes SHA-256 of the manifest's canonical serialization (key=value lines, artifacts sorted by relativePath)
  4. Aggregate digest check — if registeredModel.artifactDigest is pinned, verifies the canonical manifest digest matches (safe code: artifact-aggregate-digest-mismatch)
  5. Per-file verification — for each artifact file in the manifest:
    • Path resolution — resolves relative path against each allowed root
    • Path traversal check — ensures resolved normalized path stays within the root
    • File existence — returns null if not found in any root (safe code: artifact-file-not-found)
    • Strict symlink policy — calls Files.isSymbolicLink() on the resolved path; also checks via toRealPath() comparison and rejects any symlink in the resolution chain (safe code: artifact-file-symlink-rejected)
    • Regular file check — rejects directories (safe code: artifact-directory-substituted-for-file) and non-regular files (safe code: artifact-not-a-regular-file)
    • Size check before hash — verifies file size matches manifest via Files.size() (safe code: artifact-file-size-mismatch)
    • Streaming SHA-256 hash — hashes file with 64KB buffer via MessageDigest.getInstance("SHA-256") (safe code: artifact-file-digest-mismatch)
    • Size check after hash — verifies file size again to detect concurrent modification (safe code: artifact-file-size-mismatch)
    • I/O errors produce artifact-file-access-failed
  6. Total size check — verifies no arithmetic overflow in total size via Math.addExact (safe code: artifact-total-size-overflow)

Safe-Code Reasons

All verification failures use deterministic safe-code strings:

Safe CodeWhen
artifact-manifest-not-foundNo manifest for this registry entry
artifact-manifest-identity-driftManifest identity fields don't match registry
artifact-aggregate-digest-mismatchCanonical manifest digest doesn't match pinned value
artifact-file-not-foundArtifact file not found in any allowed root directory
artifact-file-symlink-rejectedSymlink detected in the resolution chain
artifact-file-size-mismatchFile size doesn't match manifest size
artifact-file-digest-mismatchSHA-256 digest doesn't match manifest digest
artifact-file-access-failedI/O error reading the file
artifact-traversal-rejectedPath traversal detected
artifact-directory-substituted-for-fileExpected a regular file, found a directory
artifact-not-a-regular-fileNot a regular file (special file, device, etc.)
artifact-total-size-overflowTotal artifact size exceeds Long.MAX_VALUE

LocalModelArtifactManifestV1

Immutable snapshot of expected artifact state at build time:

class LocalModelArtifactManifestV1(
    val schemaVersion: Int,               // Must be 1
    val registryEntryId: String,
    val providerId: String,
    val modelName: String,
    val revision: String,
    artifacts: List<LocalModelArtifactFileV1>,
) {
    val artifacts: List<LocalModelArtifactFileV1>  // Unmodifiable
}

Init validation:

  • schemaVersion must be 1
  • All string fields validated: no blanks, no control characters, max 256 chars
  • At least one artifact file required
  • No duplicate artifact paths (case-sensitive)
  • Paths validated for relative format, no traversal, no self-references, no double slashes, no Windows drive prefixes, no UNC paths

LocalModelArtifactFileV1

data class LocalModelArtifactFileV1(
    val relativePath: String,
    val sizeBytes: Long,
    val digest: ModelArtifactDigest,
)

Init validation on relativePath:

  • Must not be blank
  • Must be relative (no /, \\, C:\ prefixes)
  • Must not traverse upward (..)
  • Must not contain self-reference segments (./)
  • Must not contain double slashes
  • Must not contain control characters
  • Must use consistent forward-slash separators

ModelArtifactDigest

@JvmInline
value class ModelArtifactDigest private constructor(val value: String) {
    companion object {
        fun of(raw: String): ModelArtifactDigest  // validated: "sha256:<64 lowercase hex>"
    }
}

Canonicial Bytes

The manifest's canonicalBytes() method produces a deterministic byte representation used for aggregate digest verification:

schemaVersion=1
registryEntryId=ollama-llama3.2
providerId=ollama
modelName=llama3.2
revision=1.0
artifact_count=1
  relativePath=models/llama3.2/model.bin
  sizeBytes=4123456789
  digest=sha256:abc123...

VerifiedLocalModelArtifact Receipt

data class VerifiedLocalModelArtifact(
    val registryEntryId: String,
    val manifestDigest: ModelArtifactDigest,
    val modelName: String,
    val verifiedAt: Instant,
    val artifactCount: Int,
    val totalSizeBytes: Long,
)

Receipts are collected during SovereignTramai.Builder.build() and stored in an immutable list accessible via SovereignTramai.verificationReceipts().


ModelArtifactVerificationSettings

data class ModelArtifactVerificationSettings(
    val enabled: Boolean = false,
    val requireDigestForLocalModels: Boolean = false,
)
FieldDefaultDescription
enabledfalseWhen false, no verification is performed and receipts are empty
requireDigestForLocalModelsfalseWhen true, every local model must have an artifactDigest pinned in the registry (IllegalStateException("artifact-digest-required-for-local-model") if missing)

Sovereign Builder Integration

The sovereign builder automatically during build():

  1. Collects all (providerName, modelName) pairs from primary and fallback routes
  2. Filters to LOCAL trust zone providers only (trust zone check happens before registry lookup)
  3. Looks up each approved model in the registry via modelRegistry.findApprovedModel()
  4. If requireDigestForLocalModels is true and artifactDigest is null → throws IllegalStateException("artifact-digest-required-for-local-model")
  5. Calls verifier.verify(registeredModel) for each
  6. Collects non-null receipts into an immutable list
// Internal: buildSet of verification targets
val verificationTargets = buildSet {
    primaryModelRoutes.forEach { (modelName, providerName) -> add(providerName to modelName) }
    fallbackRoutes.forEach { route -> add(route.providerName to route.fallbackModelName) }
}
// Only LOCAL-trust-zone targets are verified

NoOpModelArtifactVerifier

For testing or when no verification is needed:

object NoOpModelArtifactVerifier : ModelArtifactVerifier {
    override suspend fun verify(registeredModel: RegisteredModel): VerifiedLocalModelArtifact? = null
}

RegisteredModel and Artifact Digest

data class RegisteredModel(
    val registryEntryId: String,
    val providerId: String,
    val modelName: String,
    val revision: String,
    val artifactDigest: ModelArtifactDigest? = null,  // null = no aggregate digest check
    val enabled: Boolean = true,
)

When artifactDigest is null and requireDigestForLocalModels is true, the builder throws before calling the verifier.


Limitations

  • File-system only — verification checks on-disk files against manifests; no support for remote artifact verification
  • Manifests must be pre-built — manifests are constructed in code and passed to the verifier; no auto-generation
  • Size check race window — size is checked before and after hashing, but concurrent modification during hashing could theoretically bypass the check (acceptable for build-time verification)
  • No automatic re-verification — once verified at build time, the receipts are immutable; no runtime re-verification
  • Local-only — verification only applies to LOCAL trust zone providers; cloud models are not verified

Minimum Configuration

val verifier = FileSystemModelArtifactVerifier(
    allowedRootDirectories = setOf(Paths.get("/opt/models")),
    manifests = manifestMap,
)

SovereignTramai.builder()
    .profile(profile)
    .modelRegistry(registry)
    .auditStore(auditStore)
    .provider(ollamaProvider, name = "ollama", default = true)
    .model("llama3.2", "ollama")
    .modelArtifactVerifier(verifier)
    .modelArtifactVerificationSettings(
        ModelArtifactVerificationSettings(enabled = true)
    )
    .build()

For development without strict enforcement, set requireDigestForLocalModels = false to allow local models without a pinned artifactDigest.


Next Steps