Schema Extensions¶
Rigid schemas kill platform adoption. Schema extensions let any app attach its own namespaced metadata to a transaction — with no schema change, no migration, and no coordination with kasas or with other apps. This is the seam that lets integrations innovate independently.
Source: internal/extensions.
The model¶
Extensions are a JSON object on transactions.extensions, mapping dotted keys
to arbitrary JSON values — string, number, boolean, null, object, or array:
{
"id": "…",
"amount": "-45.23",
"extensions": {
"tax.category": "meal",
"forecast.recurring": true,
"custom.myapp.score": 88,
"budget.envelope": { "name": "Dining", "limit": 300 }
}
}
The dotted prefix is a namespace convention: tax.* belongs to a tax app,
forecast.* to a forecaster, custom.myapp.* to your own integration. Apps stay
out of each other's way by owning a namespace.
Parallel to labels, not a replacement¶
Extensions and labels are deliberately separate models:
| Labels | Extensions | |
|---|---|---|
| Value type | strings only | any JSON value |
| Keys | lowercased, canonical | namespaced, case-preserved |
| Audience | human categorization | app-owned data |
| Mutators | UI, REST, rules, plugins | REST, MCP, rules, plugins (transaction cell is read-only) |
Where labels are normalized and lowercased, extension keys are case-preserved
and values keep their exact JSON type, stored verbatim. A PUT replaces the
whole set (send the full object; {} clears it). And like labels, re-syncs
never touch them.
Reading & writing¶
# replace a transaction's full extension set
curl -X PUT localhost:8080/api/v1/transactions/<id>/extensions \
-H 'content-type: application/json' \
-d '{"extensions":{"tax.category":"meal","forecast.recurring":true,"custom.myapp.score":88}}'
# the extension vocabulary, with per-key transaction counts
curl "localhost:8080/api/v1/extensions"
# -> [{"namespace":"tax","key":"tax.category","transaction_count":17}, …]
| Surface | Capability |
|---|---|
| REST | PUT /api/v1/transactions/{id}/extensions; GET /api/v1/extensions |
| MCP | set_transaction_extensions (replace); list_extensions (vocabulary) |
| Plugins | kasas.set_extension / kasas.remove_extension with extensions:write |
| Dashboard | Displays extensions read-only |
Why the dashboard is read-only for extensions
Extensions are app-owned data with arbitrary structure; a generic key:value UI can't safely edit a nested JSON object without risking corruption. So writes go through the API/MCP/plugins (where the owning app knows the shape), and the UI surfaces them for visibility.
Searching extensions¶
The search language's ext: field filters on extensions, matching
values as text (so ext:forecast.recurring=true works regardless of the
underlying JSON type):
ext:tax.category=meal key equals value (as text)
ext:custom.myapp.score key present, any value
ext:tax.category~me value contains
ext:tax.category!=meal value not equal
Like every search field, ext: is evaluated in Go (not SQL), so it works
identically across REST, MCP, and the in-browser dashboard.
Events, history, and the write boundary¶
An extension change routes through the same emitter seam as
everything else: it emits granular extension.set / extension.removed events
(one per changed key) and appends an extended
history snapshot — so apps watching the event stream
react to extension changes exactly as they react to labels or amounts.
RawMessage vs. any
Internally, extension values are stored and written as json.RawMessage to
preserve them byte-for-byte. At the MCP and DTO boundary they are decoded to
any, because the MCP SDK's schema generator rejects json.RawMessage. The
write path keeps values verbatim; the read/MCP path presents them as
structured JSON.