prodc 0.3.80__tar.gz → 0.3.82__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. {prodc-0.3.80 → prodc-0.3.82}/PKG-INFO +1 -1
  2. {prodc-0.3.80 → prodc-0.3.82}/pyproject.toml +1 -1
  3. {prodc-0.3.80 → prodc-0.3.82}/src/prodc/agent.md +2 -0
  4. {prodc-0.3.80 → prodc-0.3.82}/src/prodc/cli/app.py +49 -4
  5. {prodc-0.3.80 → prodc-0.3.82}/src/prodc/cli/commands.py +89 -2
  6. {prodc-0.3.80 → prodc-0.3.82}/src/prodc/cli/render.py +43 -2
  7. {prodc-0.3.80 → prodc-0.3.82}/src/prodc/cli/serialize.py +29 -2
  8. {prodc-0.3.80 → prodc-0.3.82}/src/prodc/loader.py +68 -0
  9. {prodc-0.3.80 → prodc-0.3.82}/src/prodc/model.py +28 -0
  10. {prodc-0.3.80 → prodc-0.3.82}/src/prodc/status.py +50 -0
  11. {prodc-0.3.80 → prodc-0.3.82}/src/prodc/writer.py +79 -3
  12. prodc-0.3.82/tests/test_decisions.py +325 -0
  13. {prodc-0.3.80 → prodc-0.3.82}/tests/test_principles.py +114 -0
  14. {prodc-0.3.80 → prodc-0.3.82}/.gitignore +0 -0
  15. {prodc-0.3.80 → prodc-0.3.82}/CHANGELOG.md +0 -0
  16. {prodc-0.3.80 → prodc-0.3.82}/LICENSE +0 -0
  17. {prodc-0.3.80 → prodc-0.3.82}/README.md +0 -0
  18. {prodc-0.3.80 → prodc-0.3.82}/examples/quickstart/features/orders.feature +0 -0
  19. {prodc-0.3.80 → prodc-0.3.82}/examples/quickstart/needs/jordan.yaml +0 -0
  20. {prodc-0.3.80 → prodc-0.3.82}/examples/quickstart/prodc.toml +0 -0
  21. {prodc-0.3.80 → prodc-0.3.82}/src/prodc/__init__.py +0 -0
  22. {prodc-0.3.80 → prodc-0.3.82}/src/prodc/adapters.py +0 -0
  23. {prodc-0.3.80 → prodc-0.3.82}/src/prodc/board.py +0 -0
  24. {prodc-0.3.80 → prodc-0.3.82}/src/prodc/callbacks.py +0 -0
  25. {prodc-0.3.80 → prodc-0.3.82}/src/prodc/cli/__init__.py +0 -0
  26. {prodc-0.3.80 → prodc-0.3.82}/src/prodc/cli/common.py +0 -0
  27. {prodc-0.3.80 → prodc-0.3.82}/src/prodc/config.py +0 -0
  28. {prodc-0.3.80 → prodc-0.3.82}/src/prodc/dedup.py +0 -0
  29. {prodc-0.3.80 → prodc-0.3.82}/src/prodc/py.typed +0 -0
  30. {prodc-0.3.80 → prodc-0.3.82}/src/prodc/report.py +0 -0
  31. {prodc-0.3.80 → prodc-0.3.82}/src/prodc/report_html.py +0 -0
  32. {prodc-0.3.80 → prodc-0.3.82}/tests/test_adapters.py +0 -0
  33. {prodc-0.3.80 → prodc-0.3.82}/tests/test_answer.py +0 -0
  34. {prodc-0.3.80 → prodc-0.3.82}/tests/test_callbacks.py +0 -0
  35. {prodc-0.3.80 → prodc-0.3.82}/tests/test_citation.py +0 -0
  36. {prodc-0.3.80 → prodc-0.3.82}/tests/test_claim_gating.py +0 -0
  37. {prodc-0.3.80 → prodc-0.3.82}/tests/test_cli.py +0 -0
  38. {prodc-0.3.80 → prodc-0.3.82}/tests/test_command_hook.py +0 -0
  39. {prodc-0.3.80 → prodc-0.3.82}/tests/test_contradicts.py +0 -0
  40. {prodc-0.3.80 → prodc-0.3.82}/tests/test_dedup.py +0 -0
  41. {prodc-0.3.80 → prodc-0.3.82}/tests/test_description.py +0 -0
  42. {prodc-0.3.80 → prodc-0.3.82}/tests/test_drafts.py +0 -0
  43. {prodc-0.3.80 → prodc-0.3.82}/tests/test_evalgate.py +0 -0
  44. {prodc-0.3.80 → prodc-0.3.82}/tests/test_external_adapter.py +0 -0
  45. {prodc-0.3.80 → prodc-0.3.82}/tests/test_gitlab_source.py +0 -0
  46. {prodc-0.3.80 → prodc-0.3.82}/tests/test_importance.py +0 -0
  47. {prodc-0.3.80 → prodc-0.3.82}/tests/test_loader.py +0 -0
  48. {prodc-0.3.80 → prodc-0.3.82}/tests/test_models.py +0 -0
  49. {prodc-0.3.80 → prodc-0.3.82}/tests/test_needref.py +0 -0
  50. {prodc-0.3.80 → prodc-0.3.82}/tests/test_relink.py +0 -0
  51. {prodc-0.3.80 → prodc-0.3.82}/tests/test_report.py +0 -0
  52. {prodc-0.3.80 → prodc-0.3.82}/tests/test_report_html.py +0 -0
  53. {prodc-0.3.80 → prodc-0.3.82}/tests/test_review.py +0 -0
  54. {prodc-0.3.80 → prodc-0.3.82}/tests/test_roles.py +0 -0
  55. {prodc-0.3.80 → prodc-0.3.82}/tests/test_source_role.py +0 -0
  56. {prodc-0.3.80 → prodc-0.3.82}/tests/test_sources.py +0 -0
  57. {prodc-0.3.80 → prodc-0.3.82}/tests/test_staleness.py +0 -0
  58. {prodc-0.3.80 → prodc-0.3.82}/tests/test_status.py +0 -0
  59. {prodc-0.3.80 → prodc-0.3.82}/tests/test_suggest.py +0 -0
  60. {prodc-0.3.80 → prodc-0.3.82}/tests/test_variants.py +0 -0
  61. {prodc-0.3.80 → prodc-0.3.82}/tests/test_who_variants.py +0 -0
  62. {prodc-0.3.80 → prodc-0.3.82}/tests/test_withdrawn.py +0 -0
  63. {prodc-0.3.80 → prodc-0.3.82}/tests/test_writer.py +0 -0
  64. {prodc-0.3.80 → prodc-0.3.82}/tests/test_writer_comments.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: prodc
3
- Version: 0.3.80
3
+ Version: 0.3.82
4
4
  Summary: Product management as code: roles, flows, and features traced to evidence.
5
5
  Project-URL: Homepage, https://gitlab.com/jorgeecardona/prodc
6
6
  Project-URL: Repository, https://gitlab.com/jorgeecardona/prodc
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "prodc"
3
- version = "0.3.80"
3
+ version = "0.3.82"
4
4
  description = "Product management as code: roles, flows, and features traced to evidence."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -74,6 +74,8 @@ Read first, then act, then check.
74
74
  | declare a role (the roster) | `prodc add role <id> --text "…" --file needs/<f>.yaml` |
75
75
  | declare a product principle (the vision, held as given) | `prodc add principle <id> --text "…" --file needs/<f>.yaml` — the frame that decides between clients who want opposite things; what an `out_of_scope` decision cites |
76
76
  | revise a principle (fires the re-review ripple) | `prodc revise principle <id> [--text "…"] [--date ISO]` — re-stamps `revised_at`, so every need withdrawn under it with an earlier date comes back up |
77
+ | promote a need you commit to into a principle | `prodc add principle <id> --from-need <need-id> --file needs/<f>.yaml` — copies the need's text (unless `--text` overrides) and its sources (the client signals that surfaced it); the need stays on the board, so a need and the principle it inspired coexist |
78
+ | record how a need gets built (the chosen solution) | `prodc add decision <id> --text "<the solution>" --needs <ids> [--alternative "<rejected>" …] [--consequences "…"] [--principles <ids>] --file needs/<f>.yaml` — the OST node: which solution you picked for a need, over what, resting on which principle. `--needs` is required (a decision with no need is refused); `lint` flags one left serving only withdrawn needs, or decided before a principle it cites was revised |
77
79
  | attach evidence after a user said something | `prodc capture <id> --kind primary --who <person> [--role <id>] --date <ISO> --quote "…"` (`--role` records which declared role vouched for it) |
78
80
  | set or append a field | `prodc set <id> key=value` (append a list with `key+=value`, e.g. `proofs+=web:orders.feature#…`) |
79
81
  | mark two needs as opposing each other | `prodc set <id> contradicts+=<other-id>` — symmetric (declare once); `lint` flags a pair left both-live (clients want opposite things and the vision has not decided), and `withdraw … --kind out_of_scope --principle <id>` resolves it |
@@ -167,14 +167,59 @@ def main(argv: list[str] | None = None) -> int:
167
167
 
168
168
  ad = sub.add_parser(
169
169
  "add",
170
- help="create a new agent-authored need, or declare a role or a principle (writes YAML)",
170
+ help="create a need, or declare a role, a principle, or a decision (writes YAML)",
171
171
  )
172
- ad.add_argument("noun", choices=["need", "role", "principle"], help="what to add")
172
+ ad.add_argument("noun", choices=["need", "role", "principle", "decision"], help="what to add")
173
173
  ad.add_argument(
174
174
  "need_id",
175
- help="the new need's id (or role id for `add role`, principle id for `add principle`), e.g. N-9",
175
+ help="the new item's id — need (N-9), role, principle (P-1), or decision (D-1)",
176
+ )
177
+ ad.add_argument(
178
+ "--text",
179
+ default=None,
180
+ help="the need text (or the role / principle text); required except `add principle "
181
+ "--from-need`, which takes the text from the need",
182
+ )
183
+ ad.add_argument(
184
+ "--from-need",
185
+ default=None,
186
+ metavar="ID",
187
+ help="principle only: promote a need into a principle — copy its text (unless --text "
188
+ "overrides) and its sources as the provenance that surfaced it; the need stays on the board",
189
+ )
190
+ ad.add_argument(
191
+ "--needs",
192
+ default=None,
193
+ metavar="IDS",
194
+ help="decision only: comma-separated ids of the needs this decision serves (required; "
195
+ "validated at load)",
196
+ )
197
+ ad.add_argument(
198
+ "--alternative",
199
+ action="append",
200
+ default=None,
201
+ dest="alternatives",
202
+ metavar="TEXT",
203
+ help="decision only: an option that was rejected (repeatable) — what makes it a choice",
204
+ )
205
+ ad.add_argument(
206
+ "--consequences",
207
+ default=None,
208
+ help="decision only: what the chosen solution costs / rules out",
209
+ )
210
+ ad.add_argument(
211
+ "--principles",
212
+ default=None,
213
+ metavar="IDS",
214
+ help="decision only: comma-separated principle ids the decision rests on (validated); "
215
+ "revising one flags the decision stale",
216
+ )
217
+ ad.add_argument(
218
+ "--date",
219
+ default=None,
220
+ metavar="ISO",
221
+ help="decision only: when it was decided (default: today) — the comparand for the ripple",
176
222
  )
177
- ad.add_argument("--text", required=True, help="the need text (or the role / principle text)")
178
223
  ad.add_argument(
179
224
  "--who",
180
225
  default=None,
@@ -37,19 +37,23 @@ from ..status import (
37
37
  applicable_needs,
38
38
  argument_defects,
39
39
  contradictions,
40
+ decisions_for,
40
41
  evidence_age_days,
41
42
  importance_revisit_due,
43
+ orphaned_decisions,
42
44
  principle_report,
43
45
  proof_suggestions,
44
46
  recurrence,
45
47
  relink_suggestions,
46
48
  resolve,
49
+ stale_decisions,
47
50
  summarize,
48
51
  )
49
52
  from ..writer import (
50
53
  Edit,
51
54
  WriteError,
52
55
  accept,
56
+ add_decision,
53
57
  add_need,
54
58
  add_principle,
55
59
  add_proof,
@@ -81,6 +85,7 @@ from .render import (
81
85
  render_argument,
82
86
  render_brief,
83
87
  render_columns,
88
+ render_decisions,
84
89
  render_goals,
85
90
  render_handoffs,
86
91
  render_orphans,
@@ -263,6 +268,24 @@ def cmd_lint(args: argparse.Namespace) -> int:
263
268
  warnings = [
264
269
  (r.need.id, w) for r in sorted(results, key=lambda x: x.need.id) for w in r.warnings
265
270
  ]
271
+ # Decision drift (keyed by the decision id, not a need): a decision left serving only
272
+ # withdrawn/non-product needs is engineering with no live product reason; a decision decided
273
+ # under a principle that was revised later comes back up (the same ripple a withdrawal gets).
274
+ decisions = list(model.decisions)
275
+ for d in orphaned_decisions(decisions, needs):
276
+ warnings.append(
277
+ (
278
+ d.id,
279
+ "decision serves only withdrawn/non-product needs — re-tie it to a live need or withdraw it",
280
+ )
281
+ )
282
+ for d, pid in stale_decisions(decisions, list(model.principles)):
283
+ warnings.append(
284
+ (
285
+ d.id,
286
+ f"decided {d.decided_at} under principle {pid}, revised later — re-review this solution",
287
+ )
288
+ )
266
289
  if args.json:
267
290
  print(
268
291
  json.dumps(
@@ -346,6 +369,7 @@ def cmd_status(args: argparse.Namespace) -> int:
346
369
  unroled,
347
370
  hos,
348
371
  principles=board.principles,
372
+ decisions=list(model.decisions),
349
373
  )
350
374
  )
351
375
  else:
@@ -380,6 +404,8 @@ def cmd_status(args: argparse.Namespace) -> int:
380
404
  print(render_recurrence(recs))
381
405
  if model.principles:
382
406
  print(render_principles(board.principles))
407
+ if model.decisions:
408
+ print(render_decisions(list(model.decisions), needs=list(model.needs)))
383
409
  if orphan_map:
384
410
  print(render_orphans(orphan_map))
385
411
 
@@ -397,7 +423,12 @@ def cmd_show(args: argparse.Namespace) -> int:
397
423
  if r is None:
398
424
  print(f"prodc: no need '{args.need_id}'", file=sys.stderr)
399
425
  return 2
400
- print(show_json(r) if args.json else render_show(r, cfg.importance_label(r.need.importance)))
426
+ decisions = decisions_for(args.need_id, list(model.decisions)) # the 'how' behind this need
427
+ print(
428
+ show_json(r, decisions=decisions)
429
+ if args.json
430
+ else render_show(r, cfg.importance_label(r.need.importance), decisions=decisions)
431
+ )
401
432
  return 0
402
433
 
403
434
 
@@ -495,6 +526,9 @@ def cmd_add(args: argparse.Namespace) -> int:
495
526
  print(f"prodc: no config at {cfg_path}", file=sys.stderr)
496
527
  return 2
497
528
  if args.noun == "role":
529
+ if args.text is None:
530
+ print("prodc: add role requires --text", file=sys.stderr)
531
+ return 2
498
532
  try:
499
533
  cfg = Config.load(cfg_path)
500
534
  edit = add_role(
@@ -512,12 +546,62 @@ def cmd_add(args: argparse.Namespace) -> int:
512
546
  if args.noun == "principle":
513
547
  try:
514
548
  cfg = Config.load(cfg_path)
549
+ text, sources = args.text, ()
550
+ if args.from_need:
551
+ # Promote a need into a principle (the discovery lifecycle): the signals that
552
+ # surfaced it travel with it as provenance, so the history stays honest. The origin
553
+ # need stays on the board unchanged — a need and the principle it inspired coexist.
554
+ origin = next((n for n in load(cfg).needs if n.id == args.from_need), None)
555
+ if origin is None:
556
+ print(f"prodc: no need '{args.from_need}' to promote", file=sys.stderr)
557
+ return 2
558
+ text = args.text or origin.text
559
+ sources = origin.sources
560
+ if text is None:
561
+ print(
562
+ "prodc: add principle requires --text (or --from-need to take it from a need)",
563
+ file=sys.stderr,
564
+ )
565
+ return 2
515
566
  edit = add_principle(
516
567
  cfg,
517
568
  args.need_id,
518
- args.text,
569
+ text,
519
570
  file=args.file,
520
571
  revised_at=date.today().isoformat(),
572
+ sources=sources,
573
+ expect_sha=args.expect_sha,
574
+ dry_run=args.dry_run,
575
+ )
576
+ except (ConfigError, LoaderError, WriteError) as exc:
577
+ print(f"prodc: {exc}", file=sys.stderr)
578
+ return 2
579
+ return emit_edit(edit, args.dry_run, args.json)
580
+ if args.noun == "decision":
581
+ if args.text is None:
582
+ print("prodc: add decision requires --text (the chosen solution)", file=sys.stderr)
583
+ return 2
584
+ needs = tuple(n.strip() for n in (args.needs or "").split(",") if n.strip())
585
+ if not needs:
586
+ print(
587
+ "prodc: add decision requires --needs (the need(s) the solution serves)",
588
+ file=sys.stderr,
589
+ )
590
+ return 2
591
+ principles = tuple(p.strip() for p in (args.principles or "").split(",") if p.strip())
592
+ try:
593
+ cfg = Config.load(cfg_path)
594
+ edit = add_decision(
595
+ cfg,
596
+ args.need_id,
597
+ args.text,
598
+ file=args.file,
599
+ needs=needs,
600
+ decided_at=args.date or date.today().isoformat(),
601
+ alternatives=tuple(args.alternatives or ()),
602
+ consequences=args.consequences,
603
+ principles=principles,
604
+ draft=args.draft,
521
605
  expect_sha=args.expect_sha,
522
606
  dry_run=args.dry_run,
523
607
  )
@@ -528,6 +612,9 @@ def cmd_add(args: argparse.Namespace) -> int:
528
612
  if args.who is None:
529
613
  print("prodc: add need requires --who (the author recorded on the source)", file=sys.stderr)
530
614
  return 2
615
+ if args.text is None:
616
+ print("prodc: add need requires --text", file=sys.stderr)
617
+ return 2
531
618
  try:
532
619
  cfg = Config.load(cfg_path)
533
620
  if not args.no_dupe_check:
@@ -12,6 +12,7 @@ from collections.abc import Callable
12
12
 
13
13
  from ..board import warrant as _warrant
14
14
  from ..model import (
15
+ Decision,
15
16
  Goal,
16
17
  Model,
17
18
  Need,
@@ -21,7 +22,15 @@ from ..model import (
21
22
  Role,
22
23
  ears_shape,
23
24
  )
24
- from ..status import Handoff, PrincipleReport, Recurrence, Summary, rank, state_priority
25
+ from ..status import (
26
+ Handoff,
27
+ PrincipleReport,
28
+ Recurrence,
29
+ Summary,
30
+ orphaned_decisions,
31
+ rank,
32
+ state_priority,
33
+ )
25
34
  from .common import PortfolioTotals, RepoRow, brief_open
26
35
 
27
36
  _LABEL = {
@@ -280,7 +289,34 @@ def render_principles(reports: list[PrincipleReport]) -> str:
280
289
  return "\n".join(out)
281
290
 
282
291
 
283
- def render_show(r: NeedResult, importance_label: str | None = None) -> str:
292
+ def render_decisions(decisions: list[Decision], needs: list[Need]) -> str:
293
+ """The solution chosen for each need (OST), what it rejected, and the needs it serves.
294
+
295
+ Shows the decisions still in force (a superseded one is off the board, its record kept). A
296
+ decision left serving only withdrawn/non-product needs is flagged orphaned — the 'how' lost its
297
+ 'what'.
298
+ """
299
+ orphans = {d.id for d in orphaned_decisions(decisions, needs)}
300
+ active = sorted((d for d in decisions if d.withdrawn is None), key=lambda x: x.id)
301
+ out = ["\ndecisions (the solution chosen for a need — how it gets built):"]
302
+ for d in active:
303
+ tag = " (proposed)" if d.draft else ""
304
+ out.append(f" {d.id:<6}{tag} {d.text}")
305
+ out.append(f" serves: {', '.join(d.needs)}")
306
+ if d.alternatives:
307
+ out.append(f" over: {', '.join(d.alternatives)}")
308
+ if d.principles:
309
+ out.append(f" rests on: {', '.join(d.principles)}")
310
+ if d.id in orphans:
311
+ out.append(" ⚠ serves only withdrawn/non-product needs — re-tie or withdraw")
312
+ return "\n".join(out)
313
+
314
+
315
+ def render_show(
316
+ r: NeedResult,
317
+ importance_label: str | None = None,
318
+ decisions: list[Decision] | None = None,
319
+ ) -> str:
284
320
  """One need as a citable answer — the artifact you drop into a thread."""
285
321
  need = r.need
286
322
  who = (need.who + (f" ({need.label})" if need.label else "")) if need.who else "(unassigned)"
@@ -349,6 +385,11 @@ def render_show(r: NeedResult, importance_label: str | None = None) -> str:
349
385
  lines += [f" - {d}" for d in r.detail]
350
386
  elif not need.children:
351
387
  lines.append(" proofs: (none — HOLE)")
388
+ if decisions:
389
+ lines.append(" decisions (how it's built):")
390
+ for d in decisions:
391
+ alt = f" (over {', '.join(d.alternatives)})" if d.alternatives else ""
392
+ lines.append(f" - {d.id}: {d.text}{alt}")
352
393
  lines += [f" ⚠ {w}" for w in r.warnings]
353
394
  return "\n".join(lines)
354
395
 
@@ -12,11 +12,35 @@ import json
12
12
  from ..board import need_payload
13
13
  from ..board import warrant as _warrant
14
14
  from ..dedup import DupPair, VariantGroup
15
- from ..model import Goal, Model, NeedResult, NeedState, Release, Role
15
+ from ..model import Decision, Goal, Model, NeedResult, NeedState, Release, Role
16
16
  from ..status import Handoff, PrincipleReport, Recurrence, Summary, rank
17
17
  from .common import brief_open
18
18
 
19
19
 
20
+ def _decision_payload(d: Decision) -> dict[str, object]:
21
+ """One decision as a JSON record — the OST chosen-solution node and its edges."""
22
+ return {
23
+ "id": d.id,
24
+ "text": d.text,
25
+ "needs": list(d.needs),
26
+ "alternatives": list(d.alternatives),
27
+ "consequences": d.consequences,
28
+ "principles": list(d.principles),
29
+ "decided_at": d.decided_at,
30
+ "draft": d.draft,
31
+ "withdrawn": (
32
+ {
33
+ "reason": d.withdrawn.reason,
34
+ "date": d.withdrawn.date,
35
+ "kind": d.withdrawn.kind.value,
36
+ "principle": d.withdrawn.principle,
37
+ }
38
+ if d.withdrawn
39
+ else None
40
+ ),
41
+ }
42
+
43
+
20
44
  def _summary_payload(summary: Summary, orphan_map: dict[str, list[str]]) -> dict[str, object]:
21
45
  return {
22
46
  "total": summary.total,
@@ -44,6 +68,7 @@ def to_json(
44
68
  unroled: list[NeedResult],
45
69
  hos: list[Handoff],
46
70
  principles: list[PrincipleReport] | None = None,
71
+ decisions: list[Decision] | None = None,
47
72
  ) -> str:
48
73
  payload = {
49
74
  "summary": _summary_payload(summary, orphan_map),
@@ -111,17 +136,19 @@ def to_json(
111
136
  }
112
137
  for rep in (principles or [])
113
138
  ],
139
+ "decisions": [_decision_payload(d) for d in (decisions or [])],
114
140
  "needs": [need_payload(r) for r in results],
115
141
  }
116
142
  return json.dumps(payload, indent=2)
117
143
 
118
144
 
119
- def show_json(r: NeedResult) -> str:
145
+ def show_json(r: NeedResult, decisions: list[Decision] | None = None) -> str:
120
146
  need = r.need
121
147
  payload = need_payload(r) # the shared core — never drifts from the board
122
148
  payload.update(
123
149
  {
124
150
  "label": need.label,
151
+ "decisions": [_decision_payload(d) for d in (decisions or [])],
125
152
  "sources": [
126
153
  {
127
154
  "kind": s.kind.value,
@@ -22,6 +22,7 @@ from .model import (
22
22
  ArgNode,
23
23
  Attestation,
24
24
  AttestationKind,
25
+ Decision,
25
26
  Goal,
26
27
  Model,
27
28
  Need,
@@ -326,6 +327,39 @@ def _principle(raw: object, where: str) -> Principle:
326
327
  )
327
328
 
328
329
 
330
+ def _decision(raw: object, where: str) -> Decision:
331
+ """Parse one ``decisions:`` entry — the solution chosen for a need, and what was rejected.
332
+
333
+ ``needs`` is required and must be non-empty: a decision with no need it serves is engineering
334
+ with no product reason, refused here rather than nagged at by a soft lint. ``withdrawn`` reuses
335
+ the Need withdrawal (a superseded decision keeps its record).
336
+ """
337
+ tbl = _as_mapping(raw, where)
338
+ needs = tuple(
339
+ _as_str(n, f"{where}.needs[{i}]")
340
+ for i, n in enumerate(_as_list(tbl.get("needs"), f"{where}.needs"))
341
+ )
342
+ if not needs:
343
+ raise LoaderError(f"{where}: a decision must serve at least one need (needs: [...])")
344
+ return Decision(
345
+ id=_as_str(tbl.get("id"), f"{where}.id"),
346
+ text=_as_str(tbl.get("text"), f"{where}.text"),
347
+ needs=needs,
348
+ alternatives=tuple(
349
+ _as_str(a, f"{where}.alternatives[{i}]")
350
+ for i, a in enumerate(_as_list(tbl.get("alternatives"), f"{where}.alternatives"))
351
+ ),
352
+ consequences=_opt_str(tbl.get("consequences"), f"{where}.consequences"),
353
+ principles=tuple(
354
+ _as_str(p, f"{where}.principles[{i}]")
355
+ for i, p in enumerate(_as_list(tbl.get("principles"), f"{where}.principles"))
356
+ ),
357
+ decided_at=_opt_str(tbl.get("decided_at"), f"{where}.decided_at"),
358
+ draft=_as_bool(tbl["draft"], f"{where}.draft") if "draft" in tbl else False,
359
+ withdrawn=_withdrawal(tbl.get("withdrawn"), f"{where}.withdrawn"),
360
+ )
361
+
362
+
329
363
  def _arg_node(raw: object, where: str) -> ArgNode:
330
364
  tbl = _as_mapping(raw, where)
331
365
  kind_name = _as_str(tbl.get("kind"), f"{where}.kind")
@@ -359,6 +393,7 @@ class _Loaded:
359
393
  releases: list[Release] = field(default_factory=list[Release])
360
394
  roles: list[Role] = field(default_factory=list[Role])
361
395
  principles: list[Principle] = field(default_factory=list[Principle])
396
+ decisions: list[Decision] = field(default_factory=list[Decision])
362
397
  argument: list[ArgNode] = field(default_factory=list[ArgNode])
363
398
 
364
399
 
@@ -403,6 +438,10 @@ def _load_file(path: Path, seen: dict[str, dict[str, Path]]) -> _Loaded:
403
438
  principle = _principle(raw, f"{where}.principles[{i}]")
404
439
  _unique("principle", principle.id)
405
440
  out.principles.append(principle)
441
+ for i, raw in enumerate(_as_list(tbl.get("decisions"), f"{where}.decisions")):
442
+ decision = _decision(raw, f"{where}.decisions[{i}]")
443
+ _unique("decision", decision.id)
444
+ out.decisions.append(decision)
406
445
  for i, raw in enumerate(_as_list(tbl.get("argument"), f"{where}.argument")):
407
446
  node = _arg_node(raw, f"{where}.argument[{i}]")
408
447
  _unique("argument node", node.id)
@@ -427,6 +466,7 @@ def load(config: Config) -> Model:
427
466
  "release": {},
428
467
  "role": {},
429
468
  "principle": {},
469
+ "decision": {},
430
470
  "argument node": {},
431
471
  }
432
472
  for pattern in config.need_globs:
@@ -437,6 +477,7 @@ def load(config: Config) -> Model:
437
477
  acc.releases.extend(f.releases)
438
478
  acc.roles.extend(f.roles)
439
479
  acc.principles.extend(f.principles)
480
+ acc.decisions.extend(f.decisions)
440
481
  acc.argument.extend(f.argument)
441
482
  _check_variants(acc.needs)
442
483
  _check_children(acc.needs)
@@ -451,6 +492,7 @@ def load(config: Config) -> Model:
451
492
  {a.id for a in acc.principles},
452
493
  )
453
494
  _check_source_roles(acc.needs, acc.principles, {p.id for p in acc.roles})
495
+ _check_decisions(acc.decisions, {n.id for n in acc.needs}, {p.id for p in acc.principles})
454
496
  _check_argument(acc.argument)
455
497
  acc.needs = _symmetrize_contradicts(acc.needs)
456
498
  return Model(
@@ -459,6 +501,7 @@ def load(config: Config) -> Model:
459
501
  releases=tuple(acc.releases),
460
502
  roles=tuple(acc.roles),
461
503
  principles=tuple(acc.principles),
504
+ decisions=tuple(acc.decisions),
462
505
  argument=tuple(acc.argument),
463
506
  )
464
507
 
@@ -521,6 +564,31 @@ def _check_children(needs: list[Need]) -> None:
521
564
  raise LoaderError(f"need {need.id!r}: child {cid!r} is not a known need id")
522
565
 
523
566
 
567
+ def _check_decisions(
568
+ decisions: list[Decision], known_needs: set[str], known_principles: set[str]
569
+ ) -> None:
570
+ """Every ``needs`` / ``principles`` id on a decision must name a known need / principle.
571
+
572
+ ``needs`` is already guaranteed non-empty by :func:`_decision`; here each id is checked to
573
+ exist, the same hard-error treatment the other cross-references get. A superseded decision's
574
+ ``withdrawn.principle`` is checked too, so a stale citation cannot hide on a retired record.
575
+ """
576
+ for d in decisions:
577
+ for nid in d.needs:
578
+ if nid not in known_needs:
579
+ raise LoaderError(f"decision {d.id!r}: needs {nid!r} is not a known need id")
580
+ for pid in d.principles:
581
+ if pid not in known_principles:
582
+ raise LoaderError(
583
+ f"decision {d.id!r}: principle {pid!r} is not a known principle id"
584
+ )
585
+ if d.withdrawn and d.withdrawn.principle and d.withdrawn.principle not in known_principles:
586
+ raise LoaderError(
587
+ f"decision {d.id!r}: withdrawn principle {d.withdrawn.principle!r} "
588
+ "is not a known principle id"
589
+ )
590
+
591
+
524
592
  def _check_contradicts(needs: list[Need]) -> None:
525
593
  """Every id in a need's ``contradicts`` must name a known need, and never the need itself."""
526
594
  known = {n.id for n in needs}
@@ -162,6 +162,33 @@ class Principle:
162
162
  sources: tuple[Source, ...] = () # the signals that surfaced it — provenance, not validation
163
163
 
164
164
 
165
+ @dataclass(frozen=True)
166
+ class Decision:
167
+ """How a need gets built: the solution chosen for it, and the alternatives rejected.
168
+
169
+ Not an ADR (an engineer's record) but the Opportunity-Solution-Tree node (Torres): a need is
170
+ the opportunity, and a decision is *the solution picked for it over the ones considered*. prodc
171
+ pushes solutions out of :class:`Need` (the solution-verb lint), so this is their only honest
172
+ home. ``text`` is the chosen solution; ``alternatives`` are what was rejected — without them a
173
+ decision is a memo, not a choice. ``needs`` is required (a decision with no need it serves is
174
+ engineering with no product reason, which the loader refuses), and ``principles`` ties the
175
+ choice to the vision it rests on, so revising a principle after ``decided_at`` flags the
176
+ decision stale — the same ripple a withdrawal gets. Like a Principle it never enters coverage,
177
+ the plan, or the opinion check; its lifecycle reuses the Need verbs (``draft`` = proposed,
178
+ ``withdrawn`` = superseded, keeping the record) rather than a status enum of its own.
179
+ """
180
+
181
+ id: str
182
+ text: str # the chosen solution
183
+ needs: tuple[str, ...] # the needs this decision serves — required, validated at load
184
+ alternatives: tuple[str, ...] = () # the options rejected — what makes it a choice, not a memo
185
+ consequences: str | None = None # what the choice costs / rules out
186
+ principles: tuple[str, ...] = () # the principle(s) it rests on; revising one ripples to here
187
+ decided_at: str | None = None # ISO date decided — the comparand for the principle ripple
188
+ draft: bool = False # proposed, not yet committed (reuses the Need draft lifecycle)
189
+ withdrawn: Withdrawal | None = None # superseded / abandoned — the record stays as its trace
190
+
191
+
165
192
  class AttestationKind(StrEnum):
166
193
  """Who reviewed the warrant — a ladder of strength, human strongest (cf. SourceKind)."""
167
194
 
@@ -375,6 +402,7 @@ class Model:
375
402
  releases: tuple[Release, ...] = () # in declared order = ship sequence
376
403
  roles: tuple[Role, ...] = () # the roles needs' `who` play (#52)
377
404
  principles: tuple[Principle, ...] = () # the product vision, held as given
405
+ decisions: tuple[Decision, ...] = () # the solutions chosen for needs (OST), with alternatives
378
406
  argument: tuple[ArgNode, ...] = () # the GSN assurance graph (#28)
379
407
 
380
408
 
@@ -19,6 +19,7 @@ from .model import (
19
19
  ArgKind,
20
20
  ArgNode,
21
21
  AttestationKind,
22
+ Decision,
22
23
  Goal,
23
24
  Need,
24
25
  NeedResult,
@@ -679,6 +680,55 @@ def principle_report(needs: list[Need], principles: list[Principle]) -> list[Pri
679
680
  return out
680
681
 
681
682
 
683
+ def active_decisions(decisions: list[Decision]) -> list[Decision]:
684
+ """The decisions still in force — neither superseded (``withdrawn``) nor a staged ``draft``."""
685
+ return [d for d in decisions if d.withdrawn is None and not d.draft]
686
+
687
+
688
+ def decisions_for(need_id: str, decisions: list[Decision]) -> list[Decision]:
689
+ """The in-force decisions whose chosen solution serves ``need_id`` — the 'how' behind a need."""
690
+ return [d for d in active_decisions(decisions) if need_id in d.needs]
691
+
692
+
693
+ def stale_decisions(
694
+ decisions: list[Decision], principles: list[Principle]
695
+ ) -> list[tuple[Decision, str]]:
696
+ """Each in-force decision paired with a principle it cites that was revised after it was decided.
697
+
698
+ The same ripple a withdrawal gets (:func:`principle_report`), the other way round: a solution
699
+ chosen under an older statement of a principle comes back up when the principle moves. Reuses
700
+ :func:`_decided_before`; an undated decision or principle never counts (nothing to compare).
701
+ """
702
+ revised = {p.id: p.revised_at for p in principles}
703
+ out: list[tuple[Decision, str]] = []
704
+ for d in active_decisions(decisions):
705
+ for pid in d.principles:
706
+ if pid in revised and _decided_before(d.decided_at or "", revised[pid]):
707
+ out.append((d, pid))
708
+ return out
709
+
710
+
711
+ def orphaned_decisions(decisions: list[Decision], needs: list[Need]) -> list[Decision]:
712
+ """In-force decisions whose every served need is withdrawn or non-product — the real orphan.
713
+
714
+ A decision must name a need at load, but that need can later be withdrawn (``out_of_scope``) or
715
+ marked non-product. A decision left serving only such needs is engineering with no live product
716
+ reason — re-tie it to a live need, or withdraw it. (A need not in ``needs`` at all is a load
717
+ error, never reached here.)
718
+ """
719
+ by_id = {n.id: n for n in needs}
720
+ out: list[Decision] = []
721
+ for d in active_decisions(decisions):
722
+ live = [
723
+ nid
724
+ for nid in d.needs
725
+ if (n := by_id.get(nid)) is not None and not n.is_withdrawn and n.product
726
+ ]
727
+ if not live:
728
+ out.append(d)
729
+ return out
730
+
731
+
682
732
  @dataclass(frozen=True)
683
733
  class Contradiction:
684
734
  """Two needs in tension — clients wanting opposite things. ``unresolved`` = both still live.