prodc 0.3.78__tar.gz → 0.3.79__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 (62) hide show
  1. {prodc-0.3.78 → prodc-0.3.79}/PKG-INFO +1 -1
  2. {prodc-0.3.78 → prodc-0.3.79}/pyproject.toml +1 -1
  3. {prodc-0.3.78 → prodc-0.3.79}/src/prodc/agent.md +2 -0
  4. {prodc-0.3.78 → prodc-0.3.79}/src/prodc/board.py +8 -1
  5. {prodc-0.3.78 → prodc-0.3.79}/src/prodc/cli/app.py +42 -4
  6. {prodc-0.3.78 → prodc-0.3.79}/src/prodc/cli/commands.py +61 -0
  7. {prodc-0.3.78 → prodc-0.3.79}/src/prodc/cli/render.py +22 -2
  8. {prodc-0.3.78 → prodc-0.3.79}/src/prodc/cli/serialize.py +12 -1
  9. {prodc-0.3.78 → prodc-0.3.79}/src/prodc/loader.py +51 -16
  10. {prodc-0.3.78 → prodc-0.3.79}/src/prodc/model.py +27 -0
  11. {prodc-0.3.78 → prodc-0.3.79}/src/prodc/status.py +48 -0
  12. {prodc-0.3.78 → prodc-0.3.79}/src/prodc/writer.py +120 -20
  13. prodc-0.3.79/tests/test_principles.py +332 -0
  14. {prodc-0.3.78 → prodc-0.3.79}/tests/test_withdrawn.py +1 -0
  15. {prodc-0.3.78 → prodc-0.3.79}/.gitignore +0 -0
  16. {prodc-0.3.78 → prodc-0.3.79}/CHANGELOG.md +0 -0
  17. {prodc-0.3.78 → prodc-0.3.79}/LICENSE +0 -0
  18. {prodc-0.3.78 → prodc-0.3.79}/README.md +0 -0
  19. {prodc-0.3.78 → prodc-0.3.79}/examples/quickstart/features/orders.feature +0 -0
  20. {prodc-0.3.78 → prodc-0.3.79}/examples/quickstart/needs/jordan.yaml +0 -0
  21. {prodc-0.3.78 → prodc-0.3.79}/examples/quickstart/prodc.toml +0 -0
  22. {prodc-0.3.78 → prodc-0.3.79}/src/prodc/__init__.py +0 -0
  23. {prodc-0.3.78 → prodc-0.3.79}/src/prodc/adapters.py +0 -0
  24. {prodc-0.3.78 → prodc-0.3.79}/src/prodc/callbacks.py +0 -0
  25. {prodc-0.3.78 → prodc-0.3.79}/src/prodc/cli/__init__.py +0 -0
  26. {prodc-0.3.78 → prodc-0.3.79}/src/prodc/cli/common.py +0 -0
  27. {prodc-0.3.78 → prodc-0.3.79}/src/prodc/config.py +0 -0
  28. {prodc-0.3.78 → prodc-0.3.79}/src/prodc/dedup.py +0 -0
  29. {prodc-0.3.78 → prodc-0.3.79}/src/prodc/py.typed +0 -0
  30. {prodc-0.3.78 → prodc-0.3.79}/src/prodc/report.py +0 -0
  31. {prodc-0.3.78 → prodc-0.3.79}/src/prodc/report_html.py +0 -0
  32. {prodc-0.3.78 → prodc-0.3.79}/tests/test_adapters.py +0 -0
  33. {prodc-0.3.78 → prodc-0.3.79}/tests/test_answer.py +0 -0
  34. {prodc-0.3.78 → prodc-0.3.79}/tests/test_callbacks.py +0 -0
  35. {prodc-0.3.78 → prodc-0.3.79}/tests/test_citation.py +0 -0
  36. {prodc-0.3.78 → prodc-0.3.79}/tests/test_claim_gating.py +0 -0
  37. {prodc-0.3.78 → prodc-0.3.79}/tests/test_cli.py +0 -0
  38. {prodc-0.3.78 → prodc-0.3.79}/tests/test_command_hook.py +0 -0
  39. {prodc-0.3.78 → prodc-0.3.79}/tests/test_dedup.py +0 -0
  40. {prodc-0.3.78 → prodc-0.3.79}/tests/test_description.py +0 -0
  41. {prodc-0.3.78 → prodc-0.3.79}/tests/test_drafts.py +0 -0
  42. {prodc-0.3.78 → prodc-0.3.79}/tests/test_evalgate.py +0 -0
  43. {prodc-0.3.78 → prodc-0.3.79}/tests/test_external_adapter.py +0 -0
  44. {prodc-0.3.78 → prodc-0.3.79}/tests/test_gitlab_source.py +0 -0
  45. {prodc-0.3.78 → prodc-0.3.79}/tests/test_importance.py +0 -0
  46. {prodc-0.3.78 → prodc-0.3.79}/tests/test_loader.py +0 -0
  47. {prodc-0.3.78 → prodc-0.3.79}/tests/test_models.py +0 -0
  48. {prodc-0.3.78 → prodc-0.3.79}/tests/test_needref.py +0 -0
  49. {prodc-0.3.78 → prodc-0.3.79}/tests/test_relink.py +0 -0
  50. {prodc-0.3.78 → prodc-0.3.79}/tests/test_report.py +0 -0
  51. {prodc-0.3.78 → prodc-0.3.79}/tests/test_report_html.py +0 -0
  52. {prodc-0.3.78 → prodc-0.3.79}/tests/test_review.py +0 -0
  53. {prodc-0.3.78 → prodc-0.3.79}/tests/test_roles.py +0 -0
  54. {prodc-0.3.78 → prodc-0.3.79}/tests/test_source_role.py +0 -0
  55. {prodc-0.3.78 → prodc-0.3.79}/tests/test_sources.py +0 -0
  56. {prodc-0.3.78 → prodc-0.3.79}/tests/test_staleness.py +0 -0
  57. {prodc-0.3.78 → prodc-0.3.79}/tests/test_status.py +0 -0
  58. {prodc-0.3.78 → prodc-0.3.79}/tests/test_suggest.py +0 -0
  59. {prodc-0.3.78 → prodc-0.3.79}/tests/test_variants.py +0 -0
  60. {prodc-0.3.78 → prodc-0.3.79}/tests/test_who_variants.py +0 -0
  61. {prodc-0.3.78 → prodc-0.3.79}/tests/test_writer.py +0 -0
  62. {prodc-0.3.78 → prodc-0.3.79}/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.78
3
+ Version: 0.3.79
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.78"
3
+ version = "0.3.79"
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"
@@ -72,6 +72,8 @@ Read first, then act, then check.
72
72
  | record a new need (with honest provenance) | `prodc add need <id> --text "…" --who <you> --kind assumption --file needs/<f>.yaml` |
73
73
  | triage staged drafts | `prodc review` — list them; `--promote <id>…`/`--promote-all` onto the board, `--drop <id>…` to delete |
74
74
  | declare a role (the roster) | `prodc add role <id> --text "…" --file needs/<f>.yaml` |
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
+ | 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 |
75
77
  | 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) |
76
78
  | set or append a field | `prodc set <id> key=value` (append a list with `key+=value`, e.g. `proofs+=web:orders.feature#…`) |
77
79
  | propose / attach a proof for a HOLE | `prodc suggest <id> [--apply] [--dry-run]` |
@@ -28,6 +28,7 @@ from .model import (
28
28
  )
29
29
  from .status import (
30
30
  Handoff,
31
+ PrincipleReport,
31
32
  Recurrence,
32
33
  Summary,
33
34
  evidence_age_days,
@@ -36,6 +37,7 @@ from .status import (
36
37
  handoffs,
37
38
  last_observed,
38
39
  orphans,
40
+ principle_report,
39
41
  recurrence,
40
42
  release_report,
41
43
  resolve,
@@ -106,6 +108,7 @@ def need_payload(r: NeedResult, *, flavor: Literal["board", "html"] = "board") -
106
108
  "reason": need.withdrawn.reason,
107
109
  "date": need.withdrawn.date,
108
110
  "kind": need.withdrawn.kind.value,
111
+ "principle": need.withdrawn.principle,
109
112
  }
110
113
  if need.withdrawn
111
114
  else None
@@ -158,6 +161,7 @@ def need_payload(r: NeedResult, *, flavor: Literal["board", "html"] = "board") -
158
161
  "reason": need.withdrawn.reason,
159
162
  "date": need.withdrawn.date,
160
163
  "kind": need.withdrawn.kind.value,
164
+ "principle": need.withdrawn.principle,
161
165
  }
162
166
  if need.withdrawn
163
167
  else None
@@ -202,6 +206,7 @@ class ResolvedBoard:
202
206
  handoffs: list[Handoff]
203
207
  recurrence: list[Recurrence]
204
208
  orphans: dict[str, list[str]]
209
+ principles: list[PrincipleReport] # over the whole model — declined needs are off the board
205
210
 
206
211
 
207
212
  def gather(
@@ -218,7 +223,8 @@ def gather(
218
223
  appends the cross-need board warnings (#44/#45) to the results, as ``prodc status`` does —
219
224
  the markdown/HTML reports leave it off, exactly as they did before this was factored out.
220
225
  The declared goals/releases/roles come from ``model``; the recurrence/handoffs/orphans views
221
- are computed over the resolved ``needs``.
226
+ are computed over the resolved ``needs``. The principle report reads the whole ``model.needs``:
227
+ the needs declined under a principle are withdrawn, so the filtered ``needs`` never holds them.
222
228
  """
223
229
  use_needs = list(model.needs) if needs is None else needs
224
230
  results = resolve(use_needs, cfg.adapters, cfg.source_adapters)
@@ -239,4 +245,5 @@ def gather(
239
245
  handoffs=handoffs(use_needs),
240
246
  recurrence=recurrence(use_needs),
241
247
  orphans=orphans(use_needs, cfg.adapters),
248
+ principles=principle_report(list(model.needs), list(model.principles)),
242
249
  )
@@ -29,6 +29,7 @@ from .commands import (
29
29
  cmd_reinstate,
30
30
  cmd_report,
31
31
  cmd_review,
32
+ cmd_revise,
32
33
  cmd_set,
33
34
  cmd_show,
34
35
  cmd_status,
@@ -165,11 +166,15 @@ def main(argv: list[str] | None = None) -> int:
165
166
  st2.set_defaults(func=cmd_set)
166
167
 
167
168
  ad = sub.add_parser(
168
- "add", help="create a new agent-authored need, or declare a role (writes YAML)"
169
+ "add",
170
+ help="create a new agent-authored need, or declare a role or a principle (writes YAML)",
169
171
  )
170
- ad.add_argument("noun", choices=["need", "role"], help="what to add (need or role)")
171
- ad.add_argument("need_id", help="the new need's id (or role id for `add role`), e.g. N-9")
172
- ad.add_argument("--text", required=True, help="the need text (or the role description)")
172
+ ad.add_argument("noun", choices=["need", "role", "principle"], help="what to add")
173
+ ad.add_argument(
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",
176
+ )
177
+ ad.add_argument("--text", required=True, help="the need text (or the role / principle text)")
173
178
  ad.add_argument(
174
179
  "--who",
175
180
  default=None,
@@ -315,6 +320,13 @@ def main(argv: list[str] | None = None) -> int:
315
320
  "or duplicate (#74)",
316
321
  )
317
322
  wd.add_argument("--date", default=None, help="when (ISO, e.g. 2026-10-10; default: today)")
323
+ wd.add_argument(
324
+ "--principle",
325
+ default=None,
326
+ metavar="ID",
327
+ help="the product principle this decision rests on (validated against principles:) — revising it "
328
+ "later flags this decision for re-review",
329
+ )
318
330
  wd.add_argument("--config", default="prodc.toml", help="path to prodc.toml")
319
331
  wd.add_argument("--json", action="store_true", help="machine-readable output")
320
332
  wd.add_argument(
@@ -345,6 +357,32 @@ def main(argv: list[str] | None = None) -> int:
345
357
  )
346
358
  ri.set_defaults(func=cmd_reinstate)
347
359
 
360
+ rvz = sub.add_parser(
361
+ "revise", help="revise a product principle in place — new text and/or a fresh revised_at"
362
+ )
363
+ rvz.add_argument("noun", choices=["principle"], help="what to revise (principle)")
364
+ rvz.add_argument("principle_id", help="the principle id to revise, e.g. P-1")
365
+ rvz.add_argument(
366
+ "--text", default=None, help="new principle text (optional; omit to only re-date it)"
367
+ )
368
+ rvz.add_argument(
369
+ "--date",
370
+ default=None,
371
+ help="the new revised_at (ISO, e.g. 2026-10-15; default: today) — fires the ripple",
372
+ )
373
+ rvz.add_argument("--config", default="prodc.toml", help="path to prodc.toml")
374
+ rvz.add_argument("--json", action="store_true", help="machine-readable output")
375
+ rvz.add_argument(
376
+ "--dry-run", action="store_true", help="validate and show the diff, don't write"
377
+ )
378
+ rvz.add_argument(
379
+ "--expect-sha",
380
+ default=None,
381
+ metavar="SHA256",
382
+ help="refuse if the file's content hash differs (optimistic lock)",
383
+ )
384
+ rvz.set_defaults(func=cmd_revise)
385
+
348
386
  rv = sub.add_parser(
349
387
  "review", help="triage staged drafts: list them, or --promote / --drop a batch (#60)"
350
388
  )
@@ -38,6 +38,7 @@ from ..status import (
38
38
  argument_defects,
39
39
  evidence_age_days,
40
40
  importance_revisit_due,
41
+ principle_report,
41
42
  proof_suggestions,
42
43
  recurrence,
43
44
  relink_suggestions,
@@ -49,6 +50,7 @@ from ..writer import (
49
50
  WriteError,
50
51
  accept,
51
52
  add_need,
53
+ add_principle,
52
54
  add_proof,
53
55
  add_role,
54
56
  attest,
@@ -57,6 +59,7 @@ from ..writer import (
57
59
  link_variant_group,
58
60
  reinstate,
59
61
  relink_proof,
62
+ revise_principle,
60
63
  set_field,
61
64
  set_importance,
62
65
  withdraw,
@@ -83,6 +86,7 @@ from .render import (
83
86
  render_out_of_scope,
84
87
  render_plan,
85
88
  render_portfolio,
89
+ render_principles,
86
90
  render_recurrence,
87
91
  render_releases,
88
92
  render_roles,
@@ -232,6 +236,16 @@ def cmd_lint(args: argparse.Namespace) -> int:
232
236
  _annotate_importance_warnings(results, cfg)
233
237
  _annotate_draft_age_warnings(results, cfg)
234
238
  _annotate_label_deprecation(results)
239
+ # The principle ripple: a need withdrawn citing a principle that was revised *after* the decision
240
+ # comes back up — the frame it was judged against has changed since. A warning, never a hard
241
+ # error; prodc flags, the PM re-decides.
242
+ for rep in principle_report(needs, list(model.principles)):
243
+ for n in rep.stale:
244
+ if n.id in by_id and n.withdrawn is not None:
245
+ by_id[n.id].warnings.append(
246
+ f"withdrawn per principle {rep.principle.id} on {n.withdrawn.date}, but "
247
+ f"{rep.principle.id} was revised {rep.principle.revised_at} — re-review the decision"
248
+ )
235
249
  errors = _lint_errors(results)
236
250
  warnings = [
237
251
  (r.need.id, w) for r in sorted(results, key=lambda x: x.need.id) for w in r.warnings
@@ -318,6 +332,7 @@ def cmd_status(args: argparse.Namespace) -> int:
318
332
  role_cov,
319
333
  unroled,
320
334
  hos,
335
+ principles=board.principles,
321
336
  )
322
337
  )
323
338
  else:
@@ -350,6 +365,8 @@ def cmd_status(args: argparse.Namespace) -> int:
350
365
  print(render_handoffs(hos))
351
366
  if recs:
352
367
  print(render_recurrence(recs))
368
+ if model.principles:
369
+ print(render_principles(board.principles))
353
370
  if orphan_map:
354
371
  print(render_orphans(orphan_map))
355
372
 
@@ -479,6 +496,22 @@ def cmd_add(args: argparse.Namespace) -> int:
479
496
  print(f"prodc: {exc}", file=sys.stderr)
480
497
  return 2
481
498
  return emit_edit(edit, args.dry_run, args.json)
499
+ if args.noun == "principle":
500
+ try:
501
+ cfg = Config.load(cfg_path)
502
+ edit = add_principle(
503
+ cfg,
504
+ args.need_id,
505
+ args.text,
506
+ file=args.file,
507
+ revised_at=date.today().isoformat(),
508
+ expect_sha=args.expect_sha,
509
+ dry_run=args.dry_run,
510
+ )
511
+ except (ConfigError, LoaderError, WriteError) as exc:
512
+ print(f"prodc: {exc}", file=sys.stderr)
513
+ return 2
514
+ return emit_edit(edit, args.dry_run, args.json)
482
515
  if args.who is None:
483
516
  print("prodc: add need requires --who (the author recorded on the source)", file=sys.stderr)
484
517
  return 2
@@ -578,6 +611,7 @@ def cmd_withdraw(args: argparse.Namespace) -> int:
578
611
  reason=args.reason,
579
612
  date=args.date or date.today().isoformat(),
580
613
  kind=args.kind,
614
+ principle=args.principle,
581
615
  expect_sha=args.expect_sha,
582
616
  dry_run=args.dry_run,
583
617
  )
@@ -607,6 +641,33 @@ def cmd_reinstate(args: argparse.Namespace) -> int:
607
641
  return emit_edit(edit, args.dry_run, args.json)
608
642
 
609
643
 
644
+ def cmd_revise(args: argparse.Namespace) -> int:
645
+ """Revise a product principle in place: optional new text, always a fresh revised_at (the ripple).
646
+
647
+ Re-stamping ``revised_at`` is the point — every need withdrawn citing this principle with an
648
+ earlier date comes back up for re-review (surfaced by `lint`). Comment-safe: only the principle's
649
+ own block is spliced, the rest of the file is untouched.
650
+ """
651
+ cfg_path = Path(args.config)
652
+ if not cfg_path.exists():
653
+ print(f"prodc: no config at {cfg_path}", file=sys.stderr)
654
+ return 2
655
+ try:
656
+ cfg = Config.load(cfg_path)
657
+ edit = revise_principle(
658
+ cfg,
659
+ args.principle_id,
660
+ text=args.text,
661
+ revised_at=args.date or date.today().isoformat(),
662
+ expect_sha=args.expect_sha,
663
+ dry_run=args.dry_run,
664
+ )
665
+ except (ConfigError, LoaderError, WriteError) as exc:
666
+ print(f"prodc: {exc}", file=sys.stderr)
667
+ return 2
668
+ return emit_edit(edit, args.dry_run, args.json)
669
+
670
+
610
671
  def cmd_review(args: argparse.Namespace) -> int:
611
672
  """Triage draft needs: list them with a duplicate score, or --promote / --drop a batch (#60).
612
673
 
@@ -21,7 +21,7 @@ from ..model import (
21
21
  Role,
22
22
  ears_shape,
23
23
  )
24
- from ..status import Handoff, Recurrence, Summary, rank, state_priority
24
+ from ..status import Handoff, PrincipleReport, Recurrence, Summary, rank, state_priority
25
25
  from .common import PortfolioTotals, RepoRow, brief_open
26
26
 
27
27
  _LABEL = {
@@ -261,6 +261,25 @@ def render_recurrence(recs: list[Recurrence]) -> str:
261
261
  return "\n".join(out)
262
262
 
263
263
 
264
+ def render_principles(reports: list[PrincipleReport]) -> str:
265
+ """The product vision as principles, with the decisions resting on each and which have gone stale.
266
+
267
+ An principle is held as given, not validated; ``declined`` are the needs withdrawn citing it, and
268
+ ``stale`` (the ripple) are those decided before its latest ``revised_at`` — flagged to re-review.
269
+ """
270
+ out = ["\nprinciples (the product vision — held as given):"]
271
+ for rep in reports:
272
+ a = rep.principle
273
+ rev = f"revised {a.revised_at}" if a.revised_at else "undated"
274
+ out.append(f" {a.id:<6} {rev} {a.text}")
275
+ if rep.declined:
276
+ out.append(f" declined: {', '.join(n.id for n in rep.declined)}")
277
+ if rep.stale:
278
+ ids = ", ".join(n.id for n in rep.stale)
279
+ out.append(f" ⚠ decided before the latest revision: {ids} — re-review")
280
+ return "\n".join(out)
281
+
282
+
264
283
  def render_show(r: NeedResult, importance_label: str | None = None) -> str:
265
284
  """One need as a citable answer — the artifact you drop into a thread."""
266
285
  need = r.need
@@ -278,7 +297,8 @@ def render_show(r: NeedResult, importance_label: str | None = None) -> str:
278
297
  if need.withdrawn:
279
298
  w = need.withdrawn
280
299
  tag = "" if w.kind.value == "wont_do" else f" [{w.kind.value}]"
281
- lines.append(f" withdrawn{tag}: {w.reason} ({w.date})")
300
+ per = f" per {w.principle}" if w.principle else ""
301
+ lines.append(f" withdrawn{tag}{per}: {w.reason} ({w.date})")
282
302
  if need.description:
283
303
  lines.append(f" description: {need.description}")
284
304
  if need.importance is not None:
@@ -13,7 +13,7 @@ from ..board import need_payload
13
13
  from ..board import warrant as _warrant
14
14
  from ..dedup import DupPair, VariantGroup
15
15
  from ..model import Goal, Model, NeedResult, NeedState, Release, Role
16
- from ..status import Handoff, Recurrence, Summary, rank
16
+ from ..status import Handoff, PrincipleReport, Recurrence, Summary, rank
17
17
  from .common import brief_open
18
18
 
19
19
 
@@ -43,6 +43,7 @@ def to_json(
43
43
  role_cov: list[tuple[Role, Summary]],
44
44
  unroled: list[NeedResult],
45
45
  hos: list[Handoff],
46
+ principles: list[PrincipleReport] | None = None,
46
47
  ) -> str:
47
48
  payload = {
48
49
  "summary": _summary_payload(summary, orphan_map),
@@ -100,6 +101,16 @@ def to_json(
100
101
  for goal, gsum in goal_cov
101
102
  ],
102
103
  "ungoaled": [r.need.id for r in ungoaled],
104
+ "principles": [
105
+ {
106
+ "id": rep.principle.id,
107
+ "text": rep.principle.text,
108
+ "revised_at": rep.principle.revised_at,
109
+ "declined": [n.id for n in rep.declined],
110
+ "stale": [n.id for n in rep.stale],
111
+ }
112
+ for rep in (principles or [])
113
+ ],
103
114
  "needs": [need_payload(r) for r in results],
104
115
  }
105
116
  return json.dumps(payload, indent=2)
@@ -25,6 +25,7 @@ from .model import (
25
25
  Goal,
26
26
  Model,
27
27
  Need,
28
+ Principle,
28
29
  Release,
29
30
  Role,
30
31
  Source,
@@ -131,12 +132,13 @@ def _answer(raw: object, where: str) -> Answer | None:
131
132
 
132
133
 
133
134
  def _withdrawal(raw: object, where: str) -> Withdrawal | None:
134
- """Parse ``withdrawn: {reason, date, kind?}`` into a :class:`Withdrawal` (#74).
135
+ """Parse ``withdrawn: {reason, date, kind?, principle?}`` into a :class:`Withdrawal` (#74).
135
136
 
136
137
  ``reason`` and ``date`` are required when the key is present — a decision against a need must
137
138
  say why and when, the same way an attestation needs who and when. ``kind`` is optional and
138
139
  defaults to ``wont_do``; it is validated with the same ``Enum(name) except ValueError`` pattern
139
- as ``SourceKind``, so an unknown kind is a :class:`LoaderError` the author owns.
140
+ as ``SourceKind``, so an unknown kind is a :class:`LoaderError` the author owns. ``principle`` is
141
+ an optional Principle id, checked against the declared principles once every file is loaded.
140
142
  """
141
143
  if raw is None:
142
144
  return None
@@ -153,7 +155,8 @@ def _withdrawal(raw: object, where: str) -> Withdrawal | None:
153
155
  raise LoaderError(
154
156
  f"{where}.kind: unknown withdrawal kind {kind_name!r} ({kinds})"
155
157
  ) from None
156
- return Withdrawal(reason=reason, date=date, kind=kind)
158
+ principle = _opt_str(tbl.get("principle"), f"{where}.principle")
159
+ return Withdrawal(reason=reason, date=date, kind=kind, principle=principle)
157
160
 
158
161
 
159
162
  # Old source-kind names (pre-0.3.58) mapped to the primary/secondary/assumption ladder, so
@@ -304,6 +307,20 @@ def _role(raw: object, where: str) -> Role:
304
307
  )
305
308
 
306
309
 
310
+ def _principle(raw: object, where: str) -> Principle:
311
+ """Parse one ``principles:`` entry — a product commitment with its provenance."""
312
+ tbl = _as_mapping(raw, where)
313
+ return Principle(
314
+ id=_as_str(tbl.get("id"), f"{where}.id"),
315
+ text=_as_str(tbl.get("text"), f"{where}.text"),
316
+ revised_at=_opt_str(tbl.get("revised_at"), f"{where}.revised_at"),
317
+ sources=tuple(
318
+ _source(s, f"{where}.sources[{i}]")
319
+ for i, s in enumerate(_as_list(tbl.get("sources"), f"{where}.sources"))
320
+ ),
321
+ )
322
+
323
+
307
324
  def _arg_node(raw: object, where: str) -> ArgNode:
308
325
  tbl = _as_mapping(raw, where)
309
326
  kind_name = _as_str(tbl.get("kind"), f"{where}.kind")
@@ -336,11 +353,12 @@ class _Loaded:
336
353
  goals: list[Goal] = field(default_factory=list[Goal])
337
354
  releases: list[Release] = field(default_factory=list[Release])
338
355
  roles: list[Role] = field(default_factory=list[Role])
356
+ principles: list[Principle] = field(default_factory=list[Principle])
339
357
  argument: list[ArgNode] = field(default_factory=list[ArgNode])
340
358
 
341
359
 
342
360
  def _load_file(path: Path, seen: dict[str, dict[str, Path]]) -> _Loaded:
343
- """Parse one prodc file for every noun list it carries (``needs:``/``goals:``/``releases:``)."""
361
+ """Parse one prodc file for every noun list it carries (``needs:``/``goals:``/``principles:``/…)."""
344
362
  where = path.name
345
363
  try:
346
364
  doc = yaml.safe_load(path.read_text(encoding="utf-8"))
@@ -376,6 +394,10 @@ def _load_file(path: Path, seen: dict[str, dict[str, Path]]) -> _Loaded:
376
394
  role = _role(raw, f"{where}.roles[{i}]")
377
395
  _unique("role", role.id)
378
396
  out.roles.append(role)
397
+ for i, raw in enumerate(_as_list(tbl.get("principles"), f"{where}.principles")):
398
+ principle = _principle(raw, f"{where}.principles[{i}]")
399
+ _unique("principle", principle.id)
400
+ out.principles.append(principle)
379
401
  for i, raw in enumerate(_as_list(tbl.get("argument"), f"{where}.argument")):
380
402
  node = _arg_node(raw, f"{where}.argument[{i}]")
381
403
  _unique("argument node", node.id)
@@ -387,10 +409,11 @@ def load(config: Config) -> Model:
387
409
  """Read every prodc file once into a :class:`~prodc.model.Model`, then validate cross-refs.
388
410
 
389
411
  One loader for every noun: ids are unique within their kind, and each string reference
390
- (``variant_of``, ``children``, ``goal``, ``release``, ``role``) is checked here as a hard
391
- error — a dangling reference to something declared in these files is a typo you own, not a
392
- runtime unknown. ``role`` is checked unconditionally like ``goal``/``release``: it is a new
393
- field, so (unlike free-text ``who``) there is no migration reason to make it lax.
412
+ (``variant_of``, ``children``, ``goal``, ``release``, ``role``, a withdrawal's ``principle``) is
413
+ checked here as a hard error — a dangling reference to something declared in these files is a
414
+ typo you own, not a runtime unknown. ``role`` is checked unconditionally like
415
+ ``goal``/``release``: it is a new field, so (unlike free-text ``who``) there is no migration
416
+ reason to make it lax.
394
417
  """
395
418
  acc = _Loaded()
396
419
  seen: dict[str, dict[str, Path]] = {
@@ -398,6 +421,7 @@ def load(config: Config) -> Model:
398
421
  "goal": {},
399
422
  "release": {},
400
423
  "role": {},
424
+ "principle": {},
401
425
  "argument node": {},
402
426
  }
403
427
  for pattern in config.need_globs:
@@ -407,19 +431,27 @@ def load(config: Config) -> Model:
407
431
  acc.goals.extend(f.goals)
408
432
  acc.releases.extend(f.releases)
409
433
  acc.roles.extend(f.roles)
434
+ acc.principles.extend(f.principles)
410
435
  acc.argument.extend(f.argument)
411
436
  _check_variants(acc.needs)
412
437
  _check_children(acc.needs)
413
438
  _check_ref(acc.needs, "goal", lambda n: n.goal, {g.id for g in acc.goals})
414
439
  _check_ref(acc.needs, "release", lambda n: n.release, {r.id for r in acc.releases})
415
440
  _check_ref(acc.needs, "role", lambda n: n.role, {p.id for p in acc.roles})
416
- _check_source_roles(acc.needs, {p.id for p in acc.roles})
441
+ _check_ref(
442
+ acc.needs,
443
+ "principle",
444
+ lambda n: n.withdrawn.principle if n.withdrawn else None,
445
+ {a.id for a in acc.principles},
446
+ )
447
+ _check_source_roles(acc.needs, acc.principles, {p.id for p in acc.roles})
417
448
  _check_argument(acc.argument)
418
449
  return Model(
419
450
  needs=tuple(acc.needs),
420
451
  goals=tuple(acc.goals),
421
452
  releases=tuple(acc.releases),
422
453
  roles=tuple(acc.roles),
454
+ principles=tuple(acc.principles),
423
455
  argument=tuple(acc.argument),
424
456
  )
425
457
 
@@ -439,18 +471,21 @@ def _check_ref(
439
471
  raise LoaderError(f"need {need.id!r}: {role} {ref!r} is not a known {role} id")
440
472
 
441
473
 
442
- def _check_source_roles(needs: list[Need], known: set[str]) -> None:
474
+ def _check_source_roles(needs: list[Need], principles: list[Principle], known: set[str]) -> None:
443
475
  """Every ``source.role`` that is set must name a declared role id (like ``need.role``, #52).
444
476
 
445
- Validated unconditionally: ``role`` is a new, opt-in field, so there is no migration reason to
446
- be lax — a dangling role on a source is a typo the author owns, surfaced at load, not a silent
447
- unknown that only shows as missing provenance later.
477
+ Validated unconditionally, on a need's sources and a principle's alike: ``role`` is a new, opt-in
478
+ field, so there is no migration reason to be lax — a dangling role on a source is a typo the
479
+ author owns, surfaced at load, not a silent unknown that only shows as missing provenance later.
448
480
  """
449
- for need in needs:
450
- for i, s in enumerate(need.sources):
481
+ owners = [("need", n.id, n.sources) for n in needs] + [
482
+ ("principle", a.id, a.sources) for a in principles
483
+ ]
484
+ for noun, oid, sources in owners:
485
+ for i, s in enumerate(sources):
451
486
  if s.role is not None and s.role not in known:
452
487
  raise LoaderError(
453
- f"need {need.id!r}: source[{i}] role {s.role!r} is not a known role id"
488
+ f"{noun} {oid!r}: source[{i}] role {s.role!r} is not a known role id"
454
489
  )
455
490
 
456
491
 
@@ -139,6 +139,29 @@ class Role:
139
139
  text: str
140
140
 
141
141
 
142
+ @dataclass(frozen=True)
143
+ class Principle:
144
+ """A founding product commitment — the vision, held as given rather than validated.
145
+
146
+ In the logic-system reading prodc already uses (a need is a proposition, a proof its
147
+ answer-with-evidence, an ``assumption`` a conjecture taken on faith *to be tested*), a principle
148
+ differs in intent: it is a chosen frame the product is built on, not a bet pending validation.
149
+ It is what decides between clients who want opposite things — a need that contradicts it is
150
+ withdrawn ``out_of_scope`` citing it (:attr:`Withdrawal.principle`).
151
+
152
+ Principles are discovered, not decreed: ``sources`` keeps the signals that surfaced one (often a
153
+ client), and ``revised_at`` dates its latest statement. A decision taken under an older version
154
+ of the principle is flagged for re-review when the principle is revised — the ripple. A separate noun
155
+ (like :class:`Role`), not a Need marker: a principle is a stance, not what a person wants, so it
156
+ never enters coverage, the plan, or the opinion check.
157
+ """
158
+
159
+ id: str
160
+ text: str
161
+ revised_at: str | None = None # ISO date the principle was last stated or revised
162
+ sources: tuple[Source, ...] = () # the signals that surfaced it — provenance, not validation
163
+
164
+
142
165
  class AttestationKind(StrEnum):
143
166
  """Who reviewed the warrant — a ladder of strength, human strongest (cf. SourceKind)."""
144
167
 
@@ -185,11 +208,14 @@ class Withdrawal:
185
208
  Unlike a draft (staged, not yet decided), a withdrawal is a decision *against*: the need is
186
209
  taken off the active board and out of coverage, with its ``reason``, ``date`` and ``kind`` kept
187
210
  on the record as the durable trace (``prodc show`` still renders it). Reinstate to put it back.
211
+ ``principle`` names the :class:`Principle` the decision rests on (typically for ``out_of_scope``), so a
212
+ later revision of that principle can flag this decision for re-review.
188
213
  """
189
214
 
190
215
  reason: str
191
216
  date: str
192
217
  kind: WithdrawalKind = WithdrawalKind.WONT_DO
218
+ principle: str | None = None # id of the Principle this decision rests on
193
219
 
194
220
 
195
221
  @dataclass(frozen=True)
@@ -345,6 +371,7 @@ class Model:
345
371
  goals: tuple[Goal, ...] = ()
346
372
  releases: tuple[Release, ...] = () # in declared order = ship sequence
347
373
  roles: tuple[Role, ...] = () # the roles needs' `who` play (#52)
374
+ principles: tuple[Principle, ...] = () # the product vision, held as given
348
375
  argument: tuple[ArgNode, ...] = () # the GSN assurance graph (#28)
349
376
 
350
377
 
@@ -23,6 +23,7 @@ from .model import (
23
23
  Need,
24
24
  NeedResult,
25
25
  NeedState,
26
+ Principle,
26
27
  ProofState,
27
28
  Release,
28
29
  Role,
@@ -631,6 +632,53 @@ def role_report(
631
632
  return coverage, unroled
632
633
 
633
634
 
635
+ @dataclass(frozen=True)
636
+ class PrincipleReport:
637
+ """One principle and the decisions that rest on it — the ripple, made visible."""
638
+
639
+ principle: Principle
640
+ declined: tuple[Need, ...] # needs withdrawn citing this principle
641
+ stale: tuple[Need, ...] # of those, decided before the principle's latest revision — re-review
642
+
643
+
644
+ def _decided_before(decided: str, revised: str | None) -> bool:
645
+ """True when a decision dated ``decided`` predates a principle revised on ``revised``.
646
+
647
+ Both are ISO dates. An undated principle, or a malformed date on either side, is never stale —
648
+ there is no revision to compare against, and the loader does not own date format here.
649
+ """
650
+ if revised is None:
651
+ return False
652
+ try:
653
+ return date.fromisoformat(decided) < date.fromisoformat(revised)
654
+ except ValueError:
655
+ return False
656
+
657
+
658
+ def principle_report(needs: list[Need], principles: list[Principle]) -> list[PrincipleReport]:
659
+ """Each declared principle with the needs withdrawn under it, and which of those are stale.
660
+
661
+ Reads the *whole* need list, withdrawn needs included — a declined need is off the board, so
662
+ the board-filtered list would hide exactly what this reports. A decision is stale when the
663
+ withdrawal is dated before the principle's ``revised_at``: the frame it was judged against has
664
+ changed since, so it comes back up for re-review. Records-only — prodc flags, the PM re-decides.
665
+ """
666
+ by_principle: dict[str, list[Need]] = {a.id: [] for a in principles}
667
+ for n in needs:
668
+ if n.withdrawn is not None and n.withdrawn.principle in by_principle:
669
+ by_principle[n.withdrawn.principle].append(n)
670
+ out: list[PrincipleReport] = []
671
+ for a in principles:
672
+ declined = sorted(by_principle[a.id], key=lambda n: n.id)
673
+ stale = [
674
+ n
675
+ for n in declined
676
+ if n.withdrawn is not None and _decided_before(n.withdrawn.date, a.revised_at)
677
+ ]
678
+ out.append(PrincipleReport(a, tuple(declined), tuple(stale)))
679
+ return out
680
+
681
+
634
682
  @dataclass(frozen=True)
635
683
  class Handoff:
636
684
  """A derived step in a cross-role journey: one flow child hands off to the next (#52)."""