diff --git a/planfile/schemas/ticket-status.v1.json b/planfile/schemas/ticket-status.v1.json new file mode 100644 index 0000000..4f9e1a0 --- /dev/null +++ b/planfile/schemas/ticket-status.v1.json @@ -0,0 +1,21 @@ +{ + "schema": "planfile.ticket-status/v1", + "description": "Every planfile TicketStatus value and its class, for consumers that classify queues without importing planfile (e.g. monag resume, fleet admission). A status missing here is a contract defect, not an unknown value to tolerate.", + "classes": { + "ready": "Pending work that may be picked up now.", + "claimed": "Pending work held by an executor.", + "awaiting_review": "Pending work waiting for review.", + "stalled": "Pending work that needs triage before anyone may pick it up again; never ready.", + "terminal_success": "Finished with the intended outcome.", + "terminal_not_done": "Finished without delivering the outcome." + }, + "statuses": { + "open": {"class": "ready", "pending": true}, + "in_progress": {"class": "claimed", "pending": true}, + "review": {"class": "awaiting_review", "pending": true}, + "blocked": {"class": "stalled", "pending": true}, + "failed": {"class": "stalled", "pending": true, "note": "Execution attempts exhausted; it may be reopened."}, + "done": {"class": "terminal_success", "pending": false}, + "canceled": {"class": "terminal_not_done", "pending": false} + } +} diff --git a/project/TICKETS.md b/project/TICKETS.md index fc694bb..a246e4c 100644 --- a/project/TICKETS.md +++ b/project/TICKETS.md @@ -122,4 +122,5 @@ This file indexes governance tickets without taking ownership of | **ticket-210** | [`README.md`](./ticket-210/README.md) | - | - | - | - | - | | **ticket-211** | [`README.md`](./ticket-211/README.md) | - | - | - | - | - | | **ticket-212** | [`README.md`](./ticket-212/README.md) | - | - | - | - | - | +| **ticket-213** | [`README.md`](./ticket-213/README.md) | - | - | - | - | - | diff --git a/project/ticket-214/README.md b/project/ticket-214/README.md new file mode 100644 index 0000000..b964adf --- /dev/null +++ b/project/ticket-214/README.md @@ -0,0 +1,34 @@ +# Ticket 214: Publish a machine-readable ticket status contract + +- **ID**: ticket-214 +- **Owner**: claude +- **Status**: IN_PROGRESS +- **Workflow state**: EDIT +- **Created**: 2026-10-15 + +## Goal and scope + +Queue consumers outside planfile keep private copies of the status +vocabulary. On 2026-10-15 `monag resume` did not know `failed`, counted it as +an unknown status, and the fleet admission gate then refused willman project +intake for every repository holding one failed ticket (352 local +repositories; fixed in semcod/monag#141). + +planfile now publishes `planfile/schemas/ticket-status.v1.json` +(`planfile.ticket-status/v1`): every `TicketStatus` value with a class and a +`pending` flag. `blocked` and `failed` are `stalled` (pending, never ready), +matching their existing behaviour (`failed` = execution attempts exhausted, +reopenable). `schemas/*.json` is already package data, so the contract ships +with every installation. A test fails if the enum and the contract diverge. + +## Acceptance criteria + +- [x] AC-01: `tests/test_ticket_status_contract.py`: the contract lists exactly + the `TicketStatus` values, each with a declared class and a consistent + `pending` flag; only `open` is ready; `blocked`/`failed` are stalled. +- [x] AC-02: full test suite: 1244 passed, 6 skipped. +- [x] AC-03: `project/governance-check.sh` passes. + +## Tracking boundary + +Adoption by consumers (monag) is a separate ticket in their repositories. diff --git a/project/ticket-214/intent.json b/project/ticket-214/intent.json new file mode 100644 index 0000000..251c1ee --- /dev/null +++ b/project/ticket-214/intent.json @@ -0,0 +1,92 @@ +{ + "schema": "new-project.intent/v3", + "ticket": "ticket-214", + "summary": "Publish a machine-readable ticket status contract", + "workstream": "application", + "classification": { + "kind": "SERVICE", + "priority": "P2", + "origin": "requested" + }, + "allowedPaths": [ + "project/ticket-214/**", + "TODO.md", + "project/TICKETS.md", + "planfile/schemas/ticket-status.v1.json", + "tests/test_ticket_status_contract.py" + ], + "forbiddenPaths": [ + "project/ticket-*/user-*.md" + ], + "stacks": [ + "python" + ], + "dependsOn": [], + "conflictsWith": [], + "integrationTicket": null, + "delivery": { + "acceptedBaseSha": "a52495a84cd6ba90aca1ee012462731b5ff1521c", + "targetBranch": "main", + "outcome": "planfile ships planfile/schemas/ticket-status.v1.json: every TicketStatus value with a class (ready, claimed, awaiting_review, stalled, terminal_success, terminal_not_done) and a pending flag, guarded by a test against the enum, so queue consumers (monag resume, fleet admission) classify statuses from one source instead of private OPEN/CLOSED sets.", + "nonGoals": [ + "No change to TicketStatus or any status behaviour.", + "No consumer change in this repository (monag adopts it separately)." + ], + "complexity": "S", + "estimatedMinutes": 20, + "budgets": { + "maxImplementationFiles": 2, + "maxAffectedComponents": 1, + "maxPublicInterfaceChanges": 1, + "maxRuntimeDependencies": 0 + }, + "architecture": { + "status": "accepted", + "decision": "Publish a JSON contract next to the existing packaged schemas (package-data schemas/*.json already ships it) and test it against TicketStatus.", + "components": [ + { + "name": "ticket-status-contract", + "paths": [ + "planfile/schemas/ticket-status.v1.json", + "tests/test_ticket_status_contract.py" + ] + } + ], + "responsibilityChanges": false, + "interfaceChanges": [ + "New packaged read-only contract planfile/schemas/ticket-status.v1.json (planfile.ticket-status/v1)" + ], + "dataChanges": [], + "ui": { + "impact": "none", + "states": [], + "evidence": [] + }, + "rollback": "git revert removes the contract file and its test." + }, + "runtimeDependencies": [], + "validation": [ + { + "criterion": "AC-01", + "commands": [ + "python -m pytest -q tests/test_ticket_status_contract.py" + ], + "evidence": "project/ticket-214/README.md" + }, + { + "criterion": "AC-02", + "commands": [ + "python -m pytest -q tests" + ], + "evidence": "project/ticket-214/README.md" + }, + { + "criterion": "AC-03", + "commands": [ + "bash project/governance-check.sh" + ], + "evidence": "project/ticket-214/README.md" + } + ] + } +} diff --git a/tests/test_ticket_status_contract.py b/tests/test_ticket_status_contract.py new file mode 100644 index 0000000..99609fe --- /dev/null +++ b/tests/test_ticket_status_contract.py @@ -0,0 +1,28 @@ +"""The published status contract covers exactly the TicketStatus enum (ticket-214).""" +import json +from importlib import resources + +from planfile.core.models.base import TicketStatus + + +def contract(): + return json.loads(resources.files("planfile").joinpath("schemas/ticket-status.v1.json").read_text()) + + +def test_contract_lists_every_ticket_status_exactly_once(): + assert set(contract()["statuses"]) == {status.value for status in TicketStatus} + + +def test_every_status_has_a_declared_class_and_pending_flag(): + data = contract() + assert data["schema"] == "planfile.ticket-status/v1" + for name, entry in data["statuses"].items(): + assert entry["class"] in data["classes"], name + assert isinstance(entry["pending"], bool), name + assert entry["pending"] == (not entry["class"].startswith("terminal_")), name + + +def test_only_open_is_ready_and_stalled_work_is_pending(): + statuses = contract()["statuses"] + assert [name for name, entry in statuses.items() if entry["class"] == "ready"] == ["open"] + assert {name for name, entry in statuses.items() if entry["class"] == "stalled"} == {"blocked", "failed"}