Skip to content

Design the Position Edit Policy #398

Description

@b-at-neu

Epic

Several decisions from the 2026-08-10 audit belong to one coherent policy rather than separate tickets. This issue holds the design; implementation lands as the linked sub-issues.

Today there is no policy at all: a manager can edit any field, flip draft ↔ open ↔ closed in any direction, and add, edit or delete questions on any position at any time — including one that closed months ago with live applications against it.

Scope

  • What freezes when — which position fields lock at publish, at first application, at close
  • Archive and read-only — archived positions are read-only for managers, still editable by admins; uses a shared isPositionActive helper derived from the predicate already in getManagedPositions
  • Question editing — questions stay fully editable, but existing answers must be preserved and must still render sensibly when the shape they were saved under no longer exists
  • Status lifecycle — is draft → open → closed one-way? Can a closed position reopen? What does reopening mean for applications already decided?
  • Manager rights — appointment is settled (managers may appoint peers, may not remove themselves); any field-level split between manager and admin is not
  • Application transition graph and its next-step quick-action UI

Decisions already made (2026-08-10)

  • Questions remain fully editable; submitted applications keep their snapshot permanently
  • Draft and withdrawn applications must answer newly-added questions before (re)submitting
  • Archived positions are read-only for managers; admins are exempt
  • Managers may appoint peers but may not remove themselves
  • accepted/rejected are terminal for the applicant, reversible by the reviewer

Acceptance criteria

  • Policy written into PERMISSIONS.md
  • Sub-issues opened or linked for each implementable piece
  • Existing behaviour contradicting the policy is listed with a migration note

From the 2026-08-10 full platform audit.


Implementation Plan

SESSION REQUIRED: touches CLAUDE.md / .claude/** — a dispatched agent can't edit those

Overview

Doc-only ticket: create .claude/docs/PERMISSIONS.md holding the decided position lifecycle and edit policy, then open the two sub-issues the policy needs to become true in code. #395 later adds the principals / route / per-action tables to the same file, so this ticket writes only its own top-level sections and does not stub #395's.

The policy resolves the three open questions in the ticket as follows (these are the decisions to write down, not options):

  • Nothing field-freezes. Title, description, dates, questions and managers stay editable at every status. Freezing them would be strictly stricter than the already-decided "questions stay fully editable", which is incoherent. The only hard freeze is archive, already built (isPositionActive + checkPositionEditable, Make Archived Positions Read-Only for Managers #360).
  • open ↔ closed is freely reversible; → draft is one-way-out. Once any non-deleted application exists (any status, including a not-yet-submitted draft), the position can never return to draft — unpublishing would hide a position out from under applicants who already have work in it. Close it instead.
  • No field-level manager/admin split. Manager = everything on a non-archived position. Admin adds exactly three things: editing an archived position, deleting a position, and removing any manager including themselves.

Changes

Implementation

  • Create .claude/docs/PERMISSIONS.md with a one-line intro and the sections below, terse tables over prose, written against the target state.
  • ## Position status lifecycle — the transition table and its rules:
    • draft → open (publish) and open → closed (close): always allowed on a non-archived position.
    • closed → open (reopen): allowed, but only coherent when closesAt is null or in the future — otherwise getPositionAvailability still returns closed_by_date and the reopen is a silent no-op. Reject with { error: "This position's close date has passed. Clear or extend the close date to reopen it." }.
    • open → draft / closed → draft (unpublish): blocked once any non-deleted application exists. { error: 'Someone has already started an application, so this position cannot go back to draft. Close it instead.' }
    • createPosition may only create draft or open — never a born-closed position.
    • Reopening changes nothing about existing applications: decisions stand, and a reviewer reverses them through the application transition graph, not by reopening. Because Application is unique on [userId, positionId], a rejected or withdrawn applicant still cannot reapply to a reopened position — recorded as known-open, not fixed here.
  • ## What freezes when — a status × capability table (draft / open / closed / archived, rows: title & description, dates, questions, managers, status moves, delete) whose only "no" column is archived-as-manager, plus the → draft cell. State explicitly that publish and first-application freeze nothing, so nobody re-derives a stricter rule later.
  • ## Guardrails instead of freezes — the confirmations that carry the risk the freezes don't: confirm closing while unresolved applications exist ("N applications are still in progress. Closing stops new applications; the ones you have stay reviewable."), confirm reopen, and the existing advisory "N applications already answered" on question delete (getPositionForEdit.answerCount). Also: closesAt may never precede opensAt.
  • ## Archive — archived is derived, never stored: isPositionActive (lib/utils.ts) is the single source of truth, fed by positionActivitySelect; a second implementation is an authorization bug. There is no manual archive/unarchive — a position leaves archive only by an admin reopening it with a future close date. Manager denial copy is the existing ARCHIVED_POSITION_EDIT_ERROR; the edit page renders the read-only twin (PositionDetailsReadonly / PositionQuestionsReadonly), it does not 404.
  • ## Manager vs admin — the three-item admin delta above; note manager is M2M membership in Position.managers, not a flag, that createPosition auto-connects its creator, and that the self-removal block already lives in removePositionManager.
  • ## Question editing and answer preservation — questions are editable at every non-archived status; answers are never rewritten or deleted by a question edit; the reviewer-visible label is the snapshot questionLabel on the answer row, and a soft-deleted question keeps rendering its answers. Drafts and withdrawn applications must satisfy newly-added required questions before (re)submitting (submitApplication, syncGlobalAnswersFromProfile).
  • ## Known-open deviations — the four items in Risks / notes below, each with the file that contradicts the policy and its owning ticket.
  • Open sub-issue "Enforce the Position Status Transition Rules": a POSITION_STATUS_TRANSITIONS map in lib/constants.ts as the single source for the server guard in updatePosition and the reachable options in the Status select; the application-existence check for → draft; the past-closesAt check for reopen; createPosition limited to draft/open; confirm dialogs for close and reopen. Copy as above.
  • Open sub-issue "Validate Position Open and Close Dates": one shared zod refinement (closesAtopensAt) applied to createPositionSchema, updatePositionSchema and positionFormSchema so client and server can't drift; error names both fields so a manager can fix it in place.
  • Link both as sub-issues of Epic: 2026-08-10 Full Platform Audit Remediation #401 (REST sub_issues with the database id, per the scope skill) and label them enhancement; add addBlockedBy from each to Design the Position Edit Policy #398.
  • Comment on Materialize The Answer Snapshot At Submission Instead Of Backfilling During Validation #445 that the answer snapshot must also capture the question type, since applicationAnswersSelect reads the live positionQuestion.type / globalQuestion.type — do not open a competing ticket.

Testing

Risks / notes

Known-open deviations (no data migration needed — the policy only constrains future transitions):

  • updatePosition accepts any status → any status: today a manager can unpublish a position with live applications, or "reopen" one whose closesAt has passed and get a silent no-op. → new transition sub-issue.
  • createPositionSchema accepts status: 'closed'. → same sub-issue.
  • No closesAtopensAt validation anywhere. → new dates sub-issue. Pre-existing rows may already violate it, which would block saving until the manager corrects the dates; acceptable, and the reason the error must name both fields.
  • Reviewer answer rendering resolves type from the live question row, so a post-submission type change re-renders a stored answer under a shape it was never saved as. → Materialize The Answer Snapshot At Submission Instead Of Backfilling During Validation #445.

PERMISSIONS.md is not referenced from CLAUDE.md by this ticket — that is #395's acceptance criterion, and #395 owns the file's remaining sections.

Metadata

Metadata

Assignees

Labels

claudeWill be worked on by ClaudedocumentationImprovements or additions to documentationin progressCurrently being worked on

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions