llm-ingestion-okf/docs/upstream-okf-upgrade-runbook.md
Kjell Tore Guttormsen b1c2f5ecee docs(upstream): run the runbook against OKF at its new canonical repo
Operator GO 2026-08-23. Two findings outrank the change that triggered
the round.

OKF moved to GoogleCloudPlatform/open-knowledge-format (6265173). The
tree this repo pinned, knowledge-catalog path okf/, is now a frozen
snapshot by upstream's own notice, and the runbook's Trigger step named
it. The two trees have already diverged: the frozen copy carries a fix
(38c713f, eight tags: values as sequences rather than one plain scalar)
that the canonical repo does not.

The spec tightened without a version bump. SPEC.md still declares
Version 0.2, but every timestamp-valued key is now an ISO 8601 datetime
with an explicit offset, so a value that conformed in July does not
conform now under the same version number. Step 4 gained a fifth diff
class for it.

Our exposure is zero, measured: 10 of 10 timestamp values across the
four goldens already carry a Z offset, gated by _INGESTED_AT_RE. The
three tightened keys (stale_after, last_modified, usage_window) are
named but never written by any door. V-A8 at the new pin: 9/9 of our
bundle documents parse, 78/78 of upstream's do.

Two reference-reader behaviour changes recorded. A date-only
stale_after now reads as never stale rather than as stale -- silent,
fails open, and the class the black box cannot absorb. Timestamps now
survive a parse as written, so our ingested_at arrives as str where it
used to arrive as datetime.

No new profile: there is no new version to express. The pin move and
consumer notification are named as deliverables and deliberately not
taken here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013j8ZEcjiZQpz22fecNsyLZ
2026-08-23 18:54:15 +02:00

15 KiB

Upstream OKF upgrade — runbook

Standing policy (operator, 2026-07-26): this library always supports the current latest version of Google OKF. This document is the procedure that policy runs on. It exists because a standing policy without a procedure is an intention, and because the v0.1 → v0.2 round produced five findings that a spec reading alone had settled wrongly — each step below names the concrete failure it prevents.

Written after the v0.2 round. Every prevented-failure note is something that actually happened, not a hypothetical.

The goal that shapes the procedure: this repo is a black box

An upstream release is our problem, not our consumers'. The target cost to a repo that consumes OKF through this library is a re-run, and nothing else.

That is a design commitment with teeth, not an aspiration:

  • Support is additive — a new profile, never a migration. Existing profiles stay byte-stable. A consumer who does not opt in sees no change at all.
  • New public parameters are keyword-only with defaults. A consumer's existing positional call sites stay source-compatible across an upgrade. This is why profile is specified as *, profile: BundleProfile = DEFAULTdecided, not yet shipped: measured 2026-07-26, no door takes the argument at all. It turns additivity from something a consumer has to measure into a property of the signature.
  • Consumer golden fixtures must not churn. If an upgrade rewrites bytes in a consumer's frozen fixtures, the upgrade is wrong, not the fixture.

The honest boundary — state it every time, never let the black box be oversold. The library absorbs shape changes. It cannot absorb upstream changes to content the consumer authored. v0.2 superseded timestamp with generated.at and the body # Citations list with sources; no library change makes a consumer's existing timestamp field become something else. For that class the deliverable is not absorption but a measured exposure report, per consumer, before they ask — which is what step 5 produces.

Trigger

"Always latest" decays silently: nothing fails when upstream ships and we do not notice. So the re-check is an item on the release checklist — run it at every release of this library, and record the result even when unchanged, because an unrecorded check is indistinguishable from a skipped one.

Check GoogleCloudPlatform/open-knowledge-format. That is the canonical home of the spec, the reference agent and the sample bundles as of 2026-08-21. A version bump appears as a commit against SPEC.md §12 and, in the v0.2 round, as an explicit migration commit (okf: migrate format and tooling to Open Knowledge Format v0.2).

Do not check GoogleCloudPlatform/knowledge-catalog, path okf/. That copy is a frozen snapshot by upstream's own notice (6265173, "anything built against it will drift out of date"), and this repo was pinned to it until the 2026-08-23 round. Two consequences, both measured that round and neither hypothetical:

  • The two trees have already diverged, and not only in the direction you would expect: the frozen copy carries a fix (38c713f, eight tags: values written as sequences rather than as one plain scalar) that the canonical repo does not. The canonical tree is authoritative for the spec; it is not automatically a superset.
  • A round run against the frozen tree reports "no change" truthfully and uselessly — the exact shape of a negative result that is not a measurement.

Prevents: tracking a tree that has stopped being the thing you promised to track. "Always latest" is a promise about an object with an identity, and the identity can move without the version number moving.

Step 1 — Pin before reading anything

Record the exact commit SHA and read everything at it.

Prevents: reading a moving branch and treating the result as fact. In the v0.2 round both this repo and portfolio-optimiser-commons independently read main, which felt like two confirmations and was one unstable reference read twice. Worse, SPEC.md was edited after the v0.2 migration commit — so "the v0.2 commit" and "the current spec text" were different objects, and either alone would have been a partial answer.

Step 2 — Enumerate the whole REPOSITORY tree, not just okf/, not just SPEC.md

List every file and directory at the pinned commit before deciding what to read. Start at the repository root:

git ls-files | awk -F/ '{print $1}' | sort -u      # top-level entries
git ls-files | sed 's/.*\.//' | sort | uniq -c     # what kinds of file exist

Prevents: assuming the directory you know about is the one that matters. This step has now failed twice at two different scopes, which is why its title names the root rather than a subdirectory:

  • v0.2 round, inside okf/. okf/ held SPEC.md, README.md, pyproject.toml, src/, tests/, samples/ and bundles/. The last one held the actual v0.2 example bundles and was not on anyone's list until the tree was enumerated.
  • 2026-07-31, one level up — the same mistake against okf/ itself. Scoping enumeration to okf/ (as this step previously instructed) hides that the repository root also carries toolbox/ and samples/. Measured at 3fcbb9f: 265 tracked files, of which 48 are Python and 43 TypeScript, with toolbox/ holding two complete tools (mdcode, enrichment) plus a second copy of an OKF bundle under toolbox/mdcode/demo/okf/catalog/. None of it was on any list.

The cost of getting this wrong is not only unread files: a negative claim derived from a partial enumeration reads exactly like a measured one. "There is no validator in okf/" was recorded as fact and used to plan a weakening of V-A8; okf/src/reference_agent/bundle/document.py:58 defines validate(), and the same module is a working v0.2 reader. Enumerate before concluding that something is absent — see Step 3a.

Step 3 — Read the shipped examples, not only the normative text

This is the step the v0.2 round skipped, and it is the reason this document exists.

A spec says what is permitted. An example shows what upstream actually emits. Those differ, and when we are about to freeze a fixture and hand it to consumers, the second one is what we are being measured against.

Two traps, both hit in the v0.2 round:

  • A directory called samples/ was not samples of the format. okf/samples/ga4_merch_store/ is a runner config (README.md + seeds.txt) that drives the reference agent to generate a bundle. It says nothing about output shape. The real examples were in okf/bundles/ — four of them.
  • Fetch tooling that converts to markdown will strip or reformat frontmatter. Byte-level questions need byte-level fetches. Check for a BOM, check the trailing newline, check whether a value is quoted. Those three are exactly the properties that break downstream gates and exactly the ones a summarizing fetch destroys.

What reading the four v0.2 bundles produced, none of it derivable from SPEC.md:

Finding Consequence
No reference bundle declares okf_version at all; root index.md has no frontmatter §12 is a MAY and upstream declines it. Our stricter posture became a decision instead of an assumption
generated is written for human-authored content (by: human:…) It can never be an ownership or machine-generated predicate. Governs the collision gate
Real frontmatter is multi-line block YAML: block lists of multi-key mappings, nested mappings, flow sequences, booleans, dates Re-sized the emitter work. It had been scoped against a list of strings
§7's canonical tool actor is <producer>/<version>, and upstream uses it Counter-evidence to a recommendation we had already sent to another repo

Step 3a — Run upstream's own reader against our fixture (V-A8)

Every other test in the suite asks whether we agree with ourselves. This one asks an independent implementation, and it is cheap enough that there is no excuse for skipping it.

It is not a dependency. okf/src/reference_agent/bundle/document.py imports only yaml, so it runs standalone under system Python against the pinned clone — nothing is installed, .venv is never touched (the one-runtime-dependency rule and its packaging test both stay intact), and nothing is written.

import sys; sys.path.insert(0, "<clone>/okf/src")
from reference_agent.bundle.document import OKFDocument, trust_tier, is_stale

Assert, over each file of the current version's golden bundle:

  1. OKFDocument.parse() accepts our bytes. This is the load-bearing one. Our own parser is line-oriented and reads inline flow forms as opaque strings, so it structurally cannot tell us whether a real YAML consumer recovers them as structures. Only an outside parser can.
  2. The values arrive as the shapes the profile intends — generated as a mapping, sources as a list of mappings — not merely as something that parsed.
  3. validate() passes. Note what this does and does not buy: at 3fcbb9f REQUIRED_FRONTMATTER_KEYS = ("type",), so it checks one key. Treating a green validate() as "upstream accepts our bundle" would overclaim badly; the parse in (1) and the shape assertions in (2) are where the signal is.
  4. The semantic readers upstream ships run over our frontmatter without raising — at 3fcbb9f: trust_tier (§5.3), normalize_verified (§5.2), is_stale (§5.5).

Record the type each value arrives as, not just that it parsed. A real YAML parser coerces, and the coercions are load-bearing facts about the value space — this is where the v0.2 round's okf_version float finding came from (see the alignment plan's A-E6). Upstream's serialize() also reflows inline flow forms to block form, so an upstream round-trip produces bytes our own line-oriented parser cannot read. That is expected and one-directional; do not "fix" it by emitting block form.

Result, v0.2 round at 3fcbb9f: 13/13 green against examples/ingest-golden-okf-v0-2/.

Result, 2026-08 round at ad30107: 9/9 of our bundle documents parse, across all four goldens, with the three semantic readers running over each without raising; 78/78 of upstream's own bundle documents parse. Do not compare the two counts — the second harness asserted validate() on root index.md files too, which correctly fails (an index carries no type), so it counts differently by construction. A clean sweep is worth nothing until the harness is shown able to fail: feed it a deliberate type: [unclosed and confirm it raises before you report the zeroes.

Step 4 — Produce the diff, classified

From the spec's own "Changes from vN" section plus the example evidence, classify every change as:

  • Breaking — name it, and measure consumer exposure in step 5.
  • Additive — new optional fields, new conventional headings.
  • Deferred upstream — a format specified but its runtime protocol left to a future revision. The format is supportable; an unspecified runtime is not something to build against. Record it as out of scope on upstream's deferral, not on our preference.
  • Observed but undocumented — present in an official bundle, not found in the spec sections read. Record as unresolved. Do not silently promote it to either category. (v0.2 round: a not: family in metrics/gross-margin.md.)
  • Tightened in place, no version bump — the normative text narrows what a key may hold, under the same version number. A value that conformed last round does not conform now, and nothing in the version string says so. (2026-08 round: every timestamp-valued key became an ISO 8601 datetime with an explicit offset, and SPEC.md still declares Version 0.2.) Two things follow. First, do not reach for the additive-profile machinery on reflex — there is no new version to express, and minting one would declare a version upstream has not. Second, "conformant with v0.2" stops identifying one document; the spec commit has to be named alongside the version, which is what okf_spec_commit is for.

Step 5 — Measure our exposure and each consumer's, in that order

Ours: grep the profiles, the fixtures, and the emitter for every field the diff touches. Cite file:line. Never reason about a key set — measure it.

Theirs: for each breaking change, state what would have to be true for it to reach them, then ask them to measure it against their real data rather than telling them whether they are affected. In the v0.2 round both portfolio-optimiser-claude and linkedin-studio came back with measured zero exposure and found the framing useful precisely because it was falsifiable.

Step 6 — Plan additively, then pilot before general availability

The plan is a new profile, sequenced deliverables, and stated invariants. Rollout is pilot-first: a pre-release tag to a small pilot set chosen for signal, revised on their feedback, then GA. Flipping the OKF_LATEST alias is the GA event, not a merge side effect.

State each pilot test's expected results before the run, numbered, plus what would surprise us. An expectation we get wrong is a better result than a clean run; only a stated expectation can be falsified by someone else's run.

Name fixtures by repo and path, never by path alone. In the v0.2 round both this repo and portfolio-optimiser-claude had a directory named examples/ingest-golden-file/ holding different fixtures with zero content overlap. A pilot baseline was agreed by name, and the name pointed at two files.

Step 7 — Inform every OKF-consuming repo

All of them, not only the pilots, and not only when something breaks.

Send a bounded loop of directed messages, not a broadcast — a broadcast is inherited by every future repo, which is the wrong lifetime for a version-specific notice.

Each message carries:

  1. The version, the pinned commit, and where to read it.
  2. The breaking changes by name, with the shape of the exposure — so they can measure themselves rather than take our word.
  3. The black-box promise and its boundary: their cost is a re-run; existing profiles are byte-stable; new parameters are keyword-only. And the part we cannot absorb, said plainly.
  4. What we want back, if anything, and what happens if they say nothing.

Consumers to cover (2026-07): portfolio-optimiser-commons (spec authorship), portfolio-optimiser, portfolio-optimiser-claude, claude-code-llm-wiki, catalog, okr, linkedin-studio, ms-ai-architect. Re-derive this list each round rather than trusting it — it is a premise like any other.

Step 8 — Close the loop in the repo

The plan doc records the pinned commit, the classified diff, the measured exposure, and every correction a consumer sent back. Corrections stay visible: a premise that survived verification is only known to have survived if the check is recorded, and a claim we withdrew is only safely withdrawn if the withdrawal is written where the claim was.

Invariants this procedure protects

  • No profile hard-codes an upstream version.
  • No bundle declares a version its shape has not earned. The spec may permit declaring conformance without a checkpoint; we decline. Conform first, claim after.
  • DEFAULT states commons' ingest-spec §5 layer — raised there, never patched here.
  • STRICT_V1 mirrors a consumer's ratified contract — never changed from here.
  • The value of okf_version belongs to the catalog.
  • Security stays the guard's domain in every version.