# Forkctl Workflow Redesign — JSON API
## Boundary
The API is a local, single-request stdin/stdout protocol—not a daemon, socket, or REST service. `api call` reads one invocation, executes the same typed domain handler as the CLI, writes exactly one response envelope to stdout, and exits. JSON mode never writes diagnostics, progress, Git, or StGit output to stderr. Adapter metadata utilities (`completion`, `--usage-spec`, `api schema`) are generated from the Clap/protocol types and do not masquerade as repository-domain API commands.
The first and only protocol is `protocol_version: 1`. No previous request, response, manifest, or state type is accepted.
## Schema Discovery
```text
forkctl api schema [-k|--kind KIND]
forkctl api call
```
`KIND` values:
| `bundle` | Default document containing every schema below |
| `manifest` | Tracked manifest schema |
| `invocation` | API invocation envelope and all command arguments |
| `response` | Success/error envelopes and every command result |
| `active-state` | Git-private active patch state |
| `operation` | Git-private current-operation journal |
All documents declare JSON Schema 2020-12 and are generated by Schemars from the production types. `api schema` itself has no JSON envelope because its output is the requested schema document.
Bundle shape:
```json
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"forkctl_protocol_version": 1,
"schemas": {
"manifest": {},
"invocation": {},
"response": {},
"active_state": {},
"operation": {}
}
}
```
## Invocation
```json
{
"protocol_version": 1,
"manifest": "patches/fork.json",
"mode": "execute",
"request": {
"command": "patch.refresh",
"arguments": {
"patch": null,
"capture": {"source": "staged"}
}
}
}
```
| `protocol_version` | yes | Must equal `1` |
| `manifest` | no | Repository-relative or absolute manifest path; omission follows CLI/environment/default resolution |
| `mode` | yes | `execute` or `plan`; read-only commands accept only `execute` |
| `request` | yes | Discriminated command-specific request |
`request` is a Rust enum serialized with `command` as its tag and `arguments` as its content. Each dotted command has one exact argument schema. Unknown fields are rejected everywhere.
## Command Requests
### Repository lifecycle
| `init` | execute/plan | `InitArgs` |
| `status` | execute | `{}` |
| `check` | execute | `CheckArgs` |
| `contract.edit` | execute/plan | `ContractEditArgs` |
| `rebase` | execute/plan | `RebaseArgs` |
| `publish` | execute/plan | `{}` |
| `instructions` | execute | `{}` |
`InitArgs`:
```json
{
"upstream_remote": "upstream",
"upstream_url": "https://github.com/example/project.git",
"upstream_ref": "refs/heads/main",
"downstream_remote": "origin",
"downstream_branch": "main",
"base": "refs/heads/main",
"ledger": "PATCHES.md",
"exports": "patches/downstream",
"bookkeeping_patch": "fork-tooling",
"bookkeeping_scope": ["mise.toml", "lefthook.yml", "FORK.md"],
"allow_base": ["vendor/**"],
"required_text": [{"path":"FORK.md","contains":"forkctl check"}]
}
```
- If the manifest exists, every bootstrap field must be absent; init hydrates that manifest.
- If the manifest does not exist, every identity/base/document/bookkeeping field except extra bookkeeping scope and optional contract arrays is required. `allow_base` and `required_text` initialize the complete declarative contract without manual manifest edits.
- Bootstrap refuses `HEAD != resolved base` and never imports existing downstream commits.
`CheckArgs`:
```json
{
"scope": "repository",
"patch": null
}
```
- `scope`: `repository` (default in CLI) or `staged` (`-s`/`--staged`).
- `patch` is valid only with staged scope and overrides active selection.
- Staged scope with an empty index succeeds without requiring a patch.
`RebaseArgs`:
```json
{"onto": "refs/heads/main"}
```
### Patch lifecycle
| `patch.list` | execute | `{}` |
| `patch.show` | execute | `PatchTarget` |
| `patch.create` | execute/plan | `PatchCreateArgs` |
| `patch.select` | execute/plan | `PatchName` |
| `patch.edit` | execute/plan | `PatchEditArgs` |
| `patch.refresh` | execute/plan | `PatchRefreshArgs` |
| `patch.finish` | execute/plan | `PatchTarget` |
`PatchTarget`:
```json
{"patch": null}
```
Omission means active patch and fails with `active_patch_required` when the command needs one.
`PatchCreateArgs`:
```json
{
"name": "reliable-busy-close",
"kind": "source",
"purpose": "Protect destructive close while a daemon-side process runs.",
"upstream_status": "not-submitted",
"drop_when": "Upstream provides equivalent daemon-aware close safety.",
"scope": ["Macterm/**/*.swift", "MactermTests/**/*.swift", "e2e/**/*.py"]
}
```
`PatchEditArgs`:
```json
{
"patch": null,
"kind": null,
"purpose": null,
"upstream_status": null,
"drop_when": null,
"scope": {
"mode": "add_remove",
"add": ["README.md"],
"remove": ["docs/obsolete.md"]
}
}
```
Scope edit is a discriminated union:
```json
{"mode": "set", "patterns": ["src/**", "tests/**"]}
```
or:
```json
{"mode": "add_remove", "add": ["README.md"], "remove": ["old/**"]}
```
At least one metadata/scope edit is required. `set` cannot combine with add/remove.
`PatchRefreshArgs`:
```json
{
"patch": null,
"capture": {"source": "staged"}
}
```
Capture is one of:
```json
{"source": "staged"}
```
```json
{"source": "all"}
```
```json
{"source": "paths", "pathspecs": ["src/model.rs", "tests/model.rs"]}
```
The variants correspond exactly to CLI default/`--staged`, `--all`, and repeated `--path`.
### Current operation
| `operation.status` | execute | `{}` |
| `operation.continue` | execute/plan | `{}` |
| `operation.abort` | execute/plan | `OperationAbortArgs` |
```json
{"confirmed": true}
```
The CLI maps `-y`/`--yes` to `confirmed`. API execution rejects abort with `confirmed: false`; plan mode accepts it because planning is non-destructive.
## Success Response
```json
{
"status": "success",
"protocol_version": 1,
"command": "patch.refresh",
"mode": "execute",
"operation_id": null,
"result": {
"type": "patch_refresh",
"patch": "reliable-busy-close",
"capture": {"source": "staged"},
"captured_paths": ["Macterm/Model/Pane.swift"],
"old_commit": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
"new_commit": "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"generated_paths": [
"PATCHES.md",
"patches/downstream/0001-reliable-busy-close.patch"
],
"check": {"ok": true, "source_tree": "cccccccccccccccccccccccccccccccccccccccc"}
},
"notices": []
}
```
Success fields:
| `status` | Literal `success` |
| `protocol_version` | Literal `1` |
| `command` | Echoes the request command |
| `mode` | Echoes execute/plan |
| `operation_id` | Stable ID when a journaled operation exists; otherwise null |
| `result` | Command-specific discriminated result |
| `notices` | Stable typed non-fatal notices |
Plan mode uses the same envelope with a command-specific plan result:
```json
{
"type": "patch_refresh_plan",
"patch": "reliable-busy-close",
"capture": {"source": "staged"},
"reads": ["Git index", "patches/fork.json"],
"writes": ["StGit patch reliable-busy-close", "PATCHES.md", "patch export"],
"hooks": ["pre-commit via stg refresh"],
"ref_updates": [],
"captured_paths": ["Macterm/Model/Pane.swift"],
"requires_confirmation": false
}
```
Plans are semantic effects, not echoed shell command strings.
## Command Results
| `init` | created/hydrated, repository identities, base target, bookkeeping commit, check result |
| `init_plan` | resolved target, generated paths, StGit/ref/write effects |
| `status` | repository, base, series, active state, worktree/index inventory, operation, check summary |
| `check` | scope, target patch when staged, checked paths/contracts, typed findings, check result |
| `patch_list` | ordered patch summaries plus active marker |
| `patch_show` | complete patch metadata, commit, scope, changed paths, export, active marker |
| `patch_create` | active draft state |
| `patch_create_plan` | proposed draft write and validated metadata |
| `patch_select` | previous and new active state |
| `patch_select_plan` | proposed local state transition |
| `patch_edit` | old/new metadata, commit IDs, generated paths, check result |
| `patch_edit_plan` | metadata/scope diff and expected stack/evidence effects |
| `patch_refresh` | capture, paths, old/new commit, generated paths, check result |
| `patch_refresh_plan` | complete capture and mutation effects |
| `patch_finish` | cleared active state and full check result |
| `patch_finish_plan` | outstanding blockers and local state effect |
| `contract_edit` | resulting contracts, generated paths, full check result |
| `rebase` | target, old/new base/tip, recovery object, report object, dropped patches, check result |
| `rebase_plan` | resolved target, lease, recovery/ref/patch/evidence effects |
| `publish` | branch, head, recovery tag/object, lease |
| `publish_plan` | exact refspecs, lease, remote, atomic requirement |
| `operation_status` | current typed journal or null plus next actions |
| `operation_continue` | phase transition and command-specific result |
| `operation_continue_plan` | validated next phase and effects |
| `operation_abort` | restored old state and check result |
| `operation_abort_plan` | discarded paths/commits, recovery steps, confirmation requirement |
| `instructions` | generated Markdown contract |
Every result struct denies unknown fields and derives Serde plus Schemars.
## Error Response
```json
{
"status": "error",
"protocol_version": 1,
"command": "check",
"mode": "execute",
"error": {
"code": "staged_scope_violation",
"message": "2 staged paths are outside patch reliable-busy-close",
"causes": [],
"details": {
"type": "paths",
"patch": "reliable-busy-close",
"paths": ["README.md", "mise.toml"]
},
"retryable": false,
"suggested_command": "forkctl patch edit --add-scope README.md --add-scope mise.toml"
}
}
```
Error fields are always present except `suggested_command`, which is nullable. `details` is a typed tagged enum, not arbitrary JSON.
### Error detail variants
| `none` | none |
| `paths` | patch, paths |
| `patch` | requested, available, active |
| `operation` | operation ID, kind, phase, next actions |
| `check` | findings with stable codes and subjects |
| `remote` | remote, ref, expected, actual, stderr |
| `subprocess` | program, args, cwd, exit code, stderr |
| `request` | field and validation issue |
### Stable error codes
| `invalid_request` | no | CLI/API grammar or mode invalid |
| `unsupported_protocol` | no | Protocol version unsupported |
| `repository_not_found` | no | Repository root unavailable |
| `manifest_invalid` | no | Manifest parse/contract invalid |
| `dirty_worktree` | no | Clean-only operation found changes |
| `active_patch_required` | no | Staged/mutation request has no target |
| `active_patch_exists` | no | Draft creation conflicts with active state |
| `patch_not_found` | no | Named patch absent |
| `staged_scope_violation` | no | Staged paths exceed ownership |
| `capture_conflict` | no | Capture modes or partial file staging are ambiguous |
| `operation_in_progress` | no | Another journaled operation is active |
| `operation_conflict` | no | Manual conflict resolution required |
| `check_failed` | no | Repository audit contract failed |
| `remote_advanced` | yes | Exact lease is stale; refetch/review required |
| `publication_rejected` | no | Remote policy rejected atomic publication |
| `subprocess_failed` | depends | Unclassified Git/StGit failure |
| `internal_error` | no | Unexpected forkctl invariant failure |
Rust error types choose codes/details at the owning domain boundary. No string-prefix or stderr substring decides the API code, except a narrow remote-error classifier may attach provider diagnostics while retaining `subprocess_failed` when uncertain.
## Notices
```json
{
"code": "upstream_patch_dropped",
"message": "Dropped patch old-change after upstream replay.",
"details": {
"patch": "old-change",
"commit": "<pre-rebase-commit>",
"recovery_tag": "forkctl/recovery/<id>"
}
}
```
Notice codes and details are typed. Initial codes:
- `upstream_patch_dropped`
- `active_patch_retained`
- `hook_modified_index`
- `no_changes_captured`
## CLI Mapping
| `forkctl check` | `check`, `{scope:"repository"}` |
| `forkctl check -s` | `check`, `{scope:"staged",patch:null}` |
| `forkctl patch refresh` | `patch.refresh`, staged capture |
| `forkctl patch refresh -a -n` | `patch.refresh`, all capture, envelope mode plan |
| `forkctl operation abort -y` | `operation.abort`, `{confirmed:true}` |
Clap adapter tests assert this mapping for every command and every short/long equivalent.
## Output Integrity
- Pretty success: stdout.
- Pretty error: stderr.
- CLI `--format json`: one full API response envelope on stdout, stderr empty.
- `api call`: one full API response envelope on stdout, stderr empty.
- `api schema`: one schema document on stdout, stderr empty.
- `--usage-spec`: one Usage KDL specification on stdout, stderr empty.
- Broken pipe exits successfully without printing another error.
- Child output is captured; structured fields carry relevant diagnostics.