NAME
App::karr::CrossBoard - Cross-board dependencies -- a link from a card here to a card on another board
VERSION
version 0.600
SYNOPSIS
# the syntax half -- class methods, no board needed
my $refs = App::karr::CrossBoard->parse_refs( '--needs', 'other-repo#7' );
my @open = App::karr::CrossBoard->needs_of($task);
# the resolution half -- needs to know where the other board is
my $fleet = App::karr::CrossBoard->new( overrides => { 'other-repo' => $path } );
my $state = $fleet->link_state( $refs->[0], origin => { board => 'home', id => 5 } );
DESCRIPTION
--depends-on is board-local. In a fleet the most common real interruption is "this cannot be done until X is fixed somewhere else", and that X lives in another repository. The escalation protocol karr-foundation's spec writes down (karr get-refs refs/karr-foundation/spec/fleet-execution.md) is a convention and nothing more: raise a card in the other repository tagged escalated-from:<repo>#<id>, block your own, release the claim and leave. Nothing checks that the two cards name each other, and nothing lifts the block when the other card is closed.
This module is the explicit link that makes both ends resolvable (ticket #192). It has two halves that deliberately do not know about each other:
the syntax -- what a cross-board reference is, how it is written on a card and how it is read back. Class methods, usable anywhere, no repository involved.
the resolution -- which directory on this machine a board name means, and what state the card at the far end is in. An object, because that answer is per-machine configuration and has to be built before it can be asked.
What the card carries, and what it does not
A reference is <board>#<id>: a board name and a task id. Never a path. That split is the epic's dividing line -- coordination is shared and travels in refs, execution is local -- and a path fails it on the first test: two clones of the same fleet have the same cards and different directories, so a path written onto a card is wrong on every machine but the one that wrote it. "parse_ref" therefore refuses anything with a / in the board name, rather than accepting it and letting the mistake travel.
The name is the fleet's own name for the repository, which is its directory basename -- the name karr-foundation --status already prints and the name the spec's escalated-from:<repo>#<id> convention already uses. Turning that name into a directory is "resolve"'s job, from local configuration.
Why a tag and not a frontmatter field
The link is stored as a tag: needs:<board>#<id> on the waiting card, escalated-from:<board>#<id> on the card raised in the other repository.
A new frontmatter field was the obvious alternative and is the worse one. The task document is interop-compatible with kanban-md, which unmarshals into a Go struct and marshals back out of it: a key it does not model survives being read and is dropped the first time it writes the card. karr keeps unknown keys ("extra" in App::karr::Task), kanban-md does not reciprocate, so a cross-board link stored that way would disappear through exactly the bridge the shared format exists for. tags is modelled on both sides and survives. The field that would otherwise be the natural home, depends_on, cannot take it at all: it is an IntSlice over there, and a string in it makes the card unreadable rather than merely lossy.
The cost is stated rather than hidden: karr edit --add-tag needs:whatever bypasses the validation this module does, and --remove-tag can break a link. That is true of every convention layered on a free-text field; the typed doors (karr create --needs, karr edit --add-needs, karr needs) are the documented ones, and they are what makes the link explicit -- not the slot it is stored in.
What a link does not do
Nothing. A cross-board link blocks no command, exactly as depends_on blocks none (ticket #123): karr pick hands the card over and says what it is waiting on, and App::karr::Foundation::Picker does not filter on it either, because foundation must not become stricter than the board it coordinates (ticket #185).
What keeps the waiting card out of the assignable set is the blocked flag the escalating agent sets on purpose -- the link is the fact, blocked is the decision. karr needs --resolve is the missing half: when the last link on a card settles, it drops the link and lifts that block.
SEE ALSO
karr, App::karr, App::karr::Cmd::Needs, App::karr::Task, App::karr::Role::DependencyCheck, App::karr::Foundation
parse_ref
my $ref = App::karr::CrossBoard->parse_ref( '--needs', 'other-repo#7' );
Parses one <board>#<id> reference into { board => 'other-repo', id => 7 }, with the id numified so it round-trips as a number through YAML and --json. Anything else is a usage error (exit 2) naming the flag and the value -- including, deliberately, anything path-shaped, because a path on a card is wrong on every machine but the one that wrote it.
parse_refs
my $refs = App::karr::CrossBoard->parse_refs( '--needs', 'a#1,b#2' );
"parse_ref" over a comma-separated list, in order, duplicates collapsed -- the shape "parse_dependency_ids" in App::karr::Role::DependencyArgs gives the local dependency flags, and refused on the same terms: one bad reference condemns the whole invocation, because it is wrong for every id in a batch at once. An empty value is a usage error rather than an empty list.
format_ref
my $text = App::karr::CrossBoard->format_ref($ref); # "other-repo#7"
The inverse of "parse_ref": the canonical text form of a reference, which is also what goes on the card behind the tag prefix.
needs_tag
escalated_from_tag
my $tag = App::karr::CrossBoard->needs_tag($ref); # "needs:other-repo#7"
The tag a reference is stored as: needs: on the waiting card, escalated-from: on the card raised in the other repository. See "Why a tag and not a frontmatter field".
needs_of
escalated_from_of
my @refs = App::karr::CrossBoard->needs_of($task);
The cross-board references a card carries, in tag order, as the hashrefs "parse_ref" returns. A tag that carries the prefix but not a well-formed reference is skipped rather than reported: tags is a free-text field, karr owns the doors that write these, and a third state for a hand-typed tag would be surface bought with nothing measured.
add_needs
remove_needs
App::karr::CrossBoard->add_needs( $task, $refs );
Append-unique and remove, the shape --add-tag/--remove-tag and --add-depends-on/--remove-depends-on already have, returning how many tags actually changed. Removing a link the card does not carry is a no-op and stays legal -- it is how a link to a card that was deleted on the other board is cleaned up.
overrides
Board name to directory, supplied by the invocation (karr needs --board NAME=PATH) and consulted before the fleet config. What it is for: a fleet member the local config does not list, a one-off check, and tests, which must never depend on the developer's own ~/.config.
config_file
The fleet config to read board locations from, defaulting to ~/.config/karr-foundation/config.yml when not given. That file is where this machine's view of the fleet already lives -- dirs:, scan: and hub: -- and a second local file describing the same fleet would be a second thing to keep in step with it, which is the argument App::karr::Foundation used for resolving the hub exactly once.
A config file that was named explicitly and is not there is an error; the default location simply being absent is not, because most repositories are not part of a fleet at all.
config_data
The same fleet config, already parsed, for a caller that has read it itself -- App::karr::Foundation holds it as it runs, and its chain executor resolves cross-board links out of that (App::karr::Foundation::Executor). Supplied, it wins over "config_file" and nothing on disk is read.
It is not a second source: it is the one source, read once. Handing the directories over instead would be, because dirs:, scan: and the basename match that turns them into a board name are one rule and belong in one place. Reading the file again here would be a second answer where the caller's own copy came from somewhere else -- --config, or a foundation constructed with its configuration in hand -- and the two would then disagree about which fleet this machine is part of.
resolve
my ( $dir, $why ) = $fleet->resolve('other-repo');
Turns a board name into a directory on this machine, or into a sentence saying why it could not: "overrides" first, then the fleet config's dirs: and the direct children of its scan: parents, matched on the directory basename -- the name karr-foundation --status prints and the name the escalation convention already uses.
An unknown name and an ambiguous one are both answers, not failures. A machine that holds four repositories of a six-repository fleet still has an honest report to give about the four, and a command that died on the first name it could not place would give none.
link_state
my $state = $fleet->link_state( $ref, origin => { board => 'home', id => 5 } );
What is known about one link right now, as a plain hashref carrying ref, board, task, state, verified and a human detail sentence, plus status/title/back_refs where the far card could be read. state is one of:
settled-- the far card is in one of the far board's own terminal statuses. This is the only statekarr needs --resolveacts on.open-- the far card exists and is not finished.missing-- the board is here, the card is not.unknown-board-- the name could not be placed on this machine.detailsays how to place it.no-board-- the directory is there and holds no karr board.
origin is the near card as a reference, and is what turns the far card's escalated-from:<board>#<id> tag into a verdict: verified is true only when the far card names this card back. That is the half of the escalation protocol nothing checked before -- a convention both ends merely believed in.
Nothing is fetched. The far board is read exactly as it stands in that working copy, so what this reports is as fresh as that repository's last karr sync. Pulling somebody else's repository from inside a command run here would be a transport decision taken behind the operator's back, and a fleet run syncs each repository as it reaches it anyway.
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, licensed under:
The Artistic License 2.0 (GPL Compatible)