← home

Configuration and file invariants

A stage with sort is always stored in that order. A stage without it keeps insertion order. The rule belongs to the file, not to one command or one view.

Declare a sorted file

Add sort to the stage that owns the file. This example also configures identities, validation, recent dates, and lifecycle maps:

{ "identity": {"primary": "sol_id", "fallback": ["active_key"]}, "pipeline": { "stages": [ { "name": "active", "file": "active.json", "recent_key": "deadline", "sort": { "key": "deadline", "type": "iso-date", "direction": "asc", "nulls": "last", "tiebreak": "active_key" }, "fields": { "sol_id": {"required": true, "type": "string"}, "agency": {"required": true, "type": "string"}, "classification": { "required": true, "enum": ["PRIME", "SUB", "EVALUATE", "ESCALATE"] }, "status": {"forbidden": true} } }, { "name": "passive", "file": "passive.json", "sort": {"key": "check_at", "type": "iso-date", "tiebreak": "sol_id"} }, {"name": "declined", "file": "declined.json"} ], "transitions": { "active": ["passive", "submitted", "declined"], "passive": ["active", "declined"] }, "maps": { "active->passive": { "rename": {"deadline": "check_at"} }, "active->submitted": { "drop": ["deadline"], "set": { "id": "{today}-{sol_id}", "submitted_at": "{today}", "kind": "submitted" } } } } }

active.json and passive.json are sorted; declined.json is deliberately unsorted.

Every operation preserves it

# validated, duplicate-safe, sorted insert jsoncrm add --database_file active.json --item_json '{ "sol_id":"SOL-42", "active_key":"acme-42", "agency":"Acme", "classification":"PRIME", "deadline":"2026-11-12" }' # deadline changes remove and reinsert in sorted order jsoncrm update --database_file active.json --item_json \ '{"sol_id":"SOL-42","deadline":"2026-11-10"}' # arbitrary configured transition + field map jsoncrm move SOL-42 --from active --to passive --dry-run jsoncrm move SOL-42 --from active --to passive --apply # compact date-window query without huge notes jsoncrm find --database_file active.json \ --item_json '{"deadline__within":7}' \ --fields sol_id agency deadline # audit identities, fields, dates, and sorted order jsoncrm validate # uses the stage's recent_key jsoncrm recent --stage active -n 10

promote and demote remain convenience wrappers for marketing pipelines. Use move for arbitrary stage names and multi-branch lifecycles. CLI and MCP mutations, merges, pending updates, and web edits all use the same invariant-preserving writer.

Sort contract

type defaults to string; sorting also supports number, integer, boolean, and strict YYYY-MM-DD ISO dates. Direction is ascending by default, nulls go last by default, and identity fields are the default tiebreakers. A canonical record representation breaks remaining ties, so repeated writes are byte-stable. shuffle is rejected for sorted stages.

Identity and field rules

Adds and moves reject duplicates using every configured identity field across the pipeline. Field rules support required, forbidden, enum, and type (string, number, integer, boolean, array, object, iso-date). Arbitrary additional fields remain welcome. validate audits the complete file; mutations reject invalid records before writing.

Queries and transition maps

find supports exact matches plus __lt, __lte, __gt, __gte, and __ne. __within includes today through N future days. --fields accepts space- or comma-separated projections. Maps run in drop, rename, set order; set supports literal values, {today}, and source fields such as {sol_id}.

Use the same operations from Python

Automation shares the CLI implementation. Operations live in jsoncrm.tool, invariant helpers in jsoncrm.invariants, and each independent CRMContext owns one CRM's paths and configuration:

from jsoncrm.context import CRMContext from jsoncrm.tool import add_record, update_record, move_record, query_records crm = CRMContext.from_file("/absolute/path/to/.crm.json") rows, added = add_record( rows, record, stage=crm.stage_for_name("active"), fields=crm.identity_fields, existing_records=crm.other_records(crm.stage_path("active")), ) crm.write_json(crm.stage_path("active"), rows)

Individual files are replaced atomically. Concurrent writers are not locked, so coordinate writers or reconcile their git changes. Moves validate both sides before writing, but two file replacements are not a cross-file transaction.