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)