Releases: paul80nd/knowledge-as-code
Release list
v0.28.0
Added
-
spans-more-than-one-servicewarns where one service delivers the whole of an offering. An offering naming
one service inimplemented-byrestates the service record beside it. The rule is guarded on the field, so a
corpus that declinedservicesis not warned about one it cannot fill. -
A rule expression can count a field's entries.
entries('implemented-by')answers how many, where
present()answers whether. A scalar counts as one, and an absent field as none, so a rule that must not fire on
an absent field guards withpresent()first.min-items:states the same floor as an error. Use the fact where
the shortfall is worth a warning and not a failure.docs/design/expressions.mdlists it. -
An integration travels to a consumer.
.schema/integrations.yamldeclares anexport:block, sokac export
writes a file per integration record.What it doesandFailure modestravel atfull.Contract,Commercials
andContactsstay behind, because each describes owning the account.docs/design/export.mdnow listsdataas
the one type declaring no block. -
An explanation travels to a consumer.
.schema/explanations.yamldeclares anexport:block, sokac export
writes a file per explanation record.Where the detail livesis the one section it declares, so it is the only one
that travels, atfull. The rest of the body is free-form and no schema can name it.docs/design/export.mdno
longer listsexplanationsamong the types declaring no block. -
A postmortem travels to a consumer.
.schema/postmortems.yamldeclares anexport:block, sokac export
writes a file per postmortem record.Summary,Root cause,Contributing factorsandWhat went welltravel at
full, andImpactas its opening paragraph.TimelineandActionsstay behind, because a timeline names one
estate's clocks and each action links a work item the reader cannot open.docs/design/export.mdno longer lists
postmortemsamong the types declaring no block. -
An offering travels to a consumer.
.schema/offerings.yamldeclares anexport:block, sokac export
writes a file per offering record. Every section travels atfull, becauseWhere the detail livesis the only
place an offering states its work items and the rest of the record is short by design.docs/design/export.mdno
longer listsofferingsamong the types declaring no block. -
feature-file-repowarns where a feature file names a repository no implementing service does. A path in
feature-filesstarts with its repository, spelled as that repository's service spellsrepo:. The check compares
that first segment against the services inimplemented-byand names the ones it found. It warns rather than fails,
because a regression pack can live in a repository no service claims. Nothing reads the rest of the path until
feature-file-orphansruns. -
A service names the NFRs that bind it.
.schema/services.yamldeclaresnfrs:, and it travels in the services
export.nfrs.applies-toand bothnfrs:fields declarereciprocal:, so an NFR and the record it binds each name
the other, andvalidatereports either end that does not.
Changed
-
An offering says who it is for.
Who it is foris a required section, and it travels in the export. ITIL 4
defines a service offering by the consumer group it serves, and that group is what decides where one offering ends
and the next begins. TheWhy it existsguidance in each_template.mdno longer asks for the audience, because
the new section holds it. An existing offering gains one heading. -
An offering that is
livestates an NFR.nfrsis required once the status reacheslive: a customer
already has the offering, and nothing else on the record says how well it has to work. A corpus that declined
nfrsis not asked, and adopting the type starts the obligation with no edit to.schema/offerings.yaml. -
offeringsnames its prior art.lineagesaid "None that fits" and leftalignmentanddivergenceempty.
It now names ITIL 4 Foundation 2.3.2, the service offering, and states what the type takes from ITIL, the GOV.UK
Service Manual and Backstage, and where it parts from each. Both values render into every adopting corpus's
knowledge-as-code/lineage.md.docs/framework/lineage.mdrecords that ITIL is paywalled, beside the rows that
already were. -
The
capabilitytype is nowoffering. A record lands inofferings/asofr-borrowing, the page beside
it isofferings.md, and the identity line readsOffering:. ITIL 4 calls this document a service offering and
defines it by the consumer group it serves, which is what this type always meant.Capabilitymeant something else
to two of its likely readers: ArchiMate uses it for an ability an organisation possesses, and SAFe for functionality
below an epic. A corpus that adoptedcapabilitiesrenames the folder and the page. It changes each record's
type:,id:and identity line, and rewrites everycap-reference in the records of other types. It then writes
offeringsovercapabilitiesintypes:and deletes.schema/capabilities.yamlby hand.kac update --drop-type capabilities
refuses that last step, because the template no longer declares the name. The export isofferings@1, so a consumer
seescapabilitiesstop andofferingsstart. The template version moves to 18, andkac newstamps
template-version: 18. -
kac validateno longer asks for a field no record in the corpus can fill. A field whoseref:names only
types nothing there supplies is dropped from the required pass, and arequired-when:on such a field never fires.
A type an import publishes counts as supplied, so a standard citing a producer's policy clause is still asked for in
a corpus adopting nopoliciesof its own. Adopting one of those types starts the obligation with no edit to
.schema/. A field withallow-literal:is fillable without them, so it is still asked for.ref-resolvesis
unchanged: a value a record does write is held to the same standard as before. -
A postmortem records what ended the incident, and all three of the lessons.
Resolutionand two further
sections,What went wrongandWhere we got lucky, joinWhat went well, and all five travel in the export.
Google SRE groups the three lessons under oneLessons Learnedheading; each is declared on its own here, so
required-sectionasks for it andempty-sectionrefuses a bare one. An existing postmortem gains three headings. -
A postmortem may name more than one root cause. The template said "Resist listing several". Google SRE writes
Root causesin the plural and PagerDuty records contributing factors and no root cause at all, so the guidance now
says to name more than one where more than one stands out. Nothing about the section changes. -
A postmortem states when service came back, and
durationis checked against it.restored-atis a third
timestamp, required once a postmortem is published, anddurationis now an ISO 8601 duration such asPT4H20M.
duration-matches-the-momentsfails a value the two moments refuse, and its message carries the span they give, so
the fix is a paste.restored-not-before-occurredfails service coming back before it went. Nothing requires
restored-atto followdetected-at: an incident can recover before anybody notices it. -
A rule expression can ask for the time between two moments.
span('occurred-at', 'restored-at')answers with an
ISO 8601 duration in hours, minutes and seconds, and with nothing where either field is absent, is not a moment, or
where the second is the earlier.docs/design/expressions.mdcarries it in the table of what an expression may call. -
A postmortem states when an incident began and when it was noticed, to the second.
occurred-onand
detected-onare nowoccurred-atanddetected-at, and each takes a UTC timestamp as2026-09-07T20:18:00Z. The
gap between the pair is what the pair is for, and it is usually measured in minutes.-onnames a date everywhere
else in the schema, so the names moved with the type. A corpus holding postmortem records renames both keys and
writes a time into each:validatereports the old spelling asunknown-key. -
Where the detail livesand the frontmatter say the same thing, andvalidatechecks it.implemented-byand
nfrsdeclaremirrors-section: Where the detail lives, sorelated-matches-sectionreports either end naming an
id the other does not. That section is a bulleted list rather than a headerless table, which is the formservices
already uses for the same shape and the one a screen reader can read. It lost itsTested byline, which restated
feature-filesand had already drifted from it, and itsDecided inline, which no field ever backed. -
kac newseeds an offering template and type page that name no tracker. Both said functional detail lives in
Azure DevOps epics. They now describe a work item and leave the tracker to the corpus. Inside the corpus the
template links only toservices, so one that adopted neitheradrsnornfrsno longer receives a definition
into a folder it does not have. The type page also states the floor the type has: an offering whose
implemented-bynames one service is a synonym for that service. Both files seed, so an existing corpus keeps the
wording it was created with. -
A report's
generated.atdates the content's last meaningful change..schema/reports.yamldescribed it as
"the moment the content last changed", dropping the qualifier [OKF v0.2] states, while the framework moves the stamp
for neither a hand-raisedsourcesversion nor theImported:bullet an imported entry's raise rewrites. The
...
v0.27.0
Added
-
The documentation site lists every skill, under Skills.
Sixteen of them across three trees, and which tree a skill lives in decides who can read it and what moving it
costs. The three tables are generated from the files that already decide the split:plugin.jsonfor what travels
inside a plugin,manifest.yamlfor what travels into a corpus, and the.claude/skills/directory for what stays
here.SkillReferenceTestsfails a stale table, so the page cannot drift from the manifests the way a hand-written
one would. Nothing aboutkacchanged. -
kac newandkac updatesend a corpus thewriting-a-reportskill. It joinstechnical-writingand
writing-a-recordin the overlay, under one rule: a writing skill travels where it governs a surface a corpus
holds. A corpus can adoptreports, so the skill that says how to fill a report's judgement cells travels with it.
writing-in-the-toolandwriting-the-docsdescribe C# and a documentation site no corpus has, andi-want-to
routes to this repository's own processes where a corpus reads its own throughprocess-lookup, so all three stay
behind.reports/_template.mdgains the four verdicts as well, for an author who opens the template and loads no
skill. Before this the wordsCovered,Covered by its pair,GapandOut of scopewere stated in one file that
never left this repository, so a corpus adoptingreportswas told to answer the judgement cells and nowhere told
with what.manifest.yamlmoves to 15, andkac updatestampsupstream.template-version. -
kac newandkac updatesend a corpus theharvest-findingsskill, which triages filed findings and drafts the
record one asks for.raise-findingfiles an observation as an issue and stops, so nothing moves it afterwards.
triagereads every finding without akac:triagedlabel, sorts each into one of five routes, shows a person the
table and writes nothing until they agree.draft <issue>takes akac:route-recordfinding and opens a pull
request carrying the record. Both invocations write: to the tracker, and to the records themselves. So the reader is
the corpus's own maintainer, and the skill lands in the corpus's working tree under.claude/skills/, beside the
writing skills. No plugin ships it, because a consumer holds a frozen export and not the source. It reads the
tracker,frameworkandpublishingblocks of.corpus.yamlfor the backlog, for where the tool is reported and
for where the record lives. A framework finding filed on a tracker other than the framework's own is drafted as a
comment and copied by hand, because an Azure DevOps user has no GitHub account.manifest.yamlmoves to 16, and
kac updatestampsupstream.template-version. -
kac validaterefuses a target the descriptor names and the tool cannot act on, under a new
descriptor-targetcheck.publishing-targettakes the five values the link rules are written for.tracker.target
andframework.targettakegithub,azure-devopsornone, because a wiki and a documentation site publish
records and hold no backlog. The message names the key, the value and the list it takes. Onlykac newheld its
flags to these lists before, and every one of the keys is written by hand after that, so a misspelling read as a
corpus that publishes nowhere and files nowhere. -
.corpus.yamlstates where work about its records is filed, under a newtracker:key. It takes atargetand
abase, the same pairframework:takes. A corpus that states no block gets the tracker itspublishing:block
implies, so a corpus on GitHub configures nothing: a repository there has an issue list of its own. State the block
where the backlog and the published form are two places. One Azure DevOps project holds one backlog and many
repositories, so a repository URL does not address the backlog, andkac exportderives the project from it.
manifest.jsonstates a tracker three times: for the corpus, for the framework, and for every entry insources.
Each one gains anid, which is the target and the base normalised, so a caller comparing two of them compares two
strings and never parses a URL.baseandidare bothnullwherever a block addresses no backlog, so a base is
never written beside a target that cannot use it.descriptor-versionmoves to 4, andkac updatestamps it. No
skill reads the key yet. -
.corpus.yamlstates where to report a problem with the framework, under a newframework:key. It takes a
targetand abase, the same pairpublishing-targetandpublishing.basetake.kac exportwrites the block
intomanifest.json, beside theidevery tracker there gains, so the address travels to whoever installs the
corpus as a plugin. Itsbaseis written the way every tracker's is, so an Azure DevOps base is the project holding
the backlog. Before this, the only address an export carried was the corpus's ownpublishing.base.
upstream.urlwas never one: it says where the template was copied from, and that is often a folder.kac new
writes the block, taking the base from--fromwhere that names a repository and from the framework's own
repository where it names a folder.descriptor-versionmoves to 3, andkac updatestamps it. No skill reads the
key yet. -
A
fixtakesstatus: draft, and a draft states no verification.verifiedis required of every other
status, so a session can write a fix from an observation nobody has checked andkac validatepasses. The type's
export shape moves tofixes@2, becauseverifiedarrives as null on a draft. A corpus listingdraftunder
export.exclude:in.corpus.yamlwithholds a draft, which is how an unchecked resolution stays out of
fix-lookup. -
fix-lookup, the skill that says whether a problem has already been solved here. It searches
symptom-keywords, which a fix over-fills with error text and the words somebody arrives with, then reports the
Symptom, Cause and Resolution the corpus settled. It states the record's derivedtruston every answer, so a
caller can tell a resolution a person checked from one an agent ran. A fix declares no part, so the skill searches
corpus/fixes/and the record is the unit. It ships in every corpus'splugin.jsonand leaves the bundle wherever
the export carries no fix. Take it withkac update --from <template>. -
raise-findingsearches the fixes before it files anything about a problem. Seven sessions meeting the same
problem should not open seven issues. Where the plugin has nofix-lookup, the corpus declined thefixestype and
the finding is the only route. Take it withkac update --from <template>.
Changed
-
A verification taken before
generated.atreaches no trust tier.kac exportderivedtrustfromverified
alone, so a report somebody read in March still shipped ashuman-reviewedafter an agent rewrote its verdicts in
September. Authorship now passes to whoever answers those cells, which movesgenerated.atpast every reading of
the words before them, so the exporter leaves an older entry out. A moment that will not parse still counts, because
timestamp-formatalready reports it against the record. -
A report's
generatedsays who wrote the content, and authorship passes to whoever edits it.kac reportwrote
itself intogenerated.byand left it there, so a report an agent finished credited the tool with the agent's
verdicts.bynow names whoever wrote the content a reader meets, which the Open Knowledge Format defines it as and
illustrates with a person. Two keys survive the handover:reportstates the report the run used, ascoverage, so
a record says whichkac reportname regenerates it, andtoolkeeps the version that wrote the mechanical half.
Both are optional, and a report nobody produced with the tool states neither.generated-by-a-produceris renamed
generated-by-a-known-actorand admits all three actor forms, sohuman:alex.doeis now a legal author. -
A report states no verification until somebody reads it.
verifiedwas required with at least one entry, so an
agent finishing a report had to name a verifier to get pastkac validate, and the two worked corpora each named an
agent that does not exist. The field now followsfixes: a draft states none, and every other status states one.
A report with no entry is the Open Knowledge Format's unverified tier, whichkac exportalready ships astrust. -
An accepted ADR is edited in place, and only a changed decision needs a superseding ADR.
immutable-after-acceptedallowed a typo fix, a link correction and a status transition, and nothing else. That
list left out an edit changing no decision. A sentence that no longer matched the decision the ADR already stated
read as forbidden. The rule now asks whether the decision changed, andadrs.mdsays to name the edit in the commit
message.immutable-after-publishedtakes the same shape for a postmortem, where a new understanding is a new
postmortem. Both are still declared and do not run, because telling a changed decision from a correction needs git
history. The Decided tier note in_tiers.yamldrops "never rewritten" for the same reason, so a corpus's
knowledge-as-code/taxonomy.mdchanges when you regenerate it.kac update --from <template>takes all three, and
kac generaterewrites the blocks under them.adrs.mdandpostmortems.mdseed a corpus, so the Immutability
paragraph on each stays that corpus's own to reword. -
kac exportreports every reason it refuses in one run. A type the corpus has not adopted, anexport-exclude
key it cannot act on, a consumed corpus nothing is restored for, a consumed ...
v0.26.0
Added
-
kac validatechecks that a quoted clause still says what it is quoted as saying. A control quotes the clause it
verifies, and nothing until now compared the two.clause-quoted-faithfullytakes every double-quoted span on a line
that also cites a clause, and reports it as an error where the cited clause no longer contains those words.
Whitespace is collapsed on both sides, so a quotation wrapped across two lines is read whole. A quoted span on a line
citing nothing is left alone, and so is a citation into a corpus you consume: an export sends a record's ids and
fields, not its wording..schema/controls.yamldeclares the rule, so take it with
kac update --from <template>. -
A fix travels in an export.
.schema/fixes.yamldeclares anexport:block at shape 1, sokac exportwrites
one JSON per fix. The record carriessymptom-keywords, which is what a lookup searches on. It carries Symptom,
Cause, Resolution and Why it happens whole, because a resolution read without its cause is half an answer. It carries
verified, and the record'strustis derived from that list. Three things stay behind:How we found it, which
names commands a consumer cannot run,promoted-from, which names a discovery that travels nowhere, andowner.
Take the schema withkac update --from <template>, and adopt the type withkac update --add-type fixes. -
kac report --out <path>writes the report to a file. Without it the report still goes to standard output, so
a caller piping one loses nothing. With itkacwrites the file after it has read the corpus, which is what keeps
a run off the console encoding and out of the way of a shell holding the same path open. A path a file already
occupies is refused and nothing is written, because a finished report holds verdicts somebody wrote. -
Every record can say where its content came from.
.schema/_universal.yamldeclaressources, an optional list
whose entries carry a requiredresource. Aresourcenames something a reader can follow, such as a ticket URL, or
the population the content was drawn from.sourcesis what the Open Knowledge
Format calls the same list, and
resourceis its one required key. No type carries the universal field into an export, so a record a consumer holds
stands up without the ticket behind it.reportskeeps its ownsources, which requires the field and adds the
versioneach corpus was at. Take the field withkac update --from <template>.
Changed
-
The prose in
.schema/is rewritten to the writing rules. Everydescription:,notes:,message:and comment
in the schema was rewritten against them. A record author reads a shorter field description in the## Metadata
table, and a plainer sentence fromkac checksand from a rule that fails. No check id, severity,expr:, pattern,
threshold or export shape changed. Reasoning that had grown into anotes:now sits on the design site, under
Checks,
Shaping a type and
Reports, and thenotes:cites it. Run
kac generateafter taking the schema withkac update --from <template>. -
The
## Metadatatable says what a field is, where it used to describe the schema. A field declaring only
notes:fell back to them for its table cell, so maintainer commentary was published to whoever writes a record.
Every field now declares adescription:. An ADR'ssuperseded-byreads "The ADR that replaces this one." where it
read "CI reconciles both directions, so a one-sided supersession fails the build." Runkac generateafter taking
the schema withkac update --from <template>. -
Every check message opens lower case. Twenty-three rule messages in
.schema/opened with a capital, where
kacprints a message mid-line after the check id. Several also ran to four or five sentences. Each now opens lower
case and states what is wrong, then what to write instead. -
.schema/states the reasoning local to a field and cites the site for the rest. Anotes:had grown into a
multi-paragraph design argument in forty-six places, and much of it repeated a documentation page. The prose in
.schema/is a third shorter. The per-type export choices and the framework-register rules now sit at
https://paul80nd.github.io/knowledge-as-code/design/shaping-a-type/, which each type file cites. -
kac report frameworkssays more beside each framework's table. Every reference row carries aCitationscount,
so a reference one clause cites reads as1without counting the cell next to it. Each framework's section opens on
the standing the register files it under, linked to the register entry that placed it, which is the line a reader
would otherwise scroll back to the totals table for. -
Each report writes its own
## Limits. Both printed one wording, written for a reading of clause coverage.
frameworksnow says that anAlignmentcell stays in the corpus that wrote it, so it counts the citations written
here, and that a citation records the naming rather than a clause meeting what it cites. -
The five lookup skills drop their search procedure.
kac new,kac updateandkac bundlesend skills that
name no search tool and no search flags. A trial ran three variants ofpolicy-lookupover five questions: the
skill as it shipped, one without the tool name, and one without the search section at all. Every variant found and
cited every governing clause. Each run of the shipped skill spent tool calls hunting a Grep tool the session did
not hold. What stays is what an agent cannot work out for itself: the file map, the field table, and the warning
that a field name likestatusmatches every line of the file. -
glossary-lookup,policy-lookupandstandards-lookupanswer the near miss. Each carries a section for a
subject the corpus has not written down that sits beside one it has. "Password rotation" meets a policy about
rotating secrets, and the skill now says to name the nearest clause as the nearest one and leave the reading to
its owner. -
confirmedis nowverified, it takes any actor, and the export carries the trust tier derived from it. The
field is renamed onfixesandreports, which is what the Open Knowledge
Format calls the same list. It no
longer refuses an agent: a session that reproduced a symptom and ran the resolution has checked something real, and
verified-by-a-known-actoradmits it, named with its version the way the tool names itself. That rule still refuses
arole:, because a post cannot read an answer. Who is in the list decides the record's tier, which each record file
now ships astrust: an empty list isunverified, agents alone aremachine-confirmed, and onehuman:actor is
human-reviewed. A type whose export does not nameverifiedcarriestrustasnull. One actor is still
refused, andno-self-verificationis the new rule and reports asself-verification: a report may not be verified
by the producer itsgenerated.bynames. A fix declaresraiser-does-not-verifyand nothing runs it, because
nothing on a fix names who raised it.kac reportwritesverified: []where it wroteconfirmed: []. A corpus
that adopted either type renames the key in every record and in its_template.md, and takes the new schema with
kac update --from <template>. Thereportstype'sshapeVersionmoves to 2, so a consumer reading records of
that type reads the new key. -
The
faqtype is nowfix, and itsFixsection is nowResolution. A record lands infixes/as
fix-0001, the page beside it isfixes.md, andkac validateholds the record to Symptom, Cause and
Resolution. The type and its third section no longer share a word. A corpus that adoptedfaqsrenames the
folder and the page, changes each record'stype:,id:, identity line andFixheading, then writesfixes
overfaqsintypes:and deletes.schema/faqs.yamlby hand.kac update --drop-type faqsrefuses that
step, because the template no longer declares the name.kac update --from <template>then takes the new
schema file. The type's page and its_template.mdare seeds, so a corpus keeps the copies it has, and
validatenames every line of them still saying FAQ.
Fixed
-
kacprints UTF-8 on Windows. Standard output took whatever code page the machine was installed with, so a
clause citing§9reached the reader as a replacement character while the same text in the record was intact.
Every command writes through the same stream.kac reportis where it showed, because a report quotes citation text
back. -
kac reportwrites frontmatter a parser accepts.owner: human:is not valid YAML, so a generated report met
frontmatter-parsesover the whole document rather than a message naming what to fill in.id,ownerand
verifiednow arrive empty, andkac validatereportsrequired-fieldagainst each one. -
kac reportcalls a fresh report a draft. The schema requiresstatusand no run wrote it, so every generated
report failedkac validateuntil somebody added the field by hand. The frontmatter now carriesstatus: draft. -
id-matches-filenamenames the two things that disagree. It printed the whole id where it meant the id's own
slug, so a record filed asreports/rpt-clause-coverage.mdwas told thatrpt-clause-coveragedoes not match
rpt-clause-coverage. It now reads `id 'rpt-clause-coverage' carries slug 'cla...
v0.25.0
Added
- Seven more types travel in an export.
.schema/declares anexport:block at shape 1 onadrs,
deviations,nfrs,reports,runbooks,servicesandtools, sokac exportwrites one JSON per record for
each of them.deviationscarriesowner, because a register that says what was excused without saying who excused
it is not a register.runbookscarriesSymptomsand no steps, for the reasonprocessescarries no steps.
discoveriesholds everything back still, and its schema file says why. A standard now carriesderived-fromtoo,
so the ADR behind a rule resolves for a consumer holding both. Take them withkac update --from <template>. kac validatewarns where an optional field is written with no value.empty-optional-keyreports a
bare key on a field the schema does not require, because it says exactly what leaving the key out says. A
required field is the other case, andrequired-fieldstill reports that one. A field declaring
required-when:is exempt. The templates a corpus starts from now carry the required fields alone, and each
one names the optional fields it leaves out. Take those withkac update --from <template>.- A plugin component can declare itself standalone.
metadata.componentsinplugin.jsontakes
"standalone": trueon a component whoserequiresis empty.kac bundletrims an empty-requirescomponent when
every component reading a type has gone, because such a component exists to support those. A standalone one supports
nothing and serves the reader, so it now survives that sweep. Take it withkac update --from <template>. raise-finding, the skill that files what a session noticed about a corpus. The plugin's export is frozen, so an
issue on the repository that published it is the only route back. The body carries the keys adiscoveriesrecord
needs, and the skill asks before it files, every time. Take it withkac update --from <template>.request-deviation, the skill that asks a clause's owner to accept a departure from it. The body carries the keys
adeviationsrecord needs, and leavesownerandaccepted-onto the reply, because the individual accepting the
risk is what the request asks for. It files inside the organisation holding the plugin and nowhere else, and it asks
before it files. Take it withkac update --from <template>.- A control travels in an export.
.schema/controls.yamldeclares anexport:block at shape 1, sokac export
writes one JSON per control carryingverifies,mechanism,frequency,evidenceand the three sections a
control holds. A control declares no part, so no flat file is written and the record is the unit. Take it with
kac update --from <template>. controls-lookup, the skill that says what proves a rule. It answers what checks a standard, where the evidence
lives, and which standards nothing claims.mechanism: not-enforcedis what makes the last of those answerable. It
reads eachverifiesentry to see whether the control named a record or one rule inside it, and refuses a per-rule
figure where only records were named. Take it withkac update --from <template>.- A corpus states the ranges the framework cannot know. A field declaring
values: $corpus.<name>in.schema/
draws its range fromenums:in.corpus.yaml, so one schema above several catalogues stands behind the list each
of them wrote.services.platformis the first field to use it: what a service is built on is one list in a library
and another in a payments platform.kac validatereportscorpus-enum-undeclaredonce against.corpus.yamlwhere
a corpus holds a record carrying such a field and has stated a range no record can satisfy, meaning none at all or
one carrying a value that is not lower case. An out-of-range value stays an ordinaryenumfailure quoting the
corpus's own values.kac newopens the block and leaves it empty. Take it withkac update --from <template>.
Changed
.corpus.yamlis at descriptor format 2, andservices.platformno longer carries a range of its own. A corpus
holding services statesenums.platformin its descriptor beforekac validatepasses. Derive the values from your
own deployables and close the list on what you found, which is what the type's page has always asked for. There is no
migration:kac updatestamps the format and writes no values, because only the corpus can say what its estate runs
on.
Fixed
- An export field declared as an object carries its keys. A field such as a report's
generatedreached a consumer
asnull, because only a list of objects was read. It now travels as an object carrying the keys itsshape:or
entry:block names. kac reportstamps the release without the commit behind it.generated.bytakes the Open Knowledge Format's
<producer>/<version>form, and the value carried the build metadata as well, as
kac/0.24.0+24dcea21945982d92104c78a854465207d644ad6. It now readskac/0.24.0. A regenerated report no longer
shows a moved commit where the tool's version stood still.- A discovery can be promoted to a standard.
promoted-tonames an FAQ or a standard and declares
reciprocal: promoted-from, but onlyfaqscarried that field, so promoting to a standard failedreciprocaland
adding the key to the standard failedunknown-key.standardsnow declarespromoted-fromas well, optional and
pointing back at the discovery. Take it withkac update --from <template>, which brings the schema down with it. label-canonicalcatches a shortcut label that leads to a record it does not name. The check compared a label
against the canonical spelling of its own id, so[std-BOGUS]defined as../standards/workflows.mdpassed:
link-resolveswas happy with the path, and the reader was shown an id no record carries.kac validatenow holds a
label to the id in the frontmatter of the record it resolves to, which reaches a label the id styles do not
recognise at all. A template is exempt, since its definitions demonstrate the form under labels nobody has chosen
yet. The row this check gets on a type page is reworded to match, so runkac generateafter upgrading.
v0.24.0
Added
-
kac report <name>prints a report over the corpus and everything it imports. Two reports ship.kac report coveragenames every policy clause and what discharges it, with the deviations departing from it, the controls behind
each covering standard, and any clause elsewhere sharing its key.kac report frameworksnames every external
framework reference the clause tables cite, the standing the register files each framework under, the clauses citing
each one, and how many rest on a single citation. Each row of both carries an emptyNote, for whoever confirms
the report. Output is markdown on standard output, so a caller pipes it where they want it. Every run stamps
generatedandsourcesinto the frontmatter it writes, naming the tool version, the moment, and the
content-versioneach corpus answered at. The tool printscoveredanduncoveredand never splits a gap from
something out of scope, because only a person can tell those apart. -
A standard's
implements:and itsCoverslines reach a consumer. The record carriesimplements, and each
rule line carriescovers, holding the clause ids that rule discharges. A corpus inheriting the policies it answers
to can now count its own coverage: before this, it saw what its own standards covered and nothing that arrived with
the policies.part.citations.<Label>is the export source behind the rule line, and it takes the ids from the
labelled footnote closing a part. Neither addition movesstandards@1, because a reader written against the shape
before them is still correct. -
framework-uncitedfails a framework on the register that no clause cites. The register is the list of
frameworks an estate has taken a standing against, so an entry nothing reaches is a standing nobody acts on, and it
reads as coverage to whoever is looking for evidence. It is the third checkalignment-rollupreports under. The
register is found by following a clause's own link, so a corpus whose clauses cite nothing has none in view. A
finding lands on the policy that reached the page and names the page the entry is deleted from. -
policies/frameworks.jsonltravels in the export. One line per external framework reference, naming the
standing the register files it under, the clauses citing it, the policies holding those clauses, and the page and
anchor the register entry sits at. A type names the file withframeworks:in itsexport:block and the exporter
fills the keys, because a reference is read from a clause's cell and from the register the cell links to rather than
from any field a type declares. Navigation stays one way:clauses.jsonlis unchanged, and a clause still carries no
framework. Bothpolicies@2andformatVersionstand, because a reader written against the shape before this is
still correct. -
report-stalewarns where a report answers for a version the corpus has left behind. Every other record is
about the estate, so a corpus that moved leaves it as true as it was; a report is about the corpus, and the same
change can make it wrong with nothing in the record showing it. Eachsourcesentry is held against the version in
front of the reader: the descriptor's owncontent-version, or the version a consumed corpus's restore resolved to.
A warning, because the report may well still hold, and whoever owns it either confirms that and raises the version by
hand or runs it again. -
reportsis a knowledge type the framework ships. A finished report is a record: it has an
owner, a person confirms it before it is published, and a reader browsing the corpus finds it beside everything else.
generatednames what produced the content and when,sourcesnames each corpus it answers for and the
content-versioneach was at, andconfirmednames every person who has checked it since. Sections are free-form,
because a report's headings follow the question it answers. Take it withkac update --add-type reports. -
A field may hold one object, and a shared shape may say what it holds.
type: objectdeclares a value that is
one mapping, and its keys are held to their own declarations exactly as a list's object entries are._shapes.yaml
joins_enums.yamlas a shared block, declaring an object shape a field takes whole withshape: <name>. Nothing
narrows a shape at the point of use, so a type holding one of its keys to a narrower value writes a rule.event,
an actor doing something at a point in time, is the shape that ships. This moves the template to version 8, so a
corpus takes_shapes.yamlwithkac update. -
entries_match('field', 'key', 're')joins the expression facts. It reads one key inside every object a field
holds: each entry of a list of them, and the one anobjectfield holds. It is true where the field is absent and
true where an object omits the key, because presence isrequired-field's question andentry-key's, so a rule
written on top of it reports one fault once. -
A whole number is a field type the tool checks.
type: int, andof: inton a list, are read by
int-format: plain decimal with an optional leading sign, and within what a 64-bit number holds. A separator or a
base prefix is refused rather than decoded, because YAML reads1_000and0x1fas numbers of its own and an
author should not have to know which spellings the parser admits.ado-epicson a capability is the field this
reaches, and its entries were checked by nothing before.
Changed
- An FAQ's
confirmed.byis held to a person by a rule rather than by a pattern. The finding moves from
field-patternon the entry's own line toconfirmed-by-a-personagainst the record, and the message says why a
post, an agent and a team alias are each refused.confirmednow takes the sharedeventshape, whosebyis a
plain string, and who may confirm is the FAQ type's own question to ask.
Fixed
-
kac updatewrites the descriptor's upstream block with one space after each colon. It padded every key it
stamped to a column, andyamllint --strictrefuses that under itscolonsrule, so a corpus running the linter its
template ships went red oncommit,template-versionandtaken-onthe moment it updated. The next run repairs a
descriptor an earlier one aligned. -
A field's
type:andof:are held to what the tool dispatches. Either naming a value no check reads now fails
schema-dispatchwhen the schema loads, sotype: tiemstampis reported rather than loading and holding the field
to nothing. An entry key answers to the same vocabulary, to whatever depth anentry:block nests, because its value
goes back through the same checks. The types aredate,enum,id,int,list,stringandtimestamp; a
list's entries areid,int,objectandstring.boolwas offered bymeta/type.schema.jsonand dispatched
by nothing, as wasof: date, and both are gone from it. -
An
of:on a field that is not a list is reported. It is read from a list's entries and nowhere else, so a
scalar carrying one states a shape its value can never take.values:,min-items:andmin-records:were already
held to the field type they are read against, andof:now joins them. -
An unquoted placeholder in a record is reported. YAML reads
owner: {{owner}}as a flow mapping rather than as
text, so the value reached no check at all and the record validated clean.bare-keynow reports it and names the
quoting that fixes it, andrequired-fieldreports the field missing where the type requires it. A template still
reports the same spelling undertemplate-fields, which answers for the documents copied from it. The two seed
recordskac newwrites carried the mark, and now name an owner.
v0.23.0
Added
-
A schema field can hold a moment.
type: timestampsits besidetype: datein a type'sfields:block, and
takes2026-09-07T20:18:00Z: UTC, to the second, unquoted. A date is a day and is written quoted, because YAML
rereads an unquoted one as a datetime and a reader's own zone then shifts the day it shows. AZinstant carries
its zone in the value, so no reread moves it.timestamp-formaterrors on a value written in another shape, and on
one naming a moment the calendar does not have. -
A field naming a person carries an actor prefix.
ownertakeshuman:alex.doefor a person, or
role:head-of-engineeringfor a post. Exactly one person holds a post, so a role keeps answerability with one human
and survives a handover that leaves every record naming the previous holder wrong.confirmed.byon an FAQ and
deciderson an ADR takehuman:alone: each records who performed an act, and a post cannot perform one. A
bare name, an agent, a session id and a team alias all failfield-pattern, so the tier boundary between a
discovery and an FAQ is checked rather than described. The three prefixes are [OKF v0.2]'s, whose trust tiers key
offhuman:the same way. A corpus created before this rewrites the field in each record it holds, and
kac validatenames the ones still bare. -
Every record carries its own
type. The universal schema requires the field, directly afterid. Its value is
the singular type name the record's folder declares:standardinstandards/,adrinadrs/. A record read away
from its folder therefore says what it is.type-matches-foldererrors where the field and the folder disagree.
Every_template.mdcarries the line, sokac newwrites it. A corpus created before this adds the line to each
record it holds, andkac validatenames the ones that are missing it.
Changed
- An FAQ records every confirmation, rather than the last one.
confirmedreplacesconfirmed-byand
confirmed-onwith a list, one entry per confirmation and oldest first:
- { at: 2026-09-07T20:18:00Z, by: human:alex.doe }. The moment and the person are one entry, so they are edited
together and neither can be left behind. A reader asking when the answer was last checked, by whom, and who checked
it before that now has all three. The shape is [OKF v0.2]'s. A corpus holding FAQs written before this rewrites the
two keys as one entry per record, andkac validatenames the ones still carrying the old pair.
v0.22.0
Added
- The framework declares a
deviationstype. A deviation records a knowing departure from a policy or a standard:
the clauses it departs from, the person who accepted the risk, the day they accepted it, and the day somebody looks
at it again.departs-fromnames those clauses one by one and refuses a bare policy or standard id, because a bare
id claims a departure from every clause the rule carries.kac update --add-type deviationstakes it, andkac new
offers it beside the rest. Three rules run over a record.review-after-acceptanceerrors where the review date falls
on or before the acceptance date, so a deviation cannot expire as it is written.not-open-endedwarns where the
record reads as a standing departure rather than a bounded one.expirywarns where a record is stillactiveon a
day itsreview-byhas gone by, and stays a warning so a late review never makes deleting the record the cheapest
way to a green build. - A rule expression can call
today(). It answers with the day the run happens, as an ISO date, so a rule compares
it against a date field under the string comparison the grammar already uses between two dates. The day is read once
for the whole run, so a corpus validated across midnight cannot answer one way for its first record and another for
its last.
Changed
- The template's shape is at version 7. It carries the
deviationspage, its index and its record template, so a
corpus runningkac updateis offered the type.
Fixed
- A field may name a part spelled unlike the record holding it.
id-formatread the whole entry as one id, so
std-ERRORS.a-failure-says-what-happenedfailed: a standard's record id carries a mnemonic and its rules are
heading slugs. The check now reads the record and leaves the part toref-resolves, which is what answers for
whether the part exists.
v0.21.0
Added
- A corpus adopting
processesnow publishes them.kac exportwrites one JSON per process carrying its
frontmatter, itsWhen to use thistrigger and itsPrerequisites, and stops there.StepsandVerification
stay in the record, because a procedure is followed whole and in order against the version in force rather than
against a copy taken on an earlier day. The type declares no parts, so nothing writes a flat file for it and
manifest.jsonreportspartsFile,recordKey,partKey,idKeyandseeAlsoKeyas null besidepartsat
zero. kac bundleships two more skills.process-lookupfinds the procedure written for a planned task and reads
its trigger before deciding it is yours. It is trimmed where the export carries no processes.corpus-retrieval
reaches a record's published source and builds a link to it, namingghandaz devops invokeas the clients that
authenticate to each platform and saying what to do where neither reaches. The three skills that shipped before now
hand their link building to it.
Changed
-
A component your plugin manifest declares with an empty
requiresnow travels only where a component that reads a
type did. It reads no export itself, so it is there to support the ones that do, and a plugin shipping it alone
would carry a skill supporting nothing a reader can reach.bundle.jsongives the reason as
no component it supports survived, and a run that trims every component warns as it did before. A file no
component claims is unchanged: it needs no declaration and travels whatever the corpus adopted. -
The template's shape is at version 6. It carries the two skills above, so a corpus running
kac updatereceives
them. -
kac checksasks you for the half of a drifted checks table you hold. Where the reader-facing table and your
.schema/_checks.yamldisagree, every line of the report nameson-type-page:in that file and says where it
sits. The row beside it is in the tablekacships, which your corpus holds no copy of, so the report says whose
it is rather than naming a source file you cannot open.
Fixed
- The lookup skills
kac bundleships now state the type of every field they describe.obligations,
definitionandnotare one string of markdown holding the record's bullets, and a skill calling any of them a
list sent a reader looping over a string. A field with no value arrives asnullbeside a key that is still there,
so test the value rather than the key.shortcodeis the one key a line can be missing outright.
v0.20.0
Added
kac exportcarries the corpora your corpus consumes. Every type a producer exported travels, so a consumer
receives types it never adopted and a citation into them resolves. Their parts merge into one flat file per type,
and their records are filed under the shortcode of the corpus that wrote them. An inherited line carries that
shortcode onid, onrecordand on everyseeAlsovalue, and again undershortcode; a line with none is your
own. The manifest gainssources, one entry per corpus inherited, each holding the publishing block its producer
wrote, because a record of theirs is read at their commit in their repository.kac packseals all of it, so a
third corpus inherits the chain.kac exportrefuses rather than writing a hole. It stops with the reason and no files where a declared import
has not been restored, where a consumed corpus is at an export format this build does not read, where two corpora
export one type at different shapes or section fidelities, and where one corpus arrives twice at two versions.
Changed
-
The breadcrumb names the corpus each count belongs to. A merged export holds several corpora's records under one
type, so a type now gets a line for your own records and another for each corpus you consume, reading
standards (from eng). 37 entries across 12 records: …. A type you wrote none of gets no line under your own name.
The closing line warns against answering from memory rather than naming what a word means, because a corpus may ship
any of the three lookup skills or none of them. -
glossary-lookup,policy-lookupandstandards-lookupreadsources. Each builds a link and a fetch from the
publishing block of the corpus that wrote the line, reached through the line'sshortcode, rather than from yours.
Each says what a prefixed id means and where an inherited record's file sits.standards-lookupstates that one file
already holds the whole union, and reads what stayed behind offtypesin the manifest rather than naming a fixed
four.kac updatebrings the three down. -
A type's manifest entry names two more of its part line's keys.
idKeyandseeAlsoKeyjoinrecordKeyand
partKey, so a corpus merging that type stamps the keys the producing type actually named.formatVersionmoves to
4, so runkac exportagain beforekac bundle, which refuses an export built to another shape. -
kac validatereports aCoversline that names nothing.mirrors-citationsnow reports a labelled footnote
gathering no citation the field could carry, against the line. A line naming only ids of types the field does not
point at gathers nothing either. A section carrying no line at all stays silent, which is how a rule discharging no
clause is written. -
kac validatereports aCoversline a space left out of italic. Markdown will not read an emphasis mark with a
space against it, so_**Covers:** [pol-SCRT].EMBED _is not the form and the marks reach the page.
mirrors-citationsreports the line, and its citations count as before, so a standard covering six clauses gets one
finding rather than six against its frontmatter. A line that is bold alone is still the labelled prose form and is
passed over. -
kac validatetells aCoversline naming a record whole to name the part. Where the field declares
part-required:,mirrors-citationsreports the line in the wordsref-resolvesuses, naming the target type's own
word for a part:this 'Covers' line names 'pol-EVER' whole, and 'implements' names a clause. It used to report that
the field did not list the id, which sent an author to put a bare policy id there and meetref-resolvesrefusing it
on the next run. A field that admits a bare id keeps the message it had.
v0.19.0
Added
- A standard exports its rules, and a corpus adopting standards ships a skill that reads them. The type declares
parts:over the H3 headings under## Rules, sokac exportwrites one line per rule to
standards/rules.jsonl, carrying the obligations in the markdown the standard wrote them in. Each record travels
beside them with its Summary and its conformance checklist.kac bundleincludes the newstandards-lookupskill
wherever the export carries standards, and trims it where it does not.kac newships the skill in the plugin tree,
andkac updatesends it to a corpus already created. - A field can be held to the citations its prose gathers. A type declares
mirrors-citations: <Label>beside the
field'sref:, andkac validatereports drift in both directions between the field and the labelled lines. A line
is written in italic with the label bold, and closes the section whose citations it gathers, so a line standing in
the middle of one is reported where it sits. A standard'simplements:declares it: each rule closes on
_**Covers:** …_naming the clauses it discharges, so the frontmatter says which obligations the standard answers
and each rule says which of them it answers. The obligations under a rule then carry no clause citation of their
own, andkac exportdrops the footnote before it takes them, so a part carrying nothing else travels with no
obligations rather than with a coverage line standing where its words belong.kac validatealso reports the key
declared with noref:to resolve against.kac newships the form in the standards template. - A standard may carry a
Sources and further readingsection. It names the external documents the standard defers
to, each marked normative or informative. A rule built on somebody else's conventions then says where the rest of it
lives.kac newships the section in the standards template, and a standard deferring to nothing deletes it.
Changed
- A standard's rules sit under
###headings, andkac validatereports a Rules section with none. The heading
is what the rules beneath it hold a reader to, and it is the address a citation and an export both carry, so
part-nonenow reaches standards as it already reached glossaries. A standard whose rules are a bare bullet list
gains one heading.kac newships the grouping in the standards template. - A tool's
categoryis the folder it sits in undertools/, as a policy's and a standard's already were. It was
the one of the three still written by hand, and the only field in the schema carrying nodescription:. A record
that writes the key now failsderived-key: delete the line and file the record under the folder you want it to
name. It is no longer required, so a tool filed directly intools/simply has no category, which is what the other
two do.kac newships the template without the key.
Fixed
kac validatereports a schema declaringof: objectwith noentry:block, rather than ending in a stack
trace. The schema pass already names that fault asschema-shape. A record filling such a field reached the
entry check first and took the run down, so the message never printed.- Declining
kac update --drop-typesaysupdate, where it used to saynew. The message is the tail of the
command that printed it, and the one it named was a command the reader had not run. - A rule whose
expr:names a number too large for a whole number is reported, rather than ending in a stack
trace.words() < 99999999999reached the parser's integer literal and overflowed past the exception the schema
load catches.kac validatenow names the number, its position and the rule. kac restorerefuses a package that unpacks to more than 256MB, or that holds a single entry over 16MB. The
path each entry names was already held inside the import folder, and what it unpacks to was not, so a malformed
package was read whole into memory instead. Both caps count the bytes actually read, because a zip entry's
declared size is the package's own claim about itself.- A part id written against a link takes no delimiter that closes an emphasis.
_[pol-SCRT].EMBED_read the
citation aspol-SCRT.EMBED_, because the id was measured off the source rather than off the text markdown makes
of it.part-refreported a clause nobody could write.