Skip to content

Shared take-decision operations

Implementation milestones: CRE-594/CRE-595/CRE-596. studio.decisions provides complete take decisions through a Python API and bin/studio_decisions.py, without an app, an index, Node, or a running HTTP service. Studio's verdict, note, and live-selection endpoints and Review's workspace verdict/live endpoints delegate to these operations. Other app/engine writers remain outside this lock; general concurrent mutation safety is not established.

The caller records an actual director decision. by is attribution; setting it to director does not authorize an assistant to invent a verdict. These operations never generate or move media, approve a first-frame plate, close a gate, or select a replacement take automatically.

API

The following example assumes an existing writable production named example-film in the workspace selected by studio.paths, an S1 registry entry, and a filed take at the stated path. It is API reference, not a new-user rehearsal or a cue to modify a real production. The take paths below are the numbered layout's; on the plain layout the same take is shots/S1/<file> (docs/WORKSPACE.md § The two layouts), and list always prints the exact path to pass.

from studio import decisions

decisions.set_verdict("example-film", "S1", "06-takes/S1/v1/t01.mp4", "approved", operation_id="review-001", note="Keep this take", t=1.5)
decisions.add_note("example-film", "S1", "06-takes/S1/v1/t01.mp4", "Watch the entrance", operation_id="note-001", by="reviewer", t=0)
decisions.set_live("example-film", "S1", "06-takes/S1/v1/t01.mp4", operation_id="live-001")
decisions.clear_live("example-film", "S1", operation_id="clear-live-001")
decisions.set_verdict("example-film", "S1", "06-takes/S1/v1/t01.mp4", None, operation_id="clear-verdict-001")

All four operations accept by (default director), expected (optional mapping of production-relative file paths to SHA-256 revisions or None for expected absence), and timeout (lock wait in seconds, default 10). An optional workspace_root explicitly selects the directory containing productions without changing process environment. set_verdict also accepts an optional note and t; add_note requires text and accepts t. Timecodes are finite nonnegative seconds. A timecode without a note is refused. Timestamps are generated once while preparing the operation and retained on retry.

Session commands

Run from the repository root. python -m studio.decision_cli is equivalent to the shim. Each subcommand supports --help; these examples assume the existing production described above.

Set --workspace <directory> before the subcommand to override the shared root resolver.

python bin/studio_decisions.py list example-film S1
python bin/studio_decisions.py verdict example-film S1 06-takes/S1/v1/t01.mp4 approved --operation-id review-001 --note "Keep this take" --t 1.5
python bin/studio_decisions.py note example-film S1 06-takes/S1/v1/t01.mp4 "Watch the entrance" --operation-id note-001 --by reviewer --t 0
python bin/studio_decisions.py live example-film S1 06-takes/S1/v1/t01.mp4 --operation-id live-001
python bin/studio_decisions.py clear-live example-film S1 --operation-id clear-live-001
python bin/studio_decisions.py verdict example-film S1 06-takes/S1/v1/t01.mp4 clear --operation-id clear-verdict-001

Writes require --operation-id; keep it for retries. Success is one JSON object on stdout. Refusals are one JSON object on stderr (ok: false, code, message); exit codes are 0 for success, 2 for invalid input/domain refusal, 3 for a revision/recovery/lock conflict, and 4 for a filesystem failure. Help is ordinary text with exit 0. A filesystem failure may leave a prepared operation: retain its ID and reconcile/recover it rather than inventing a replacement decision.

list reads the registered shot's takes directly from disk and creates no index, journal, or lock. It reports exact path, version/take numbers, verdict, notes, and an expected revision map for each take, plus the shot's live pointer. Pass a row's revision map as JSON to --expected when the write should refuse if that observed state changed. Listing is an observation, not a locked snapshot; a concurrent writer can make it stale. problems reports malformed candidate metadata, and pruned names links omitted from discovery. Neither an empty list nor a partial listing authorizes a write. Unreadable directories fail the command. Archives can be inspected if their workspace registry is present; listing does not migrate legacy repository text.

A sidecar-bearing file inside the requested shot's take folder (06-takes/<shot>/ on the numbered layout, shots/<shot>/ on the plain one) whose studio address is absent or names the wrong identity appears in problems with code: take_identity. It is not silently hidden, assigned a verdict or treated as safe to mutate. Other shots' files remain outside that query. A binding alone may still lack the positive version/take numbers required for review; preserve provenance and resolve the full address before recording decisions (repair-addresses, below). The workflow snapshot separately reports media with no sidecar at all.

One verdict record

A take's verdict is studio.verdict on its own sidecar plus the event in .studio/events.jsonl, written only by these operations. Nothing moves on a verdict. The retired review-app stores are not part of the record and no session tool writes them: a legacy inbox's _approved/ · _needs-work/ · _rejected/ folders, its notes.json and catalog.jsonl, and the Desk's decisions.jsonl bus.

  • bin/judge_takes.py lists a shot's takes through list_takes and appends its [judge] notes through add_note, attributed by: judge. It cannot set a verdict. Notes are history, so a judgment is appended and never rewritten: re-applying an identical judgment is a retry of the same operation (no second note), and an amended judgment is a new note, so the newest [judge] note on a take is the current one.
  • bin/taste_ledger.py snapshot reads the record, on either layout. build, stats and gaps mine the retired legacy inbox for history and refuse to run without --legacy.
  • The Studio and Review apps were deleted in packs phase 4 PR C (restore point: tag parked-apps-2026-10-16); the session commands are the only adapter.

Repairing an address

python bin/studio_decisions.py repair-addresses <production> is for takes that sit in the right folder but cannot be addressed, because an older saver or a hand filing left the sidecar with no studio block, or with one that names no production. It is a dry run unless --apply is passed, and the dry run prints exactly what it would write: one row per sidecar with the fields it would stamp, where each came from (source), what was there (was), and a refused list with a reason per sidecar.

python bin/studio_decisions.py repair-addresses example-film
python bin/studio_decisions.py repair-addresses example-film --apply --operation-id repair-addresses-001

What it derives, and from where:

  • production: the production folder.
  • shot: the block's own shot when gates/shots.json has it; otherwise the take's shot folder when the registry has that (a folder that differs from one code only by case is accepted). A sidecar whose shot cannot be determined is refused and listed: an unregistered folder, or a block naming an unregistered shot inside a registered shot's folder. Register the shot and run it again.
  • kind: take, when the block names none. A block that says plate, sheet or any other kind is refused, not rewritten.
  • version: the v<N> folder on the numbered layout and the _v<N> token in the filename (read after the shot name is removed, so a shot called X-v2 is not version 2). When both exist they must agree; on the plain layout the filename is the only source, and a name without one is refused.
  • take: the _t<NN> token in the filename. A file whose name carries no take number (an hf_<date>_<id> save) is numbered above the numbers its shot and version already use, in created_at then filename order, which is the rule the landing path numbers by; these rows say so in source.take, and --no-assign-takes refuses them instead. A number another take already holds is refused.
  • A stored number written as text ("t01", "v31") is normalized to its integer.

What it never does: it never writes, clears, moves or infers a verdict or a note, never moves or renames a file, and never touches a top-level key. A verdict key found at the top level of a sidecar, outside the studio block, is reported under legacy_verdicts and left where it is; recording it is a director decision made with verdict. Sidecars with no media beside them, a block naming another production, and links under the takes folder are refused. An archive (legacy structure) is refused whole, like every other write.

--apply commits every stamp of the production as one operation in the same journal the decision operations use (.studio/operations/<id>.json, one address_repair event in .studio/events.jsonl), so it requires --operation-id, retries exactly, and rolls forward after a failure. New stamps record bound_by (from --by, default agent:session), bound_at and bound_reason unless the block already carries them.

Gate 6c is bound to take sidecars by hash, so rewriting one makes it stale. When 6c verified before the repair, --apply re-closes it from the takes with the receipt's previous ruled_by, and the result's gate_6c block says what happened; if the re-close fails it prints the one command to run (python bin/phase_gate.py close <slug> --gate 6c --from-takes). A gate that was not closed is left alone. A repaired sidecar that a shot declared as a deps entry is listed under stale_deps; restamp it with shots.py set <slug> <SHOT> --restamp after the repair.

operation_id is required and must be a portable lowercase ASCII identifier of 1–100 characters, starting with a letter or digit, followed by letters, digits, dots, underscores, or hyphens. Windows device names are refused. A UUID in its usual lowercase hyphenated form works. Reuse an ID only for a retry of the same semantic request and expected revisions. A new decision needs a new ID. A committed retry returns its original result, even after a later decision or an archive-policy change, without mutating current state.

Identity and validation

  • Inputs name the production, registered shot, and exact production-relative media path. A bare filename is interpreted as a file at the production root, never an index search.
  • The production must exist and workspace.structure_of must classify its manifest as writable. Archives retain the existing explicit structure-migration path. This API does not resolve CRE-555's separate policy question.
  • A take must exist and have a valid sidecar whose studio block names the selected production/shot, kind: take, and positive integer version/take fields. Unaddressed, missing, unreadable, malformed, duplicate-key, and nonfinite JSON state is refused instead of synthesized.
  • Verdicts are approved, needs_work, rejected, inconclusive, or explicit None to clear. Clearing removes verdict attribution and retains the decision timestamp. Independent notes preserve the existing verdict and its attribution.
  • A rejection clears only the selected take's live pointer and records separate verdict/live history events. Same-named files in different versions are distinct. Legacy filename pointers use version/take metadata and must resolve unambiguously; otherwise the caller must explicitly select or clear the live pointer first.
  • New live pointers use production-relative paths through the registry's existing live_pointer builder. inconclusive cannot be selected. Other verdicts retain existing selection behavior; selecting a rejected take returns a caveat and does not change its verdict. Changing an already selected take's verdict to inconclusive retains existing behavior and does not silently clear it.
  • Existing provenance, unknown sidecar fields, and earlier notes survive. A shared fallback sidecar is copied into the chosen media file's own sidecar; siblings' shared provenance is not edited.

Results and refusals

Success returns ok, operation_id, production, shot, action, take, changed (relative paths), events (durable event objects), live_cleared, and caveat. Every event includes the operation ID and its ordinal. App adapters may notify/invalidate caches after this result; those notifications are not part of the authoritative operation.

DecisionError.code identifies invalid_input, invalid_path, missing_production, missing_artifact, unreadable_artifact, missing_shot, archive, take_identity, corrupt_state, ambiguous_take, or inconclusive. Storage additionally raises transactions.Conflict for stale revisions, reused IDs, changed recovery dependencies, or malformed history; CorruptJournal for invalid recovery plans; and storage.LockTimeout for contention. Filesystem permission/write errors propagate. Refusal does not silently downgrade to an unlocked write.

In a creative session (carried verbatim from .claude/rules/creative-loop.md)

5. Review. Inspect exact take paths with python bin/studio_decisions.py list <production> <shot> and record approved / needs_work / rejected plus the director's note through the shared session commands (docs/SESSION_DECISIONS.md) — the only writer of a verdict, which is studio.verdict in the take's sidecar plus the event in .studio/events.jsonl. A take list reports as unaddressable is fixed with studio_decisions.py repair-addresses <production> (a dry run unless --apply). The studio app and the review app are parked, not removed: they are optional clients of those operations and neither backend is required. Review auto-start is off by default; STUDIO_REVIEW_AUTOSTART=1 is a local opt-in for the existing SessionStart hook, which starts nothing when no legacy inbox sits at <workspace>/_inbox/content-studio (it is archived). A verdict is a field; nothing moves on it — what is approved shows in the production's derived selects/ (hard links, rebuilt after every verdict or live-pointer write and by python bin/selects.py build <slug>; a view, never evidence), and promotion is the live pointer plus export (studio/CLAUDE.md invariant 4); files moving on a verdict is the retired legacy-inbox convention. Team review runs on FreeFrame: python bin/freeframe.py push <slug> --selects (or --pack <dir>) sends approved media only and pull <slug> writes the team's comments to the production's review/notes/; a comment is a note that informs the director's verdict and never writes one (docs/WORKSPACE.md § Review with FreeFrame). Best-of-N ("judge these"): python bin/judge_takes.py <project> <shot> → you read the takes as the VLM → --apply … --top 3 writes [judge] notes through the same decisions path; the judge never sets a verdict, never moves a take and never generates. No per-shot Generation.md — the sidecar and the log row are the record.

The director's own review round is a local page. One static HTML page in the production's review/<YYYY-MM-DD>_<label>/ folder, opened straight from disk with no server (a page served on 127.0.0.1 once collided with another on the same port and showed him the wrong one): python bin/review_page.py verdicts <slug> --takes <files or dirs> for takes and stills, python bin/review_page.py prompts <slug> <files> for prompts before they fire. Every candidate is on it in cut order with the original file one click away at full size, his marks stay in the browser until he presses "Copy my answers", and he pastes that block back into the session. python bin/review_page.py ingest <file> [--prompts] is a dry run that prints the exact bin/studio_decisions.py commands (or the prompt saves); --apply records them. Publish a page as an Artifact only when he asks to share. A mark on the page is a note until it is recorded through bin/studio_decisions.py.

5b. Act on verdicts. On "I've reviewed" / "what's next", inspect the exact production/shot through studio_decisions.py list; record clear verbal verdicts immediately with the shared verdict command, a stable operation ID, and a note prefixed Verbal director approve/reject in session <date>. Select a live take only on that explicit instruction, then use the shared cut/export path when requested. The legacy review inbox is archived (studio.json legacy_inbox names where); maintaining it is a separate explicit workflow, never a workspace prerequisite. Route selected work through the finishing floor (the finishing doctrine § 3 rule 1: people shots ship on a Topaz 1080p master, graded on the clip and never baked before the upscale, while no-upscale + add-grain stays the default for non-people and stylised shots). A needs_work note is the direction — iterate on the same lock only after a new scoped cue. Untagged → surface. Ambiguous reactions ("interesting", "hmm") are not verdicts, and silence is never approval.

Recovery and limits

The operation captures manifest, registry, and sidecar revisions under the production lock. Before changing state, it records exact replacements, history bytes, and its result in .studio/operations/<id>.json. Recovery prevalidates every destination, read dependency, and required media file. It finishes recognized partial writes or refuses unexplained changes. Media bytes are not hashed by a decision operation; media immutability remains the landing contract. Deleting/moving media is outside this transaction's scope.

Recovery is roll-forward, not a promise of instantaneous multi-file visibility. If the process fails after removing a live pointer but before saving the verdict, the next cooperating call (or transactions.recover(root)) completes the recorded operation. If policy, sidecar state, or required media changed meanwhile, recovery refuses and the prepared journal remains for reconciliation. Do not delete it or fabricate a new ID to bypass the conflict.

Only participating writers share the lock. There are no network-share or universal Windows power-loss guarantees. Unrelated engine writers remain outside these operations. Tests use disposable fixtures, including real concurrent processes, real CLI/HTTP parity, notification failures, duplicate filenames, compound Review recovery, and app-import blocking; no production decision was recorded to validate this API.

Tests: tests/studio/test_decisions.py, tests/studio/test_transactions.py, tests/studio/test_address_repair.py, tests/studio/test_judge_on_decisions.py, bin/tests/test_shots.py, and the existing sidecar/boundary regressions.