Skip to content

feat(decisions): let a consumer read back a decision it raised - #1116

Merged
rubenvdlinde merged 2 commits into
developmentfrom
feat/decision-state-read-seam
Sep 3, 2026
Merged

feat(decisions): let a consumer read back a decision it raised#1116
rubenvdlinde merged 2 commits into
developmentfrom
feat/decision-state-read-seam

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

Why

dossiq.requestDecision raises a decision in decidiq and then waits. When the conclusion event is missed — a refused signal, a worker that died between the write and the dispatch — the flow engine's heartbeat wakes, has nothing to consult, and re-suspends. The run wedges forever.

openregister#3358 gave the engine the ability to recover by re-reading terminal state, and dossiq#1756 applied it to dossiq.askPerson, which reads back its own case task. requestDecision could not follow, because decidiq had no way to answer "what happened to the decision I raised?" — it could accept a raise and announce a conclusion, and nothing in between.

The seam

DecisionStateRequestedEvent, dispatched by the consumer and answered in place by DecisionStateRequestedListener. This follows the ADR-041 event recipe rather than adding an HTTP endpoint, for two reasons: the fleet's cross-app rule is events, not RPC, and the raise and the conclusion already travel that way — a third mechanism for the same conversation is how the double-engine defects in this programme started.

It answers with exactly the three states a waiting node can act on: still open, decided (with the outcome), or terminal without a decision. That last one matters most: a withdrawn or cancelled decision is not "keep waiting", and a node that cannot tell those apart either strands or invents an answer.

Authorization is scoped, not bypassed. DecisionIntegrationAuthorizationGuard decides whether the asking context may see the decision it names, so the seam cannot become a way to enumerate decisions a caller has no business reading. It answers about one decision, by the reference the caller already holds.

What this does not do

It does not deliver conclusions. The existing ApprovalRouteConcludedEvent path is still how an answer normally arrives, and is unchanged. This is the recovery path only: it makes state readable so a heartbeat has something to consult, which is why nothing here duplicates the announcer.

Tests

DecisionStateRequestedListenerTest covers the three states plus the branches that decide whether an answer is given at all: an unknown reference, and a caller the guard refuses. The fakes refuse what live OpenRegister refuses, following the lesson from #1107 — a fake that accepts what production rejects cannot fail.

Verification

phpunit 1386 green (30 pre-existing skips) · phpcs, psalm, phpstan clean · phpmd clean per subdirectory across both rulesets · hydra-gates v1.11.1 --scope-to-diff: all 32 applicable gates passed, and all 32 ran.

Also takes hydra-gates v1.11.1 in composer.lock, which picks up the vendored manifest-schema sync.

The dossiq half — requestDecision re-reading through this seam with #1756's semantics — follows in its own PR, since it cannot be verified until this is on development.

🤖 Generated with Claude Code

Decidiq can be asked to raise a Decision and it announces the conclusion.
It could not be ASKED what became of one, so a consumer whose
DecisionConcludedEvent went missing had nothing to consult.

DecisionStateRequestedEvent + DecisionStateRequestedListener close that,
in the same synchronous request/response-over-the-bus shape
DecisionRequestedEvent already uses. It is NOT a second delivery
mechanism: the announcement stays how a conclusion arrives, and this is
what a consumer reads when the announcement did not.

It derives nothing of its own. The status comes from
getOutcomeEnvelope() -- the same array DecisionConcludedEvent carries --
and the authorization from the REQ-DCDH-101 outcome-read rule the HTTP
endpoint enforces. The bus carries no session, so the read is scoped to
the uid the event names, and an event naming none is REFUSED rather than
read as a system caller. No admin bypass on this path.

DecisionIntegrationAuthorizationGuard gains resolveOutcomeReadAccess(),
reporting allowed / denied / unresolved, and the boolean now delegates to
it. An unreachable OpenRegister must not read as a refusal: it would fail
a consumer's waiting run on an authorization error it never had. The HTTP
behaviour is unchanged -- unresolved still collapses to false there.
Picks up the vendored manifest-schema sync, so the gate stops rejecting
open-form properties the library has supported since 2.29.0.
@rubenvdlinde
rubenvdlinde merged commit ec377f3 into development Sep 3, 2026
34 checks passed
@github-actions

github-actions Bot commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Quality Report — ConductionNL/decidiq @ 2aee268

Check PHP Vue Security License Tests
lint
phpcs
phpmd
psalm
phpstan
phpmetrics
eslint
stylelint
build
check-manifest
check-nav-ceiling
test-l10n
format
check-l10n-js
check-schema-l10n
composer ✅ 104/104
npm ✅ 537/537
app:check-code ⏭️
info.xml
REUSE
PHPUnit
Newman
Playwright ⏭️ deferred — runs on the promotion into beta/main, not on a pull request into development
Hydra gates

Quality workflow — 2026-09-03 14:32 UTC

Download the full PDF report from the workflow artifacts.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant