Writing records · ADR
0012: Custom metadata fields
Context#
The built-in front matter covers what every team shares: status, dates, people, tags, links. Teams also track things only they care about: the risk of a change, the yearly cost, whether the payment card scope is touched, the compliance regime. Today those end up as tags (risk-high), which cannot be validated, sorted or compared, or in the body, where nothing can read them. Unknown front matter keys are ignored by the site, so inventing a key does not help either.
Decision#
A fields map declares extra front matter keys. Each field has a type, and the build checks every record against it. Declared fields appear in the record header, as filters on /decisions/, in decisions.json, in llms-full.txt and in the MCP server. fields in inkan.config.ts applies to every project. A project adds its own fields, or redefines or removes a site field, under fields in its _project.yml.
Values are top-level keys in the record’s front matter (risk: high), not a nested object. Keys that are not declared are still ignored. A value that does not fit its type, or a missing value on a required field, stops the build.
How to use it#
Declare fields for the site#
export default defineConfig({ fields: { risk: { type: 'enum', values: ['low', 'medium', 'high'], column: true, default: 'medium' }, cost: { type: 'number', unit: 'k€/year', description: 'Expected yearly run cost.' }, decided: { type: 'date', label: 'Decided on' }, runbook: { type: 'url' }, },});Add, change or remove fields in a project#
name: Paymentsowners: [jules, noor]fields: pci: type: boolean label: PCI scope required: true risk: type: enum values: [low, medium, high, critical] # replaces the site's definition cost: false # Payments does not track costA project’s field with the name of a site field replaces it completely (the definition is not merged). false removes the site field for that project: its records may still carry the key, but it is ignored. A project without fields gets the site’s.
Set values on a record#
---title: Store card tokens in the vaultstatus: accepteddate: 2026-10-01tags: [payments, security]risk: highpci: true---Options of a field#
| Option | Applies to | What it does |
|---|---|---|
type | all | One of the types below. Default text. |
label | all | Name in the record header, the filters and the table. Default: the key in sentence case (dataClass becomes Data class). |
description | all | What the field means. Returned by the MCP list_fields tool. |
values | enum, list | Allowed values. Required for enum, optional for list. Also the order of the filter chips. |
required | all | A record without a value fails the build and inkan check. Default false. |
filter | all except url | Show a filter on /decisions/. Default true. |
column | all | Add a column to the table view. Default false. |
unit | number | Printed after the value (40 k€/year). |
default | all | Written into new records by inkan new. Must be valid for the type. |
Field names start with a letter and use letters, digits, - and _. They are front matter keys and URL parameters, so they cannot be a built-in key: title, description, status, date, authors, deciders, tags, supersedes, supersededBy, related, reviewBy, reviewed, reviewEvery, order, draft, slug, the index’s own project, type, author, tag, q, view (and fields), or a key records already carry in decisions.json and the MCP server: id, ref, number, url, markdown, source, file, body, current, currentRecord, codeRefs, review.
Types#
| Type | Front matter | Normalised to | Filter on the index |
|---|---|---|---|
text | owner: platform | trimmed string | chips, one per distinct value |
number | cost: 40 or cost: "40" | number | min-max range |
boolean | pci: true ("true" and "false" are accepted) | boolean, shown as Yes or No | chips Yes and No |
date | decided: 2026-01-18 | YYYY-MM-DD | min-max range |
enum | risk: High | the spelling in values (high); matching ignores case | chips, in the order of values |
list | regions: [eu, us] or regions: eu | list of strings without duplicates, checked against values when set | chips, one per item |
url | runbook: https://... | the URL; http://, https:// or a path starting with / | none |
An empty value (risk:, risk: "", risk: " ", regions: []) counts as missing. Dates must be real calendar days (2026-02-30 is rejected), and text values are kept on one line.
What the site shows#
- Record header: each field with a value is listed with its label. Values of chip types (
text,enum,list,boolean) link to the index filtered by that value; numbers print with theirunit, URLs are links. - Filters:
/decisions/gets a facet group per field withfilteron that at least one record sets. Chips work like the built-in facets: OR inside a group, AND between groups, and each chip shows how many records have that value in total. Values in the URL match chips regardless of case. Anumberordatefield shows a min and a max input; a bound that is not a number or aYYYY-MM-DDdate is ignored. - Table: a field with
column: trueadds a column to the table view.
The filters are part of the URL, named after the field:
| Query parameter | Example | Meaning |
|---|---|---|
| chip field | ?risk=high,medium | risk is high or medium. |
| boolean field | ?pci=true | pci is Yes. false selects No. |
| number range | ?cost=10..50 | cost between 10 and 50, both included. |
| open range | ?cost=..50 or ?cost=10.. | At most 50, or at least 10. |
| date range | ?decided=2025-01-01.. | decided on or after 2025-01-01. |
Records without a value never match a filter on that field. Because the key is the parameter name, a field cannot be called project, type, author, tag, q or view.
Validation#
Invalid fields in decisions/payments/adr/0004-store-card-tokens.md: risk: "severe" is not one of low, medium, high, critical; pci is requiredThe build stops with a line like this for every record with problems. inkan check reports the same problems with the rule field, and a bad fields block in a _project.yml with the rule project-fields. An unknown option, a missing values on an enum, a default that does not fit the type or a reserved name is an error in the config or in _project.yml.
One name may not have different types in different projects: cost as a number in Payments and as text in SRE would give the index one filter with two meanings, so the build stops. The same goes for different units. Give the second field another name. Enum and list values of the same field are merged across projects for the filter, the label of the first definition is used for it, and the filter is left out when any project sets filter: false.
Where agents get the values#
decisions.jsonhas afieldsobject on every record, with normalised values:"fields": { "risk": "high", "cost": 40, "pci": true }.llms-full.txthas aname: valueline per field in each record’s header block (risk: high, lists comma-separated).- The MCP server includes
fieldsin its records.search_recordstakesfield: "risk=high"to keep records whose field has that value, andlist_fieldsreturns the declared fields with their types, allowed values and descriptions, so an agent can ask for the right values.list_fieldsneeds the config, so it works withinkan mcp --rootbut not--url.
New records#
inkan new writes every field that has a default into the front matter of the new record. Fields without a default are not written; add them by hand, or mark the field required so check reminds you.
Consequences#
- Teams get typed, filterable metadata without a plugin or a custom component, and the values reach
decisions.jsonand agents the same way as the built-in fields. - Values are validated, so a typo in
riskfails the build instead of producing a filter chip nobody notices. The price is that addingrequired: trueto a field fails every older record until it has a value. Add the field as optional first, fill it in, then require it. - Fields are flat. There is no nesting, no computed field and no cross-field rule.
- Declared keys live next to the built-in ones in the front matter, so the set of reserved names cannot grow without risk: a new built-in key would collide with a field of the same name in someone’s config. The build reports the collision.
- Removing a field from the config does not touch the records; the key stays in the files and is ignored.
- Filters on a field with many distinct values (free
text) produce many chips. Useenumorlistwithvalueswhen the set is known.
History 1
- ImplementedInkan maintainers
9e5e535commit diff