diff --git a/Sources/SpecGraph/Documentation.docc/ProductWorkspacePilots.md b/Sources/SpecGraph/Documentation.docc/ProductWorkspacePilots.md index f963e4dd..90d7d7df 100644 --- a/Sources/SpecGraph/Documentation.docc/ProductWorkspacePilots.md +++ b/Sources/SpecGraph/Documentation.docc/ProductWorkspacePilots.md @@ -360,6 +360,7 @@ The follow-up backlog from that smoke is: candidates as a live product story; done in proposal `0204` with `make real-idea-smoke-depth-baseline` and `product_demo_depth_report.json`; 11. add human-friendly display aliases for long generated candidate node ids; + done producer-side in Proposal `0212` while preserving canonical refs; 12. reconcile custom run-dir repaired candidates with the existing Platform arbitrary-route approval/promotion dry-run path, then either document that path as sufficient or add a small producer handoff that avoids copying @@ -736,9 +737,9 @@ contracts instead of creating a second depth metric: workspace-bound templates now publish `answers_required`, `clarification_not_required`, or `clarification_blocked`, and mandatory policy context is represented as an ordinary browser-answerable target; -2. candidate graphs should keep stable machine ids while publishing - human-friendly display aliases for generated nodes, candidate overview, PR - artifacts, and topology/readiness panels; +2. Proposal `0212` keeps candidate graph machine ids stable while publishing + deterministic human-friendly display aliases for generated nodes, candidate + overview, review YAML, and topology/readiness panels; 3. the older custom-run approval/promotion follow-up should be reconciled with the managed-operation path: either document the existing arbitrary-route Platform dry-run capability as sufficient, or add a small producer handoff so diff --git a/docs/archive/proposal_sources/0212_human_friendly_candidate_display_aliases.md b/docs/archive/proposal_sources/0212_human_friendly_candidate_display_aliases.md new file mode 100644 index 00000000..68751e9e --- /dev/null +++ b/docs/archive/proposal_sources/0212_human_friendly_candidate_display_aliases.md @@ -0,0 +1,12 @@ +# 0212 Human-Friendly Candidate Display Aliases + +## Source Draft + +Generated candidate node ids are authoritative machine references but are too +long for Product Workspace, topology review, and promoted candidate documents. +Add one deterministic SpecGraph-owned presentation alias per candidate node. + +The alias must remain distinct from ontology aliases and route aliases. It must +never replace canonical node ids, graph refs, materialized filenames, or +promotion paths. SpecSpace may render the alias, but must keep canonical refs +for navigation and managed operations. diff --git a/docs/product_workspace_graph_versioning_roadmap.md b/docs/product_workspace_graph_versioning_roadmap.md index ef64382f..1e50a420 100644 --- a/docs/product_workspace_graph_versioning_roadmap.md +++ b/docs/product_workspace_graph_versioning_roadmap.md @@ -1646,11 +1646,12 @@ more reliable and easier for SpecSpace to present: `clarification_not_required` outcome, or a strict `clarification_blocked` finding. SpecSpace can remove its deterministic product-demo fallback and rely on producer evidence. -2. **Human-friendly candidate display aliases.** Keep stable machine ids for - refs, materialization, and promotion paths, but add deterministic display - aliases or readable labels for long generated candidate node ids. Candidate - overview, PR artifacts, and SpecSpace topology/readiness panels should show - the alias while preserving the canonical id in evidence refs. +2. **Human-friendly candidate display aliases.** Implemented producer-side in + proposal `0212`. Candidate nodes retain stable machine ids for refs, + materialization, and promotion paths while publishing deterministic, + public-safe `display_alias` labels for candidate overview, topology, and + materialized review artifacts. Consumers must preserve canonical ids in + evidence refs and managed-operation inputs. 3. **Custom-run approval/promotion handoff reconciliation.** The local managed-operation chain can already drive arbitrary workspace routes through approval and promotion dry-run paths. Reconcile that with the older @@ -1809,10 +1810,10 @@ The same run exposed the next product-flow friction points: runs can now call `make real-idea-smoke-depth-baseline` to fail shallow demos that lack event-storming depth, workflow topology, candidate overview, or Idea Maturity before SpecSpace presents them as a product story. -11. **Human-friendly candidate ids.** Planned. Long constraint statements currently - produce truncated node ids. Keep stable machine ids, but add shorter - readable slugs or display aliases for UI, PR artifacts, and candidate - overview documents. +11. **Human-friendly candidate ids.** Done in proposal `0212`. Long constraint + statements retain their stable machine ids, while deterministic display + aliases provide shorter readable labels for UI, topology, review YAML, and + candidate overview documents. 12. **Custom-run Platform promotion dry-run.** Needs reconciliation. Later SpecSpace/Platform managed-operation smokes proved arbitrary-route approval/promotion dry-runs, but this cash-flow-specific follow-up should be diff --git a/docs/proposals/0212_human_friendly_candidate_display_aliases.md b/docs/proposals/0212_human_friendly_candidate_display_aliases.md new file mode 100644 index 00000000..c0968996 --- /dev/null +++ b/docs/proposals/0212_human_friendly_candidate_display_aliases.md @@ -0,0 +1,53 @@ +# 0212 Human-Friendly Candidate Display Aliases + +## Status + +Implemented bounded presentation-contract slice. + +## Summary + +Long generated candidate node ids and constraint statements are necessary for +stable machine references but are difficult to scan in operator-facing product +surfaces. Candidate graph consumers must not independently shorten ids because +that would create inconsistent and potentially ambiguous labels. + +## Decision + +`candidate_spec_graph.nodes[]` now carries a presentation-only +`display_alias` and `display_alias_source`: + +- `id` remains the only canonical node identity for graph edges, repair refs, + materialization, promotion paths, and Git operations; +- aliases are deterministic, public-safe, bounded, and disambiguated inside a + candidate graph; +- aliases prefer a supplied public-safe alias, otherwise derive from the + public node title; +- aliases are propagated into `candidate_overview` and candidate + materialization outputs without replacing canonical refs. + +`candidate_overview` exposes a complete `candidate_nodes.alias_by_node_id` +presentation index and topology endpoint aliases. Materialized review YAML uses +the alias as its short title while preserving the source title and canonical +candidate node id in `specification`. + +## Authority and Privacy Boundary + +This proposal does not: + +- change canonical ids, edge refs, materialized ids, filenames, or promotion + paths; +- add aliases as graph lookup keys or acceptance inputs; +- mutate canonical specs, write Ontology packages, accept ontology terms, or + execute Platform or Git Service; +- expose raw idea text, private paths, secrets, or operator notes. + +## Acceptance Criteria + +- Candidate graphs emit stable public-safe aliases for ready nodes. +- Alias collisions resolve deterministically without changing canonical ids. +- Invalid explicit aliases block graph readiness rather than leaking private or + multiline input. +- Candidate overview and topology retain canonical refs while exposing aliases. +- Materialized review files retain canonical ids and paths while carrying the + short alias and original source title. +- Older artifacts without aliases remain readable by downstream consumers. diff --git a/tests/test_candidate_overview.py b/tests/test_candidate_overview.py index 4a20b6fc..83321c71 100644 --- a/tests/test_candidate_overview.py +++ b/tests/test_candidate_overview.py @@ -102,6 +102,8 @@ def candidate_graph( { "id": "candidate-spec.product-boundary", "kind": "product_spec_boundary", + "display_alias": "Cash flow boundary", + "display_alias_source": "provided", "title": "Cash Flow Product Boundary", "description": "Track recurring payments and prevent overspending.", "source_event_refs": ["actor.user", "command.record-payment"], @@ -114,6 +116,8 @@ def candidate_graph( { "id": "candidate-spec.record-payment", "kind": "command_spec", + "display_alias": "Record a payment", + "display_alias_source": "provided", "title": "Record Payment", "source_event_refs": ["command.record-payment", "event.payment-recorded"], "requirements": [{"id": "req.record-payment"}], @@ -292,6 +296,11 @@ def test_candidate_overview_builds_public_safe_narrative() -> None: assert overview["summary"]["candidate_id"] == "cash-flow-control" assert overview["summary"]["graph_source"] == "repaired_candidate_graph" assert overview["sections"]["topology"]["workflow_edge_count"] == 2 + aliases = overview["sections"]["candidate_nodes"]["alias_by_node_id"] + assert aliases["candidate-spec.record-payment"] == "Record a payment" + topology_edge = overview["sections"]["topology"]["examples"][0] + assert topology_edge["from_display_alias"] == "Cash flow boundary" + assert topology_edge["to_display_alias"] == "Record a payment" assert overview["sections"]["repair"]["ready_for_candidate_approval"] is True assert overview["sections"]["project_local_ontology"]["accepted_decision_count"] == 1 assert ( diff --git a/tests/test_candidate_spec_graph.py b/tests/test_candidate_spec_graph.py index 4a2aa52b..8db2925d 100644 --- a/tests/test_candidate_spec_graph.py +++ b/tests/test_candidate_spec_graph.py @@ -132,6 +132,148 @@ def test_candidate_spec_graph_filters_raw_seed_fields() -> None: assert graph["privacy_boundary"]["raw_model_output_published"] is False +def test_candidate_spec_graph_derives_stable_short_display_aliases() -> None: + module = load_module() + seed = load_json(CANDIDATE_READY) + candidate_graph = seed["candidate_graph"] + assert isinstance(candidate_graph, dict) + nodes = candidate_graph["nodes"] + assert isinstance(nodes, list) + nodes[0]["title"] = ( + "Safe-to-spend calculations must reserve mandatory recurring payments " + "before discretionary spending." + ) + + graph = module.build_candidate_spec_graph( + intake=load_json(INTAKE_READY), + seed=seed, + intake_path=INTAKE_READY, + seed_path=CANDIDATE_READY, + ) + + node = graph["nodes"][0] + assert node["id"] == "candidate-spec.calculator-product" + assert node["display_alias"] == "Reserve mandatory recurring payments" + assert node["display_alias_source"] == "derived_title" + assert graph["pre_sib_readiness"]["ready"] is True + + +def test_candidate_spec_graph_disambiguates_duplicate_display_aliases() -> None: + module = load_module() + seed = load_json(CANDIDATE_READY) + candidate_graph = seed["candidate_graph"] + assert isinstance(candidate_graph, dict) + nodes = candidate_graph["nodes"] + assert isinstance(nodes, list) + nodes[0]["display_alias"] = "Review calculation" + nodes[1]["display_alias"] = "Review calculation" + + graph = module.build_candidate_spec_graph( + intake=load_json(INTAKE_READY), + seed=seed, + intake_path=INTAKE_READY, + seed_path=CANDIDATE_READY, + ) + + aliases = [node["display_alias"] for node in graph["nodes"]] + assert aliases[0] == "Review calculation" + assert aliases[1] != aliases[0] + assert graph["pre_sib_readiness"]["ready"] is True + + +def test_candidate_spec_graph_keeps_disambiguator_for_max_length_aliases() -> None: + module = load_module() + seed = load_json(CANDIDATE_READY) + candidate_graph = seed["candidate_graph"] + assert isinstance(candidate_graph, dict) + nodes = candidate_graph["nodes"] + assert isinstance(nodes, list) + nodes[0]["display_alias"] = "A" * 64 + nodes[1]["display_alias"] = "A" * 64 + + graph = module.build_candidate_spec_graph( + intake=load_json(INTAKE_READY), + seed=seed, + intake_path=INTAKE_READY, + seed_path=CANDIDATE_READY, + ) + + aliases = [node["display_alias"] for node in graph["nodes"]] + assert aliases[0] == "A" * 64 + assert aliases[1] != aliases[0] + assert len(aliases[1]) <= 64 + + +def test_candidate_spec_graph_blocks_private_or_multiline_display_aliases() -> None: + module = load_module() + seed = load_json(CANDIDATE_READY) + candidate_graph = seed["candidate_graph"] + assert isinstance(candidate_graph, dict) + nodes = candidate_graph["nodes"] + assert isinstance(nodes, list) + nodes[0]["display_alias"] = "/Users/operator/private candidate" + nodes[1]["display_alias"] = "Review\ncalculation" + + graph = module.build_candidate_spec_graph( + intake=load_json(INTAKE_READY), + seed=seed, + intake_path=INTAKE_READY, + seed_path=CANDIDATE_READY, + ) + + assert graph["pre_sib_readiness"]["ready"] is False + assert "candidate_node_display_alias_invalid" in finding_ids(graph) + serialized = json.dumps(graph) + assert "/Users/operator/private candidate" not in serialized + assert "Review\ncalculation" not in serialized + assert "display_alias" not in graph["nodes"][0] + assert "display_alias" not in graph["nodes"][1] + + +def test_candidate_spec_graph_blocks_macos_private_temp_display_alias() -> None: + module = load_module() + seed = load_json(CANDIDATE_READY) + candidate_graph = seed["candidate_graph"] + assert isinstance(candidate_graph, dict) + nodes = candidate_graph["nodes"] + assert isinstance(nodes, list) + nodes[0]["display_alias"] = "/var/folders/example/private candidate" + + graph = module.build_candidate_spec_graph( + intake=load_json(INTAKE_READY), + seed=seed, + intake_path=INTAKE_READY, + seed_path=CANDIDATE_READY, + ) + + assert graph["pre_sib_readiness"]["ready"] is False + assert "candidate_node_display_alias_invalid" in finding_ids(graph) + assert "/var/folders/" not in json.dumps(graph) + + +def test_candidate_spec_graph_does_not_leak_unsafe_kind_in_alias_suffix() -> None: + module = load_module() + seed = load_json(CANDIDATE_READY) + candidate_graph = seed["candidate_graph"] + assert isinstance(candidate_graph, dict) + nodes = candidate_graph["nodes"] + assert isinstance(nodes, list) + nodes[0]["display_alias"] = "Review calculation" + nodes[1]["display_alias"] = "Review calculation" + nodes[1]["kind"] = "/Users/operator/private-kind" + + graph = module.build_candidate_spec_graph( + intake=load_json(INTAKE_READY), + seed=seed, + intake_path=INTAKE_READY, + seed_path=CANDIDATE_READY, + ) + + assert graph["pre_sib_readiness"]["ready"] is True + assert "/Users/operator" not in graph["nodes"][1]["display_alias"] + assert graph["nodes"][1]["display_alias"].endswith("(node)") + + def test_candidate_spec_graph_rejects_duplicate_node_ids() -> None: module = load_module() seed = load_json(CANDIDATE_READY) diff --git a/tests/test_candidate_spec_materialization.py b/tests/test_candidate_spec_materialization.py index 4f842a07..e2767cf0 100644 --- a/tests/test_candidate_spec_materialization.py +++ b/tests/test_candidate_spec_materialization.py @@ -110,6 +110,7 @@ def test_candidate_spec_materialization_skips_review_only_workflow_edges( "nodes": [ { "id": "candidate-spec.product-boundary", + "display_alias": "Product boundary", "title": "Product Boundary", "kind": "product_spec_boundary", "description": "Boundary", @@ -118,6 +119,7 @@ def test_candidate_spec_materialization_skips_review_only_workflow_edges( }, { "id": "candidate-spec.record-decision", + "display_alias": "Record a decision", "title": "Record Decision", "kind": "behavior_requirement", "description": "Record decision", @@ -159,6 +161,10 @@ def test_candidate_spec_materialization_skips_review_only_workflow_edges( assert boundary["depends_on"] == ["CANDIDATE-CANDIDATE-SPEC-RECORD-DECISION"] assert command["depends_on"] == [] + assert boundary["title"] == "Product boundary" + assert boundary["specification"]["candidate_display_alias"] == "Product boundary" + assert boundary["specification"]["candidate_source_title"] == "Product Boundary" + assert report["materialized_files"][0]["display_alias"] == "Product boundary" def test_candidate_spec_materialization_rejects_authority_expansion( @@ -179,6 +185,72 @@ def test_candidate_spec_materialization_rejects_authority_expansion( assert report["local_files_written"] == [] +def test_candidate_spec_materialization_rejects_unsafe_display_alias( + tmp_path: Path, +) -> None: + module = load_module() + candidate_graph = { + "artifact_kind": "candidate_spec_graph", + "contract_ref": "specgraph.idea-to-spec.candidate-spec-graph.v0.1", + "canonical_mutations_allowed": False, + "tracked_artifacts_written": False, + "nodes": [ + { + "id": "candidate-spec.private-node", + "display_alias": "/Users/operator/private candidate", + "title": "Private node", + "kind": "product_spec_boundary", + "description": "Boundary", + "requirements": [{"id": "req.private", "statement": "Boundary"}], + "acceptance_criteria": [], + } + ], + "edges": [], + } + + report = module.build_candidate_spec_materialization_report( + candidate_graph=candidate_graph, + output_dir=tmp_path / "materialized", + ) + + assert report["readiness"]["ready"] is False + assert "candidate_node_display_alias_invalid" in finding_ids(report) + assert report["materialized_files"] == [] + + +def test_candidate_spec_materialization_rejects_unbounded_display_alias( + tmp_path: Path, +) -> None: + module = load_module() + candidate_graph = { + "artifact_kind": "candidate_spec_graph", + "contract_ref": "specgraph.idea-to-spec.candidate-spec-graph.v0.1", + "canonical_mutations_allowed": False, + "tracked_artifacts_written": False, + "nodes": [ + { + "id": "candidate-spec.long-alias", + "display_alias": "A" * 65, + "title": "Long alias node", + "kind": "product_spec_boundary", + "description": "Boundary", + "requirements": [{"id": "req.long", "statement": "Boundary"}], + "acceptance_criteria": [], + } + ], + "edges": [], + } + + report = module.build_candidate_spec_materialization_report( + candidate_graph=candidate_graph, + output_dir=tmp_path / "materialized", + ) + + assert report["readiness"]["ready"] is False + assert "candidate_node_display_alias_invalid" in finding_ids(report) + assert report["materialized_files"] == [] + + def test_candidate_spec_materialization_cli_writes_report_and_files( tmp_path: Path, ) -> None: diff --git a/tests/test_idea_to_spec_rerun_materialization.py b/tests/test_idea_to_spec_rerun_materialization.py index b939b575..2319cd8d 100644 --- a/tests/test_idea_to_spec_rerun_materialization.py +++ b/tests/test_idea_to_spec_rerun_materialization.py @@ -161,6 +161,8 @@ def candidate_graph_artifact() -> dict[str, object]: "nodes": [ { "id": "candidate-spec.product-boundary", + "display_alias": "Product boundary", + "display_alias_source": "provided", "gaps": [ { "id": "ontology-gap.decision-owner", @@ -334,6 +336,8 @@ def test_rerun_materialization_removes_resolved_gap_in_preview() -> None: preview = report["materialization_preview"]["candidate_graph_preview"] node = preview["nodes"][0] + assert node["display_alias"] == "Product boundary" + assert node["display_alias_source"] == "provided" assert [gap["id"] for gap in node["gaps"]] == ["ontology-gap.record-decision"] assert preview["summary"]["gap_count"] == 1 assert node["ontology_gap_resolutions"][0]["gap_id"] == "ontology-gap.decision-owner" diff --git a/tools/README.md b/tools/README.md index 37761505..0293967a 100644 --- a/tools/README.md +++ b/tools/README.md @@ -420,6 +420,9 @@ Supervisor modes: report-only visibility, not a Metrics schema change, score, gate, or authority expansion. - Proposal 0210 makes real-intake clarification templates fallback-free. +- Proposal 0212 adds deterministic public-safe candidate display aliases for + operator-facing overview, topology, and materialized review surfaces without + changing canonical node ids, refs, or promotion paths. Clarification requests and answer templates carry workspace identity and a stable source digest, publish `answers_required`, `clarification_not_required`, or `clarification_blocked`, and treat diff --git a/tools/candidate_overview.py b/tools/candidate_overview.py index 33833592..17c2cb2f 100644 --- a/tools/candidate_overview.py +++ b/tools/candidate_overview.py @@ -374,11 +374,29 @@ def _node_summary(candidate_graph: dict[str, Any]) -> dict[str, Any]: nodes = [node for node in _list(candidate_graph.get("nodes")) if isinstance(node, dict)] by_kind = Counter(_text(node.get("kind"), "unknown") for node in nodes) compact_nodes: list[dict[str, Any]] = [] + aliases: list[dict[str, str]] = [] + alias_by_node_id: dict[str, str] = {} + for node in sorted(nodes, key=lambda item: _text(item.get("id"))): + node_id = _text(node.get("id")) + display_alias = _safe_text(node.get("display_alias")) + if node_id and display_alias: + alias_by_node_id[node_id] = display_alias + aliases.append( + { + "node_id": node_id, + "display_alias": display_alias, + "display_alias_source": _safe_text(node.get("display_alias_source")), + "title": _safe_text(node.get("title")), + "kind": _safe_text(node.get("kind")), + } + ) for node in nodes[:24]: gaps = [gap for gap in _list(node.get("gaps")) if isinstance(gap, dict)] compact_nodes.append( { "id": _text(node.get("id")), + "display_alias": _safe_text(node.get("display_alias")), + "display_alias_source": _safe_text(node.get("display_alias_source")), "title": _text(node.get("title")), "kind": _text(node.get("kind")), "description": _text(node.get("description")), @@ -397,11 +415,16 @@ def _node_summary(candidate_graph: dict[str, Any]) -> dict[str, Any]: return { "count": len(nodes), "kind_counts": dict(sorted(by_kind.items())), + "aliases": aliases, + "alias_count": len(aliases), + "alias_by_node_id": alias_by_node_id, "items": [_public_safe(node) for node in compact_nodes], } -def _topology_summary(candidate_graph: dict[str, Any]) -> dict[str, Any]: +def _topology_summary( + candidate_graph: dict[str, Any], *, alias_by_node_id: dict[str, str] +) -> dict[str, Any]: edges = [edge for edge in _list(candidate_graph.get("edges")) if isinstance(edge, dict)] relation_counts = Counter(_text(edge.get("relation"), "unknown") for edge in edges) workflow_edge_count = sum(relation_counts.get(relation, 0) for relation in WORKFLOW_RELATIONS) @@ -410,7 +433,9 @@ def _topology_summary(candidate_graph: dict[str, Any]) -> dict[str, Any]: "id": _text(edge.get("id")), "relation": _text(edge.get("relation")), "from": _text(edge.get("from")), + "from_display_alias": _safe_text(alias_by_node_id.get(_text(edge.get("from")))), "to": _text(edge.get("to")), + "to_display_alias": _safe_text(alias_by_node_id.get(_text(edge.get("to")))), "source_event_refs": _list(edge.get("source_event_refs"))[:8], } for edge in edges[:24] @@ -787,7 +812,7 @@ def build_candidate_overview( ) event_storming = _event_storming_summary(intake) nodes = _node_summary(selected_graph) - topology = _topology_summary(selected_graph) + topology = _topology_summary(selected_graph, alias_by_node_id=nodes["alias_by_node_id"]) repair = _repair_summary( repair_session=repair_session, repaired_repair_session=repaired_repair_session, diff --git a/tools/candidate_spec_graph.py b/tools/candidate_spec_graph.py index cbaa9505..11ebd7e6 100644 --- a/tools/candidate_spec_graph.py +++ b/tools/candidate_spec_graph.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse +import hashlib import json import re from datetime import datetime, timezone @@ -54,6 +55,20 @@ "raw_response", "raw_text", } +DISPLAY_ALIAS_MAX_LENGTH = 64 +DISPLAY_ALIAS_PRIVATE_MARKERS = ( + "/Users/", + "/home/", + "/private/", + "/tmp/", + "/var/folders/", + "-----BEGIN", + "api-key", + "apikey", + "api_key", + "bearer ", + "token=", +) def _now_iso() -> str: @@ -81,6 +96,125 @@ def _slug(value: str, fallback: str) -> str: return slug or fallback +def _display_alias_text(value: Any) -> str: + if not isinstance(value, str): + return "" + if any(ord(character) < 32 for character in value): + return "" + text = " ".join(value.split()) + if not text: + return "" + lowered = text.lower() + if any(marker.lower() in lowered for marker in DISPLAY_ALIAS_PRIVATE_MARKERS): + return "" + return text + + +def _bounded_display_alias(value: str) -> str: + text = value.rstrip(". ") + if len(text) <= DISPLAY_ALIAS_MAX_LENGTH: + return text + candidate = text[: DISPLAY_ALIAS_MAX_LENGTH - 1].rsplit(" ", 1)[0].rstrip(" ,;:") + return f"{candidate or text[: DISPLAY_ALIAS_MAX_LENGTH - 1].rstrip()}…" + + +def _display_alias_with_suffix(alias: str, suffix: str) -> str: + if len(alias) + len(suffix) <= DISPLAY_ALIAS_MAX_LENGTH: + return f"{alias}{suffix}" + prefix_limit = max(1, DISPLAY_ALIAS_MAX_LENGTH - len(suffix)) + prefix = alias[:prefix_limit].rsplit(" ", 1)[0].rstrip(" ,;:") + return f"{prefix or alias[:prefix_limit].rstrip()}{suffix}" + + +def _derived_display_alias(title: str) -> str: + sentence = title.rstrip(". ") + match = re.search(r"\bmust\s+(.+)", sentence, flags=re.IGNORECASE) + if match: + sentence = match.group(1) + for separator in (" before ", " after ", " when ", " unless ", " while "): + position = sentence.lower().find(separator) + if position > 0: + sentence = sentence[:position] + break + sentence = sentence[:1].upper() + sentence[1:] + return _bounded_display_alias(sentence) + + +def _node_display_alias(node: dict[str, Any]) -> tuple[str, str, bool]: + explicit = _display_alias_text(node.get("display_alias")) + if explicit: + return _bounded_display_alias(explicit), "provided", False + if node.get("display_alias") not in (None, ""): + return "", "", True + title = _display_alias_text(node.get("title")) + if not title: + return "", "", True + alias = _derived_display_alias(title) + return alias, ("title" if alias == title.rstrip(". ") else "derived_title"), False + + +def _apply_display_aliases(nodes: list[dict[str, Any]]) -> list[dict[str, Any]]: + findings: list[dict[str, Any]] = [] + seen: set[str] = set() + for node in nodes: + alias, source, invalid = _node_display_alias(node) + node_id = _text(node.get("id")) + if invalid or not alias: + node.pop("display_alias", None) + node.pop("display_alias_source", None) + findings.append( + _finding( + finding_id="candidate_node_display_alias_invalid", + severity="review_required", + message="Candidate node display alias must be public-safe single-line text.", + evidence={"node_id": node_id}, + ) + ) + continue + key = alias.casefold() + if key in seen: + kind = _display_alias_text(node.get("kind")) or "node" + kind = kind.replace("_", " ") + alias = _display_alias_with_suffix(alias, f" ({kind[:24]})") + if not _display_alias_text(alias): + node.pop("display_alias", None) + node.pop("display_alias_source", None) + findings.append( + _finding( + finding_id="candidate_node_display_alias_invalid", + severity="review_required", + message=( + "Candidate node display alias must be public-safe single-line text." + ), + evidence={"node_id": node_id}, + ) + ) + continue + key = alias.casefold() + if key in seen: + digest = hashlib.sha256(node_id.encode("utf-8")).hexdigest()[:6] + alias = _display_alias_with_suffix(alias, f" [{digest}]") + if not _display_alias_text(alias): + node.pop("display_alias", None) + node.pop("display_alias_source", None) + findings.append( + _finding( + finding_id="candidate_node_display_alias_invalid", + severity="review_required", + message=( + "Candidate node display alias must be public-safe single-line text." + ), + evidence={"node_id": node_id}, + ) + ) + continue + key = alias.casefold() + seen.add(key) + node["display_alias"] = alias + node["display_alias_source"] = source + return findings + + def _relative_ref(path: Path) -> str: try: return path.relative_to(ROOT).as_posix() @@ -368,6 +502,7 @@ def _normalize_nodes(seed: dict[str, Any]) -> tuple[list[dict[str, Any]], list[d message="Candidate spec graph requires at least one candidate node.", ) ) + findings.extend(_apply_display_aliases(nodes)) return nodes, findings diff --git a/tools/candidate_spec_materialization.py b/tools/candidate_spec_materialization.py index a7b6522e..3b226d2e 100644 --- a/tools/candidate_spec_materialization.py +++ b/tools/candidate_spec_materialization.py @@ -28,6 +28,20 @@ DEFAULT_REPAIR_LOOP_PATH = ROOT / "runs" / "candidate_repair_loop_report.json" DEFAULT_OUTPUT_DIR = ROOT / "runs" / "materialized_candidate_specs" DEFAULT_OUTPUT_PATH = ROOT / "runs" / "candidate_spec_materialization_report.json" +DISPLAY_ALIAS_MAX_LENGTH = 64 +DISPLAY_ALIAS_PRIVATE_MARKERS = ( + "/Users/", + "/home/", + "/private/", + "/tmp/", + "/var/folders/", + "-----BEGIN", + "api-key", + "apikey", + "api_key", + "bearer ", + "token=", +) def _now_iso() -> str: @@ -50,6 +64,18 @@ def _text_list(value: Any) -> list[str]: return [item.strip() for item in _list(value) if isinstance(item, str) and item.strip()] +def _display_alias(value: Any) -> str: + if not isinstance(value, str) or any(ord(character) < 32 for character in value): + return "" + alias = " ".join(value.split()) + if not alias or len(alias) > DISPLAY_ALIAS_MAX_LENGTH: + return "" + lowered = alias.lower() + if any(marker.lower() in lowered for marker in DISPLAY_ALIAS_PRIVATE_MARKERS): + return "" + return alias + + def _slug(value: str, fallback: str) -> str: slug = re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") return slug or fallback @@ -142,6 +168,21 @@ def _validate_candidate_graph(candidate_graph: dict[str, Any]) -> list[dict[str, message="Materialization requires at least one candidate node.", ) ) + for node in _nodes(candidate_graph): + if node.get("display_alias") not in (None, "") and not _display_alias( + node.get("display_alias") + ): + findings.append( + _finding( + finding_id="candidate_node_display_alias_invalid", + severity="review_required", + message=( + "Materialization requires public-safe single-line candidate " + "display aliases." + ), + evidence={"node_id": _text(node.get("id"))}, + ) + ) return findings @@ -251,6 +292,8 @@ def _build_spec_yaml( source_ref: str, ) -> dict[str, Any]: node_id = _text(node.get("id"), "candidate-node") + candidate_title = _text(node.get("title"), node_id) + display_alias = _display_alias(node.get("display_alias")) requirements = [item for item in _list(node.get("requirements")) if isinstance(item, dict)] acceptance_criteria = [ item for item in _list(node.get("acceptance_criteria")) if isinstance(item, dict) @@ -262,7 +305,7 @@ def _build_spec_yaml( acceptance = ["Review candidate requirements and acceptance criteria before promotion."] return { "id": id_map[node_id], - "title": _text(node.get("title"), node_id), + "title": display_alias or candidate_title, "kind": "spec", "created_at": generated_at, "updated_at": generated_at, @@ -282,6 +325,8 @@ def _build_spec_yaml( "specification": { "materialization_mode": "candidate_review_preview", "candidate_source_id": node_id, + "candidate_display_alias": display_alias or None, + "candidate_source_title": candidate_title, "candidate_kind": _text(node.get("kind")), "description": _text(node.get("description")), "source_event_refs": _text_list(node.get("source_event_refs")), @@ -347,6 +392,7 @@ def build_candidate_spec_materialization_report( materialized_files.append( { "candidate_node_id": node_id, + "display_alias": _display_alias(node.get("display_alias")) or None, "materialized_id": spec_yaml["id"], "path": local_ref, "promotion_path": local_ref, diff --git a/tools/proposal_promotion_registry.json b/tools/proposal_promotion_registry.json index 206d5eea..7a378c23 100644 --- a/tools/proposal_promotion_registry.json +++ b/tools/proposal_promotion_registry.json @@ -2728,5 +2728,18 @@ "required_provenance_links": [ "source_draft_ref" ] + }, + { + "proposal_id": "0212", + "source_artifact_class": "working_draft", + "source_refs": [ + "docs/archive/proposal_sources/0212_human_friendly_candidate_display_aliases.md" + ], + "motivating_concern": "Long generated candidate ids and constraint statements are hard to scan in operator-facing surfaces, while consumers must not independently shorten canonical refs.", + "normalized_title": "Human-Friendly Candidate Display Aliases", + "bounded_scope": "Emit deterministic public-safe presentation aliases for candidate graph nodes and propagate them through candidate overview, topology endpoint labels, and materialized review files without changing canonical ids, graph refs, materialized ids, filenames, promotion paths, gates, matching, canonical specs, Ontology packages, Git operations, or raw-idea privacy boundaries.", + "required_provenance_links": [ + "source_draft_ref" + ] } ] diff --git a/tools/proposal_runtime_registry.json b/tools/proposal_runtime_registry.json index ff9bc383..683d3123 100644 --- a/tools/proposal_runtime_registry.json +++ b/tools/proposal_runtime_registry.json @@ -12090,5 +12090,65 @@ } ], "notes": "SpecGraph product workspace initialization now emits digest-bound workspace-relative binding evidence for Platform without taking ownership of external routing, state namespaces, execution roots, or Git operations." + }, + { + "proposal_id": "0212", + "posture": "bounded_runtime_followup", + "runtime_surfaces": [ + "docs/proposals/0212_human_friendly_candidate_display_aliases.md", + "docs/archive/proposal_sources/0212_human_friendly_candidate_display_aliases.md", + "tools/candidate_spec_graph.py", + "tools/candidate_overview.py", + "tools/candidate_spec_materialization.py", + "tests/test_candidate_spec_graph.py", + "tests/test_candidate_overview.py", + "tests/test_candidate_spec_materialization.py", + "docs/product_workspace_graph_versioning_roadmap.md", + "Sources/SpecGraph/Documentation.docc/ProductWorkspacePilots.md", + "tools/README.md" + ], + "runtime_markers": [ + { + "path": "tools/candidate_spec_graph.py", + "pattern": "def _apply_display_aliases" + }, + { + "path": "tools/candidate_overview.py", + "pattern": "alias_by_node_id" + }, + { + "path": "tools/candidate_spec_materialization.py", + "pattern": "candidate_display_alias" + } + ], + "validation_markers": [ + { + "path": "tests/test_candidate_spec_graph.py", + "pattern": "test_candidate_spec_graph_derives_stable_short_display_aliases" + }, + { + "path": "tests/test_candidate_overview.py", + "pattern": "from_display_alias" + }, + { + "path": "tests/test_candidate_spec_materialization.py", + "pattern": "candidate_display_alias" + } + ], + "observation_markers": [ + { + "path": "docs/product_workspace_graph_versioning_roadmap.md", + "pattern": "Implemented producer-side in proposal `0212`" + }, + { + "path": "Sources/SpecGraph/Documentation.docc/ProductWorkspacePilots.md", + "pattern": "Proposal `0212` keeps candidate graph machine ids stable" + }, + { + "path": "tools/README.md", + "pattern": "Proposal 0212 adds deterministic public-safe candidate display aliases" + } + ], + "notes": "SpecGraph now emits deterministic presentation-only display aliases for candidate nodes and propagates them into overview, topology, and materialization outputs without changing canonical ids, refs, paths, or authority." } ]