Skip to content

DESIGN — ID-429 Bundle index: format, axis, and one index per subdirectory

Artefact: {429.1} · Status: design ruled, not implemented · Satisfies: id-429 AC-1 (the design written down before implementation) Author’s stance: the theme machinery is a strip, not a replacement; the axis is the directory, not a taxonomy; and the one change that looks cosmetic (the separator) is the only one with no consumer cost, while the one that looks structural (per-subdir indexes) carries a measured producer defect that must be fixed in the same subtask.

Section numbers are OKF v0.2 throughout (§8 index, §9 log, §12 versioning, §6.1 bundle-absolute links, §3.1 reserved filenames, §11 conformance). The vendored copy in the repo is still v0.1 and numbers these §6/§7/§11/§5.1/§3.1/§9 — id-426 re-vendors.


0. Why this task exists — stated in the only terms that survive a reader’s check

Section titled “0. Why this task exists — stated in the only terms that survive a reader’s check”

This is a design decision the owner has taken, not a conformance fix (DR-141 Consequences bullet 6; id-429 status_note). §8 says an index.md MAY appear in any directory; §11 forbids rejecting a bundle for missing ones. Any justification of this work by conformance cites a document that says the opposite.

The requirement each surface serves is named in §1. Where I could not name a requirement and its current source, the verdict is UNDECIDABLE and it is carried verbatim in §7.


1. Requirement-first: what each index surface is for

Section titled “1. Requirement-first: what each index surface is for”
SurfaceRequirement it servesCurrent source of that requirement
Root index.mdA reader (human or agent) landing on the bundle must learn what groups of knowledge exist before opening anything — §8’s stated purpose, progressive disclosure. Post-id-427 the bundle becomes corpus-complete by construction (DR-141), so the root must not be a flat enumeration: the thing it enumerates is about to become the whole corpus.OKF §8 (purpose); DR-141 Decision (corpus-completeness makes a flat root index unusable)
Per-subdirectory index.mdA reader who has navigated into a group must get the group’s own contents, on the axis they just navigated by. Today they get nothing: opening /certifications/ shows a bare file list with no titles and no descriptions.Owner ruling S545 verbatim: “Index format, design, and including one per-subdir is required”; DR-141 Consequences bullet 6 (the axis mismatch)
okf_version stampA generic consumer must be able to learn which spec revision the bundle targets, from the one place the spec permits frontmatter in an index.§12 + §8 (“with one exception: a bundle-root index.md MAY carry an okf_version key”)
Bullet form (* [Title](url) - description)A generic OKF consumer that was written against the spec’s shown form must parse our bundle without special-casing us. We ship the first generic consumer ourselves (id-439); the second one will not be ours.§8 shown form, read verbatim from upstream main this session (§2, row 7)
~17 themesUNDECIDABLE — see §7 Q1. No requirement with a current source could be named.

2. Measured ground (what I executed, against canonical main d1e6e14ad)

Section titled “2. Measured ground (what I executed, against canonical main d1e6e14ad)”

Everything below was executed this session unless the row says otherwise. file:line cites are from the read, not carried.

  1. One emission site. write_bundle builds themes = build_index_themes(theme_config, written) and declares exactly one file — bundle_dir / INDEX_FILENAME (bundle_writer.py:1366-1368). No other index.md is ever written.
  2. written is keyed by the PHYSICAL path. ConceptWriteResult.rel_path = bundle_write_path(draft) (bundle_writer.py:330-331, :351, :369), so the won-bid redirect (case-studies/won-bid/<slug>.md) is already what the index links to. No latent broken-link bug; grouping written by dirname is sound.
  3. The theme axis has no supplier. theme_config defaults () at both call sites (bundle_writer.py:1149, producer/flow_def.py:414; pass-through at :555). Repo-wide there is no non-test supplier. The sibling config file its own docstring names as the feeding convention — index-themes.json (bundle_writer.py:993) — does not exist anywhere in the repo: one docstring hit, zero files, not in _RESERVED_BUNDLE_FILENAMES.
  4. The measured defect that per-subdir indexes would trip. _existing_concept_paths (bundle_writer.py:1114-1120) compares the full bundle-relative path against _RESERVED_BUNDLE_FILENAMES (a set of bare basenames). So certifications/index.md is not reserved: it is counted as a previous-run concept, never appears in written, and therefore lands in RunSummary.removed on every run, forever — polluting log.md and the human-edit reconcile set. §3.1 is explicit that the reserved filenames hold “at any level of the hierarchy”, so this is a real deviation today, latent only because we emit no nested index.
  5. The TS side already gets §3.1 right. bundle-graph.ts:417 skips by basename (basename === INDEX_NAME || basename === LOG_NAME), so a nested index is already excluded from the concept graph — no change needed there. RESERVED_ROOT_DOCS (:84, README/CONFORMANCE) is deliberately root-only and stays so.
  6. The separator needs no consumer change. CONCEPT_BULLET_RE (parse-index.ts:27-28) is /^[*-]\s*\[(.+?)\]\(([^)\s]+\.md)\)(?:\s*[-—]\s*(.*))?$/ — the class [-—] already accepts both glyphs, and the writer’s own S451 rider docstring says so (bundle_writer.py:25-27: “both -/ are accepted, so a hyphen would ALSO round-trip”). This corrects the dispatch brief: parse-index.ts does not have to move in step with the separator. The only artefact that must move is the byte-exact round-trip fixture __tests__/fixtures/okf/bundle-writer-index.md, which is prettier-excluded by design (.prettierignore:65-70, S451 rider; re-affirmed in d1e6e14ad’s message) so the formatter will not silently undo it.
  7. The spec’s shown form, read from upstream main (WebFetch of GoogleCloudPlatform/knowledge-catalog okf/SPEC.md, this session): * [Title 1](relative-url-1) - short description of item 1asterisk marker, ASCII hyphen separator — and, in the same fenced block, * [Subdirectory](subdir/) - short description of the subdirectory. The vendored copy renders these with a - marker (lib/okf/.claude/skills/okf/reference/SPEC.md:280-289); that is the prettier-style drift S546 already found in the vendored file (list-marker normalisation, alongside the reflow and *emphasis*_emphasis_), not an upstream difference. id-426’s re-vendor must land upstream bytes, or this AC will be checked against a formatted copy and read as already-conformant.
  8. Heading contract. HEADING_RE (parse-index.ts:26) matches ##/### only; IndexTheme.level is validated ∈ {2,3} (bundle_writer.py:394-417); the root H1 is the literal # OKF Concept Bundle (:460); the okf_version stamp is at :456-459.
  9. Nav paths are matched against graph node ids. bundle-nav.tsx:157,171 does row.path === selectedConceptId, and row.path is stripMdSuffix(link) from the bullet, while node ids are bundle-root-relative-without-extension (bundle-graph.ts:424-427). So the link form in an index is load-bearing for selection, and a leading / (§6.1 house form) would break it today.
  10. description is required and non-empty (validator.py:336, check_required_keys :386-404), so every concept bullet always carries one.
  11. Directory layout today (from ConceptKey.rel_path, sources/l_records.py): topics/, products/, company/, certifications/, case-studies/ + case-studies/won-bid/ (bundle_writer.py:224-284), references/ (web_pass.py:683) — and the config-gated feeder grain already mints f"{concept_type}/{slug}.md" from a raw, unpluralised type (l_records.py:815). The fixed grains are hand-written plurals. That inconsistency is id-427’s to resolve; §5 states what this design needs from it.
  12. git_sync is index-agnostic. Only log.md is special-cased (git_sync.py:685-710, by exact root path); its INDEX_FILENAME constant (:146) is defined and never used. Nested indexes flow through the managed set as ordinary paths, so a human-edited generated index raises the normal HumanEditConflict — the desired behaviour, no change needed.

Carried, not re-measured: the published showcase index being one ## Other bucket over 19 concepts (S546 producer audit §3). The only bundle in this repo is okf-prototype-bundle/ (7 files, dirs navigation/ + tool/), which is not the producer’s output.


D1 — The axis of every index is the directory it sits in

Section titled “D1 — The axis of every index is the directory it sits in”

Each index.md enumerates its own directory’s immediate members: the concept files in it, and its immediate child directories. Nothing else. No taxonomy, no cross-cut, no config.

Rationale. It is the only axis that is (a) definitionally the same as the thing the reader just navigated by — which is precisely what DR-141 says the current index is not; (b) total over an open type vocabulary (id-427/DR-141) with no registry to maintain; (c) derivable from data the writer already holds (written, §2 row 2), so it has a supplier by construction — the failure mode that killed the theme axis cannot recur; (d) invariant under whatever type→directory mapping id-427 chooses, including a many-to-one one, because the index never reads type.

Rejected. Grouping a directory’s concepts by type within the index: post-id-427 a directory may hold one type (making the grouping a no-op) or several (making it a second axis inside the first). Either way it re-introduces a vocabulary read where D1 needs none.

D2 — The root index becomes a directory listing, not a concept listing

Section titled “D2 — The root index becomes a directory listing, not a concept listing”

The root index.md emits an entry per immediate child directory, plus a section for bundle-root concept files if any exist. It stops enumerating every concept.

Rationale. §8’s purpose is to see what is available before opening documents; under DR-141’s corpus-complete enumeration the concept count becomes the corpus size, and a flat root listing stops being disclosure and becomes the dump it was meant to replace. §8’s own example shows a * [Subdirectory](subdir/) - … entry, so this is the shown form, not an invention.

Consequence, and it is not free. parseBundleNav ignores any bullet whose target is not .md (§2 row 6), so a root index of directory entries yields themes with zero concepts — and because the array is non-empty, <BundleNav>’s absent-index fallback does not fire: the nav rail renders empty headings. This makes id-439 a co-requisite, not a nicety — see §6. Sequencing is in PLAN.md {429.6}.

Rejected. Keeping a full flat concept list at the root “for the viewer”: it preserves exactly the defect DR-141 names, and it does so by letting a consumer’s current parser dictate the bundle’s shape — the bundle is the client’s asset (DR-016), not our viewer’s backing store.

D3 — The ~17-theme capability is retired, not replaced

Section titled “D3 — The ~17-theme capability is retired, not replaced”

Strip build_index_themes, the theme_config parameter at both call sites (bundle_writer.write_bundle, flow_def run entry), and the unthemed_heading fallback. Rename IndexThemeIndexSection and keep it as the renderer’s structural node (heading + level + entries + children); keep regenerate_indexes’ renderer core.

Rationale. Requirement-first, the theme axis fails at the second half of the test: I can name its origin (the first client’s own ~17-theme “BID RESPONSE TOPIC INDEX”, bundle_writer.py:471-486) but not a current source — no supplier, and the sibling config file its own docstring names does not exist (§2 row 3). What actually ships is a single ## Other bucket. This is the owner’s standing strip case: machinery with no supplier, whose one live behaviour is its fallback. DR-130 already rules subject taxonomy is display metadata and not a driving axis; §3.1 rules the same way for the closest thing the spec discusses — “OKF does not specify a separate file format for aggregating documents by tag; a consumer that wants a tag-browsing view can synthesize one at consumption time by scanning frontmatter.” A cross-cut is a consumption-time synthesis.

The re-entry path, if the requirement is later shown live (§7 Q1): a theme becomes a concept — a document with links, in its own directory — which post-DR-141’s open vocabulary needs no machinery at all; or a viewer-side facet over tags. Neither is a producer index axis. Do not re-add theme_config.

Rejected. Keeping the machinery “in case a client supplies a theme map”: that is the argument that produced a capability with no supplier the first time. DR-139 is directly on point — a production consumer is not a reason to keep something incorrect, and here there is not even a consumer.

D4 — House style: # document title, ## section headings, ### for nesting

Section titled “D4 — House style: # document title, ## section headings, ### for nesting”

Each index opens with a single # title, then ## section headings; ### is reserved for a future nested grouping and is not emitted by this design.

Rationale. The spec states no heading-level constraint (S546 correctly reclassified this AC from “reconcile with the spec” to a house-style call; §8’s # group headings are an example). Three reasons pick this one: (i) the spec’s own §9 log.md example uses exactly this shape — # Directory Update Log title with ## date groups — so the title-H1/groups-H2 pattern is the specification document’s own house style; (ii) it keeps one H1 per rendered document, which is what the file render pane and any markdown reader expect; (iii) it costs zero consumer change (§2 row 8), whereas # group headings would make parseBundleNav return [] and silently degrade the nav — a real regression bought for nothing. AC-4 is answered as “reconciled by ruling, not by moving”: we keep our levels, on stated grounds, because there is nothing to reconcile against.

D5 — Entry links are relative to the index’s own directory

Section titled “D5 — Entry links are relative to the index’s own directory”

certifications/index.md links iso-27001.md; case-studies/index.md links won-bid/; the root index links certifications/. No leading slash anywhere.

Rationale. §8’s shown form is relative-url, and §6.1’s bundle-absolute recommendation is scoped to concept-to-concept links, where it buys stability under moves. An index is regenerated in full on every run (D6), so move-stability buys nothing here — the entry is re-derived, not repaired. Relative links additionally make a subdirectory self-contained: a directory lifted out of the bundle still has a working index. For the root index, directory-relative and bundle-root-relative coincide, so today’s emitted form is unchanged and <BundleNav> selection keeps working (§2 row 9).

Consumer consequence, handed to id-439: for a nested index the parsed BundleNavConcept.path must be joined against the index’s own directory to remain comparable to a graph node id. parseBundleNav takes a base-dir argument; the root call passes '' and is unchanged. See §6.

Rejected. Bundle-absolute (/certifications/iso-27001.md) for uniformity: it breaks row.path === selectedConceptId at the root today (§2 row 9) for a stability property the regeneration already provides, and it departs from the shown form the AC pins.

D6 — Bullet form, descriptions, ordering, titles

Section titled “D6 — Bullet form, descriptions, ordering, titles”
  • Concept entry: * [<frontmatter title>](<relative path>.md) - <frontmatter description>. Asterisk marker (already ours), ASCII hyphen-minus separator (the drift AC). Description is the concept’s frontmatter description, verbatim — §8’s SHOULD, and it is validator-guaranteed present and non-empty (§2 row 10). Defensive: if a description is ever empty, emit the bullet with no trailing separator rather than a dangling - .
  • Directory entry: * [<Sentence-cased basename>](<basename>/) - <N> concept(s)[, <M> subdirector(y|ies)]. The count is of immediate members, so it predicts the page the reader is about to open. Where §8 says entries SHOULD carry the linked concept’s frontmatter description, a directory has no frontmatter, so the SHOULD does not reach it; a derived count is honest and needs no supplier. Rejected: client-authored directory descriptions — that is theme_config again, wearing a different name.
  • Heading/label text: basename → -/_ to spaces → sentence case (case-studies → “Case studies”). No lookup table and no title-case stop-word list: the function must be total over directory names id-427 has not minted yet.
  • Section headings: the literals ## Concepts (emitted first, when the directory holds concept files) then ## Directories (when it holds child directories). Structural headings are the honest label once there is no theme axis to name.
  • Ordering: ASCII-ascending by link target, within each section. Determinism is not cosmetic — the bundle is a client-owned git repo (DR-016) and a non-deterministic index produces a diff on every run.

D7 — Regeneration: same declare_file pass, one index per directory with members

Section titled “D7 — Regeneration: same declare_file pass, one index per directory with members”

After the concept loop and before log.md, group written (physical paths, §2 row 2) by directory; for each directory on the path from the root to every concept, declare <dir>/index.md. Rules:

  • Membership is written — this run’s validated set — not the on-disk tree. A tree read would include concepts that cocoindex’s reconciliation is about to orphan-delete.
  • A re-affirmed failed concept (_reaffirm_failed_concepts) is absent from its index for that run, though its file survives. Stated as a decided consequence: including it would require re-parsing frontmatter off disk to recover title/description, and the index is defined as a projection of what the run validated. It remains reachable via the file tree and the graph, and it is named in log.md’s failed.
  • Intermediate directories are always declared, even when they hold no concepts of their own (e.g. a case-studies/ that only contains won-bid/), so the root→leaf chain is never broken.
  • The root index is always declared, even for an empty bundle — it carries the okf_version stamp (§12), which must not disappear when the corpus is empty.
  • Empty/vanished directories need no special case: an index simply is not declared, and cocoindex’s own orphan-delete reconciliation removes the stale file on the next run (bundle_writer.py module docstring, EXECUTOR-VERIFY). New directories need no special case either — create_parent_dirs=True.
  • Unchanged directories cost nothing: identical content re-declared is a fingerprint no-op in the engine, so a directory nobody touched produces no write and no commit.
  • Frontmatter: the root index alone opens with okf_version: "0.2" (post-id-426; §12 + §8’s single exception). Nested indexes carry none — the renderer takes the stamp as a parameter, so “nested index acquires frontmatter” becomes structurally impossible rather than conventionally avoided. (id-429 AC-5.)
  • Prerequisite in the same subtask: _existing_concept_paths must reserve index.md/log.md by basename at any depth (§2 row 4, §3.1). Without it, per-subdir indexes are reported removed on every run. This is the one place the feature would otherwise ship a defect.

§9 permits a log.md at any level; this design emits one, at the root, unchanged.

Rationale. The requirement log.md serves is “what did the producer do this run”, and a run is bundle-scoped: RunSummary is a bundle-wide diff (bundle_writer.py:528-570), and <BundleLog> reads the root only (graph/route.ts:37-38). Per-directory logs would need per-directory run diffs nothing asks for, and would multiply append-only files in a client-owned repo. The only change this task makes to the log surface is defensive: the basename reservation in D7 means a client-hand-authored nested log.md (which §9 permits and DR-016 makes plausible) is never miscounted as a concept. It is never producer-declared, so it is never orphan-deleted.

D9 — Out-of-scope cleanups this design names but does not claim

Section titled “D9 — Out-of-scope cleanups this design names but does not claim”

git_sync.INDEX_FILENAME is dead (§2 row 12) — delete it with {429.4} or leave it; either way it is not a behaviour change. The bundle_writer module docstring’s “~17 client themes” narrative and its v0.1 §-cites are swept by {429.9} alongside id-426’s renumbering.


Root (index.md), post-id-426 stamp:

---
okf_version: "0.2"
---
# OKF Concept Bundle
## Directories
* [Case studies](case-studies/) - 3 concepts, 1 subdirectory
* [Certifications](certifications/) - 4 concepts
* [Company](company/) - 1 concept
* [Products](products/) - 3 concepts
* [References](references/) - 2 concepts
* [Topics](topics/) - 12 concepts

A subdirectory with both members (case-studies/index.md), no frontmatter:

# Case studies
## Concepts
* [Acme Council rollout](acme-council-rollout.md) - How the 2025 rollout was delivered.
## Directories
* [Won bid](won-bid/) - 3 concepts

A leaf (certifications/index.md):

# Certifications
## Concepts
* [ISO 27001](iso-27001.md) - Information security management certification.

5. Interface assumptions on id-427’s directory scheme (the Coordinator reconciles)

Section titled “5. Interface assumptions on id-427’s directory scheme (the Coordinator reconciles)”

D1 deliberately reads no type, so most of id-427’s freedom is unconstrained. What this design does require of id-427’s scheme, stated so it can be satisfied or refused:

  • IA-1 — Every concept has a directory. Each concept’s physical bundle path is <dir>/<slug>.md with at least one directory component, decided at draft time and stable across runs for an unchanged concept. Why: a concept at the bundle root lands in the root index’s ## Concepts section, and if the residual grain routes there, D2’s root listing degrades back into the flat dump it exists to prevent.
  • IA-2 — The residual grain gets a named directory of its own. Corollary of IA-1, and the one most likely to be missed: the residual grain is where an unrouted record lands, so it is the grain most likely to be given no home.
  • IA-3 — index and log are refused as concept slugs. §3.1 makes index.md/log.md reserved at any level and MUST NOT be used for concept documents. Under a closed vocabulary this was unreachable; opening the vocabulary makes it reachable — a concept whose title slugifies to index in any directory would silently overwrite that directory’s index and then be deleted by the next run’s reconciliation. The slug function must refuse or suffix both names. This is a net-new requirement created by id-427, not by id-429.
  • IA-4 — Directory names are filesystem-safe slugs, and many-to-one is fine. Two types may share a directory; the index never reads type. What must not happen is a directory name that varies run-to-run for the same type (the count and ordering would churn the client’s git history).
  • IA-5 — Nesting is arbitrary but finite, and every intermediate directory is a real directory (D7 declares an index at each level).

Not required by this design, and explicitly left to id-427: pluralisation (certifications/ vs certification/) and whether the existing hand-written plurals are regularised against the feeder’s raw-type form (§2 row 11). D6’s sentence-casing works on either.


6. Consumer coupling — the id-439 hand-off

Section titled “6. Consumer coupling — the id-439 hand-off”

id-439 owns the parser surfaces; this design specifies what it must accept, not how.

  1. Directory entries (blocking, D2). parseBundleNav currently drops any bullet whose target is not .md, and a root index of directory entries therefore renders an empty nav rather than triggering the absent-index fallback. The nav model needs a directory entry kind (target ending /, no concept id). {429.6} must not merge before this.
  2. Base-dir resolution (D5). For a nested index, BundleNavConcept.path must be joined against the index’s directory to stay comparable to a graph node id (bundle-nav.tsx:157). Root behaviour is unchanged ('' base).
  3. Separator: no change required. [-—] already accepts both (§2 row 6). This corrects the dispatch brief’s premise; the round-trip fixture is the only artefact that moves.
  4. okf_version: "0.2" acceptance is already id-439’s AC-1.

Nothing else on the consumer side changes: bundle-graph.ts already skips nested indexes by basename (§2 row 5), walk-bundle-tree.ts lists them as ordinary renderable files, and file-explorer.tsx:55-58 deliberately looks for the root index.md only as the default selection — still correct.


Q1. “What requirement does the ~17-theme axis serve, and is that requirement still live?” — Origin is nameable: the first client’s own ~17-theme “BID RESPONSE TOPIC INDEX”, recorded as PRODUCT BI-5’s owner’s-call in bundle_writer.py:471-486. Its current source is not. No file in the repo supplies a theme map; index-themes.json, the sibling-file convention the code’s own docstring names as the feed, does not exist (§2 row 3). Whether that client still navigates their bid content by that index is an owner/client fact not decidable from the codebase, and it was not asked this session. D3 retires the machinery either way — a live client artefact would still not be a directory’s own axis — and names the re-entry path that needs no machinery.

Q2. “For a directory whose name is an opaque slug minted by id-427’s residual grain, is a sentence-cased basename an adequate heading, or does the reader need a human label the producer does not currently hold?” — Not decidable until id-427 names the residual directory (IA-2). D6 ships sentence-casing; if the answer is “needs a label”, the label belongs to id-427’s grain definition, not to a config file here.

Q3. “Does the client’s own editing workflow want a per-directory log.md?” — §9 permits it; nothing in the platform requires it; the bundle is a client-owned git repo (DR-016), so the people who would read a per-directory history are outside this codebase. D8 rules root-only and makes the defensive reservation that keeps a hand-authored nested log safe.


  • No producer run, no bundle regenerated. Every shape in §4 is specified, not observed. The published showcase bundle lives in an external client repo (OKF_BUNDLE_ROOT, DR-016) and was not read; the “19 concepts in one ## Other bucket” fact is carried from the S546 audit, not re-measured. The only in-repo bundle is okf-prototype-bundle/, which is not the producer’s output.
  • No test bodies read. scripts/tests/test_producer_bundle_writer.py was located by name and line (:297-393, five index tests) but its assertions were not read; PLAN.md’s per-subtask test strategy is therefore written as what must be proven, not as a diff against what is currently asserted.
  • Formatter risk in the client repo is unmeasured. Our fixture is prettier-excluded (§2 row 6), but if the client’s bundle repo runs a markdown formatter on commit, a * list marker will be normalised to - there and the AC will read as violated in the artefact a reader actually opens. Not checkable from this repo.
  • parse-log.ts, <BundleLog>, file-render-pane.tsx were greppd, not read. .claude/worktrees/ copies were excluded from every sweep.
  • No database queried; no repo file edited.
  • id-427’s directory scheme is not settled here — §5 states assumptions; it does not pre-empt them.
  • CONFORMANCE.md / ontology.json interactions (id-430, id-431) are untouched: this design adds no superset and removes none.
  • §7’s theme-axis UNDECIDABLE is RULED (owner, S546): the requirement is not live. D3’s retirement is fully ratified; supplier task id-323 closed with the supersession note (its 2026-07-15/16 config-home/filename/type-grouped-default ratifications answered the pre-DR-141 question).