scitex_cards — Python API

scitex-cards: a canonical task-card store with pluggable adapters.

The task store (SQLite, one tasks table) is the single source of truth. Adapters render or import it; the mermaid adapter (store -> dependency PNG) ships today. See the project roadmap for org and Web-UI adapters.

Quick Start

>>> import scitex_cards as card
>>> tasks = card.load_tasks()
>>> src = card.build_mermaid(tasks)
>>> card.render(src, "tasks.png")
'mmdc'
class scitex_cards.AgentDirectoryPort(*args, **kwargs)[source]

Bases: Protocol

Read-only feed of agent-runtime rows to enrich board membership.

scitex-cards is SSOT for board membership; scitex-agent-container is SSOT for agent runtime. This port is how the runtime SSOT feeds the board so a member row can show “running / stopped” without scitex-cards importing sac (ADR-0009). The join key is host@name.

Default impl: EmptyAgentDirectorylist_agents() returns [] and get_agent() returns None. Installed by default so the board works STANDALONE when no provider is present (mirrors how scitex_cards._adapters.OpenACL is the default IdentityACLPort).

Provider impl (lives OUTSIDE this package, e.g. in scitex-agent-container): registers a zero-arg factory under AGENT_DIRECTORY_GROUP that returns an object satisfying this Protocol — typically wrapping sac agents list --json. Discovered by resolve_agent_directory().

The port is a LIBRARY SEAM, not a board verb: there is intentionally no MCP tool for it. Membership stays authoritative on the card side; the directory only annotates.

list_agents()[source]

Return every agent the provider knows about (may be empty).

Return type:

list[AgentInfo]

get_agent(host_at_name)[source]

Return the agent whose canonical id is host_at_name.

None when the provider has no such agent.

Return type:

AgentInfo | None

exception scitex_cards.AgentIdentityError[source]

Bases: ValueError

A caller passed a malformed agent identity string.

Raised by canonical_agent_id() / parse_agent_id() on an empty / whitespace-only name or a structurally invalid host@name. The message always echoes the offending value (fail-loud per the SciTeX constitution).

class scitex_cards.AgentInfo(host_at_name, name, host, status=None, extra=<factory>)[source]

Bases: object

One agent row surfaced by an AgentDirectoryPort.

The shared shape scitex-cards uses to enrich board membership with runtime facts from a provider. The host_at_name field is the canonical join key (see canonical_agent_id()) and the dedup key (see dedup_agents()).

Variables:
  • host_at_name (str) – Canonical host@name join key. REQUIRED and the only field the core relies on for identity; everything else is descriptive.

  • name (str) – The agent’s short name (the part after @).

  • host (str) – The host the agent runs on (the part before @); "" when the host is unknown (a bare id).

  • status (str | None) – Runtime status as the provider reports it — conventionally one of "running" / "idle" / "stopped" / "unknown" — or None when the provider declines to say.

  • extra (dict) – Open bag for provider-specific fields (heartbeat, current task, quota %, …). The core never interprets these; downstream consumers may. Defaults to an empty dict.

host_at_name: str
name: str
host: str
status: str | None = None
extra: dict[str, Any]
class scitex_cards.EmptyAgentDirectory[source]

Bases: object

Standalone-safe default AgentDirectoryPort.

Knows about zero agents — the board runs with no runtime enrichment and never depends on a provider being installed. This is what resolve_agent_directory() returns when no entry-point provider is registered.

Examples

>>> d = EmptyAgentDirectory()
>>> d.list_agents()
[]
>>> d.get_agent("anyhost@anyname") is None
True
list_agents()[source]
Return type:

list[AgentInfo]

get_agent(host_at_name)[source]
Return type:

AgentInfo | None

exception scitex_cards.TaskNotFoundError[source]

Bases: KeyError

Raised when an update/complete target id is not in the store.

exception scitex_cards.TaskValidationError[source]

Bases: ValueError

Raised when a task store fails structural validation.

scitex_cards.add_task(store=None, *, id, title, status='deferred', scope=None, assignee=None, priority=None, parent=None, note=None, depends_on=None, blocks=None, repo=None, created_by=None, entry_points=None, **extras)[source]

Append a new task to store and persist via save_tasks().

Returns the inserted task mapping (a fresh dict, not the underlying YAML node) for convenient round-trip use by callers — the CLI prints it, the MCP tools serialize it as the JSON result.

The **extras keyword catches operator-co-designed Task dataclass fields (task / project / host / agent / goal / last_activity / blocker / pr_url / issue_url / kind + compute metadata job_id / command / started_at / finished_at) without an explosion of named parameters. None values are dropped; non-None values flow into the new task dict and the writer’s validator gates closed enums (status / kind / blocker) — typos raise TaskValidationError with the bad value and the valid set. Unknown keys are accepted at this layer (forward-compat); the validator decides whether they’re shape-valid.

Raises:

TaskValidationError – On duplicate id or any other structural fault — save_tasks re-runs the full validation gate before touching disk.

Return type:

dict

scitex_cards.canonical_agent_id(name, host=None)[source]

Return the canonical agent id in `host@name` form.

The canonical join key between scitex-cards board membership and the sac agent runtime (ADR-0009). One agent may run on exactly one host, so the pair (host, name) uniquely identifies it.

Resolution rules (in order):

  1. If name already contains @, it is treated as an already-qualified host@name and returned as-is after validation — passing an already-joined id through this function is idempotent (canonical_agent_id("h@a") == "h@a"). An explicit host argument is ignored in this case (the embedded host wins); this keeps the function a pure normaliser rather than a re-joiner.

  2. Else, if host is truthy (non-empty after strip), return f"{host}@{name}".

  3. Else (no host known), fall back to the bare name. A bare id is valid and round-trips through parse_agent_id() with an empty host — it represents an agent whose host is not yet known (e.g. a board-only human member, or a row before the runtime provider has reported in).

Fail-loud: an empty / whitespace-only name raises AgentIdentityError. A name containing @ is validated as a well-formed host@name (non-empty host AND non-empty name, a single @) before being returned.

Parameters:
  • name (str) – The agent’s short name, OR an already-qualified host@name.

  • host (str | None) – The host the agent runs on. Ignored when name already contains @.

Return type:

str

Examples

>>> canonical_agent_id("worker-1", "ywata-note-win")
'ywata-note-win@worker-1'
>>> canonical_agent_id("ywata-note-win@worker-1")  # already-qualified
'ywata-note-win@worker-1'
>>> canonical_agent_id("worker-1")  # no host → bare fallback
'worker-1'
scitex_cards.comment_task(store=None, task_id=None, text=None, by=None, kind=None, entry_points=None)[source]

Append an entry to task.comments[] (the established Issue- activity-log shape from skill 30, Gitea-compatible field).

by overrides the $SCITEX_CARDS_AGENT_ID → $USER precedence used by add_task / complete_task.

kind is an optional feedback-ring / event tag (e.g. push / done / card-message) stamped onto the entry so the board can render “how the card was routed” (operator 2026-06-17). Lenient: the model only requires text, so the extra key round-trips cleanly.

entry_points is forwarded to scitex_cards._hooks.dispatch_event() for the card-message bus emit below: an explicit iterable of entry-point-shaped objects to receive the event instead of the ones discovered from packaging metadata. None (the default) uses the real installed plugins. This is the in-process injection seam used by in-process consumers and by no-mock tests (PA-306-compliant) that observe the emitted event via a real fake handler.

Return type:

dict

scitex_cards.complete_task(store=None, task_id=None, *, by=None, entry_points=None)[source]

Mark task_id as done and stamp _log_meta.completed_{at,by}.

Idempotent per GITIGNORED/QUESTIONS.md #3: re-completing a done task is a no-op (timestamps stay frozen from the first completion). Pass by= to override the $SCITEX_CARDS_AGENT_ID$USER"unknown" precedence chain.

Returns the (post-mutation) task mapping.

Raises:

TaskNotFoundError – If no task matches task_id.

Return type:

dict

scitex_cards.dedup_agents(agents)[source]

De-duplicate agents by their host_at_name join key.

First wins: when two rows share a host_at_name, the first one encountered is kept and later duplicates are dropped. Order is otherwise preserved (stable). Used to merge rows from one or more providers onto the board without double-listing an agent.

Examples

>>> a = AgentInfo("h@x", "x", "h", "running")
>>> b = AgentInfo("h@x", "x", "h", "stopped")  # same key, later
>>> [r.status for r in dedup_agents([a, b])]
['running']
Return type:

list[AgentInfo]

scitex_cards.delete_task(store=None, task_id=None)[source]

TOMBSTONE a task + scrub references to it. Returns the lossless payload the client can pass to restore_task for Undo.

2026-07-21 P0 (third board wipe) — operator ruling 一度書いたものは 消えない, “a written card never disappears”: this NO LONGER physically removes the row. It marks it in place — status flips to cancelled, _log_meta.deleted_at (+ deleted_by) records when and who — and the row is retained forever (see _task._is_tombstoned()). Physical removal is IMPOSSIBLE through this, the normal API; a genuine purge is a deliberate admin verb, not this one.

The board v3 Delete-with-Undo flow uses this via handlers/crud.py; exposing the same operation here lets MCP agents do the same delete + later undo without round-tripping HTTP. Reads (list_tasks / get_task / set_edge / every other lookup) treat a tombstoned row as ABSENT by default, so board behaviour is unchanged.

Returns {"removed": <full pre-tombstone task dict>, "refs": [<refs scrubbed>]} where each ref is the id of another task whose depends_on / blocks / parent pointed at the deleted task (the client passes removed back to restore_task to lossless-revert).

Return type:

dict

scitex_cards.get_task(store=None, task_id=None)[source]

Return a single task by id, or raise TaskNotFoundError.

Companion to add_task / update_task / list_tasks — the natural “read one” verb every CRUD surface expects but the Python API was missing (PR #56 audit gap). The MCP wrapper exposes this as get_task per Convention A.

A TOMBSTONED row (see scitex_cards._task._is_tombstoned()) is treated as NOT FOUND — the 2026-07-21 tombstone change keeps a deleted card’s row on disk forever, but this read must behave exactly as it did when delete_task physically removed it.

Return type:

dict

scitex_cards.list_tasks(store=None, *, scope=None, assignee=None, status=None, statuses=None, agent=None, project=None, host=None, repo=None, blocker=None, kind=None, id_prefix=None, blocking_me=False, overdue=False)[source]

Snapshot the store, then filter by any combination of fields.

Filter semantics:

  • scope=None (default): use $SCITEX_CARDS_SCOPE if set, else no filter. scope="" opts out of the env default explicitly. scope="agent:<id>" names an OWNER, not a lens: it returns every card assigned to <id> as well as those filed under that scope, so work a peer filed under fleet or under no scope still reaches the agent responsible for it (_in_scope()).

  • assignee / agent / project / host / repo / status: None = no filter; any string = exact match. (Generic Req 8 — no fuzzy / glob; callers compose.) repo matches the card’s repo field (owner/repo) — the reusable seam a producer uses to resolve repo->card at emit time (find-card verb). (hook-bypass: line-limit)

  • statuses (list) AND status (single) are OR-combined.

  • blocker="__none" matches rows with no blocker field; any other value is an exact match (closed-enum gating at the CLI layer).

  • kind="task" matches both explicit "task" AND absent rows (since absent ≡ "task" per ADR-0002).

  • id_prefix matches the front of id (cheap project-rollup lookup without exact id).

  • blocking_me=True is the board’s BLOCKING-YOU predicate (status == "blocked" AND blocker == "operator-decision"); composes with the other filters via AND.

The returned list contains fresh dicts, safe to mutate without affecting the on-disk store (no save here).

Reads always go through scitex_cards._model.load_tasks(), which reads the ONE canonical SQLite database and raises rather than returning an empty or stale document when the store cannot be resolved (see the module docstring — the S2 SQLite-indexed accelerator that used to dispatch here is deleted).

Return type:

list[dict]

scitex_cards.parse_agent_id(host_at_name)[source]

Split a canonical agent id into its (host, name) pair.

Inverse of canonical_agent_id(). A bare id (no @) yields an empty host string — parse_agent_id("worker-1") == ("", "worker-1") — so callers can branch on host == "" to mean “host unknown”.

Fail-loud: an empty / whitespace-only input, or a malformed host@name (empty host, empty name, or more than one @), raises AgentIdentityError echoing the bad value.

Examples

>>> parse_agent_id("ywata-note-win@worker-1")
('ywata-note-win', 'worker-1')
>>> parse_agent_id("worker-1")
('', 'worker-1')
Return type:

tuple[str, str]

scitex_cards.reassign_task(store=None, task_id=None, new_owner=None, *, by=None, entry_points=None)[source]

Atomically change a card’s owner — the primitive the board lacked.

C5 (cards-reassign-verb-with-owner-notify). In ONE locked write:

  • set agent = assignee = new_owner (keep the legacy assignee in lock-step with the operator-co-designed agent so every reader — old dict-style and new — agrees on the owner), AND

  • set scope = "agent:<new_owner>" (the convention the fleet slices on), AND

  • append an audit comment "reassigned <old> -> <new> by <actor>".

THEN (post-persist, outside the lock, fail-soft) emit a canonical reassigned card-event with extra={"from_owner", "to_owner"}. The EVENT is the notification path — there is intentionally NO bespoke notify/delivery here (delivery is C4, a separate card; this primitive EMITS, it does not deliver).

Idempotent: reassigning to the SAME current owner is a no-op — no write, no audit comment, no spurious event — so a replayed/duplicate reassign is harmless.

Parameters:
  • task_id (str | None) – The card to reassign (required).

  • new_owner (str | None) – The new owning agent (required, non-empty).

  • by (str | None) – The actor performing the reassignment; resolved through the usual $SCITEX_CARDS_AGENT_ID$USER"unknown" chain.

  • entry_points (iterable, optional) – In-process injection seam forwarded to the event emit (real fake handler in tests); None uses real plugin discovery.

Returns:

{"task_id", "from_owner", "to_owner", "actor", "changed", "task"} where changed is False on the same-owner no-op path.

Return type:

dict

Raises:
scitex_cards.reopen_task(store=None, task_id=None, by=None)[source]

Un-resolve a task — flip status=done back to blocked with blocker=operator-decision (the original LOUD halo state). Used by the board v3 Resolve→Undo loop.

ALSO CLEARS _log_meta.completed_{at,by}. Un-completing a card that keeps its completion stamp is not a reopen — it is a card that is open and completed at the same time, and the stamp is the half that gets believed: _django/handlers/fleet/timing.py and timeline.py aggregate throughput solely on completed_at, never on status. So a stamped-but-open card is counted as delivered work forever, while simultaneously nagging its owner as backlog.

(2026-07-14: found 5 such cards on the live board — one of them sac-keystone, whose status had just been corrected from a mistaken done to cancelled. The STATUS was fixed; the STAMP was not, so the false completion survived the correction. A lie outlives its retraction if it is written in two places and you only fix one.)

Return type:

dict

scitex_cards.resolve_agent_directory(entry_points=None)[source]

Return an installed agent-directory provider, or the empty default.

Discovers a provider registered under AGENT_DIRECTORY_GROUP and calls its zero-arg factory to obtain the port object. Returns EmptyAgentDirectory when no provider is installed — so the board is always usable STANDALONE.

Multi-provider resolution: if more than one provider is registered, the one whose entry-point name sorts FIRST lexicographically wins (deterministic + stable across packaging-metadata implementations). A provider whose factory fails to load or raises is logged and skipped — one broken provider must not break the board (mirrors scitex_cards._hooks._run_plugins()).

Parameters:

entry_points (Optional[Iterable]) – Explicit set of entry-point-shaped objects (each with a .name attribute and a .load() method returning the zero-arg factory) to use instead of packaging-metadata discovery. None (the default) reads the real AGENT_DIRECTORY_GROUP group via _iter_agent_directory_entry_points(). This is the in-process injection seam (mirrors scitex_cards._hooks._run_plugins()’s entry_points=): tests pass a concrete list of real fake entry points — no monkeypatch of importlib.metadata required (PA-306-compliant).

Return type:

AgentDirectoryPort

scitex_cards.resolve_store(store=None)[source]

Return the resolved task store path and the precedence chain.

Mirrors the data the scitex-cards resolve-store CLI verb and the resolve_store MCP tool emit. Keeping a Python API by the same name as the MCP tool satisfies audit §6 (Convention A: tool_name == api_name).

Output shape:

{
  "resolved":         "/abs/path/to/cards.db",
  "explicit":         <the `store` arg you passed, or None>,
  "db_env":           <value of $SCITEX_CARDS_DB, or None>,
  "user_store":       "/abs/path/to/~/.scitex/cards/cards.db",
  "pkg_short":        "cards",
  "exists":           bool,
  "store_uuid":       <the database's own identity, or None>,
  "expected_uuid":    <$SCITEX_CARDS_STORE_UUID, or None>,
  "instance_id":      <the SERVER's own identity, or None>,
  "expected_instance": <$SCITEX_CARDS_STORE_INSTANCE, or None>,
  "identity_verdict": "matches" | "differs" | "cannot-tell",
  "identity_reason":  <why, when the verdict is not "matches">,
  "may_proceed":      bool,
}

store_uuid is contract point 8, machine-readable half (design §11). The identity is what a host registry must record next to this board’s endpoint, and “open the database and run a SQL query” is archaeology. This function already answers “WHICH store did I actually resolve”; it answers “and WHAT IS IT” in the same breath. None means the database is absent or carries no identity yet — bind it with scitex-cards store adopt-uuid.

expected_uuid is reported beside it deliberately: the two most useful facts about an identity mismatch are the value the database carries and the value this process was told to expect, and reading them from two different surfaces is how a mismatch stays undiagnosed.

instance_id AND WHY store_uuid WAS NOT ENOUGH. On 2026-08-12 three live PostgreSQL databases all answered store_uuid = 1d55dd6e-3d2a-4c24-a429-a78835ab988f while holding 3843, 3743 and 3422 cards. store_uuid is a schema_meta ROW and a dump/restore carries rows, so every field reported here was byte-identical across stores that were hundreds of cards apart — a report that cannot distinguish them is a report that confirms whichever one you happened to reach. instance_id is the SERVER’s own system_identifier, minted by initdb and present in no dump, so it is the one value a copy cannot carry. See _store_instance and _store_pin.

may_proceed IS THE FIELD TO BRANCH ON, never identity_verdict. A caller testing verdict != "differs" reads “I cannot tell which store this is” as a pass, which is exactly how three databases shared one identity for five days without anything complaining.

THIS FUNCTION IS PURE REPORTING — AND THAT INCLUDES THE VERDICT. It reports a refusal; it does not perform one. Reading the identity here never mints one, never stamps one, and never changes what resolves, and a differs verdict raises nothing: this is the verb an operator runs WHEN THINGS ARE ALREADY BROKEN, and on 2026-07-31 it was the one verb that CRASHED on the case being diagnosed. The enforcing twin is _store_pin.require_pinned_store(), which raises.

Return type:

dict

scitex_cards.resolve_task(store=None, task_id=None, actor=None, *, entry_points=None)[source]

Flip a task from status=blocked (typically blocker=operator- decision) to done and clear the blocker. Appends an audit comment naming the actor.

Idempotent on already-resolved tasks (re-resolves are no-ops, just log a “noop” comment).

Return type:

dict

scitex_cards.restore_task(store=None, task=None, refs=None)[source]

Undo a delete_task: UN-TOMBSTONE the row back to its pre-delete state (or, for a row with no tombstone at all — legacy/never-deleted — re-insert it, the original pre-tombstone-era behaviour).

Idempotent on a duplicate id that is NOT a tombstone — raises ValueError (use update_task to mutate; this verb is the Delete-Undo partner only). A tombstoned row is exactly what this verb expects to find and reverses in place.

Return type:

dict

scitex_cards.set_collaborator(store=None, *, task_id=None, who=None, action='add')[source]

Add or remove who on a card’s collaborators (ADR-0009).

action in {“add”, “remove”}. Adding a collaborator ALSO subscribes them (the ADR default — subscribers ⊇ collaborators), so they get feedback by default. Removing a collaborator leaves their subscription intact; call set_subscriber() with action="remove" to also stop their notices. Returns the (post-mutation) task mapping.

Return type:

dict

scitex_cards.set_edge(store=None, action=None, kind=None, source=None, target=None)[source]

Add or remove a depends_on / blocks edge — and SUBSCRIBE THE WAITER.

action in {“add”, “remove”}. kind in {“depends_on”, “blocks”}. Mutates tasks[source][kind] (adding/removing target).

* ADDING AN EDGE SUBSCRIBES THE WAITING CARD’S OWNER TO THE CARD THEY ARE WAITING ON. Until 2026-07-13 it did not, and that was a SILENT NO-OP. *

Measured by scitex-writer, with a controlled experiment:

depends_on edge + set_subscriber -> notification FIRES depends_on edge ALONE -> NOTHING. Total silence.

The entire reason to record “A depends_on B” is so that FINISHING B TELLS A. An agent who wants to hear when their blocker clears reaches for depends_on — it is the semantically obvious call and it is literally named for the relationship — and got silence. And SILENCE IS INDISTINGUISHABLE FROM “the gate has not cleared yet”, so nobody ever finds out. A silent no-op wearing the costume of a working mechanism is strictly WORSE than no mechanism at all: with no mechanism, you go and check.

Not hypothetical. FOUR cards on the live board sat blocked on gates that had ALREADY CLEARED — including a mutual deadlock between two agents, each recorded as waiting on the other, built out of two stale sentences, neither ever told.

THE RULE, stated once and applied to both kinds — THE OWNER OF THE WAITING CARD IS SUBSCRIBED TO THE CARD THEY WAIT ON:

A depends_on B — A waits on B => subscribe A’s owner to B A blocks B — B waits on A => subscribe B’s owner to A

blocks is the same relationship pointing the other way; leaving it silent would just move the landmine one call to the left.

REMOVING an edge does NOT unsubscribe. The owner may have subscribed for their own reasons, and silently dropping that subscription would re-create this very bug from the other side. An extra notification is a nuisance; a missing one strands a card for weeks. Unsubscribe explicitly with set_subscriber() when you mean it.

Returns subscribed: WHO will now be told when the awaited card completes, or None when the edge was removed or the waiting card has no owner. The caller can SEE that delivery is wired instead of assuming it — which is the whole complaint this fixes.

CAVEAT WORTH KNOWING WHEN YOU TEST THIS: a self-completion does not notify, because actor == subscriber is suppressed. Anyone who exercises the mechanism on their OWN card sees nothing and concludes it is broken. That suppression is correct — it just needs saying.

Return type:

dict

scitex_cards.set_subscriber(store=None, *, task_id=None, who=None, action='add')[source]

Add or remove who on a card’s subscribers — the notify list (ADR-0009).

action in {“add”, “remove”}. Anyone may unsubscribe — even a collaborator (the ADR’s “always unsubscribable” rule): a remove here drops them from the notify list without touching collaborators. Returns the (post-mutation) task mapping.

Return type:

dict

scitex_cards.summarize_tasks(store=None, *, scope=None, assignee=None)[source]

Return numeric progress counts grouped by status, scope, assignee.

Output shape (always present keys):

{
  "store": "/abs/path/to/cards.db",
  "total": int,
  "by_status": {<status>: int, ...},  # one key per VALID_STATUSES
  "by_scope": {<scope|"">: int, ...},
  "by_assignee": {<assignee|"">: int, ...},
}

Tasks with no scope / assignee bucket under the empty string "". The by_status map is densified to all VALID_STATUSES so consumers (web UI, progress widgets) don’t have to special-case zero-count keys.

AGGREGATED IN PYTHON, ON PURPOSE. It could be a handful of GROUP BY queries, and that is precisely the temptation to resist: a second aggregation written in SQL is a second implementation to keep in step with this one, and it is not covered by the equality proof that makes list_tasks() safe to switch. One path at a time, each proven identical before the next.

(This paragraph said “Still YAML-only, ON PURPOSE” until 2026-08-09. That was false and had become misleading: the counts come from load_tasks, which reads whatever the canonical resolver resolves – PostgreSQL on this fleet. The stale word is what made the mislabelled store key below look intentional rather than wrong.)

Return type:

dict

scitex_cards.update_task(store=None, task_id=None, *, entry_points=None, expected_revision=None, **fields)[source]

Update fields of the task with id task_id; return the merged dict.

Any keyword argument becomes a field on the task. Passing None for a field DELETES it (matches the operator’s mental model: “clear the scope” = update_task(…, scope=None)). To leave a field untouched, just omit it.

expected_revision makes the write a COMPARE-AND-SET: pass the revision you read and it lands only if nobody has written since. On a mismatch NOTHING is written and RevisionConflictError is raised, so a caller re-reads and re-applies rather than clobbers.

IT IS OPT-IN, and that is load-bearing. _migrate_v6_to_v7 records that REJECT-by-default was RULED UNUSABLE – “an UPDATE from a writer that knows nothing about revision would ABORT, so fleet writes would fail until every container is current”, which this fleet cannot establish. With None no guard is emitted and the write is byte-identical to before.

It RAISES here while the bulk path REPORTS, and the predicate is the opt-in rather than the layer: passing a revision IS an assertion, and a violated explicit assertion that returns quietly is an invisible lost update.

The ONE exception is _CONTROL_KWARGS – names that are control parameters elsewhere in this stack. Those are REFUSED with a message naming the real path, because silently storing a requested guard as a data field is worse than not offering it: the caller is then wrong about whether they are protected.

ONE clear rule, closed enums included: an empty string "" on a CLOSED-ENUM field (blocker / kind) also DELETES the key — it is a delete instruction, consumed here, never written as a value. This is what the MCP/CLI surfaces have always promised (“pass ‘’ to CLEAR”); previously "" was written literally and the validator rejected the save, so the documented way to clear a blocker was the one way that could not work. The validator is NOT weakened: a genuinely invalid value (blocker="banana") still raises.

status is the exception and CANNOT be cleared — every card must carry a decision. status="" raises with the reason and the valid set rather than silently dropping the request. See _store_enums.

Raises:
  • TaskNotFoundError – If no task matches task_id.

  • TaskValidationError – If the resulting mutation is structurally invalid, or if status was passed the "" clear-sentinel (status cannot be cleared).

Return type:

dict