Skip to content

feat(export): Native Open Knowledge Format (OKF) v0.2 bundle export/import support #623

Description

@d-oit

Background

Google Cloud released OKF v0.2 (2026-07-24): an open, vendor-neutral format for knowledge as a directory of Markdown files with YAML frontmatter, authored by people and agents.
Spec: https://github.com/GoogleCloudPlatform/knowledge-catalog/blob/main/okf/SPEC.md

The studio currently exports json | markdown | html | pdf | docx | encrypted (src/components/studio/views/export-types.ts). The Markdown export concatenates all entities into a single .md with non-standard frontmatter. ADR 010 flags the missing canonical schema and missing round-trip import.

OKF v0.2 gives us: (a) agent-readable exports with zero SDK, (b) trust/provenance/freshness signals for LLM-generated knowledge, (c) a governed schema closing ADR 010's round-trip gap.

Spec requirements to implement

  • Bundle = directory tree of .md files; only type frontmatter REQUIRED
  • Reserved files: index.md (root may carry okf_version: "0.2"), log.md (date-grouped, newest first)
  • Provenance: sources[] (id, resource REQUIRED, title, author, usage_count, last_modified) + sibling usage_window; per-claim attribution via footnotes keyed to sources[].id
  • Trust: generated: { by, at }, verified: [{ by, at }]; bare mapping MUST be treated as 1-element list; actor convention human:<id> | <producer>/<version> | process:<id>
  • Lifecycle: status: draft|stable|deprecated (absent ⇒ stable), stale_after: YYYY-MM-DD (stale when today >= stale_after)
  • New concept type Attested Computation: runtime REQUIRED, parameters, computation, executor, attester
  • Conformance §11: consumers MUST NOT reject unknown types/keys, broken links, or missing optional fields

Code changes (file by file)

1. NEW src/lib/okf/types.ts — Zod schemas mirroring the spec

import { z } from 'zod'

/** OKF actor convention (§7): human:<id> | process:<id> | <producer>/<version> */
export const OkfActorSchema = z
  .string()
  .regex(/^(human:|process:|[\w.-]+\/).+$/, 'invalid OKF actor')

export const OkfIsoDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/)

export const OkfSourceSchema = z.object({
  id: z.string().optional(), // stable join key for footnote attribution (§5.1)
  resource: z.string().min(1), // REQUIRED within an entry (§5.1)
  title: z.string().optional(),
  author: OkfActorSchema.optional(),
  usage_count: z.number().int().nonnegative().optional(),
  last_modified: OkfIsoDateSchema.optional(),
  usage_window: z.object({ from: OkfIsoDateSchema, to: OkfIsoDateSchema }).optional(),
})

export const OkfActorEventSchema = z.object({
  by: OkfActorSchema, // REQUIRED within generated/verified (§5.2)
  at: z.string().datetime({ offset: true }).optional(),
})

export const OkfStatusSchema = z.enum(['draft', 'stable', 'deprecated'])

/** Frontmatter shared by every OKF concept (§4.1 + §5). */
export const OkfConceptFrontmatterSchema = z
  .object({
    type: z.string().min(1), // the ONLY always-required key (§4.1)
    title: z.string().optional(),
    description: z.string().optional(),
    resource: z.string().optional(),
    tags: z.array(z.string()).optional(),
    sources: z.array(OkfSourceSchema).optional(),
    usage_window: z.object({ from: OkfIsoDateSchema, to: OkfIsoDateSchema }).optional(),
    generated: OkfActorEventSchema.optional(),
    // §5.2: a bare mapping MUST be accepted as a one-element list
    verified: z.union([OkfActorEventSchema, z.array(OkfActorEventSchema)]).optional(),
    status: OkfStatusSchema.optional(),
    stale_after: OkfIsoDateSchema.optional(),
  })
  .passthrough() // §4.1 extensions: consumers MUST preserve unknown keys

/** Attested Computation contract (§10.2). */
export const OkfAttestedComputationSchema = OkfConceptFrontmatterSchema.extend({
  type: z.literal('Attested Computation'),
  runtime: z.string().min(1), // REQUIRED for this type (§10.2)
  parameters: z
    .array(z.object({ name: z.string(), type: z.string(), required: z.boolean().default(false) }))
    .optional(),
  computation: z.string().optional(), // path (§6.2); absent ⇒ body "# Computation" fence
  executor: z.object({ resource: z.string(), receipt: z.array(z.string()) }).optional(),
  attester: z.object({ resource: z.string() }).optional(),
})

export interface OkfBundleFile {
  path: string // bundle-relative, e.g. "concepts/foo.md"
  content: string
}
export interface OkfBundle {
  files: OkfBundleFile[]
  okfVersion: '0.2'
}

2. NEW src/lib/okf/bundle.ts — studio state → OKF bundle

import yaml from 'yaml'
import type { Entity, Claim, GraphEdge } from '@/lib/studio/types'
import type { OkfBundle, OkfBundleFile } from './types'

export const slug = (s: string): string =>
  s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'untitled'

/** §4.1: type values are not centrally registered; pick descriptive, self-explanatory strings. */
const OKF_TYPE_MAP: Record<string, string> = {
  note: 'Note',
  concept: 'Concept',
  person: 'Person',
  project: 'Project',
}

/** §3.1: index.md / log.md are reserved and MUST NOT be used for concepts. */
const RESERVED = new Set(['index', 'log'])

function conceptPath(e: Entity): string {
  const typeName = OKF_TYPE_MAP[e.type] ?? 'Concept'
  let name = slug(e.name)
  if (RESERVED.has(name)) name = `${name}-concept` // never collide with reserved filenames
  return `${typeName.toLowerCase()}s/${name}.md`
}

interface SourceEntry { id: string; resource: string; title?: string; last_modified?: string }

function buildConceptDoc(e: Entity, claims: Claim[], studioVersion: string, now: Date): string {
  const frontmatter: Record<string, unknown> = {
    type: OKF_TYPE_MAP[e.type] ?? 'Concept',
    title: e.name,
    description: e.summary, // adjust to the actual Entity field used for one-line summaries
    tags: e.tags?.map((t) => t.name),
    status: 'stable',
    generated: { by: `do-knowledge-studio/${studioVersion}`, at: now.toISOString() },
  }

  // §5.1 provenance: claims with a source become sources[] entries with STABLE ids
  const sources: SourceEntry[] = []
  const sourceIdByResource = new Map<string, string>()
  for (const c of claims) {
    if (!c.source) continue
    let id = sourceIdByResource.get(c.source)
    if (!id) {
      id = `src-${sources.length + 1}`
      sourceIdByResource.set(c.source, id)
      sources.push({
        id,
        resource: c.source,
        title: c.sourceTitle,
        last_modified: c.updatedAt?.slice(0, 10),
      })
    }
  }
  if (sources.length) frontmatter.sources = sources

  const body = [
    e.content ?? '',
    claims.length ? '\n# Claims\n' : '',
    ...claims.map((c) => {
      const id = c.source ? sourceIdByResource.get(c.source) : undefined
      return `- ${c.text}${id ? `[^${id}]` : ''}`
    }),
    // §5.1: footnote label is the join key into sources[], NOT positional
    ...sources.map((s) => `[^${s.id}]: ${s.title ?? s.resource}`),
  ].join('\n')

  return `---\n${yaml.stringify(frontmatter)}---\n\n${body}\n`
}

function buildIndex(files: OkfBundleFile[], entities: Entity[]): string {
  // §8: root index.md MAY carry okf_version frontmatter (the only index allowed frontmatter)
  const byDir = new Map<string, { title: string; href: string; desc: string }[]>()
  for (const f of files) {
    if (f.path === 'index.md' || f.path === 'log.md') continue
    const dir = f.path.split('/')
    const entity = entities.find((e) => f.path.endsWith(`${slug(e.name)}.md`))
    const entries = byDir.get(dir) ?? []
    entries.push({
      title: entity?.name ?? f.path,
      href: `/${f.path}`, // §6.1: bundle-relative absolute links are the recommended form
      desc: entity?.summary ?? '',
    })
    byDir.set(dir, entries)
  }
  const sections = [...byDir.entries()]
    .map(([dir, items]) =>
      [`# ${dir[0].toUpperCase()}${dir.slice(1)}`, '',
       ...items.map((i) => `* [${i.title}](${i.href}) - ${i.desc}`)].join('\n'))
    .join('\n\n')
  return `---\nokf_version: "0.2"\n---\n\n# Knowledge Bundle\n\n${sections}\n`
}

function buildLog(now: Date): string {
  // §9: date headings MUST be ISO YYYY-MM-DD, newest first
  const day = now.toISOString().slice(0, 10)
  return `# Directory Update Log\n\n## ${day}\n* **Export**: Bundle generated by do-knowledge-studio.\n`
}

export function buildOkfBundle(
  entities: Entity[],
  claims: Claim[],
  edges: GraphEdge[],
  studioVersion: string,
  now: Date = new Date(),
): OkfBundle {
  const claimsByEntity = new Map<string, Claim[]>()
  for (const c of claims) {
    claimsByEntity.set(c.entityId, [...(claimsByEntity.get(c.entityId) ?? []), c])
  }

  const conceptFiles: OkfBundleFile[] = entities.map((e) => ({
    path: conceptPath(e),
    content: buildConceptDoc(e, claimsByEntity.get(e.id) ?? [], studioVersion, now),
  }))

  // §6.1: rewrite GraphEdge relationships as bundle-relative markdown links appended
  // under a "# Related" heading in each linked concept (edges are untyped relationships).
  const pathByEntityId = new Map(entities.map((e) => [e.id, `/${conceptPath(e)}`]))
  for (const edge of edges) {
    const from = conceptFiles.find((f) => f.path === pathByEntityId.get(edge.from)?.slice(1))
    const toPath = pathByEntityId.get(edge.to)
    if (from && toPath && !from.content.includes(`](${toPath})`)) {
      const name = entities.find((e) => `/${from.path}` === pathByEntityId.get(edge.from))?.name
      from.content = from.content.replace(
        /\n?$/,
        `\n\n# Related\n\n* [${entities.find((e) => e.id === edge.to)?.name ?? toPath}](${toPath})\n`,
      )
      void name
    }
  }

  const files: OkfBundleFile[] = [
    { path: 'log.md', content: buildLog(now) },
    ...conceptFiles,
  ]
  files.unshift({ path: 'index.md', content: buildIndex(conceptFiles, entities) })
  return { files, okfVersion: '0.2' }
}

3. NEW src/lib/okf/import.ts — round-trip (closes ADR 010 gap)

import yaml from 'yaml'
import { OkfConceptFrontmatterSchema } from './types'
import type { Entity, Claim } from '@/lib/studio/types'

export interface OkfImportResult {
  entities: Entity[]
  claims: Claim[]
  errors: string[]
}

const OKF_TYPE_REVERSE: Record<string, Entity['type']> = {
  Note: 'note', Concept: 'concept', Person: 'person', Project: 'project',
}

/** Parse an OKF bundle (path → content) back into studio state.
 * §11: MUST NOT reject unknown types, unknown keys, broken links, or missing
 * optional fields — collect errors and continue. */
export function parseOkfBundle(files: Map<string, string>): OkfImportResult {
  const result: OkfImportResult = { entities: [], claims: [], errors: [] }

  for (const [path, content] of files) {
    if (/(^|\/)index\.md$/.test(path) || /(^|\/)log\.md$/.test(path)) continue // reserved (§3.1)

    const match = content.match(/^---\n([\s\S]*?)\n---\n([\s\S]*)$/)
    if (!match) {
      result.errors.push(`${path}: missing or unparseable frontmatter`) // §11 conformance rule 1
      continue
    }
    const parsed = OkfConceptFrontmatterSchema.safeParse(yaml.parse(match))
    if (!parsed.success) {
      result.errors.push(`${path}: ${parsed.error.issues[0]?.message ?? 'invalid frontmatter'}`)
      continue
    }

    const fm = parsed.data // passthrough preserves unknown keys for round-trip (§4.1)
    const entity: Entity = {
      id: path.replace(/\.md$/, ''), // Concept ID = path minus .md (§2)
      name: fm.title ?? path.split('/').pop()!.replace(/\.md$/, ''),
      type: OKF_TYPE_REVERSE[fm.type] ?? 'concept', // unknown types tolerated (§11)
      content: match.trim(),
      tags: (fm.tags ?? []).map((name) => ({ name })),
      // … map remaining Entity fields with defaults
    } as Entity
    result.entities.push(entity)

    // Per-claim attribution: footnote labels join back to sources[].id (§5.1)
    const sourceById = new Map((fm.sources ?? []).filter((s) => s.id).map((s) => [s.id!, s]))
    for (const m of match.matchAll(/^- (.+?)(?:\[\^([\w-]+)\])?$/gm)) {
      result.claims.push({
        entityId: entity.id,
        text: m,
        source: m ? sourceById.get(m)?.resource : undefined,
        // … map remaining Claim fields with defaults
      } as Claim)
    }
  }
  return result
}

4. src/components/studio/views/export-types.ts — register the format

// BEFORE
export type ExportFormatId = 'json' | 'markdown' | 'html' | 'pdf' | 'docx' | 'encrypted'
// AFTER
export type ExportFormatId = 'json' | 'markdown' | 'html' | 'pdf' | 'docx' | 'encrypted' | 'okf'

// Add to FORMATS:
{
  id: 'okf',
  name: 'OKF Bundle',
  description:
    'Open Knowledge Format v0.2 — agent-readable Markdown bundle with provenance, trust & lifecycle frontmatter',
  color: 'sky',
}

5. src/components/studio/views/use-export-handlers.ts — export handler

import { zipSync, strToU8 } from 'fflate' // NEW dep: fflate (tiny, client-side zip; §3 allows zip distribution)
import { buildOkfBundle } from '@/lib/okf/bundle'
import pkg from '../../../package.json'

const handleExportOkf = () => {
  const bundle = buildOkfBundle(entities, claims, edges, pkg.version)
  const zipped = zipSync(
    Object.fromEntries(bundle.files.map((f) => [`okf-bundle/${f.path}`, strToU8(f.content)])),
  )
  downloadBlob(
    `do-knowledge-studio-okf-${stamp}.zip`,
    new Blob([zipped], { type: 'application/zip' }),
  )
  toast.success('OKF v0.2 bundle exported', {
    description: `${bundle.files.length} files — consumable by any OKF-aware agent, no SDK required`,
  })
}

// in handleExport switch:
//   case 'okf': return handleExportOkf()

6. Import flow (handleImportClick path) — accept OKF bundles

// In the existing import handler: detect .zip with a root index.md carrying okf_version
import { unzipSync, strFromU8 } from 'fflate'
import { parseOkfBundle } from '@/lib/okf/import'

async function parseImportFile(file: File): Promise<ImportResult> {
  if (file.name.endsWith('.zip')) {
    const entries = unzipSync(new Uint8Array(await file.arrayBuffer()))
    const files = new Map(
      Object.entries(entries)
        .filter(([p]) => p.endsWith('.md'))
        .map(([p, data]) => [p.replace(/^okf-bundle\//, ''), strFromU8(data)]),
    )
    const rootIndex = files.get('index.md') ?? ''
    if (!rootIndex.includes('okf_version')) {
      return { errors: ['zip does not contain an OKF bundle (no okf_version in index.md)'] }
    }
    // §12: unknown versions → best-effort consumption, not refusal
    const { entities, claims, errors } = parseOkfBundle(files)
    return { data: { entities, claims }, errors } // merge via existing ImportPreview + Zod validation
  }
  // … existing json/markdown paths unchanged
}

7. NEW src/lib/okf/trust.ts — trust tiers & staleness (phase 2 UI badges)

import type { z } from 'zod'
import type { OkfConceptFrontmatterSchema } from './types'

type Frontmatter = z.infer<typeof OkfConceptFrontmatterSchema>

/** §5.3 trust tiers — derived, never stored. */
export function trustTier(
  verified: Frontmatter['verified'],
): 'unverified' | 'machine-confirmed' | 'human-reviewed' {
  const list = !verified ? [] : Array.isArray(verified) ? verified : [verified]
  if (list.some((v) => v.by.startsWith('human:'))) return 'human-reviewed'
  return list.length ? 'machine-confirmed' : 'unverified'
}

/** §5.5: stale when today >= stale_after (plain date comparison). */
export const isStale = (staleAfter?: string, today = new Date()): boolean =>
  !!staleAfter && today.toISOString().slice(0, 10) >= staleAfter

8. Tests (matching existing vitest conventions)

  • src/lib/okf/bundle.test.ts:
    • every entity → exactly one concept file; every frontmatter has non-empty type (§11)
    • concepts never use reserved filenames index/log (§3.1) — slug collision test
    • root index.md carries okf_version: "0.2"; other indexes carry none (§8)
    • footnote ids stable when sources reorder (keyed, not positional — §5.1)
    • generated.by matches <producer>/<version> actor convention (§7)
    • graph edges become bundle-relative absolute links (/dir/x.md — §6.1)
  • src/lib/okf/import.test.ts:
    • round-trip export → import preserves entities/claims incl. sources
    • unknown frontmatter keys survive (passthrough, §4.1)
    • unknown type tolerated, maps to generic concept (§11)
    • bare verified mapping accepted as one-element list (§5.2 MUST)
    • broken links tolerated, no rejection (§6.1)
  • src/lib/okf/trust.test.ts: tier derivation (none/machine/human), stale_after boundary (today == stale_after ⇒ stale)
  • Update use-export-handlers.test.ts & export-format-grid.test.tsx for the 'okf' case

9. Dependencies & docs

  • package.json: add fflate (runtime dep) and yaml (if not already transitive)
  • New ADR plans/ADRs/03x-okf-v02-export.md superseding the Markdown portion of ADR 010
  • agents-docs/ + README.md: document OKF as the canonical agent-facing export; link spec

Out of scope (follow-ups)

  • Attested Computation export for LLM-extracted claim statistics (schema is already forward-compatible)
  • Serving the bundle via MCP (see okf-go okf mcp for precedent)
  • verified/stale_after editing UI

Acceptance criteria

  • pnpm test passes incl. new OKF suites (coverage ≥ current threshold)
  • Exported bundle conforms to OKF v0.2 §11 (parseable frontmatter, non-empty type, reserved-file structure)
  • Round-trip: export OKF → import OKF yields equivalent entities/claims
  • Bundle opens in an OKF-aware agent (Claude Code / okf-skills) with no SDK

Refs:

Metadata

Metadata

Assignees

No one assigned

    Labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions