NAME

App::karr::BoardStore - Ref-backed board storage for karr

VERSION

version 0.500

SYNOPSIS

my $store = App::karr::BoardStore->new( git => $git );
my $config = $store->load_config;
my $id = $store->allocate_next_id;
my @tasks = $store->load_tasks;

DESCRIPTION

App::karr::BoardStore treats refs/karr/* as the canonical board state. It can merge sparse config overrides with code defaults, allocate numeric task ids through a dedicated metadata ref, and materialize or serialize temporary board views for command handlers that still work with files internally.

SEE ALSO

karr, App::karr, App::karr::Git, App::karr::Task, App::karr::Config

board_exists

True when this repository holds an initialized board, which means exactly one thing: refs/karr/config is there. It used to accept refs/karr/meta/next-id on its own as well, and that is how a stray karr create in the wrong directory produced a board that karr init then refused to touch for good -- the half-board counted as existing, so the name, the statuses and the .gitignore entries could never be written (#62).

my $whole = $store->board_exists;

Callers state the refusal through "require_board" in App::karr::Role::BoardDiscovery rather than testing this themselves: a repository that fails this check may still hold a half-board's tasks, and the two cases need different words (#133).

has_board_refs

True when anything at all lives under refs/karr/, initialized board or not. This is the question the commands that clean up or read raw refs (backup, destroy, materialize, repair) actually have: refusing them on a half-board would strand the refs a pre-fix karr already left behind, with no way to remove them from inside karr.

my $anything_here = $store->has_board_refs;

load_config_overrides

Returns the board's raw config overrides -- whatever refs/karr/config currently holds, decoded but not merged with the code defaults. A board with no config ref yet, or one whose ref does not decode to a mapping, answers {} rather than undef or dying.

my $overrides = $store->load_config_overrides;   # sparse, not effective

This is the input "load_config" merges over "default_config" in App::karr::Config; see that method for the merged result, and "effective_config" for its cached form.

load_config

Reads "load_config_overrides" and merges them over the code defaults via "effective_config" in App::karr::Config, returning a plain hash reference -- not a blessed App::karr::Config object. Every call re-reads the config ref; "effective_config" is the cached wrapper most callers want instead.

my $ec = $store->load_config;

effective_config

The board's merged config, cached for the lifetime of this $store instance. The first call runs "load_config"; every call after returns the same hash reference until "save_config" invalidates the cache. This is the entry point almost every command and role uses -- App::karr::Config->from_merged( $store->effective_config ) is the standard way to get a queryable App::karr::Config object for the current board (see "from_merged" in App::karr::Config).

Not to be confused with the class method "effective_config" in App::karr::Config, which does the actual default/override merge and takes no board at all; this method is the per-store cache built on top of it.

my $ec = $store->effective_config;
my $config = App::karr::Config->from_merged($ec);

all_status_names

Returns a list of all status names from the effective config.

my @statuses = $store->all_status_names;

status_requires_claim

Returns true if the given status requires a claim.

if ($store->status_requires_claim('in-progress')) {
    # must use --claim to move here
}

is_terminal_status

Returns true if the status is terminal for this board -- its final configured status, or archived.

unless ($store->is_terminal_status($task->status)) {
    # task is still active
}

foundation_enabled

Returns true when automated agent runs are allowed on this board (foundation.enabled in refs/karr/config; boards default to enabled).

unless ($store->foundation_enabled) {
    # karr-foundation skips this board entirely
}

foundation_reason

Returns the reason recorded with the disable flag, or undef when none was given.

my $why = $store->foundation_reason;

set_foundation_enabled

Writes the board-level agent switch and its optional reason back into refs/karr/config. Re-enabling drops the reason, and because enabled then matches the code default the whole foundation key disappears from the sparse overrides again.

$store->set_foundation_enabled( 0, 'abandoned driver' );
$store->set_foundation_enabled( 1 );

save_config

Validates a config and writes it back to refs/karr/config as sparse overrides. $effective may be a full effective config or the sparse contents of a config.yml -- both are merged over the defaults first (a no-op for an already-effective config), then validated with "validate" in App::karr::Config (dies on a broken schema), then diffed against "default_config" in App::karr::Config so only the keys that differ from the code defaults are actually stored. This is the single write choke point for the config ref, so every writer (karr config set, karr init, karr disable/karr enable, karr import) shares one validation gate (ticket #78).

Invalidates this store's "effective_config" cache before writing, so the next read reflects what was just saved.

$store->save_config($effective);

peek_next_id

Returns the board's next-id counter as it currently stands, without allocating or advancing it. Contrast with "allocate_next_id", which hands out the value and moves the counter past it atomically.

my $next = $store->peek_next_id;

allocate_next_id

Returns the next free task id and moves the counter past it, atomically. Two agents running karr create at the same time are guaranteed different ids; before this was a compare-and-swap they could both be handed the same one and the second task overwrote the first (#44).

my $id = $store->allocate_next_id;

set_next_id

Writes the next-id counter directly to $next_id, with no compare-and-swap and no check that the value only moves forward -- callers that need either of those guarantees provide them themselves (see "ensure_next_id" and "serialize_from", the only two callers). Prefer "allocate_next_id" for ordinary id allocation.

$store->set_next_id(42);

ensure_next_id

Seeds the id counter without ever handing out an id that is already taken. On a fresh board that is 1; on a board init is completing rather than creating it is one past the highest task ref, and an existing counter that is already further ahead is left alone. init used to write 1 unconditionally, which on a half-board (see "board_exists") meant the next karr create reused id 1 and overwrote the task that was already there.

$store->ensure_next_id;

stamp_encoding_version

Records in refs/karr/meta/encoding that this board's payloads follow the current character-encoding contract, so nothing reading it applies the legacy-mojibake repair (see App::karr::Encoding).

$store->stamp_encoding_version;

The claim covers every ref under refs/karr/, so only a caller that can vouch for all of them may make it: karr repair --yes, which rewrites them, and the two board-birth paths -- karr init and "serialize_from" -- but in their case only when the board really was born there, i.e. when nothing lived under refs/karr/ beforehand. Neither may stamp a board it is merely adding to: the refs it did not write may be a 0.402 board's double-encoded ones, and the marker would silently turn off the repair that still reads them correctly, while making karr repair report the board as up to date (#132).

board_id

The board's identity from refs/karr/meta/board-id, or undef when the board predates identities (or does not exist). The id is what a pull compares against the remote's to tell "this board, changed" apart from "a different board entirely" (#95).

my $id = $store->board_id;

ensure_board_id

Stamps the board's identity ref when it is missing and returns the id, existing or new. It never re-keys a board: changing the id under a live board would make every other clone read it as a foreign one (#95). Called by karr init and the import path below, the two board-birth paths; boards from before identities existed are stamped by the first pull that finds no id on either side.

$store->ensure_board_id;

load_tasks

Returns every task on the board as a list of App::karr::Task objects, in ascending id order ("list_task_refs" in App::karr::Git sorts numerically). Any ref that resolves to no data blob -- see "find_task" -- is silently skipped rather than returned as undef; a single undef in this list once took out list, board, materialize and pick at once, since every consumer calls methods on each element (ticket #45). A ref whose data blob is present but fails to parse still dies, propagating out of this call.

my @tasks = $store->load_tasks;

find_task

Returns the App::karr::Task for $id, or undef when its ref does not exist or resolves to no data blob at all -- the same tolerant case "load_tasks" filters out of a whole-board read. A ref that does exist and does carry a data blob but fails to parse as a task still dies, the same as "from_string" in App::karr::Task would.

my $task = $store->find_task(7) or die "No such task\n";

find_task_with_oid

Returns ($oid, $task) for one task: the card, plus the OID of the commit it was read from. Pair it with "save_task_cas" to write the card back only if nobody else has touched it in between.

my ( $oid, $task ) = $store->find_task_with_oid(7);

save_task

Writes $task to its ref (refs/karr/tasks/ID/data), unconditionally -- no compare-and-swap, so a concurrent writer's change can be lost under it; see "save_task_cas" when that matters. Bumps updated to now first, but only when the ref already exists: a brand new task keeps the updated value it was constructed with (which for a fresh App::karr::Task equals created). The restore/import path bypasses this bump entirely by calling "save_task_ref" in App::karr::Git directly, to preserve original timestamps -- see "serialize_from".

$store->save_task($task);

save_task_cas

Writes a card back only if its ref still points at $expected_oid, the OID "find_task_with_oid" read it from. Returns true when the write landed and false when another agent changed the card first -- at which point the caller must re-read and decide again, never retry with the value that already lost.

This is what makes karr pick exclusive. The lock ref serialises agents but cannot bind them: its holder identity is the clone's user.email, which every agent on one machine shares, so twelve parallel picks were all told they owned the lock and all wrote their claim over each other's (#86). The compare-and-swap is on the card itself and does not care who thinks it holds what.

my ( $oid, $task ) = $store->find_task_with_oid(7);
$task->claimed_by('agent-fox');
$store->save_task_cas( $task, $oid ) or ...;  # someone else got there first

delete_task

Deletes the ref for task $id (refs/karr/tasks/ID/data). Returns 1 when this call removed it and 0 when there was no such task -- deleting a card that is not there is not an error. A removal that is attempted and refused dies rather than returning 0, so a false answer here always means "no such card", never "it may still be on the board" (see "delete_ref" in App::karr::Git).

$store->delete_task(7);

list_karr_refs

Returns every ref name under refs/karr/, board and metadata refs alike -- the same broad question "has_board_refs" asks with just a boolean answer.

my @refs = $store->list_karr_refs;

delete_all_karr_refs

Deletes every ref under refs/karr/ -- the whole board, metadata included. Used only by karr destroy; there is no per-piece variant, because a board is what is being removed, not a task.

$store->delete_all_karr_refs;

materialize_to

Writes the board out to $board_dir as a kanban-md file view: a config.yml plus a tasks/ directory of cards. Stale cards from an earlier run are swept first, but only files named the way karr and kanban-md name them (NNN-slug.md) -- anything else in tasks/ belongs to the project.

Dies without writing anything when the view would overwrite or delete a file Git tracks, naming each one; force => 1 proceeds anyway (ticket #48).

$store->materialize_to( $git_root );
$store->materialize_to( $git_root, force => 1 );

file_view_gitignore_entries

Returns the exact two path entries the materialized file view claims -- tasks/ and config.yml, named exactly as "materialize_to" writes them. The single source of truth for those two strings, shared by "ensure_gitignore" (what to append) and "project_owned_view_paths" (what to check for project-tracked content) so the two can never drift apart -- see that method for why the check matters (tickets #48, #89, #100, #104).

my @entries = $store->file_view_gitignore_entries;   # ('tasks/', 'config.yml')

project_owned_view_paths

Which of the file_view_gitignore_entries the project already has content of its own at, named exactly as .gitignore would have named them. Empty is the ordinary case, and means the file view owns those paths.

my @owned = $store->project_owned_view_paths($git_root);
$store->ensure_gitignore($git_root) unless @owned;

tasks/ and config.yml at a repository root are perfectly ordinary names for a project to already use, and git applies no ignore rule to a file it already tracks. An entry for such a path would therefore change nothing at all while telling every later reader that karr owns a path the project owns -- and it would say so right where "materialize_to" refuses to write, for that very reason (tickets #48, #89). init asks before writing the entries, and materialize asks before topping them up (#100).

"is_tracked_under" in App::karr::Git answers the whole question in one index read, for a file or a directory alike -- tasks/notes/old.md makes tasks/ just as owned as tasks/README.md would. It used to be answered by walking the working tree and asking "is_tracked" in App::karr::Git per file found, which cost one status call per card and, more importantly, could not find a path git tracks but that is currently missing from the working tree -- there is no file there to walk onto. Both are fixed by asking the index directly instead (#104).

ensure_gitignore

Idempotently appends any of "file_view_gitignore_entries" missing from $board_dir/.gitignore (creating the file, and a header comment, on first use). Returns the list of entries actually added -- empty when the file already covers everything.

This method does not itself check whether the project already tracks content at those paths; it only ever appends. The check is "project_owned_view_paths", a separate call so a caller can ask before writing anything: karr init and karr materialize both call it first and skip ensure_gitignore entirely when it returns anything, because appending an entry for a path git already tracks would be inert at best and misleading at worst (tickets #48, #89, #100, #104, #107).

my @owned = $store->project_owned_view_paths($board_dir);
my @added = @owned ? () : $store->ensure_gitignore($board_dir);

serialize_from

Reads a file view at $board_dir back into refs/karr/*: task refs are replaced by the cards, refs the view does not mention are pruned, and the id counter is moved up to whichever is higher, the highest imported id plus one or the view's own next_id. It is never moved down -- an id another tool has already handed out must not be handed out again (ticket #90).

The config is reconciled rather than replaced. Tasks are the whole truth of the file view; its config.yml is not, because anything that loads the view may rewrite it into a schema of its own. So the view speaks only for the keys it carries and karr models, and refs/karr/config keeps the rest -- see "reconcile_view_config" in App::karr::Config.

All or nothing. Every card is parsed before the first ref is written, so a malformed file aborts the whole import -- listing each rejected file and its reason -- with the board left exactly as it was (ticket #70). Refusing an empty view is the caller's job; see App::karr::Cmd::Import.

Importing into a repository that held nothing under refs/karr/ also creates the board: it writes a config when the view has none, seeds the counter, stamps the board identity, and stamps the encoding marker. Importing into a board that was already there does not stamp the marker -- see "stamp_encoding_version".

$store->serialize_from( $git_root );

snapshot

Reads every ref under refs/karr/ into a plain hash reference: { version => 1, refs => { $ref_name => $content, ... } }, where $content is that ref's raw stored text (config YAML, a task's Markdown document, or a bare id/hex string for the meta refs) via "read_ref" in App::karr::Git. karr backup writes this straight to YAML; pair with "restore_snapshot" to write one back onto refs/karr/*.

my $snapshot = $store->snapshot;

restore_snapshot

Makes the board consist of exactly the refs in the snapshot. Every ref name is checked and every commit object built before the first ref moves, so a snapshot karr cannot write is refused with the board untouched instead of destroying it on the way through (#47). See "replace_board_refs" in App::karr::Git.

$store->restore_snapshot( $snapshot );

SUPPORT

Issues

Please report bugs and feature requests on GitHub at https://github.com/Getty/karr/issues.

IRC

Join #langertha on irc.perl.org or message Getty directly.

CONTRIBUTING

Contributions are welcome! Please fork the repository and submit a pull request.

AUTHOR

Torsten Raudssus <getty@cpan.org>

COPYRIGHT AND LICENSE

This software is copyright (c) 2026 by Torsten Raudssus <torsten@raudssus.de> https://raudssus.de/.

This is free software; you can redistribute it and/or modify it under the same terms as the Perl 5 programming language system itself.