Source code for scitex_cards._store_add

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""``add_task`` -- the INSERT half of the store's write surface.

Extracted from :mod:`scitex_cards._store_mutate` (PURE MOVE -- no behaviour
change), which re-exports ``add_task`` so ``from ._store_mutate import add_task``
and ``from ._store import add_task`` both keep working.

Split because `_store_mutate` had reached the repo's 512-line ceiling and
``update_task`` could not gain the ``expected_revision`` parameter scitex-dev's
reconciler needs -- five lines of headroom does not fit a signature, a docstring
and a call site. The two verbs were the obvious seam: they share only helpers
that already live in their own modules.

The owner/creator FAIL-LOUD rules and the ambient-store guard travel with the
function they protect, including the measured 2026-07-20 chain that produced
them.
"""

from __future__ import annotations

from pathlib import Path

from ._model import (
    TaskValidationError,
    _save_doc_unlocked,
    _store_lock,
)
from ._paths import refuse_ambient_store_creation as _refuse_ambient_store_creation
from ._store_arg import refuse_ineffective_store
from ._store_enums import resolve_enum_clears as _resolve_enum_clears
from ._store_events import _emit_card_event
from ._store_list import _resolved_store
from ._store_target import resolve_store_target


[docs] def add_task( store: str | Path | None = None, *, id: str, title: str, status: str = "deferred", scope: str | None = None, assignee: str | None = None, priority: int | None = None, parent: str | None = None, note: str | None = None, depends_on: list[str] | None = None, blocks: list[str] | None = None, repo: str | None = None, created_by: str | None = None, # hook-bypass: line-limit entry_points=None, expected_revision: int | None = None, **extras, ) -> dict: """Append a new task to ``store`` and persist via :func:`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 checks closed enums (``status`` / ``kind`` / ``blocker``) — but a bad VALUE only warns, never refuses, per the 2026-07-10 ruling (a status value must never cost someone their card): the write lands and the warning is returned in ``result["warnings"]`` (see :mod:`scitex_cards._validate` and ``test__tolerated_warnings_reach_the_writer.py``). That warn-only contract belongs to the STORE layer (shared-store writes, where the bad row may be another, newer agent's). This verb is a SOURCE, so it stays strict on ABOLISHED values like ``update_task`` / ``upsert_task`` and the board create handler: a caller-supplied ``status`` in ``ABOLISHED_STATUSES`` (``"pending"``, abolished 2026-07-10) raises ``TaskValidationError`` BEFORE the store guard, carrying the abolition hint. A merely-UNKNOWN value is not refused — it may be a newer agent's status, so it flows to the warn-only save path. Only STRUCTURAL faults (duplicate id, missing id/title, ...) raise ``TaskValidationError`` at save time. Unknown keys are accepted at this layer (forward-compat); the validator decides whether they're shape-valid. An explicit ``status=None`` is refused: ``None`` is the store's delete signal, and a status-less card has no lane on the board, so it would HIDE the card rather than create it. Omit ``status`` for the ``'deferred'`` default, or set one of ``VALID_STATUSES``. Raises ------ TaskValidationError On duplicate id or any other structural fault — `save_tasks` re-runs the full validation gate before touching disk — on an explicit ``status=None`` (a card cannot be born status-less), or on an ABOLISHED ``status`` (``"pending"``, which carries the abolition hint naming what to pick instead). RevisionConflictError When ``expected_revision`` is armed and the id is already held — by a live row OR a tombstone (resurrection rides ``restore_task``, never create). NOTHING is written: the winner's row, comments, edges and roles are untouched. ``expected_revision`` makes the create a COMPARE-AND-SET on absence, and it is OPT-IN exactly like ``update_task``'s: ``None`` (the default) is byte-identical to before. The fence closes the cross-reader race the in-doc duplicate check cannot see — two writers on different containers share PostgreSQL but not the file lock, so both can pass validation and the second's upsert silently overwrites the first's card. Armed, the loser is refused loudly instead. On a create there is no prior revision to compare — compare against WHAT does not exist — so the predicate is ABSENCE, not equality (the same reason scitex-dev's store uses the ``NEW_RECORD`` sentinel rather than a number). Pass any int to arm it; the value is the caller's fencing declaration, echoed verbatim in the conflict report, and a ``bool`` or non-int is refused up front. A tombstoned id counts as HELD: re-creating a deleted card must go through ``restore_task`` (guarded update-only), never through create. """ from . import _task from ._store import _read_write_doc, _resolve_creator_or_raise, _utc_now_iso # An EXPLICIT ``status=None`` must not reach the insert: None is the # store's delete signal, and writing it would mint a status-less row # with no lane on the board — the exact state this verb's contract # forbids ("a card cannot be born status-less"). The board handler # defaults the same payload to 'deferred'; the verb refuses it here so # direct Python callers get a loud answer. BEFORE the store guard so a # doomed insert never touches the store. if status is None: raise TaskValidationError( "add_task: status cannot be None — every card is born carrying " "a decision. Omit 'status' for the 'deferred' default, or set " f"one of {_task.VALID_STATUSES}." ) # Same ONE rule as `update_task` (the sibling write path): a `""` on a # closed-enum field is a clear, so the key is simply NOT written on # insert — rather than written as `""` for the validator to reject. A # `status=""` is refused loudly (a card cannot be born status-less). _enum_in = _resolve_enum_clears({"status": status, **extras}, source="add_task") status = _enum_in.pop("status") extras = _enum_in # An ABOLISHED ``status`` must not reach the warn-only save path: the # saver only WARNS on bad values (operator ruling 2026-07-10 — a status # value must never cost someone their card ON THE SHARED STORE, where # the bad row may be another, newer agent's), so without this a direct # Python/MCP caller passing ``status="pending"`` would mint a row in a # status abolished 2026-07-10 that no live view serves (measured # 2026-10-14: the probe row reached the live board, warning only). # Same source-strict class as the update/upsert/reopen/restore gates. # Refusing the CREATE costs nobody their card — no card exists yet, and # the caller retries with a legal status — so the ruling still holds # where it applies (reads tolerate legacy rows; save_tasks never # destroys another writer's row). A merely-UNKNOWN value is NOT refused # here: it may be a newer agent's status (forward-compat), so it flows # to the warn-only save path — the ``not-a-status`` tolerance pair in # ``test__store.py`` pins that contract. BEFORE the store guard so a # doomed insert never touches the store; ``""``/whitespace sentinels # never reach here (refused above by ``_resolve_enum_clears``) and # ``None`` neither (refused above as status-less). if status in _task.ABOLISHED_STATUSES: raise _task.TaskValidationError( f"add_task: {_task.ABOLISHED_STATUSES[status]}" ) refuse_ineffective_store(store, verb="add_task") resolved = _resolved_store(store) if expected_revision is not None and ( isinstance(expected_revision, bool) or not isinstance(expected_revision, int) ): raise TypeError( "add_task: expected_revision must be an int (the caller's fencing " f"declaration) or None (fence off); got {expected_revision!r}." ) # A write against a store that does not exist must not INVENT one when # nothing named the path — that is how a decoy board accumulates and then # gets imported over the real one. See the guard's docstring for the # measured 2026-07-20 chain. An explicit `store` is the opt-in. # # The guard asks ONE question — "would this write MANUFACTURE a board?" — # and answers it with `path.exists()`. So it must be handed the store's real # LOCATION: the canonical database, which is what `save_tasks` writes and # what `init-store` provisions. `_resolved_store` returns a DISPLAY LABEL # (`<db_dir>/tasks.yaml`) that the store backend maps to that database — # good enough to name a store in a message, never a thing on disk. The YAML # tier was deleted (#512), so that label can NEVER exist, and passing it here # made the guard refuse unconditionally: every `add` failed for any agent # without $SCITEX_STORE_DSN while its own reads and updates succeeded, and the # error told you to run `init-store` — which did not help, because the file # it created was not the file being tested. Reported and reproduced by # scitex-ui on 0.17.7. Guard the database, not the label. # resolve_store_target, NOT resolve_db_path: the latter RAISES on a server # target, and it raises while evaluating this ARGUMENT — so every write # against PostgreSQL died here, before the guard it feeds ever ran. The # guard itself is fine with a DSN (it returns early; a server store cannot # be manufactured by a write). Handing it the target as written keeps the # existing behaviour byte-identical and stops the coercion happening on # the way in. _refuse_ambient_store_creation(resolve_store_target(store), store) resolved.parent.mkdir(parents=True, exist_ok=True) # FAIL-LOUD on a missing/blank OWNER (operator mandate 2026-06-26, # constitution rule 2 "no silent fallbacks"). The OWNER is `assignee` # OR `agent` (lock-step below). A card with neither reached a blank # creator/assignee on the board + a fallback lane + an owner-less # comment relay that silently no-op'd — so an owner is REQUIRED. # `agent` arrives via **extras (operator-co-designed field). # noqa: E501 # hook-bypass: line-limit _agent_in = extras.get("agent") _owner_in = assignee or _agent_in or "" _owner_in = _owner_in.strip() if isinstance(_owner_in, str) else _owner_in if not _owner_in: raise TaskValidationError( "assignee is required — pass assignee=<user> (or agent=<user>); " "creator+assignee are mandatory and an owner-less card is " "rejected (no silent fallback; see constitution)." ) # RESOLVE the creator STRICTLY — raises a clear, actionable error when # it can't be resolved (blank / "unknown"). Done BEFORE any write so a # creatorless card never touches disk. (hook-bypass: line-limit) _creator = _resolve_creator_or_raise(created_by) new: dict = {"id": id, "title": title, "status": status} # D11 partial-fix (ADR-0008): auto-stamp ``created_at`` + # ``last_activity`` at insert time. ``created_at`` is the immutable # insert stamp; ``last_activity`` starts equal and ticks on every # subsequent successful update_task. Callers can override by passing # the field explicitly (e.g. importers replaying historical state). _stamp = _utc_now_iso() new["created_at"] = _stamp new["last_activity"] = _stamp # A card BORN blocked starts its blocked-check clock now, stated rather than # inferred. Without this the row carries no `blocked_at` and # `_blocked_age_hours` falls back to `created_at` — which returns the RIGHT # answer here, since for a card born blocked those two instants are the same. # That is correct-by-luck, and the luck is spent the moment the fallback # changes. Surfaced by grant 2026-07-30 while measuring why their blocker # change produced no stamp; the fallback is load-bearing enough that a path # relying on it silently should not exist. if status == "blocked": from ._stale.active_clocks import FIELD_BLOCKED_AT new[FIELD_BLOCKED_AT] = _stamp # `created_by` — the creating USER, STRICTLY resolved above (never a # blank/"unknown" placeholder). Drives the board detail ROLES section + # ADR-0009's creator auto-subscribe. (hook-bypass: line-limit) new["created_by"] = _creator if scope is not None: new["scope"] = scope # Keep `agent` + `assignee` in LOCK-STEP: whichever the caller supplied, # BOTH are stamped to the resolved owner so the board/relay/notify never # see an owner-less or half-owned card (mirrors `reassign_task`). The # `agent` half is set from **extras after this block; force it here so # an assignee-only OR agent-only call yields a fully-owned card. The # explicit `agent` extra (if any) is overwritten with the same owner. new["assignee"] = _owner_in extras["agent"] = _owner_in if priority is not None: new["priority"] = priority if parent is not None: new["parent"] = parent if note is not None: new["note"] = note if depends_on is not None: new["depends_on"] = list(depends_on) if blocks is not None: new["blocks"] = list(blocks) if repo is not None: new["repo"] = repo # Operator-co-designed surface (TG 9667) + compute metadata # (ADR-0002). Forwarded through **extras so callers don't have to # match a long explicit parameter list and the writer's validator # gates the closed enums. for key, value in extras.items(): if value is None: continue new[key] = value # Lock for the FULL read-modify-write — without this, two concurrent # writers each load a stale snapshot and the second `save_tasks` call # silently clobbers the first writer's insert. See # tests/scitex_cards/test__store.py::test_two_concurrent_writers... # COLLECT the tolerated-value warnings this insert raises -- see the note at # the result assembly below, and `_tolerated` for what went unseen. from ._tolerated import collect as _collect_tolerated with _collect_tolerated(id) as _tolerated, _store_lock(resolved): # `missing_ok=True` is gone deliberately. It meant "an absent store # yields an empty doc", which against a database feeds an empty doc # into this read-modify-write and lets the subsequent save delete every # card absent from it. A missing database is a configuration error, not # an empty board — see `_read_write_doc`. doc, tasks = _read_write_doc(resolved) if expected_revision is not None: # Authoritative held-check, FRESHER than the snapshot above: it # reads the live row (live OR tombstone — both block a create) # rather than trusting a doc that a concurrent committer may have # already invalidated. The mirror's absence guard below stays the # arbiter for the residual race; this turns every already-held id # into the SAME RevisionConflictError instead of splitting fresh # readers (TaskValidationError duplicate) from stale ones. from ._store_errors import RevisionConflictError from ._store_single_card import read_card_or_raise _held, _held_rev = read_card_or_raise( resolve_store_target(store), id ) if _held is not None: raise RevisionConflictError(id, expected_revision, _held_rev) # WIP-validation gate (operator standing direction via lead a2a # `d99b8de6839d46e586e4ee692f43c1d9` + ``5acfbb5d0db44db8a7fa4f70c399d539``, # 2026-06-12). WARN to stderr at the limit, HARD REFUSE at 2x — EXCEPT # for the emergency band (``priority <= 1``), which is never gated and # is stamped with an audit comment when it lands over the cap. The whole # policy — thresholds, exemption, refusal text, audit stamp — lives in # ``_store_wip`` so it is readable in one screen; this is the same # focused-sibling pattern as ``_store_enums`` / ``_store_verify``. # See that module's header for the 2026-07-12 P0 the exemption closes. # (hook-bypass: line-limit) from ._store_wip import enforce_wip_gate enforce_wip_gate(new, tasks, now_iso=_stamp) tasks.append(new) # DECLARE THE ROW — same reasoning as `update_task` below, and the # enumeration is simpler: the only dict mutated here is `new`. # `enforce_wip_gate` READS `tasks` to count the agent's open cards but # its docstring is explicit that it "Mutates `new` in place (appends # the audit comment)", so no other card is written. Without this, a # single `add_task` re-asserts the caller's entire snapshot and reverts # anything committed between its read and its write. _fenced = expected_revision is not None _mirror = _save_doc_unlocked( doc, resolved, tasks=tasks, touched_ids=[new["id"]], absent_ids=[new["id"]] if _fenced else None, ) if _fenced and _mirror and _mirror.get("revision_skipped"): # The commit race: someone took the id between the held-check # above and the write. The row (and its comments/edges/roles — # the guard refuses BEFORE the drop) is untouched; report the # winner's revision so the caller can re-read instead of retrying # blind. from ._store_errors import RevisionConflictError raise RevisionConflictError( new["id"], expected_revision, (_mirror.get("revision_found") or {}).get(new["id"]), ) # C5: emit a canonical `created` card-event AFTER the card is durably # persisted + the lock released. Fail-soft (the mutation already # succeeded). Actor = the resolved creating user (same chain that # `created_by` resolves through). (hook-bypass: line-limit) _emit_card_event( "card_created", id, actor=new.get("created_by"), store=resolved, entry_points=entry_points, ) # Liveness (assignee-liveness feature): the creator just touched the # store → stamp its heartbeat; and surface the ASSIGNEE's liveness in # the result so the caller learns immediately if it just assigned to a # non-running agent. Both fail-soft (never break the durable write). from ._liveness import _assignee_liveness, _heartbeat _heartbeat(new.get("created_by"), resolved) result = dict(new) _liveness = _assignee_liveness(new.get("assignee"), resolved) if _liveness is not None: result["assignee_liveness"] = _liveness # Same shape as `assignee_liveness` above, same reason: a fact the caller # needs, attached rather than logged past them. THIS verb is where it # matters most -- all three `pending` cards created after that status was # abolished came through `add_task`, each firing a warning into the server's # stderr. Only when non-empty, so an ordinary insert is unchanged. if _tolerated: result["warnings"] = list(_tolerated) return result
# The three lifecycle clocks now live in `_store_clocks`, imported at the top of # this module and re-exported below. They moved out when a THIRD one was added # (`_clear_completion_stamp_on_leaving_done`) and this file passed the 512-line # limit: they are pure, they share one shape, and they are the only pieces of # this module another module reaches for by name. Existing imports from here # still resolve — see `__all__`. __all__ = ["add_task"] # EOF