Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
19 changes: 10 additions & 9 deletions docs/product_workspace_graph_versioning_roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
53 changes: 53 additions & 0 deletions docs/proposals/0212_human_friendly_candidate_display_aliases.md
Original file line number Diff line number Diff line change
@@ -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.
9 changes: 9 additions & 0 deletions tests/test_candidate_overview.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand All @@ -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"}],
Expand Down Expand Up @@ -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 (
Expand Down
142 changes: 142 additions & 0 deletions tests/test_candidate_spec_graph.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
72 changes: 72 additions & 0 deletions tests/test_candidate_spec_materialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down Expand Up @@ -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(
Expand All @@ -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:
Expand Down
Loading