NAME
App::karr::Foundation::Questions - karr-foundation question mailbox under refs/karr-foundation/questions/*
VERSION
version 0.600
SYNOPSIS
use App::karr::Foundation::Questions;
my $mailbox = App::karr::Foundation::Questions->new(
git => App::karr::Git->new( dir => $hub_repo ) );
my $id = $mailbox->ask(
question => 'Which registry do we publish to?',
context => 'The release gate is waiting on this.',
options => [ 'cpan', 'darkpan' ],
default => 'cpan',
policy => 'use_default',
wait => 3600,
);
$mailbox->settle( $id, 'darkpan' ); # from anywhere, by anyone
my $r = $mailbox->resolve($id); # { state => 'answered', ... }
DESCRIPTION
A question is a file with an answer field, not a dialogue. That one decision is what removes the special case for "a human happens to be present": the chain writes the question down and carries on with everything that does not depend on it, and whoever answers -- a person at a terminal, a chat bridge, the coordination agent -- writes into the same mailbox without knowing that a chain exists at all. One mailbox, many writers.
refs/karr-foundation/questions/<id>/ask the question (YAML)
refs/karr-foundation/questions/<id>/answer the answer (YAML)
Both live in the fleet namespace App::karr::Foundation::ChainStore uses, so karr sync carries them to every machine (#190) and karr get-refs reads them.
The answer is its own ref
The question could have carried an answer: field -- that is how the design document draws it -- and it deliberately does not. refs/karr-foundation/* resolves a ref that both sides changed by taking the remote's version and not keeping the local one ("pull_foundation" in App::karr::Git): a plan that lost a race is re-planned, not read back, so nothing is parked. An answer is not a plan. It is somebody's decision, typed once, and re-planning does not reconstruct it. With the answer in its own ref the asker and the answerer never write the same ref, so the case cannot arise from the ordinary use of this mailbox at all; what is left is two people answering the same question at once, where one answer winning is the right outcome and is decided here rather than by whoever pushed last (see "settle").
The other half of that split is /ask being written once. A question is never rewritten -- "ask" creates it or takes the next id -- which is what makes "the two writers never collide" true rather than merely usual.
An answer names the question it answers
Ids are small integers, minted per clone from the refs that clone can see, so two clones that both ask something between two syncs mint the same id. The question that loses is lost (that is the same window the board's task ids have, and "ask" narrows it the same way: karr-foundation ask pulls the namespace first). What must not follow is an answer left standing beside somebody else's question, so the answer records the question text it was given, and "resolve" refuses to pair them when it does not match. Loud, and one string comparison.
The same guard is what lets an id come round again. "prune_questions" really removes refs, so the highest id in an emptied mailbox drops, and the next question takes an id some long-settled one had. The alternative is a counter ref in a namespace whose conflict rule is "the remote wins", which can move a counter backwards and would therefore need its own monotonic floor to do what one string comparison already does.
Nobody answers
policy says what happens when nobody does: block (wait, the default and the only one that never invents an answer), use_default (the default becomes the answer), or escalate_to_ai (the coordination agent decides). The last two need a deadline, because a policy with no deadline fires the moment the question is asked and nobody would ever get to answer; use_default needs a default for the same reason a step needs a repo. Both are refused where they are written rather than discovered where they are read.
Acting on any of it is the runner's. This class answers what a question currently resolves to ("resolve") and nothing else -- a mailbox does not execute chains.
Retention
Deleting a settled question publishes a deletion the same way a pruned run log does (#190), so a mailbox that grows without bound is a retention decision, not a sync problem. The decision: answered questions age out, open ones never do. An open question is work nobody has done yet, whatever its age, and a mailbox that quietly forgot one would be worse than a large one. "ask" prunes before it writes, for the same reason App::karr::Foundation::ChainStore prunes when a run log is opened -- a retention policy that only runs when somebody types a command bounds nothing.
SEE ALSO
App::karr::Foundation, App::karr::Foundation::ChainStore, App::karr::Git
git
The App::karr::Git for the hub repository that carries the fleet namespace. Required.
keep_answered_days
How many days an answered question is kept before "prune_questions" drops it; 30 by default. 0 means no age limit, the same spelling max_runtime and keep_days use for "no limit". Open questions are never dropped, whatever this says.
auto_prune
Whether "ask" prunes before it writes; true by default. Once per question is cheap and it is the only moment at which the mailbox grows.
ask
my $id = $mailbox->ask(
question => 'Which registry do we publish to?',
context => 'prose', # optional
options => [ 'cpan', 'darkpan' ], # optional
default => 'cpan', # optional
policy => 'use_default', # block (default) | use_default | escalate_to_ai
wait => 3600, # seconds; or deadline => '<UTC stamp>'
step => 12, # the chain step waiting on it, optional
);
Raises a question and returns its id. wait is the relative spelling of deadline and is what the CLI passes; the stored field is always the absolute UTC stamp, because the two machines reading it are not the one that wrote it. Passing both is refused.
The id is minted from the questions this clone can see, and the write is create-only: a mint that loses the race to another tick takes the next id instead of overwriting the winner. The runner is concurrent (#186), so that is an ordinary case rather than a paranoid one.
Everything is validated first, and a refusal writes nothing at all.
ids
my @ids = $mailbox->ids;
Every question id in the mailbox, lowest first, whether or not it has been answered.
question
my $q = $mailbox->question($id);
One question as it was asked, or undef when there is no such ref (or it does not parse -- an unreadable question is skipped with a warning rather than taking a foundation tick down).
questions
my @questions = $mailbox->questions;
Every question, lowest id first.
open_questions
my @open = $mailbox->open_questions;
The questions nobody has answered -- "resolve" state open or overdue, lowest id first. An overdue question is in here even where its policy already says what to fall back on: until a step consumes it, a person can still answer it, and that is the point of the fallback being a policy rather than a deletion.
answer
my $a = $mailbox->answer($id);
The answer ref of a question as it stands, or undef when nobody has written one. Raw: whether it answers this question is "resolve"'s business.
settle
my $a = $mailbox->settle( $id, 'darkpan' );
my $a = $mailbox->settle( $id, 'darkpan', note => 'why', force => 1 );
Answers a question. Returns the answer that was written.
The answer is checked against the question's options where it has any, and an empty answer is refused: a mailbox whose answers nobody vetted is a mailbox that unblocks a step with a typo. force is the way past both, and past the one refusal that matters more -- a question that already has an answer. Answering is create-only, so two people answering at once is one answer and one refusal rather than whichever push happened to land second.
An answer that names a different question is not treated as an answer at all (see "resolve") and is overwritten without force: there is nothing there to protect.
resolve
my $r = $mailbox->resolve($id);
# { state => 'answered', answer => 'darkpan', answered => ..., answered_by => ... }
# { state => 'open', policy => 'block' }
# { state => 'overdue', policy => 'use_default', answer => 'cpan' }
What a question currently resolves to, or undef when there is no such question. answered beats everything, including a deadline that has passed. open is nobody has answered and there is either no deadline or it has not passed. overdue is nobody has answered and the deadline has, and then the policy says what follows: use_default carries the default along as answer, block and escalate_to_ai carry no answer at all, because neither of them has one to give.
Doing something about it -- waiting, taking the default, calling the coordination agent -- is the runner's, not the mailbox's.
delete_question
my $removed = $mailbox->delete_question($id);
Removes a question and its answer, and returns how many refs went. The question goes first: a delete interrupted half-way then leaves an answer nothing reads ("resolve" needs a question) which the next prune clears, rather than a question that looks open and invites a second answer.
The deletion is published like any other in this namespace -- a tombstone and an explicit delete refspec on the next karr sync (#190), never a pruning push.
prune_questions
my @gone = $mailbox->prune_questions; # the configured policy
my @gone = $mailbox->prune_questions( keep_days => 2 ); # or an explicit one
Drops every answered question whose answer is older than "keep_answered_days" and returns the ids it removed. An open question is never dropped, and neither is an answer with no readable timestamp -- the two cases where forgetting costs somebody their work rather than a stale record.
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)