DocsReference
Catalog as code
From docs/CATALOG_AS_CODE.md in the repository.
On this page
Softwarden can write its catalog as a tree of YAML files, one per record, and read such a tree back. Keep the tree in a git repository and catalog changes become pull requests: someone edits a file, a reviewer reads the diff, CI shows what applying it would change, and merging applies it. The app stays the system of record: every change still goes through the same validation, limits and audit log as an edit on screen.
Phase 11 item 7 (REVIEW.md §1.2 item 15). Code: apps/api/catalog_code/. Capability catalog_code, in every edition.
Admins only.
In the app, Admin › Catalog as code (/admin/catalog-code) downloads the export, and plans and applies an
uploaded zip, or one or more YAML files chosen on their own (the browser puts them in a zip first; leave prune off
then). The plan lists every change with its fields before and after, the errors and the warnings; apply sends the
plan’s hash, so a catalog changed in between is refused and planned again.
The tree
catalog.yaml the manifest: which kinds the tree holds, whether it has People
applications/crm.yaml
components/nightly-sync.yaml
ai-agents/helpdesk-bot.yaml
integrations/crm-to-payroll.yaml
relationships/component-nightly-sync--depends-on--application-crm--production.yaml
relationship-types/feeds_reports.yaml
vendors/contoso.yaml
teams/it-applications.yaml
people/dana-example-test.yaml only with --include-people
departments/finance.yaml
categories/crm-and-sales.yaml
tags/customer.yaml
custom-fields/contract_ref.yaml
ai-providers/anthropic.yaml
ai-models/anthropic-claude-5-5.yaml
A file’s name is only where it lives: what the file is about is in its content (its kind and natural key), so a
file can be renamed or moved, and a repository’s own layout (a catalog/ folder, GitHub’s repo-main/ in a zip)
doesn’t matter. Files that aren’t .yaml or .yml, and hidden folders such as .git/, are ignored.
A file
apiVersion: softwarden/v1
kind: Application
metadata:
slug: crm
name: CRM
description: |-
Customers.
All of them.
tags:
- Customer
- tier-1
spec:
lifecycle: active
criticality: high
businessOwner: dana@example.test
technicalOwner: priya@example.test
supportTeam: IT Applications
department: Finance
vendor: Contoso
category: CRM and sales
version: '10.2'
ssoEnabled: true
entraAppId: aaaaaaaa-0000-0000-0000-000000000001
instances:
- name: Production
environment: prod
url: https://crm.example
status: operational
- name: Sandbox
customFields:
contract_ref: C-42
tier: gold
status:
attachments:
- title: Runbook
category: sop_runbook
kind: link
url: https://wiki.example/crm
apiVersionissoftwarden/v1.kindis one of the kinds below.metadataholds the record’s natural key and its labels;speceverything else. Field names are the API’s in camelCase (business_ownerisbusinessOwner); custom field values sit underspec.customFieldsby the field’sname.statusis read-only: the export writes attachment metadata there (title, category, kind, link or file name, content type, size, SHA-256; never the file), and applying ignores it.- The file is the whole record. A field left out means its empty value (or default), so deleting a line clears the field. The export leaves out empty fields and defaults, so files stay short.
- Stable. Keys are always in the same order (the order in the tables below), lists are sorted (tags, members,
models, instances, attachments), nothing volatile is written (no
updated_at, ETag or revision), multi-line text is a literal block, and lines are never folded. The same records always give the same bytes, and the export zip has fixed timestamps, so an unchanged catalog exports to an unchanged tree and a commit holds only real changes.
Natural keys and references
Records are matched by natural key, never by name (as imports never match by name, D38):
| Kind | Folder | Key (metadata) |
|---|---|---|
| Application, Component, AIAgent, Integration | applications, components, ai-agents, integrations |
slug (lower-case letters, digits, - and _; unique per kind; kept when the record is created) |
| Relationship | relationships |
its spec.type, source, sourceInstance, target, targetInstance together |
| RelationshipType | relationship-types |
slug |
| CustomField | custom-fields |
name |
| Vendor, Team, Department, Category, Tag, AIProvider | vendors, teams, departments, categories, tags, ai-providers |
name (ignoring case) |
| AIModel | ai-models |
provider, name and version |
| Person | people |
email; a person without one by id, their Softwarden ID |
A reference names the other record the same way:
- a record:
<kind>:<slug>with kindapplication,component,ai_agentorintegration(application:crm); an instance of it by name in the field beside it (sourceInstance: Production); - a person: their email, or their Softwarden ID. Without
--include-peoplethe export names people by Softwarden ID only, so the tree holds no personal data; that ID is only known in the organization that exported it; - a team, department, vendor, category or AI provider: its name; an AI model:
{provider, name, version}; - a relationship type: its slug.
References resolve only inside the organization being applied to, and to records in the same tree: another organization’s slug, name or ID is simply not found.
A Person file without an email, keyed by a Softwarden ID the organization doesn’t have (the tree came from another one), is matched by the person’s exact name, and the person is created only when nobody has that name; the plan warns which happened. For the rest of that apply, the other files’ references to that ID point at the matched or new person, so applying the same tree twice converges instead of creating people again. The rules that keep a name from merging two people:
- A name match is reference-only. It links the tree’s references (owners, members, account managers) to the existing person and never changes that person’s fields: a difference in title, department or active is a plan warning (“Not changed (matched by name only)”), not an update. A Person file updates a person only when it matches by email or by a Softwarden ID the organization knows. A person matched by name only is never edited from the tree; to manage them from the tree, add their email (or re-export so the file carries their id).
- Only people who were there before the apply can be matched by name, never one an earlier file of the same tree just created.
- Two such files (no email, unknown ID) with the same name are both errors, as is a name that several people here
share, or a namesake here who has an email: add
metadata.emailto say who is meant. - Two files that resolve to the same person (say one by ID and one by name) are an error: “This is the same Person as …”. The same holds for every kind.
A Person file can’t remove or change the email of the person its ID names: when that person has one, the file must
give the same. metadata.id must be a UUID.
What is exported
| Kind | metadata | spec |
|---|---|---|
| Application | slug, name, description, tags | lifecycle, criticality, businessOwner, technicalOwner, supportTeam, department, costCenter, dataClassification, vendor, category, purpose, builtBy, licenseModel, deploymentModel, version, primaryUrl, adminUrl, documentationUrl, supportUrl, authenticationMethod, ssoEnabled, scimProvisioning, entraAppId, servicePrincipalId, instances (name, environment, url, status), the compliance fields, customFields, archived |
| Component | slug, name, description, tags | lifecycle, criticality, the owners, supportTeam, department, costCenter, dataClassification, componentType, purpose, builtBy, licenseModel, hosting, environment, hostRuntime, scheduleOrTrigger, repositoryUrl, documentationUrl, runbookUrl, azureResourceId, ppEnvironmentId, externalId, the compliance fields, customFields, archived |
| AIAgent | slug, name, description, tags | lifecycle, criticality, the owners, supportTeam, department, costCenter, dataClassification, purpose, platform, modelsUsed, environment, ppEnvironmentId, channels, knowledgeSources, toolsConnectors, authMode, runAsIdentity, dataAccessScope, humanInLoop, actionsCanWrite, riskClassification, repoUrl, externalId, the compliance fields, customFields, archived |
| Integration | slug, name, description | lifecycle, source, sourceInstance, target, targetInstance, technicalOwner, supportTeam, dataClassification, dataCategories (Phase 11: a list in a fixed order), dataPurpose, protocol, schedule, direction, authMethod, documentationUrl, monitoringUrl, customFields, archived |
| Relationship | type, source, sourceInstance, target, targetInstance, purpose | |
| RelationshipType | slug | name, inverseName, description, isDependency |
| CustomField | name | label, type, description, choices, required, default, appliesTo, weight, isReminderSource |
| Vendor | name | website, supportUrl, portalUrl, supportEmail, supportPhone, accountManager, notes, customFields |
| Team | name | description, members |
| Department | name | |
| Category | name | description |
| Tag | name | color, description |
| AIProvider | name | website |
| AIModel | provider, name, version | modality, hostingRegion |
Person (with --include-people) |
email or id, name | title, department, active |
The compliance fields (isGxp, validationStatus, validationDocUrl, lastPeriodicReview, nextPeriodicReview, riskTier)
are written, and read, only while the organization’s compliance profile is on. Kinds behind a capability the
organization lacks are left out: AI agents, providers and models (ai_agents), custom fields (custom_fields),
relationships and their types (relationships), attachment metadata (attachments).
Never exported: secrets, connector and connection credentials, API, SCIM and calendar tokens, user accounts and
memberships, a record’s IT steward (an account), attachment files, reviews and confirmations (evidence, recorded in the
app), assessments, contracts and license pools, renewals, discovery observations, audit events. catalog.yaml says
whether the tree holds People:
apiVersion: softwarden/v1
kind: Catalog
spec:
kinds:
- Department
- Category
- ...
includesPeople: false
Plan and apply
A plan reads the tree, matches each file to its record and compares it field by field. Each change is then tried through the same service functions the app uses and rolled back, so the plan’s errors are exactly the ones apply would meet; it writes nothing. It returns:
changes: each create, update and delete with its file, kind, key and the fields that change (path,before,after, as the file writes them; an instance is one line,spec.instances[Production]);errorsandwarnings: each with the file, the 1-based line, the field path and a message, such asapplications/crm.yaml:12 spec.lifecycle: Choose a lifecycle status.;summary(create, update, delete, unchanged, errors, files) andplan_hash.
Apply does the same in one transaction and commits only when no file has an error: each record through its service
function (validation, organization scope, limits, and an audit event marked via: catalog_code), plus one
catalog_code.applied event with the counts. It must be given the plan_hash of the plan that was reviewed, a SHA-256
of the organization’s catalog as it was, the files and the prune flag: if anyone edited the catalog since, or the files
differ, apply refuses (409 /problems/catalog-code-stale) and nothing is written. Plan again and review.
Before writing anything, a plan or apply locks the existing rows the tree may write (FOR NO KEY UPDATE, each table in
ID order): the people its Person files name, the records and vendors its files name, and with prune every record and
vendor of the kinds being pruned. It takes them in the order offboarding’s reassign does (people, records, vendors) and
in the ID order bulk edit uses, so a concurrent bulk edit, bulk delete or reassign waits for the apply, or the apply for
it, instead of deadlocking. The catalog is read again once the rows are held, and plan_hash is taken from that read.
- Records are created with their file’s slug. Creating records counts against the organization’s records limit for the
whole tree at once (
409 /problems/limit-reached, as an import’s commit). - Retiring a record that active records depend on needs an acknowledgment, which a file can’t give: that file is an error (“Active records depend on this one…”), as in an import. Retire it on its page.
- A file’s instances are created or updated by name. An instance the file leaves out is kept (a warning) unless the apply prunes.
- A Tag file matches its tag as records’ tags do: spaces collapsed and case ignored, so
Foo Baris the existingFoo Bar(admin_services.create_tagmakes a tag only when there is none, and audits only that). - A value the database refuses (too long for its column, out of range) is that file’s error.
- Applying never writes discovery observations or reconciliation decisions.
- Large first loads: use the CLI. One API request (plan or apply) creates at most 2,000 rows, so it ends well
inside the server’s 120 s request timeout; a tree that would create more is a
400 /problems/invalid-request(errors.file) and nothing is written.manage.py catalog_applyhas no such cap. Updates aren’t capped.
Prune
Without prune nothing is deleted. With prune, records of the tree’s kinds that have no file are deleted, through the
same delete services (so a record that reviews, assessments or contracts still reference can’t be deleted: that is an
error, and nothing is applied; archive it instead). The kinds are catalog.yaml’s kinds, or, without a manifest, the
kinds the tree has files for. People and tags are never deleted (anonymize a person in the app; tags have no delete),
nor the three core relationship types. Links go before the records they join, instances before their application. A
custom field’s delete line shows how many records and vendors lose their value for it (values: before, after 0).
The prune cap. A prune that deletes more than 10 records in all, more than 20% of a kind’s records (when that is 3
or more), or every record of a kind that has some, is an error unless you allow it, so nothing is applied. A partial checkout, a sparse clone or a truncated
zip (in the worst case only catalog.yaml) would otherwise plan to delete every record it is missing. The error names
the count per kind (“This tree with prune deletes 39 records (Application 12 of 12, …)”) and carries mass_delete,
the number. When the deletes are meant, plan and apply again with allow_mass_delete=true (API) or
--allow-mass-delete (CLI); the applied audit event then records allow_mass_delete. The flag doesn’t change the
plan’s plan_hash.
Limits and safety
- YAML is parsed safely: no Python objects, no tags other than the plain scalars, no anchors or aliases (so no
alias expansion bombs), no merge keys, no duplicate keys, one document per file, at most 12 levels of nesting,
20,000 values and 512 KB per file, and 400,000 values in the whole tree. A malformed number or boolean (
!!int abc,!!bool maybe, a number over 30 characters), NaN and infinity are that file’s error. A value that looks like a number but is meant as text must be quoted (version: '10.2'); the export quotes them. - The zip (API) is refused before anything is parsed when it is over 16 MB, lists more than 25,000 entries, holds
more than 20,000 YAML files or more than 16 MB of them uncompressed (counted as they inflate, an oversize file’s bytes
included, never from the sizes the zip claims), has an absolute path, a drive letter, a
..part, a symbolic link, an encrypted entry, two paths that differ only in case, or nests deeper than 8 folders. The same rules apply to a directory given to the CLI, which also never follows a link;catalog_exportrefuses to write when a kind folder or a file it would write is a symbolic link, before writing anything. - The hash names the organization too, so a plan made for one organization never applies to another.
- Who: Admins. The export is audited (
catalog_code.exported) and refused while platform staff impersonate; plan and apply are allowed for support work, and every event they write records the impersonation.
The API and the CLI
# Export (people only when asked for)
curl -H "Authorization: Bearer sw_ADMIN_TOKEN" -o catalog.zip "https://softwarden.example/api/v1/catalog-code/export.zip"
# Plan, then apply what was planned
curl -H "Authorization: Bearer sw_ADMIN_TOKEN" -F file=@catalog.zip -F prune=false https://softwarden.example/api/v1/catalog-code/plan
curl -H "Authorization: Bearer sw_ADMIN_TOKEN" -F file=@catalog.zip -F prune=false -F plan_hash=<plan_hash> https://softwarden.example/api/v1/catalog-code/apply
# From a development checkout (README, Run it locally). On the Compose stack run the same commands as
# `docker compose exec app python manage.py ...` with a --dir inside the container, and copy it out with docker compose cp.
cd apps/api
export SOFTWARDEN_DEBUG=1
uv run python manage.py catalog_export --org acme --dir ../../../acme-catalog [--include-people]
uv run python manage.py catalog_apply --org acme --dir ../../../acme-catalog --dry-run [--prune] [--json]
uv run python manage.py catalog_apply --org acme --dir ../../../acme-catalog [--prune] --expect-hash <plan_hash>
uv run python manage.py catalog_apply --org acme --dir ../../../acme-catalog [--prune] --no-plan-check
# a prune past the cap (more than 10 records, 20% of a kind, or all of a kind) also needs --allow-mass-delete
catalog_export removes YAML files left in the kind folders by an earlier export, so a deleted record disappears from
the tree; it leaves anything else (a README, .git) alone. Applying with catalog_apply needs --expect-hash (the
plan_hash of the dry run you reviewed), or --no-plan-check to say on purpose that there is no reviewed plan (it then
plans and applies in one go). It exits non-zero when the plan has errors or the hash doesn’t match. Both commands are
audited with interface: cli and no actor.
A git workflow
-
Start the repository from the app’s current state:
git init acme-catalog manage.py catalog_export --org acme --dir acme-catalog cd acme-catalog && git add -A && git commit -m "Catalog as exported" -
Change by pull request. Edit
applications/crm.yaml(saylifecycle: deprecated), or addapplications/new-tool.yaml, on a branch, and open a pull request. The diff is the change. -
CI plans it on the pull request and posts the plan for the reviewer, and keeps the hash:
manage.py catalog_apply --org acme --dir . --dry-run --json > plan.json jq -r .plan_hash plan.json > plan-hash.txtA plan with errors fails the check, and each error names its file and line.
-
Merging applies exactly what was reviewed:
manage.py catalog_apply --org acme --dir . --expect-hash "$(cat plan-hash.txt)"If someone edited the catalog in the app after the plan, the hash no longer matches and nothing is applied: plan again, review, merge.
-
Pull the app’s changes back regularly (edits made on screen, records discovery created): run
catalog_exportinto the repository and commit what changed. Because the export is stable, the commit holds only those changes.
Use --prune once the repository is the place records are removed: then deleting a file deletes the record.