Skip to main content

Minimal backlog kernel for ai-hats (epic HATS-1014): backlog.yaml topology, two-phase subscriber dispatcher, single-persist transitions, dispatch journal.

Project description

ai-hats-rack

Minimal backlog kernel (epic HATS-1014, child K1 / HATS-1020), built parallel to ai-hats-tracker — same task.yaml format, new engine. The kernel is a light FSM plus transactional machinery, shipped with the K2 doc store (fs-as-truth view + frozen pins) and project-root resolver; everything else (worktree, ownership, scaffold, plan-gate, epic-automation, bash hooks) is an extension subscribing to kernel events.

Transition pipeline

FileLock(task) → FSM-guard → in-memory mutation → in-lock subscribers → SINGLE persist (last)
                                                                       → lock release
                                                                       → post-lock subscribers
  • Single persist is unbypassable by construction: subscribers get an immutable state copy + return a delta; they hold no store reference. Any raise before the persist means zero bytes changed on disk (heirs of HATS-723 / HATS-481 / HATS-866-AC3).
  • Bare kernel = pure FSM: Kernel(tasks_dir) with no subscribers walks the full lifecycle with no side effects (heir of HATS-866/AC4). Unit tests never need git.
  • force relaxes only the FSM arrow and requires a reason (journaled). It is passed to subscribers as information, never as a safety-off switch (HATS-518/596/697).
  • backlog.yaml is the SSOT of the topology (9 states; reclaim execute→execute, reopen done→execute, blocked hub, cancelled exits). Editing it edits the kernel contract. The tasks discipline requires document (PROP-012), enforced at composition via requires_states (ADR-0017 §3) — an HYP/PROP topology without it loads. Invalid transitions answer with the legal edges.

Subscriber contract

class MyExtension:
    name = "my-extension"
    def subscriptions(self) -> list[Subscription]:  # (event_key, phase, priority)
        return [Subscription("edge:plan--execute", Phase.IN_LOCK, priority=10)]
    def on_event(self, ctx: DispatchContext) -> Delta | None:
        ...  # return a Delta, None, or raise AbortOperation("actionable reason")
  • DispatchContext carries: event, task (deep copy), caller_cwd (mandatory; subscribers never read Path.cwd() — HATS-840), is_epic (recomputed from the current child-set on every dispatch — HATS-794/977/979), actor (who triggered: session:… / agent:… / human:…), force + reason.
  • in-lock phase: blocking; AbortOperation → typed OperationAborted with the actionable reason (the reason channel); any other exception propagates raw. Either way nothing is persisted. There is no catch-and-warn mode for this phase.
  • post-lock phase: reactions after persist + lock release. Failures are journaled as error outcomes — reported, never swallowed, never aborting. Post-lock extensions may drive further kernel calls (one task lock at a time, never nested — HATS-690 rule).

Event registry (name-your-consumer, PROP-030)

Event key Fired by Named consumer
edge:<from>--<to> Kernel.transition K3 core extensions (plan-gate, ownership, worktree in-lock; epic-automation, views post-lock); K4 hook-runner
epicify Kernel.create / Kernel.set_parent (child gained) K3 ownership + worktree reconciliation handlers (idempotent release / discard_if_empty, HATS-977/979)
pre-destroy extensions via Kernel.publish K3 guards on irreversible ops (abort / extract before worktree merge-discard, PROP-047/058)
link:<kind> / unlink:<kind> Kernel.transition_ops --link/--unlink (owning side, in-lock) declared links.kinds[].handlers (e.g. a dep-cycle-check); fires only for a kind that declares handlers — the cross-backlog mirror link-target:<kind> is HATS-1044

A new event lands in this table together with its subscriber, or it does not land.

Stock extensions (K3, HATS-1022)

Pure extensions live in ai_hats_rack.extensions (no integrator/wt/git imports):

  • plan-scaffold / plan-gate — one config-driven section catalog feeds both, so template and enforcement can never drift (HATS-635); the gate names every empty required section, waives epics (HATS-794) and reopen (HATS-328).
  • frozen-integrity (HATS-1031) — in-lock on EVERY edge (the full state product, forced edges included): scans the task's frozen pins and aborts any transition whose pinned document changed or vanished; the reason carries the document name, both digests and the recovery recipe (transition <ID> --freeze <name> --ack-frozen / --rm <name> --ack-frozen). No waivers — force, epics and automation actors do not bypass evidence integrity. Priority 8: after the ownership single-slot guard, before the plan-gate.
  • stamp-lifecycle / clear-lifecycle (HATS-1043) — declaration-bound in-lock field stamps (replacing the old kernel._stamp_lifecycle hardcode): done / cancelled on_enter: [stamp-lifecycle] writes completed_at (config field:) via Delta.fields; the reopen edge's clear-lifecycle clears it and logs "Reopened from done".
  • epic-automation — post-lock; the pure decide() table maps every epic source state × child trigger to reopen/advance/activate/no-op (HATS-690/692/789) and drives the epic through journaled FSM-valid kernel hops under the rack:epic-automation actor (also the anti-cascade guard).
  • derived-views — post-lock STATE.md regeneration, own lock, atomic replace.

standalone_extensions() composes the standalone kit from the packaged backlog.yaml declarations (frozen-integrity + plan-scaffold + plan-gate + stamp/clear-lifecycle); ownership/worktree ship on the integrator side.

Ownership and worktree adapters depend on the integrator's wt engine and live on the integrator side (ai_hats.rack_wiring, with build_rack_kernel() as the assembly mirror of cli/_helpers._task_manager); the boundary stays one-directional — the rack never imports them.

Declaration-bound handlers (HATS-1043, ADR-0017 §3–§4)

backlog.yaml binds handlers where the edge is declared — the file says what fires where. Four slots, expanded to subscriptions by the loader:

  • states[].on_enter / on_exit — the FULL edge:<src>--<state> / edge:<state>--<dst> product (forced non-topology edges included, HATS-518); a declared self-loop (reclaim) is in the product, an undeclared one is not.
  • edges[].handlers — one exact edge; edges[].skip: [name] opts that edge out of an on_enter/on_exit handler (the declarative reopen skip: [plan-gate]).
  • links.kinds[].handlers — in-lock on link/unlink of that kind (link:<kind>/unlink:<kind>); may abort the mutation.

Every referenced name resolves through one open factory registry (stock_factories() + integrator closures); an unknown name is a typed, fail-closed UnknownHandlerError. A reference is a bare name or {name, priority?, ...config}: unpinned refs get a positional band (100, 110, …); an explicit priority: pins the number into the one total in-lock order. The packaged kit pins today's chain — frozen 8, plan-gate 10, stamp/clear 12, scaffold 30 — so migration is zero behaviour change. Optional contract hooks a handler may expose: bind(kernel) (post-lock kernel handle) and requires_states() (its state vocabulary, validated fail-closed at composition).

Lock model (deadlock excluded structurally)

Lock Scope Holder
tasks/<ID>/.lock transaction window: guard → in-lock → persist kernel
tasks/.alloc.lock atomic id alloc+reserve (HATS-936) kernel
resource locks (git/base) inside the owning extension's operation extension

Rules: in-lock subscribers have no API to take locks (max one task lock held at any time); post-lock subscribers are notified after release; acquisition order is always task lock → resource lock; every kernel lock uses the single loud-fail timeout (30s).

Journal

Every dispatch produces a DispatchRecord (event, task, actor, force+reason, one outcome per subscriber: ok / delta / abort / error). Records ride the result of every mutating call, including aborted dispatches. JournalSink is the persistence seam — its consumer is K7 audit log (below); the bare kernel persists nothing.

Audit log (K7)

The CLI attaches JsonlJournalSink: every dispatch appends one JSON line (schema v: 1: ts, event, detail — edge from/to / epicify child / pre-destroy operation —, actor, force+reason, per-subscriber outcomes, result: persisted|aborted, identity block) to tasks/<ID>/audit.jsonl, size-rotated into audit-NNN.jsonl segments. Lossless by contract (PROP-004): nothing is truncated or deleted. The identity block carries the writing process's AI_HATS_SESSION_ID + AI_HATS_ROOT_PID, a verdict against the claimed actor (verified/mismatch/unverified — blind zones are marked, PROP-080), and an ownership-holder cross-check when ownership.json exists (PROP-076). Journal write failures are loud on stderr but never break the already-persisted operation. rack context <ID> --attr audit (--event/--since/--actor) is the query surface; it warns when a task moved states with an empty journal (zero-events, PROP-005/076).

CLI

Four verbs plus a root group, each with --json (JSON-first, HATS-1031 API-D surface):

Verb Role
rack create <title> new card; --id/--parent/--depends/--tag/...; initial state from backlog.yaml
rack ls [<ID>] backlog scan (--grep/--tag/--state/--parent; --backlog <name> / --all-backlogs pick which mounted backlog(s), HATS-1080; --root <path> / --projects all|<ids> sweep across projects, HATS-1081) or graph walk (ls <ID> --deep N [--link <glob>…], repeatable OR)
rack context <ID…> THE read package: full card + top-level links + document paths; repeatable --with <glob> embeds, --attr audit|work_log; --root/<root_id>:<id> route a cross-project read (HATS-1081). Batch (context ID1 ID2 …, ≥2 ids) assembles all in one process → {"contexts": {id: …}}, skip-and-continue; one id stays unwrapped byte-identical (HATS-1074)
rack transition <ID> THE mutating verb: an ordered composite of ops under one lock
rack root add/ls/rm cross-project roots registry (~/.ai-hats/roots.yaml) feeding ls --projects (HATS-1081)

transition ops run in argv order under ONE task lock with a single persist; effects of earlier ops are visible to later ones, any abort rolls the whole sequence back (HATS-1030):

Op Effect
--state <s> FSM edge (guard + two-phase dispatch); transition <ID> <s> is sugar for it
--attach <src>[:n] copy a file into tasks/<ID>/ as document n (default: basename)
--freeze <name> pin {name, digest, frozen}; re-pinning drifted content requires --ack-frozen
--rm <name> trash the document (recoverable, HATS-470); a frozen one requires --ack-frozen
--log <msg> append a work_log entry
--link <kind>:<id> add an edge of any configured, non-derived kind (default kind: related)
--unlink [<kind>:]<id> remove the edge(s) to <id>
--set <field>=<value> write a declared card field (schema-validated; int fields coerce)
--append <field>=<json> append a JSON entry to a declared list field (rides the same lock/persist)

Command-level flags: --force (+ mandatory --reason) relaxes the FSM arrow only; --resolution / --final-state stamp terminal metadata; --ack-frozen is the tiered frozen hatch shared by --rm and --freeze.

The backlog root is resolved by a walk-up from CWD to the nearest ancestor holding .agent/ or ai-hats.yaml (K2, HATS-197 heir); from inside a linked task worktree (neither marker present) a pure-filesystem gitlink hop resolves the main checkout instead (HATS-1038 C2). ai-hats.yaml supplies ai_hats_dir and task_prefix. Resolution never mkdirs, and outside any project it answers with a typed no_project_root error instead of bootstrapping a phantom tracker (HATS-839 heir). --tasks-dir / RACK_TASKS_DIR stay as the explicit override (still anchoring project_dir at the real root, C2 gap #3).

$ rack transition HATS-001 done --tasks-dir tasks
error: Invalid transition for HATS-001: brainstorm → done. Legal edges from 'brainstorm': plan, blocked, cancelled

Backlog-as-filter on the scan (HATS-1080)

The no-id scan defaults to the tasks catalog (byte-for-byte its legacy output). --backlog <name> scans a named mounted backlog instead — repeatable for a subset (--backlog hyp --backlog proposal) — resolved dynamically against the workspace (cli_alias or name, the same token that names the write group below), so any sibling backlog.yaml resolves with no CLI change; an unknown name is a typed unknown_backlog error listing what is mounted. --all-backlogs scans every mounted backlog, each row carrying a backlog marker (a leading column in human output, a backlog key in --json; both absent on the default tasks scan, which stays annotation-free). This is the read-side mirror of the write-side groups — one "backlog = dimension" model. Filters stay read-tolerant: --tag/--parent/ --state on cards that lack the value simply exclude them (never an error), and --state self-narrows to the backlogs whose vocabulary uses the value.

Cross-project sweep (HATS-1081)

The scan spans several projects — "hypotheses of all projects" in one listing. --root <path> (repeatable) adds a project root to the current one; --projects all | <root_ids> sweeps the roots registered with rack root add, unioned with the current project (the CWD is always the agent's working context). Roots dedup by resolved path; each row gains a project marker (leading column / project json key, absent in the single-project default). It composes with the backlog filter — rack ls --backlog hyp --projects all hits the hyp backlog in every swept project. Read-tolerant: a registered root that is gone is skipped with a non-silent footer (the sweep survives), while an explicit --root to a non-project is a hard error. root_id is the folder name (no alias); a name collision is disambiguated by the full path via --root. A cross-project READ routes with the <root_id>:<id> qualifier (rack context projB:HATS-9) or --root; all cross-project WRITES are HATS-1088.

Per-backlog groups (HATS-1036 R2/R5, ADR-0017 §4/§7)

Every NON-tasks backlog the workspace mounts (tracker/**/backlog.yaml, HATS-1044) becomes a group named after the backlog — the packaged hypotheses / proposals read as their short CLI names hyp / proposal. Ids route by prefix, so transition/context/ls already reach them; the group adds their write verbs. The base surface stays exactly the four verbs until a sibling catalog is mounted — nothing appears in a tasks-only repo. A group carries:

  • create — schema-driven from the backlog's fields[] (required/choices/default enforced write-strict), the generic field mapping supplies e.g. --hypothesis.
  • update <ID> --<field> — scalar edits mapped onto the transition --set field ops (one mutating path; int fields coerce, choices are schema-validated).
  • the verbs its extensions contribute through the optional verbs() hook — hyp append-verdict / hyp autoclose [--k --dry-run], proposal vote.

Old ai-hats taskrack mapping (the cutover collapses onto this surface):

Old (ai-hats task …) New (rack …)
hyp create --hypothesis … rack hyp create --hypothesis …
hyp append-verdict <ID> … rack hyp append-verdict <ID> --verdict … --evidence …
hyp autoclose [--k] rack hyp autoclose [--k --dry-run]
hyp update <ID> --<field> … rack hyp update <ID> --<field> …
refute / confirm / stall … rack transition <ID> <edge-name> (named edges)
proposal create … rack proposal create --category … --target … …
proposal vote <ID> … rack proposal vote <ID> --reasoning … --session-id …
accept / reject / defer … rack transition <ID> <edge-name>

append-verdict/vote take the entry fields as named options (the 80% path, schema-validated) with a --entry <json> escape hatch for extra keys; --session-id defaults to the ambient AI_HATS_SESSION_ID so quorum counting works out of the box.

Field edits — coexistence with ai-hats task (cutover, HATS-1038 C3)

The tasks backlog stays flat (no update verb): a declared field is edited in place with rack transition <ID> --set <field>=<value> / --append, and the tracker's ai-hats task update <ID> still writes the same task.yaml during the cutover. What lives where:

Edit Home
state, work_log, documents (attach/freeze/rm), links, resolution rack transition <ID> --…
a declared card field (priority, reviewer, role, tags, …) rack transition <ID> --set
new card + initial parent rack create …
title / re-parent (no schema field / anchor move) ai-hats task update <ID> --…

Both CLIs read and write the same task.yaml under the same task lock, so the split is safe to interleave within one task.

Doc store (K2, rev4 semantics)

fs-as-truth: the only way to write a document is to write a file into tasks/<ID>/doc put/doc cat do not exist. The ledger is a view: rack context live-scans the directory (dotfiles and task.yaml excluded; legacy attachments/ blobs simply appear) and digests on the fly, so a directly-written file is visible immediately — no registration, no write→register race.

  • rack context <ID> prints a Documents block: name + absolute path + mtime + frozen mark (frozen ✓ / frozen ✗ modified|missing); --json adds size, sha256:<12hex> digest and drift. Discovery, not injection — content is never inlined without --with; the agent reads by path (the 210K-character baseline F4 lesson). Verification is internal: every context/ls checks pins; there is no verify verb. The read surface only FLAGS drift — the frozen-integrity extension (above) is what blocks transitions on it.
  • freeze/rm are transition ops (table above): pins persist in task.yaml under the task lock; removal trashes to $TMPDIR (recoverable), never hard-deletes.

Card fields (schema, HATS-1035, ADR-0017 §1–§2)

Everything beyond the engine anchor (id, state, title, work_log, timestamps, links) is declared in backlog.yaml fields[]; create requires only a non-empty title. A field entry:

fields:
  - { name: priority, type: str, default: medium, choices: [low, medium, high, critical] }
  - { name: votes, type: any, validator: prop-vote-entries, default: [] } # any + a validator
  - { name: resolution, type: str, default: "", emit: when-set }          # dropped when empty
extras: forbid # top-level: reject unknown keys on WRITES (default: allow)

type is str | int | list | any; a complex shape is any + a mandatory validator: <name> resolved through the open registry (unknown → fail-closed at composition). Writes are strictcreate, a transition's touched fields, and subscriber Delta.fields ops validate against the schema (required / choices / type / validator); a violation is a typed refusal that persists nothing. Reads stay tolerant — an old card breaking choices still loads (a context warning, never a load failure). emit: when-set drops an empty value at persist (the packaged nine keep to_dict's behaviour, held by a parity pin).

Data format

Reads/writes the tracker's task.yaml unchanged; unknown keys (e.g. the legacy attachments manifest) round-trip verbatim via extras. Old cards load with defaults. The only K2 addition is the documents: list of frozen pins.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

ai_hats_rack-0.1.0.tar.gz (212.2 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

ai_hats_rack-0.1.0-py3-none-any.whl (148.5 kB view details)

Uploaded Python 3

File details

Details for the file ai_hats_rack-0.1.0.tar.gz.

File metadata

  • Download URL: ai_hats_rack-0.1.0.tar.gz
  • Upload date:
  • Size: 212.2 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.14

File hashes

Hashes for ai_hats_rack-0.1.0.tar.gz
Algorithm Hash digest
SHA256 d4eb227d9fe89b6def550ee24da80e1f0387db5a30ab4579a1222b670b9fe483
MD5 ef571f2da7257026b6d815a6ad51ba3c
BLAKE2b-256 812b3dc688022a429f292ffa7eee43161748f037cebe123e9753b37ee4588f33

See more details on using hashes here.

Provenance

The following attestation bundles were made for ai_hats_rack-0.1.0.tar.gz:

Publisher: release-packages.yml on muratovv/ai-hats

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file ai_hats_rack-0.1.0-py3-none-any.whl.

File metadata

  • Download URL: ai_hats_rack-0.1.0-py3-none-any.whl
  • Upload date:
  • Size: 148.5 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.14

File hashes

Hashes for ai_hats_rack-0.1.0-py3-none-any.whl
Algorithm Hash digest
SHA256 040218a4297108a6b74ffd599fcedeb34a4b37df527cb598a859183bf6a902a9
MD5 07ce3145f6c773a0cf775d14fcd163df
BLAKE2b-256 4d92c9fc77006a0ab1bd071f7c870e14ec4cf4d0e45399099d8b23d5b0ad079d

See more details on using hashes here.

Provenance

The following attestation bundles were made for ai_hats_rack-0.1.0-py3-none-any.whl:

Publisher: release-packages.yml on muratovv/ai-hats

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page