dprovenance.dev / docs / add-to-an-existing-agent

Add DProvenanceKit to an existing agent in under 15 minutes

For Swift engineers who already have a working on-device agent and want provenance without refactoring it — a recorded, queryable, diffable, offline-verifiable trail of what the agent actually did. Whether the reasoning runs in Apple Foundation Models, MLX, Core ML, or a runtime of your own, you wrap the call you already have; you do not rewrite it. The live Foundation Models tracing surface needs Xcode 26 / Swift 6.2; the custom-vocabulary path and SDK-free post-hoc ingestion build on the Swift 6.0 floor. Either way, deployment stays macOS 13 / iOS 16.

# 01 · prerequisites & the time box

What you need before the clock starts

This is a working-agent tutorial, not a greenfield one. You bring an app or package target that already calls a model and produces a decision; DProvenanceKit records the path it took. The whole loop — add, wire, run, verify — fits in a coffee break because you never touch the agent's control flow.

  • An existing Swift target — an app or SwiftPM package that already runs your agent. No structural change is required to instrument it.
  • Xcode 26 (Swift 6.2+) for the live Foundation Models path (LanguageModelSession.traced and recordProvenance()).
  • Swift 6.0+ is enough for the custom-vocabulary path and for SDK-free post-hoc ingestion of a serialized transcript. CI builds this floor on a Swift 6.1 runner.
  • Deployment target unchanged — building with Xcode 26 still deploys to .macOS(.v13) / .iOS(.v16).
  • An Apple Intelligence–capable Mac only if you want step 05 to hit the real on-device model. Everything else works without it.
  • Zero third-party dependencies — the core uses system SQLite and CryptoKit and nothing else.
Honesty caveat · toolchain The live LanguageModelSession.traced / recordProvenance() surface requires Swift 6.2 / Xcode 26 and compiles out cleanly below it — on an older toolchain your build still succeeds and the module's snapshot, diff, and redaction types remain available, but the live-session tracing APIs are simply absent. Do not assume they work on the 6.0 floor: if session.traced(…) won't resolve, your toolchain is the reason.

# 02 · minute 0–2 · add the package

Add the package and pick a product

One line of dependencies, then choose the product that matches your agent. The Foundation Models adapter @_exported imports the core, so depending on it gives you the whole library; a custom agent depends on the core alone.

Package.swiftswift
// 1 — declare the dependency
dependencies: [
    .package(url: "https://github.com/Therealdk8890/DProvenanceKit", from: "0.7.0")
],

// 2 — link ONE product on the target that runs your agent
targets: [
    .target(name: "MyAgent", dependencies: [
        // Foundation Models agent — this pulls the core in via @_exported import:
        .product(name: "DProvenanceFoundationModels", package: "DProvenanceKit")

        // Custom / MLX / Core ML agent — the zero-dependency core only:
        // .product(name: "DProvenanceKit", package: "DProvenanceKit")
    ])
]
Which product? DProvenanceKit is the zero-dependency core: events, stores, the query DSL, diff, alignment, and attestation. DProvenanceFoundationModels adds the drop-in adapter for Apple's on-device LLM and re-exports the core, so you never import both. FMTrace is just a typealias for DProvenanceKit<FoundationModelTraceEvent>.
Note · platform floor Your package or app target must declare an Apple platform at or above the package floor — .macOS(.v13) or .iOS(.v16). The FM adapter re-exports the core but does not re-export FoundationModels itself, so code that touches LanguageModelSession still needs import FoundationModels alongside it.

# 03 · minute 2–6 · pick your entry path

Pick your entry path

There are two ways in, and which one you take is decided entirely by where your reasoning happens. Neither reorganizes your agent.

Path A — a Foundation Models agent

If the reasoning is Apple's on-device model, the adapter ships a frozen, diff-ready event vocabulary — you do not define one. Two flavors, depending on whether you are instrumenting existing code or writing new code.

PathA.swiftswift
import FoundationModels
import DProvenanceFoundationModels

// Greenfield — live capture. Swap your session constructor for `.traced`:
let session = LanguageModelSession.traced(instructions: "Be terse.")
_ = try await session.respond(to: "Plan my day.")

// Zero-refactor — keep your existing session exactly as-is and, after the
// fact, ingest its transcript as trace events:
session.recordProvenance()
Honesty caveat · post-hoc Plain-session recordProvenance() is stateless — calling it twice records the transcript twice. For a growing transcript, feed back the summary's nextEntryIndex cursor (session.recordProvenance(startingAt: cursor)), or use a TracedLanguageModelSession, whose recordProvenance() dedupes automatically. And, per step 01, this whole live surface requires Swift 6.2 / Xcode 26.

Path B — a custom / MLX / Core ML agent

If the reasoning runs anywhere else, describe the steps you care about as a TraceableEvent enum. typeIdentifier must stay stable across schema versions; priority governs which events survive a burst (diffs are floored at .structural, so shedding telemetry never changes a result). Then record at the points that matter — synchronous, and it never blocks on disk.

MyAIDecision.swiftswift
import Foundation
import DProvenanceKit

enum MyAIDecision: TraceableEvent {
    case promptGenerated(tokenCount: Int)
    case finalDecisionMade(approved: Bool)

    var typeIdentifier: String {
        switch self {
        case .promptGenerated:   return "promptGenerated"
        case .finalDecisionMade: return "finalDecisionMade"
        }
    }

    var priority: TracePriority {
        switch self {
        case .promptGenerated:   return .telemetry
        case .finalDecisionMade: return .critical
        }
    }
}

# 04 · minute 6–10 · wire a store and wrap one run

Wire a store and wrap one run

Construct a SQLiteTraceStore (generic over your event type), then wrap the agent call you already have inside a run. The wrapper establishes the ambient run; every capture path is a safe no-op outside one, so nothing you add fires in the wrong place.

WireItUp.swift · Path Aswift
import FoundationModels
import DProvenanceFoundationModels

let store = try SQLiteTraceStore<FoundationModelTraceEvent>(
    fileURL: URL(fileURLWithPath: "traces.sqlite")
)

try await FMTrace.run(contextID: "onboarding-chat", store: store) {
    let session = LanguageModelSession.traced(instructions: "Be terse.")
    _ = try await session.respond(to: "Plan my day.")
}

try await store.flush()   // true barrier — every event is durable past this line

Need the run back later — to diff it, or to sign it in step 06? Use runReturningID, which hands you the runID alongside the block's result. Both FMTrace (Path A) and DProvenanceKit<YourEvent> (Path B) expose it.

WireItUp.swift · runReturningIDswift
// Path A — Foundation Models:
let (_, runID) = try await FMTrace.runReturningID(contextID: "onboarding-chat", store: store) { _ in
    let session = LanguageModelSession.traced(instructions: "Be terse.")
    _ = try await session.respond(to: "Plan my day.")
}

// Path B — custom / MLX / Core ML. The store is SQLiteTraceStore<MyAIDecision>:
let (_, runID) = try await DProvenanceKit<MyAIDecision>.runReturningID(
    contextID: "Case-12345",
    store: store
) { _ in
    DProvenanceKit<MyAIDecision>.record(.promptGenerated(tokenCount: 150))
    // … your existing agent call runs here, unchanged …
    DProvenanceKit<MyAIDecision>.record(.finalDecisionMade(approved: true))
}

try await store.flush()
Why flush() is a real barrier. record(...) commits synchronously to an in-memory buffer and returns immediately — an event is queryable the instant it returns — while a background writer drains batches into WAL-mode SQLite. Because that in-memory commit is synchronous, flush() is a true durability barrier, not a best-effort hint: once it returns, everything recorded is on disk.

# 05 · minute 10–13 · see it worked

See it worked

Two ways to confirm capture, depending on whether you want to touch the real model. If you are on an Apple Intelligence–capable Mac, the shipped quickstart calls the on-device model end to end and prints exactly what persisted.

terminalsh
$ swift run FoundationModelsLiveQuickstart
$ swift run FoundationModelsLiveQuickstart -- "Summarize why provenance matters."
→ prints the model answer, then the persisted timeline:
  fm_model_availability → fm_instructions → fm_prompt → fm_response
  followed by drop/integrity status and the SQLite file path

When the model is unavailable it still records an fm_model_availability event and prints Apple's mapped reason, so a setup failure is visible rather than silent. Prefer to confirm from code? Query the run straight back and assert the fm_prompt → fm_response timeline persisted:

Confirm.swiftswift
// Path A — the prompt was followed by a response in the same run:
let runs = try await store.queryRuns(
    TraceQueryDSL<FoundationModelTraceEvent>()
        .requiring(step: FMEventType.prompt, followedBy: FMEventType.response)
)

// Path B — fetch the exact run you recorded, by its id:
let run = try await store.getRun(id: runID)
Note · what "reasoning" means here A trace is the observable, instrumented execution path — prompts, tool calls, outputs, decisions, and your application events — not the model's hidden internal deliberation. Capture is only as complete as the events you record; check store.dropStats.preservedIntegrity if a run emitted a burst.

# 06 · minute 13–15 · prove integrity offline

Prove integrity offline

Turn the completed run into a signed, self-contained artifact. Signing uses CryptoKit's P-256 and canonical serialization; nothing touches the network, on either side. Fetch the run by its runID, sign it, and write the JSON.

Sign.swiftswift
guard let completedRun = try await store.getRun(id: runID) else {
    fatalError("Recorded run was not found")
}

let signingKey = SoftwareTraceAttestationKey()
// Persist signingKey.rawRepresentation in Keychain — never in the artifact.

let document = try TraceAttestationDocument.signed(run: completedRun, using: signingKey)
try document.jsonData().write(
    to: URL(fileURLWithPath: "decision.attestation.json"),
    options: .atomic
)

Verify it anywhere, offline. A valid signature proves the covered trace hasn't changed since it was signed; pin the expected key ID when you need to prove who signed it, not just that it's intact.

terminalsh
$ swift run dpk verify --in=decision.attestation.json

# Pin the signer's key ID when identity matters, not just integrity:
$ swift run dpk verify --in=decision.attestation.json --trusted-key=<trusted-key-id>
Honesty caveat · what a signature does and doesn't prove Attestation proves the integrity of what was recorded — deletion, insertion, reordering, and field modification of covered events are all detected. It does not by itself establish truthfulness, capture completeness, trusted time, or the identity of the executing binary, and without a pinned --trusted-key it proves integrity only, not signer identity. Read the threat model before you lean on it in an audit.

# 07 · where to go next

Where to go next

You now have a recorded, queryable, offline-verifiable run wired into an agent you didn't rewrite. Three directions from here, in the order most teams need them:

  • Redact before you export. On-device capture defaults to .full; switch to .hashed before a trace ever leaves the process — see the caveat below.
  • Diff & align two runs. Compare a golden run against a fresh one to catch a dropped tool call or a reordered step — structurally, or within a formal equivalence model. See common regression patterns.
  • Gate it in CI. Fail the pull request when the reasoning path drifts, not just when a string changes — with an honest account of what dpk does and doesn't gate. See the CI-gate tutorial.
Honesty caveat · redaction before export The default redaction policy is .full — on-device capture is the whole point, and a signed document carries covered payload JSON in plaintext. Before any trace leaves the device (a SQLite export, a cloud store, OTel), switch to .hashed so text never leaves the process: FMTracingConfiguration(redaction: .hashed). A .full and a .hashed trace of the same content still diff exactly equal.