use crate::cli::SchemaTarget;
use crate::error;
use crate::output::CONTRACT;
use serde_json::{Value, json};
pub fn contract(target: SchemaTarget) -> Value {
let records = json!({
"cut": {"v":2,"kind":"cut","id":"bl_<20 lowercase hex>","ts":"RFC3339 UTC milliseconds","agent":"string","text":"string <= 10000 bytes","tags":["string"],"impact":"low|material|blocking","cwd":"repo-relative path when cwd is inside the discovered repo; ~-relative when under the home directory; otherwise the absolute path with home prefixes rewritten to ~","origin":"optional {type:string,provider?:string,ref?:string}; add writes {type:\"agent\"}; only these published members reach any envelope, and an unknown stored member survives in the log bytes and reaches none","evidence":"optional {cmd:string,exit:integer,stderr:string,note:string}; absent fields omitted; values best-effort redacted, including home-path rewriting"},
"dogear": {"v":2,"kind":"dogear","id":"bl_<20 lowercase hex>","ts":"RFC3339 UTC milliseconds","agent":"string","text":"string <= 10000 bytes","tags":["string"],"evidence":"optional string; best-effort redacted, including home-path rewriting","cwd":"repo-relative path when cwd is inside the discovered repo; ~-relative when under the home directory; otherwise the absolute path with home prefixes rewritten to ~","origin":"optional {type:string,provider?:string,ref?:string}; dogear writes {type:\"agent\"}; same published-members promise as cut"},
"resolve": {"v":2,"kind":"resolve","id":"bl_<20 lowercase hex>","ts":"RFC3339 UTC milliseconds","agent":"string","note":"string|null; best-effort redacted, including home-path rewriting","task":"optional string; omitted when absent","pr":"optional string; omitted when absent","commit":"optional string; omitted when absent","url":"optional string; omitted when absent","dropped":"optional true; omitted when false","amend":"optional true; omitted when false","disposition":"fixed|promoted|accepted|invalid; present exactly for cuts, never for dogears","disposition_ts":"RFC3339 UTC milliseconds; present exactly where disposition is; the moment the disposition was decided, so an amend that omits --disposition copies both from the winning resolution","promotion":"optional promotion ID; omitted when absent; accepted only with disposition promoted, and only when the named promotion already lists this cut in sources[]"},
"promotion": {"v":2,"kind":"promotion","id":"bl_<20 lowercase hex>","ts":"RFC3339 UTC milliseconds","agent":"string","sources":["bl_<20 lowercase hex>"],"artifact":{"type":"doc|skill|guard|test|tool|process","ref":"string <= 10000 bytes; best-effort redacted, including home-path rewriting"},"note":"optional string <= 10000 bytes; best-effort redacted; outside the ID hash","origin":"optional {type:string,provider?:string,ref?:string}; promote writes {type:\"agent\"}; same published-members promise as cut","cwd":"repo-relative path when cwd is inside the discovered repo; ~-relative when under the home directory; otherwise the absolute path with home prefixes rewritten to ~"},
"list_item": {"union":"items is a tagged union discriminated by kind; a cut or dogear item carries the record fields plus status and resolution, and a promotion item carries kind, id, ts, agent, sources, artifact, note?, cwd and origin? with no status, resolution, text, tags, impact or evidence","record":"all cut or dogear fields except v, which is a storage marker and reaches no envelope","status":"open|resolved","resolution":"{ts,agent,note,task?,pr?,commit?,url?,dropped?,amended?:true only when an amend wins,disposition?,disposition_ts?,promotion?}|omitted"}
});
let errors = json!({
"shape": {"ok":false,"error":{"code":"string","message":"string","details":{},"retryable":false,"suggested_fix":"string"},"meta":{"contract":CONTRACT}},
"codes": error::error_codes()
});
let exit_codes: Value = json!(error::exit_code_map());
match target {
SchemaTarget::Record => json!({"contract":CONTRACT,"records":records}),
SchemaTarget::Error => json!({"contract":CONTRACT,"errors":errors}),
SchemaTarget::ExitCodes => json!({"contract":CONTRACT,"exit_codes":exit_codes}),
SchemaTarget::All => json!({
"contract": CONTRACT,
"success_envelope": {"ok":true,"data":"command-specific object","meta":{"contract":CONTRACT,"file":"absolute path where relevant","agent_source":"flag|env|detected|default where relevant","warnings":["string; omitted when empty"]}},
"commands": {
"add": {"alias":["log"],"positional":"TEXT or -; optional when stdin is piped; stdin is read up to 1 MiB","flags":{"--agent":"NAME","--tag":"TAG; repeatable","--impact":"low|material|blocking; default low; consequence, not admission: file only friction that is transferable, consequential, recurring, misleading, or systemic; blocking: could not proceed; material: lost real time or produced incorrect work; low: qualified friction with limited immediate cost","--cmd":"TEXT; optional evidence command; leading hyphens accepted","--exit":"N; optional evidence exit status","--stderr-file":"PATH; regular UTF-8 file up to 1 MiB, redacted then stored up to 4096 bytes; symlinks follow targets","--evidence":"TEXT; optional evidence note; leading hyphens accepted","--dry-run":"boolean"},"output":"{changed,record}","read_only":false,"appends":true,"destructive":false},
"dogear": {"alias":["idea","finding"],"admission":"one finding in the agent's own words, interesting or possibly novel beyond the task, understandable without the repo — all three, where a cut needs any one of its grounds; a lead, not a verified result","positional":"TEXT or -; optional when stdin is piped; stdin is read up to 1 MiB","flags":{"--agent":"NAME","--tag":"TAG; repeatable","--evidence":"TEXT; what makes the finding checkable; leading hyphens accepted; best-effort redaction","--dry-run":"boolean"},"output":"{changed,record}","read_only":false,"appends":true,"destructive":false},
"promote": {"flags":{"--source":"ID; cut ID or unique prefix; repeatable; at least one required","--artifact-type":"doc|skill|guard|test|tool|process; required; an unrecognized value is invalid_argument (2)","--artifact-ref":"REF; required; non-empty, at most 10000 bytes after redaction; best-effort redacted before hashing and before append","--note":"TEXT; optional commentary, same bounds and redaction as --artifact-ref, and outside the ID hash","--agent":"NAME","--dry-run":"boolean"},"output":"{changed,record}","semantics":"read, fold, validate and append inside one exclusive-lock critical section after the version probe; each --source resolves through the single prefix rule and must be a cut, so zero matches is not_found (66), several is ambiguous_id (65), and a dogear or promotion is invalid_argument (2) naming the ID and its kind; a source may be open or resolved; sources are stored sorted and deduplicated; a computed ID that already exists appends nothing and returns the existing record with changed:false and the warning `duplicate promotion; existing record returned`; promote never writes a resolve event, and a promotion is never resolved","dry_run":"unlike add and dogear, promote --dry-run opens and folds the log because validating every --source requires it, so it probes the version and answers not_found (66) against a missing log","read_only":false,"appends":true,"destructive":false},
"list": {"flags":{"--kind":"cut|dogear|promotion|all; default cut; --impact is rejected unless kind is cut; --tag and an explicit --status open|resolved are rejected with --kind promotion","--status":"open|resolved|all; default open; the default never excludes promotions, an explicit open|resolved is a request for lifecycle records and excludes them, and --status all lifts the default and retains them, so `list --kind all --status all` shows every folded record of every kind","--agent":"NAME","--tag":"TAG","--impact":"low|material|blocking; cut only","--since":"full RFC3339|Nd|Nh","--limit":"N; default 50","--format":"json|md; default json"},"output":"{items,count,total,truncated}; items is a tagged union of cut/dogear items and promotion items; md is raw markdown","ordering":"cuts first by impact rank then ts descending then id ascending, then dogears by ts descending then id ascending, then promotions by ts descending then id ascending","empty_hints":{"cut":"no cuts matched; try --status all or broader filters","dogear":"no dogears matched; try --status all or broader filters","promotion":"no promotions matched; try broader filters","all":"no records matched; try --status all or broader filters"},"markdown":"promotions render under `## Promotions` after `## Dogears`, one `- [<id>] <artifact.type>: <artifact.ref> — <agent>, <ts>` line each with an indented ` - <note>` line when a note is present","read_only":true,"appends":false,"destructive":false},
"export": {"flags":{"--format":"otlp-json; required","--since":"full RFC3339|Nd|Nh"},"output":"raw OTLP LogsData JSON object; exactly one newline-terminated JSONL line with no success envelope or warnings","raw_output":true,"eventName":"blotter.friction.reported","semantics":"exports folded cuts of every status, excludes dogears and evidence fields, and sorts log records by cut timestamp ascending then ID ascending","read_only":true,"appends":false,"destructive":false},
"triage": {"flags":{"--min-count":"N; default 3; must be at least 2"},"output":"{clusters:[{count,occurrences,ids,tags,text,origin?}],count,scanned}","semantics":"equal non-empty normalized titles link regardless of tags; otherwise matching tags (or both untagged) use filtered-token linkage: 80% overlap with the shorter token set or 3 shared locally rare tokens","exit_codes":{"0":"no chronic clusters","1":"chronic clusters found"},"read_only":true,"appends":false,"destructive":false},
"verify": {"flags":{},"output":"{recurrences:[{resolved_id,resolved_text,origin?,resolution:{ts,disposition,disposition_ts,task?,pr?,commit?},recurrence_ids,count,first_recurrence_ts}],count,distinct_recurring_cuts,scanned}","semantics":"anchors are resolved cuts whose winning disposition is fixed or promoted; accepted and invalid are excluded, alongside dogears, dropped resolutions, and empty normalized resolved titles; each eligible anchor is independent; a recurrence is an open cut whose ts is strictly after the winning resolution's disposition_ts — the moment the disposition was decided, so a note-only --amend never moves the boundary — and that links under triage's exact title/tag/filtered-token rule; one open cut may recur against multiple anchors; count is the number of matched anchors, not the number of distinct recurring cuts; distinct_recurring_cuts is the number of distinct open cuts across every group; an empty recurrence set means no recurrence was observed in this log after disposition_ts — evidence the intervention held, not proof that the friction is fixed","exit_codes":{"0":"no recurrences","1":"recurrences found"},"read_only":true,"appends":false,"destructive":false},
"retrospect": {"flags":{},"output":"{candidates:[{pattern,suggested,title,program?,record_ids,resolved_anchor_ids?,occurrences,first_ts,last_ts,evidence:{texts:[...],resolution_notes:[...]}}],count,scanned}","patterns":["recurrent_friction","failed_intervention"],"suggested_values":["doc","skill","guard","test","tool","process"],"semantics":"pattern detection and suggested intervention are separate axes: pattern names what was observed, and suggested names what kind of artifact from the promotion vocabulary might answer it; only two patterns ship, both with a deterministic emission rule; retrospect takes no window: chronic signal is long-horizon by design; open-cut clusters use triage's representative linkage and r19 scoring at a two-record candidate threshold — recurrent_friction with a shared-failing-program cluster taking precedence over a docs-tagged cluster, and it is the shared-failing-program rule vs the docs-tag rule that decides which suggested set is emitted (tool,guard vs doc), not the pattern; resolved recurrences use verify's anchor and linkage rules — so an accepted or invalid anchor never produces a candidate — and only recurrence counts of two or more become failed_intervention with suggested:[skill]; evidence contains only bounded texts and resolution notes, never evidence command, stderr, or note fields","exit_codes":{"0":"no promotion candidates","1":"promotion candidates found"},"read_only":true,"appends":false,"destructive":false},
"digest": {"flags":{"--since":"full RFC3339|Nd|Nh; default 7d","--format":"json|md; default json"},"output":"{chronic:[{count,occurrences,ids,tags,text,origin?}],new_cuts:{count,by_tag:[{tag,count,ids}]},open_dogears:{count,items:[{id,ts,text,tags}]},accepted_cuts:{count},window:{since,until}}","semantics":"chronic uses all open cuts with triage min_count 2; new_cuts uses open cuts in the inclusive since/until window and groups untagged cuts under tag ''; open_dogears includes all open dogears; accepted_cuts counts cuts whose winning disposition is accepted and whose disposition_ts is inside the inclusive since/until window, judged by disposition_ts alone; --format md renders nothing for it","format":{"json":"success envelope","md":"raw pasteable markdown without an envelope; empty sections are omitted, an empty report is 'No friction in window.', and warnings are trailing > note: lines"},"read_only":true,"appends":false,"destructive":false},
"sweep": {"positional":"PATH...; repository directories or direct JSONL logs; at least one path from PATHS or --registry is required","flags":{"--registry":"FILE; user-owned text file with one path per line; blank lines and # comments ignored; relative paths resolve from the registry file's directory; must be a regular UTF-8 text file, so a directory or non-UTF-8 bytes are invalid_input (65)","--since":"full RFC3339|Nd|Nh; optional","--kind":"cut|dogear|all; default cut; sweep's --kind is a separate enum from list's and never accepts promotion"},"output":"{repos:[{path,counts:{open_cuts,open_dogears},by_tag:[{tag,count}],items:[ListItem...],truncated?:true}],totals:{repos_swept,repos_skipped,open_cuts,open_dogears}}","semantics":"each path is read under its own shared lock and repos sort by canonical log path; counts and totals include all open items, while items and by_tag honor --kind and --since before the per-repo 50-item cap; BLOTTER_FILE is ignored; global --file conflicts with sweep; per-repo lock timeouts and unreadable logs become skip warnings with exit 0, a sweep-scoped deliberate deviation from exit-75","read_only":true,"appends":false,"destructive":false},
"resolve": {"positional":"one or more IDs; optional bl_ plus at least 4 hex digits each","flags":{"--note":"TEXT; leading hyphens accepted; best-effort redaction","--agent":"NAME","--task":"ID; optional graduation task; valid for cuts and dogears","--pr":"URL; optional graduation pull request; valid for cuts and dogears","--commit":"SHA; optional graduation commit; valid for cuts and dogears","--url":"URL; dogear-only; where a human published the finding; conflicts with --dropped","--dropped":"boolean; dogear-only; the finding did not survive review; conflicts with --url","--disposition":"fixed|promoted|accepted|invalid; required when any named record is a cut, rejected for dogears, and a batch naming both a cut and a dogear is rejected after ID matching and before any append; on --amend it is optional and, when omitted, disposition and disposition_ts are both copied from the pre-append winning resolution","--promotion":"ID; links the resolution to an existing promotion; accepted only with --disposition promoted, resolved under the same lock through the single prefix rule, and rejected unless the named promotion already lists every resolved cut in its sources[] — the link must be mutual, and a failure fails the whole batch before any append; on --amend the link is kept while the winning disposition stays promoted and cleared when the amend moves it to fixed, accepted or invalid","--amend":"boolean; append a correction for already-resolved records; requires at least one resolution field, and --disposition counts as one","--dry-run":"boolean"},"output":"{changed,records:[...]}; always an array, including one ID; IDs are canonicalized, sorted, and duplicate inputs collapse; non-amend mixed already-resolved IDs warn with a sorted count/list; amend requires every requested record already be resolved and appends the batch atomically","amend_fold":"the fold first joins every resolve event to its record and discards the invalid ones, counting them in the warning `skipped N invalid resolutions`, then selects winners over the valid events only, so duplicate and orphan counts run over valid events and an orphan is never invalid; the first valid non-amend resolve is the base; the latest valid amend for a resolved record replaces all base user-set fields and supplies the materialized ts and agent; orphan amends warn and do not materialize; no log record is rewritten","invalid_resolution_rules":["a resolve targeting a cut with no disposition","a resolve targeting a dogear that carries one","a disposition without disposition_ts, or a disposition_ts without disposition","a promotion field with any disposition other than promoted","a promotion naming an ID that is not a promotion in this log","a promotion whose sources[] does not contain the resolved cut"],"read_only":false,"appends":true,"destructive":false},
"archive": {"flags":{"--before":"full RFC3339|Nd|Nh; required","--dry-run":"boolean; plan without writes"},"output":"{changed,archived,kept,archive_file,backup,restore_hint}; archive_file, backup, and restore_hint are null for dry-run and no-op","semantics":"archives only closed current bl_ cut and dogear groups when every group event predates the exclusive cutoff; a resolved cut named in any promotion's sources[] is pinned and never archived, however old the group or the promotion; promotions have no state to close and never archive; malformed, unknown, orphan, and non-bl_ lines remain verbatim","read_only":true,"appends":false,"destructive":false,"apply":{"read_only":false,"appends":false,"destructive":true,"semantics":"apply re-decides under an exclusive lock, writes a timestamped backup and archive sidecar whose names derive from BLOTTER_NOW, then atomically replaces the log; reruns under an identical clock fail with io_error by design"}},
"schema": {"positional":"all|record|error|exit-codes; default all","read_only":true,"appends":false,"destructive":false},
"doctor": {"flags":{"--fix":"repair torn_line, malformed, and conflict_marker findings","--dry-run":"plan repairs without writes; requires --fix","--leaks":"scan every physical line for home-path leaks (decoded text on a line that parses as JSON, raw bytes otherwise); conflicts with --fix","--deny":"LITERAL; non-empty repeatable literal raw-line deny pattern; requires --leaks and therefore conflicts with --fix"},"output":"{healthy,findings:[{line,kind,message,fixable}],checked_lines,fix?:{changed,applied:[{line,kind,action}],backup?,quarantine?,restore_hint?,dry_run}}","finding_kinds":"torn_line, malformed, conflict_marker (fixable); unknown_kind, id_conflict, duplicate_cut, duplicate_dogear, duplicate_promotion, dangling_source, orphan_resolve, invalid_resolution, unsupported_version, gitignored, leak (not fixable); dangling_source names a promotion whose sources[] holds an ID that resolves to nothing, or to a dogear or a promotion, in the folded log; invalid_resolution names the record ID and every rule the event breaks, and on a log the version probe refuses the record-model findings are replaced by one unsupported_version finding while gitignored and --leaks findings still appear, ordered unsupported_version, then leak findings in line order, then gitignored","semantics":"--leaks adds diagnose-only leak findings for home paths and --deny literals; leak is not fixable, and --leaks/--deny conflict with --fix because the gate is read-only","exit_codes":{"0":"healthy","1":"findings"},"read_only":true,"appends":false,"destructive":false,"fix":{"read_only":false,"appends":true,"destructive":true,"semantics":"--fix re-inspects under an exclusive lock; every fix is backed up, quarantined, and removed by atomic replacement"}}
},
"global_flags": ["--file <PATH>","--pretty"],
"env": {
"BLOTTER_FILE":"log-file override",
"BLOTTER_AGENT":"agent-name fallback",
"BLOTTER_NOW":"full RFC3339 clock override"
},
"records": records,
"record_version": {"field":"v","value":2,"position":"first member of every stored line","envelopes":"never materialized into any envelope; the envelope carries its version as meta.contract","probe":"every path that opens a log checks each physical line's raw JSON under the lock it already holds, before the fold, before any tear-healing byte, and before any append or copy-and-swap","known_raw_kinds":["cut","dogear","resolve","promotion"],"accepts":"only a JSON integer literal whose value is 2; null, \"2\", 2.0 and 2e0 are all refused","passes":"an empty file, a file holding only a newline, and any file with no line carrying a known raw kind","refusal":{"code":"unsupported_log_version","exit":65,"retryable":false,"details":{"file":"resolved log path","line":"1-based physical line of the first offending record","found_version":"the offending v verbatim; the key is omitted when v was absent, so absent and wrong are told apart by key presence, never by null-ness"},"suggested_fix":"names the resolved path and instructs a rename to a path that does not yet exist followed by `blotter add`","bytes":"the file is byte-identical after a refusal: no tear-heal, no backup, no quarantine, no archive sidecar","commands":"add, dogear, promote, resolve and archive append nothing; list, triage, digest, verify, retrospect and export refuse with no empty-state fallback; doctor reports one non-fixable unsupported_version finding alongside any gitignored or --leaks finding; sweep skips the log and keeps exit 0; resolve --dry-run and promote --dry-run probe, add --dry-run and dogear --dry-run do not open the log at all"}},
"id": {"prefix":"bl_","prefix_rule":"an ID argument is an optional bl_ followed by at least 4 hexadecimal digits, matched case-insensitively against the one bl2 namespace; a shorter prefix is invalid_argument (2), zero matches is not_found (66), several is ambiguous_id (65) listing every match, and a complete ID that also prefixes a longer one is ambiguous_id with no exact-match precedence","encoding":"u32 little-endian UTF-8 byte length before each field","tag_order":"ascending raw UTF-8 byte order, exact-byte deduplication, case-sensitive, no Unicode normalization; promotion sources normalize under the same rule and are stored in that form","excluded":"v and origin are excluded from every hash","cut":{"hex_digits":20,"hash":"SHA-256 first 10 bytes","fields_in_order":["literal bl2","literal cut","ts","agent","text","impact","tag count","each sorted unique tag as its own field"]},"dogear":{"hex_digits":20,"hash":"SHA-256 first 10 bytes","fields_in_order":["literal bl2","literal dogear","ts","agent","text","tag count","each sorted unique tag as its own field"]},"promotion":{"hex_digits":20,"hash":"SHA-256 first 10 bytes","fields_in_order":["literal bl2","literal promotion","ts","agent","source count","each sorted unique source id as its own field","artifact.type","artifact.ref"],"excluded":"note is outside the promotion hash; a promotion whose note is reworded is the same promotion"}},
"artifact_types": {"values":["doc","skill","guard","test","tool","process"],"closed":"the vocabulary is closed in 1.0.0; an unrecognized --artifact-type is invalid_argument (2)","used_by":["promote --artifact-type","the promotion record's artifact.type"]},
"impact": {"values":["low","material","blocking"],"meaning":{"low":"qualified friction with limited immediate cost","material":"lost meaningful time, caused incorrect work, or required substantial recovery","blocking":"the task could not proceed"},"admission":"low still means cut-worthy; admission decides entry and impact describes consequence after entry","flag":"--impact on add (default low) and on list (cut only)","sort":"impact rank blocking > material > low, then ts descending, then id ascending","markdown_headings":["## Blocking","## Material","## Low"],"otlp":{"attribute":"blotter.friction.impact","low":"9/INFO","material":"13/WARN","blocking":"17/ERROR"}},
"discovery": ["--file","BLOTTER_FILE","nearest .git directory or file then <root>/.blotter.jsonl","$HOME/.blotter/log.jsonl"],
"errors": errors,
"exit_codes": exit_codes,
"storage": {"format":"append-only JSONL","locking":"local filesystems only; 50 retries x 100ms","durability":"best effort; no fsync per append"}
}),
}
}