Skip to content

feat(portal): citizen create actions — reactions + budget proposals#354

Merged
rubenvdlinde merged 1 commit into
developmentfrom
wip/apply-portal-citizen-create-actions
Jul 23, 2026
Merged

feat(portal): citizen create actions — reactions + budget proposals#354
rubenvdlinde merged 1 commit into
developmentfrom
wip/apply-portal-citizen-create-actions

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

Summary

  • Extends the portal-citizen-create-actions OpenSpec change: the citizen ADR-046 manifest gains two type: create actions — createReaction (consultation-reaction) and createBudgetProposal (budget-proposal) — closing the write-IDOR/read-only gap left by portal-contribution.
  • Scope is stamped server-side via portaliq's scopeField mechanism (submitterId/submitter), never client-writable; the intake state (moderationStatus: 'pending' / status: 'submitted') is stamped via defaults, also never client-writable. minTrust stays low (account-less participation).
  • Finding at apply-time (tasks.md T01): portaliq's real create receiver (ContributionController::create() + PortalObjectWriter) uses scopeField + defaults, not the brief's set shorthand, and does not enforce a declared parentConstraint. Closed with a new PortalCreateOpenParentGuardListener on OpenRegister's ObjectCreatingEvent — fail-closed reject (via stopPropagation()HookStoppedException, before the row is persisted) when the parent PublicConsultation isn't status: open (createReaction) or the parent ParticipatoryBudget isn't status: submission (createBudgetProposal).
  • OpenSpec change archived to openspec/specs/portal-citizen-create-actions/spec.md; openspec validate --specs --strict green (97/97).

Test plan

  • tests/Unit/Portal/PortalContributionProviderTest.php (extended, 21 tests total): manifest shape, exact whitelists/scopeField/defaults/parentConstraint, write-IDOR/staff-field invariant, register-drift pin against decidesk_register.json.
  • tests/Unit/Listener/PortalCreateOpenParentGuardListenerTest.php (new, 9 tests): open/closed consultation, submission/draft budget round, both write-path shapes (scalar field vs relations array), missing parent, unrelated schema, non-ObjectCreatingEvent, infrastructure failure fails closed (deliberate contrast with SubmissionDeadlineListener's fail-soft business-rule posture — this is a security invariant).
  • Full unit suite in nextcloud:34.0.0-apache container: 790/790 pass except 1 pre-existing, unrelated failure (RegisterJsonTest::testGovernanceBodySchema, confirmed present on unmodified origin/development before this PR).
  • phpcs (lib/ scope): clean on all touched files (also fixed one pre-existing full-stop nit in Application.php encountered while editing it).
  • phpmd: clean on all touched/new files (refactored citizenContribution()/handle()/resolveSchemaSlug() to stay under the method-length/cyclomatic-complexity thresholds).
  • phpstan: 0 errors on touched/new files (1 pre-existing, unrelated error remains elsewhere in Application.php; project has ~66 pre-existing errors overall, masked today by composer test:all's || echo skip fallback — out of scope for this PR).
  • psalm: clean (exit 0, no reported errors).
  • Hydra gates (--scope-to-diff): 38/39 green; spec-anchor-existence fails on 52 pre-existing, unrelated broken @spec anchors already in Application.php from older archived changes (none touch this PR's new code or new @spec tags) — filed as a known systemic issue, not addressed here (out of scope).
  • No Playwright/e2e added — portaliq owns the entire portal UI/SPA (out of scope per design.md); covered by PHPUnit only, matching the archived spec's @e2e exclude scenarios.

…portal-citizen-create-actions)

Extends the citizen manifest's read-only ADR-046 contribution with two
type:create actions: createReaction (consultation-reaction) and
createBudgetProposal (budget-proposal). Scope is stamped server-side via
portaliq's scopeField mechanism (submitterId/submitter), never client-writable;
intake state (moderationStatus/status) is stamped via defaults, also never
client-writable. minTrust stays low (account-less participation).

Portaliq's shared create receiver does not enforce the declared
parentConstraint, so a new PortalCreateOpenParentGuardListener enforces it
server-side on OpenRegister's ObjectCreatingEvent (fail-closed reject before
persist): createReaction requires PublicConsultation.status == open;
createBudgetProposal requires ParticipatoryBudget.status == submission.

Unit tests cover the manifest shape, the write-IDOR/lifecycle invariant, a
register-drift pin against decidesk_register.json, and the guard listener's
fail-closed matrix (both write-path shapes, missing parent, unrelated schema,
infrastructure failure).
@rubenvdlinde
rubenvdlinde merged commit 74985da into development Jul 23, 2026
4 checks passed
@rubenvdlinde
rubenvdlinde deleted the wip/apply-portal-citizen-create-actions branch July 23, 2026 18:37
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.

2 participants