diff --git a/AGENTS.md b/AGENTS.md
index 1a14c29bc..9104dd1f4 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -446,12 +446,6 @@ in this repo.
topic components, or label evidence by a bare document, model, topic, rank,
label, or display value.
- When reviews find public/private identifier leaks, stale API fixture shapes, or recurring bug patterns, update tests, frontend mocks, E2E mocks, README examples, architecture docs, and explicitly record the anti-pattern in `AGENTS.md` so the same bug pattern does not reappear in copied examples.
-- Mail attachment surfaces must list the current email's files with opaque
- `asset_key` values and open the signed
- `/api/data/repository-assets/{asset_key}/preview` contract. Do not expose
- sequential attachment ids, render missing preview text as empty document
- content, or send buyers only to the Data repository list as a substitute for
- opening the HWPX file on the selected mail.
- Memoized id-to-record Maps must be first-wins (`if (!map.has(key)) map.set(...)`).
`new Map(items.map((item) => [String(item.id), item]))` is last-wins and
desynchronizes first-wins label maps from the selected node or edge when
@@ -599,14 +593,6 @@ in this repo.
vector counts, unsupported embedding model names, static quality totals, or
provider-write success claims; Data mocks and E2E fixtures must preserve the
bearer-session call and omit public identity headers.
-- Repository-asset preview is read-only and scoped. Unknown keys and
- cross-workspace access both return 404 `repository_asset_not_found` so
- existence cannot leak. Recognized HWPX text comes from stored ordered
- paragraphs; pending or failed recognition must keep the current asset detail
- and show an explicit next action. Do not treat missing preview text as empty
- content, and do not fetch preview indiscriminately in E2E helpers — mock
- known assets such as `roadmap.md` and `blank-notes.md`, then fail unmatched
- preview routes closed.
- Project workspace lists, milestones, task links, and decision logs must be
source-backed by signed `/api/webdav/folders` and `/api/tasks` data or
explicitly labeled pending. Do not reintroduce static project names, inert
diff --git a/backend/api/data.py b/backend/api/data.py
index 81c52d3a1..de20823d9 100644
--- a/backend/api/data.py
+++ b/backend/api/data.py
@@ -381,6 +381,23 @@ class DataDocumentActionResponse(BaseModel):
message: str
+class DataInkspanEditHandoffResponse(BaseModel):
+ """Read-only Inkspan capability probe that never mutates the source file."""
+
+ source_asset_key: str
+ source_asset_type: Literal["email_attachment", "workspace_document"]
+ parser_family: str | None
+ handoff_state: Literal["unavailable"]
+ editor_capability_name: str
+ mutation_allowed: bool
+ converts_source_to_plain_text: bool
+ overwrites_original: bool
+ provider_write_executed: bool
+ next_action: str
+ error_code: str
+ editable_document_payload: None = None
+
+
class DataRepositoryAssetPreviewResponse(BaseModel):
"""Read-only preview of recognized or blocked repository-asset text."""
@@ -395,6 +412,7 @@ class DataRepositoryAssetPreviewResponse(BaseModel):
provider_write_executed: bool
provenance: Literal["server-authoritative"]
audit_event: Literal["data.repository_asset.preview.viewed"]
+ edit_handoff: DataInkspanEditHandoffResponse | None = None
class DataDocumentWebdavMaterializationResponse(BaseModel):
@@ -2593,6 +2611,7 @@ def _preview_response(
) -> DataRepositoryAssetPreviewResponse:
"""Wrap a service preview in the signed read-only API envelope."""
+ edit_handoff = preview.edit_handoff
return DataRepositoryAssetPreviewResponse(
asset_key=preview.asset_key,
asset_type=preview.asset_type,
@@ -2605,6 +2624,24 @@ def _preview_response(
provider_write_executed=False,
provenance="server-authoritative",
audit_event="data.repository_asset.preview.viewed",
+ edit_handoff=(
+ None
+ if edit_handoff is None
+ else DataInkspanEditHandoffResponse(
+ source_asset_key=edit_handoff.source_asset_key,
+ source_asset_type=edit_handoff.source_asset_type,
+ parser_family=edit_handoff.parser_family,
+ handoff_state=edit_handoff.handoff_state,
+ editor_capability_name=edit_handoff.editor_capability_name,
+ mutation_allowed=False,
+ converts_source_to_plain_text=False,
+ overwrites_original=False,
+ provider_write_executed=False,
+ next_action=edit_handoff.next_action,
+ error_code=edit_handoff.error_code,
+ editable_document_payload=None,
+ )
+ ),
)
diff --git a/backend/services/inkspan_edit_handoff.py b/backend/services/inkspan_edit_handoff.py
new file mode 100644
index 000000000..585860ca6
--- /dev/null
+++ b/backend/services/inkspan_edit_handoff.py
@@ -0,0 +1,153 @@
+"""Probe a read-only Inkspan edit handoff for recognized HWPX attachments.
+
+Naruon already exposes ordered HWPX paragraphs through the repository-asset
+preview. This module does not convert those paragraphs into an editable
+document, does not overwrite the original attachment, and does not invent a
+write API. It only records whether a released, installed Inkspan Hangul
+document engine is present and whether an authorized editor contract exists.
+
+Released Inkspan remains a Markdown/HTML editor. Hangul import/edit/export is
+owned by unreleased inkspan Draft #320 and is not installed here, so the host
+adapter hook stays empty and the buyer-visible handoff fails closed.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Literal
+
+HANDOFF_STATE_UNAVAILABLE = "unavailable"
+EDITOR_CAPABILITY_HANGUL_DOCUMENT_ENGINE = "inkspan_hangul_document_engine"
+ERROR_INKSPAN_HANGUL_CAPABILITY_UNAVAILABLE = "inkspan_hangul_capability_unavailable"
+ERROR_INKSPAN_EDIT_CONTRACT_UNAVAILABLE = "inkspan_edit_contract_unavailable"
+NEXT_ACTION_KEEP_READING_RECOGNIZED_TEXT = "keep_reading_recognized_text"
+HWPX_PARSER_FAMILY = "hwpx"
+AUTHORIZED_EDIT_CONTRACTS: frozenset[str] = frozenset()
+
+
+@dataclass(frozen=True, slots=True)
+class InkspanEditHandoff:
+ """Carry one scoped, non-mutating Inkspan handoff for a recognized HWPX file."""
+
+ source_asset_key: str
+ source_asset_type: str
+ parser_family: str | None
+ handoff_state: Literal["unavailable"]
+ editor_capability_name: str
+ mutation_allowed: bool
+ converts_source_to_plain_text: bool
+ overwrites_original: bool
+ provider_write_executed: bool
+ next_action: str
+ error_code: str
+ editable_document_payload: None = None
+
+
+def registered_inkspan_editor_capability() -> object | None:
+ """Return the host-owned Inkspan editor adapter when one is installed.
+
+ Naruon does not vendor Inkspan. A future host adapter may register here
+ after a released Hangul document engine exists. The default workspace has
+ no such adapter.
+ """
+
+ return None
+
+
+def installed_inkspan_editor_capability() -> object | None:
+ """Return a Hangul engine adapter only when it accepts HWPX without conversion."""
+
+ adapter = registered_inkspan_editor_capability()
+ if _is_hangul_hwpx_capability(adapter):
+ return adapter
+ return None
+
+
+def _adapter_field(adapter: object | None, field_name: str) -> object | None:
+ """Read one adapter attribute without treating missing hosts as installed."""
+
+ if adapter is None:
+ return None
+ return getattr(adapter, field_name, None)
+
+
+def _accepted_source_families(adapter: object | None) -> tuple[str, ...]:
+ """Return the source families an adapter can open without conversion.
+
+ Malformed non-collection metadata is treated as absent so a recognized
+ preview can fail closed instead of raising.
+ """
+
+ families = _adapter_field(adapter, "accepted_source_families")
+ if not isinstance(families, (list, tuple, set, frozenset)):
+ return ()
+ return tuple(str(family) for family in families)
+
+
+def _is_hangul_hwpx_capability(adapter: object | None) -> bool:
+ """True only for a Hangul engine that accepts HWPX as HWPX."""
+
+ capability_name = _adapter_field(adapter, "capability_name")
+ return (
+ capability_name == EDITOR_CAPABILITY_HANGUL_DOCUMENT_ENGINE
+ and HWPX_PARSER_FAMILY in _accepted_source_families(adapter)
+ )
+
+
+def _unavailable_handoff(
+ preview: object,
+ error_code: str,
+) -> InkspanEditHandoff:
+ """Build a fail-closed handoff that keeps the exact source identity."""
+
+ return InkspanEditHandoff(
+ source_asset_key=str(getattr(preview, "asset_key")),
+ source_asset_type=str(getattr(preview, "asset_type")),
+ parser_family=str(getattr(preview, "parser_family") or "") or None,
+ handoff_state=HANDOFF_STATE_UNAVAILABLE,
+ editor_capability_name=EDITOR_CAPABILITY_HANGUL_DOCUMENT_ENGINE,
+ mutation_allowed=False,
+ converts_source_to_plain_text=False,
+ overwrites_original=False,
+ provider_write_executed=False,
+ next_action=NEXT_ACTION_KEEP_READING_RECOGNIZED_TEXT,
+ error_code=error_code,
+ editable_document_payload=None,
+ )
+
+
+def build_inkspan_edit_handoff(preview: object) -> InkspanEditHandoff | None:
+ """Return a read-only Inkspan handoff for recognized HWPX, or None.
+
+ Pending, failed, unavailable, and non-HWPX previews do not offer an edit
+ control. Recognized HWPX always preserves the preview asset key and fails
+ closed unless a released Hangul capability and an authorized editor
+ contract are both present. No authorized contract exists in this slice.
+ """
+
+ preview_state = str(getattr(preview, "preview_state", "") or "")
+ parser_family = str(getattr(preview, "parser_family", "") or "")
+ if preview_state != "recognized" or parser_family != HWPX_PARSER_FAMILY:
+ return None
+
+ adapter = registered_inkspan_editor_capability()
+ if not _is_hangul_hwpx_capability(adapter):
+ return _unavailable_handoff(
+ preview,
+ ERROR_INKSPAN_HANGUL_CAPABILITY_UNAVAILABLE,
+ )
+
+ contract_name = _adapter_field(adapter, "mutation_contract_name")
+ if (
+ not isinstance(contract_name, str)
+ or contract_name not in AUTHORIZED_EDIT_CONTRACTS
+ ):
+ return _unavailable_handoff(
+ preview,
+ ERROR_INKSPAN_EDIT_CONTRACT_UNAVAILABLE,
+ )
+
+ return _unavailable_handoff(
+ preview,
+ ERROR_INKSPAN_EDIT_CONTRACT_UNAVAILABLE,
+ )
diff --git a/backend/services/repository_asset_preview.py b/backend/services/repository_asset_preview.py
index 0250e38da..5c3ff7a39 100644
--- a/backend/services/repository_asset_preview.py
+++ b/backend/services/repository_asset_preview.py
@@ -8,9 +8,13 @@
from __future__ import annotations
from collections.abc import Sequence
-from dataclasses import dataclass
+from dataclasses import dataclass, replace
from typing import Literal
+from services.inkspan_edit_handoff import (
+ InkspanEditHandoff,
+ build_inkspan_edit_handoff,
+)
from services.text_safety import strip_html_markup
PreviewState = Literal["recognized", "pending", "failed", "unavailable"]
@@ -67,6 +71,7 @@ class RepositoryAssetPreview:
next_action: str
error_code: str | None
provider_write_executed: bool = False
+ edit_handoff: InkspanEditHandoff | None = None
def _safe_paragraph(value: object) -> str:
@@ -158,6 +163,12 @@ def _blocked_preview(
)
+def _with_edit_handoff(preview: RepositoryAssetPreview) -> RepositoryAssetPreview:
+ """Attach a fail-closed Inkspan handoff without changing preview semantics."""
+
+ return replace(preview, edit_handoff=build_inkspan_edit_handoff(preview))
+
+
def build_attachment_preview(
asset_key: str,
attachment: object,
@@ -170,30 +181,34 @@ def build_attachment_preview(
is_hwpx = parser_family == HWPX_PARSER_FAMILY or parse_status.startswith("hwpx_")
if parse_status in _DEFERRED_PENDING_STATUSES:
- return _blocked_preview(
- asset_key=asset_key,
- asset_type="email_attachment",
- preview_state="pending",
- parser_family=parser_family,
- next_action=NEXT_ACTION_WAIT_FOR_RECOGNITION,
- error_code=(
- ERROR_HWPX_RECOGNITION_PENDING
- if is_hwpx
- else "recognition_pending"
- ),
+ return _with_edit_handoff(
+ _blocked_preview(
+ asset_key=asset_key,
+ asset_type="email_attachment",
+ preview_state="pending",
+ parser_family=parser_family,
+ next_action=NEXT_ACTION_WAIT_FOR_RECOGNITION,
+ error_code=(
+ ERROR_HWPX_RECOGNITION_PENDING
+ if is_hwpx
+ else "recognition_pending"
+ ),
+ )
)
if parse_status in _FAILED_PARSE_STATUSES:
- return _blocked_preview(
- asset_key=asset_key,
- asset_type="email_attachment",
- preview_state="failed",
- parser_family=parser_family,
- next_action=NEXT_ACTION_CHOOSE_ANOTHER_FILE,
- error_code=(
- ERROR_HWPX_RECOGNITION_FAILED
- if is_hwpx
- else "recognition_failed"
- ),
+ return _with_edit_handoff(
+ _blocked_preview(
+ asset_key=asset_key,
+ asset_type="email_attachment",
+ preview_state="failed",
+ parser_family=parser_family,
+ next_action=NEXT_ACTION_CHOOSE_ANOTHER_FILE,
+ error_code=(
+ ERROR_HWPX_RECOGNITION_FAILED
+ if is_hwpx
+ else "recognition_failed"
+ ),
+ )
)
paragraph_texts = _paragraphs_from_segments(
@@ -204,21 +219,25 @@ def build_attachment_preview(
str(getattr(attachment, "content", "") or "")
)
if paragraph_texts:
- return _recognized_preview(
+ return _with_edit_handoff(
+ _recognized_preview(
+ asset_key=asset_key,
+ asset_type="email_attachment",
+ parser_family=parser_family,
+ paragraph_texts=paragraph_texts,
+ )
+ )
+ return _with_edit_handoff(
+ _blocked_preview(
asset_key=asset_key,
asset_type="email_attachment",
+ preview_state="failed",
parser_family=parser_family,
- paragraph_texts=paragraph_texts,
+ next_action=NEXT_ACTION_CHOOSE_ANOTHER_FILE,
+ error_code=(
+ ERROR_HWPX_RECOGNITION_FAILED if is_hwpx else "recognition_failed"
+ ),
)
- return _blocked_preview(
- asset_key=asset_key,
- asset_type="email_attachment",
- preview_state="failed",
- parser_family=parser_family,
- next_action=NEXT_ACTION_CHOOSE_ANOTHER_FILE,
- error_code=(
- ERROR_HWPX_RECOGNITION_FAILED if is_hwpx else "recognition_failed"
- ),
)
@@ -230,29 +249,35 @@ def build_document_preview(
document_status = str(getattr(document, "document_status", "") or "")
if document_status in _DOCUMENT_PENDING_STATUSES:
- return _blocked_preview(
- asset_key=asset_key,
- asset_type="workspace_document",
- preview_state="pending",
- parser_family=None,
- next_action=NEXT_ACTION_WAIT_FOR_RECOGNITION,
- error_code="recognition_pending",
+ return _with_edit_handoff(
+ _blocked_preview(
+ asset_key=asset_key,
+ asset_type="workspace_document",
+ preview_state="pending",
+ parser_family=None,
+ next_action=NEXT_ACTION_WAIT_FOR_RECOGNITION,
+ error_code="recognition_pending",
+ )
)
paragraph_texts = _paragraphs_from_text(
getattr(document, "document_content", None)
)
if paragraph_texts:
- return _recognized_preview(
+ return _with_edit_handoff(
+ _recognized_preview(
+ asset_key=asset_key,
+ asset_type="workspace_document",
+ parser_family=None,
+ paragraph_texts=paragraph_texts,
+ )
+ )
+ return _with_edit_handoff(
+ _blocked_preview(
asset_key=asset_key,
asset_type="workspace_document",
+ preview_state="failed",
parser_family=None,
- paragraph_texts=paragraph_texts,
+ next_action=NEXT_ACTION_CHOOSE_ANOTHER_FILE,
+ error_code="document_content_unavailable",
)
- return _blocked_preview(
- asset_key=asset_key,
- asset_type="workspace_document",
- preview_state="failed",
- parser_family=None,
- next_action=NEXT_ACTION_CHOOSE_ANOTHER_FILE,
- error_code="document_content_unavailable",
)
diff --git a/backend/tests/test_inkspan_edit_handoff.py b/backend/tests/test_inkspan_edit_handoff.py
new file mode 100644
index 000000000..004275aff
--- /dev/null
+++ b/backend/tests/test_inkspan_edit_handoff.py
@@ -0,0 +1,236 @@
+"""Read-only Inkspan edit handoff must fail closed without a Hangul engine.
+
+Mail preview can already show recognized HWPX paragraphs. These tests require an
+explicit buyer-visible ``Edit in Inkspan`` capability probe that preserves the
+exact attachment identity, refuses silent HWPX-to-text conversion, never
+overwrites the original, and does not invent a write API. Released Inkspan is a
+Markdown/HTML editor; Hangul import/edit/export remains unreleased, so the
+default installed capability is absent.
+"""
+
+from __future__ import annotations
+
+from types import SimpleNamespace
+
+import pytest
+
+from services.inkspan_edit_handoff import (
+ EDITOR_CAPABILITY_HANGUL_DOCUMENT_ENGINE,
+ ERROR_INKSPAN_EDIT_CONTRACT_UNAVAILABLE,
+ ERROR_INKSPAN_HANGUL_CAPABILITY_UNAVAILABLE,
+ HANDOFF_STATE_UNAVAILABLE,
+ NEXT_ACTION_KEEP_READING_RECOGNIZED_TEXT,
+ build_inkspan_edit_handoff,
+ installed_inkspan_editor_capability,
+)
+from services.repository_asset_preview import (
+ NEXT_ACTION_READ_RECOGNIZED_TEXT,
+ RepositoryAssetPreview,
+ build_attachment_preview,
+)
+
+
+def _recognized_hwpx_preview() -> RepositoryAssetPreview:
+ """Return one recognized HWPX preview with the exact source asset key."""
+
+ return RepositoryAssetPreview(
+ asset_key="asset_mail_hwpx_recognized",
+ asset_type="email_attachment",
+ preview_state="recognized",
+ parser_family="hwpx",
+ paragraph_texts=("Quarterly decision record", "Approve the next action."),
+ preview_text="Quarterly decision record\n\nApprove the next action.",
+ next_action=NEXT_ACTION_READ_RECOGNIZED_TEXT,
+ error_code=None,
+ provider_write_executed=False,
+ )
+
+
+def _hwpx_attachment(*, parse_status: str, content: str) -> SimpleNamespace:
+ """Build one in-memory HWPX attachment for preview-to-handoff tests."""
+
+ return SimpleNamespace(
+ filename="decision.hwpx",
+ content=content,
+ content_type="application/hwp+zip",
+ parse_content_type="application/hwp+zip",
+ parser_key="hwpx",
+ parse_status=parse_status,
+ parse_error_code=None,
+ content_segments=[
+ SimpleNamespace(
+ ordinal_index=0,
+ safe_text_content="Quarterly decision record",
+ ),
+ SimpleNamespace(
+ ordinal_index=1,
+ safe_text_content="Approve the next action.",
+ ),
+ ],
+ )
+
+
+def test_installed_inkspan_editor_capability_is_absent_by_default() -> None:
+ """Naruon has no released/installed Inkspan Hangul engine adapter."""
+
+ assert installed_inkspan_editor_capability() is None
+
+
+def test_recognized_hwpx_handoff_fails_closed_without_hangul_capability() -> None:
+ """Recognized HWPX keeps identity and tells the buyer to keep reading."""
+
+ handoff = build_inkspan_edit_handoff(_recognized_hwpx_preview())
+
+ assert handoff is not None
+ assert handoff.source_asset_key == "asset_mail_hwpx_recognized"
+ assert handoff.source_asset_type == "email_attachment"
+ assert handoff.parser_family == "hwpx"
+ assert handoff.handoff_state == HANDOFF_STATE_UNAVAILABLE
+ assert handoff.editor_capability_name == EDITOR_CAPABILITY_HANGUL_DOCUMENT_ENGINE
+ assert handoff.mutation_allowed is False
+ assert handoff.converts_source_to_plain_text is False
+ assert handoff.overwrites_original is False
+ assert handoff.provider_write_executed is False
+ assert handoff.next_action == NEXT_ACTION_KEEP_READING_RECOGNIZED_TEXT
+ assert handoff.error_code == ERROR_INKSPAN_HANGUL_CAPABILITY_UNAVAILABLE
+ assert handoff.editable_document_payload is None
+ assert "Quarterly decision record" not in repr(handoff)
+
+
+def test_malformed_adapter_family_metadata_fails_closed_without_server_error(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Non-iterable accepted_source_families must not 500 a recognized preview."""
+
+ monkeypatch.setattr(
+ "services.inkspan_edit_handoff.registered_inkspan_editor_capability",
+ lambda: SimpleNamespace(
+ capability_name=EDITOR_CAPABILITY_HANGUL_DOCUMENT_ENGINE,
+ accepted_source_families=1,
+ mutation_contract_name=None,
+ ),
+ )
+
+ handoff = build_inkspan_edit_handoff(_recognized_hwpx_preview())
+
+ assert handoff is not None
+ assert handoff.handoff_state == HANDOFF_STATE_UNAVAILABLE
+ assert handoff.mutation_allowed is False
+ assert handoff.provider_write_executed is False
+ assert handoff.converts_source_to_plain_text is False
+ assert handoff.overwrites_original is False
+ assert handoff.editable_document_payload is None
+ assert handoff.source_asset_key == "asset_mail_hwpx_recognized"
+ assert handoff.error_code == ERROR_INKSPAN_HANGUL_CAPABILITY_UNAVAILABLE
+ assert handoff.next_action == NEXT_ACTION_KEEP_READING_RECOGNIZED_TEXT
+
+
+def test_markdown_only_inkspan_adapter_is_rejected_as_silent_conversion(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """Released Markdown/HTML Inkspan must not receive HWPX as plain text."""
+
+ monkeypatch.setattr(
+ "services.inkspan_edit_handoff.registered_inkspan_editor_capability",
+ lambda: SimpleNamespace(
+ capability_name="inkspan_markdown_html_editor",
+ accepted_source_families=("markdown", "html"),
+ mutation_contract_name=None,
+ ),
+ )
+
+ handoff = build_inkspan_edit_handoff(_recognized_hwpx_preview())
+
+ assert handoff is not None
+ assert handoff.handoff_state == HANDOFF_STATE_UNAVAILABLE
+ assert handoff.converts_source_to_plain_text is False
+ assert handoff.mutation_allowed is False
+ assert handoff.overwrites_original is False
+ assert handoff.provider_write_executed is False
+ assert handoff.editable_document_payload is None
+ assert handoff.source_asset_key == "asset_mail_hwpx_recognized"
+ assert handoff.error_code == ERROR_INKSPAN_HANGUL_CAPABILITY_UNAVAILABLE
+ assert handoff.next_action == NEXT_ACTION_KEEP_READING_RECOGNIZED_TEXT
+
+
+def test_hangul_capability_without_edit_contract_still_refuses_mutation(
+ monkeypatch: pytest.MonkeyPatch,
+) -> None:
+ """A Hangul engine without an authorized editor contract cannot write."""
+
+ monkeypatch.setattr(
+ "services.inkspan_edit_handoff.registered_inkspan_editor_capability",
+ lambda: SimpleNamespace(
+ capability_name=EDITOR_CAPABILITY_HANGUL_DOCUMENT_ENGINE,
+ accepted_source_families=("hwpx", "hwp"),
+ mutation_contract_name=None,
+ ),
+ )
+
+ handoff = build_inkspan_edit_handoff(_recognized_hwpx_preview())
+
+ assert handoff is not None
+ assert handoff.handoff_state == HANDOFF_STATE_UNAVAILABLE
+ assert handoff.mutation_allowed is False
+ assert handoff.overwrites_original is False
+ assert handoff.provider_write_executed is False
+ assert handoff.converts_source_to_plain_text is False
+ assert handoff.editable_document_payload is None
+ assert handoff.source_asset_key == "asset_mail_hwpx_recognized"
+ assert handoff.error_code == ERROR_INKSPAN_EDIT_CONTRACT_UNAVAILABLE
+ assert handoff.next_action == NEXT_ACTION_KEEP_READING_RECOGNIZED_TEXT
+
+
+def test_pending_and_non_hwpx_previews_do_not_offer_edit_handoff() -> None:
+ """Edit in Inkspan is only defined for recognized HWPX attachments."""
+
+ pending = build_attachment_preview(
+ "asset_mail_hwpx_pending",
+ SimpleNamespace(
+ filename="pending.hwpx",
+ content="UEsDBAoAAAAAAretained-hwpx-bytes",
+ content_type="application/hwp+zip",
+ parse_content_type="application/hwp+zip",
+ parser_key="hwpx",
+ parse_status="hwpx_xml_package_pending",
+ parse_error_code=None,
+ content_segments=[],
+ ),
+ )
+ markdown = RepositoryAssetPreview(
+ asset_key="doc_repository_ready",
+ asset_type="workspace_document",
+ preview_state="recognized",
+ parser_family=None,
+ paragraph_texts=("# Q2 roadmap",),
+ preview_text="# Q2 roadmap",
+ next_action=NEXT_ACTION_READ_RECOGNIZED_TEXT,
+ error_code=None,
+ provider_write_executed=False,
+ )
+
+ assert pending.preview_state == "pending"
+ assert build_inkspan_edit_handoff(pending) is None
+ assert build_inkspan_edit_handoff(markdown) is None
+
+
+def test_attachment_preview_attaches_fail_closed_handoff_for_recognized_hwpx() -> None:
+ """The existing preview contract carries the read-only Inkspan handoff."""
+
+ preview = build_attachment_preview(
+ "asset_mail_hwpx_recognized",
+ _hwpx_attachment(
+ parse_status="hwpx_xml_package_parsed",
+ content="Quarterly decision record\n\nApprove the next action.",
+ ),
+ )
+
+ assert preview.preview_state == "recognized"
+ assert preview.next_action == NEXT_ACTION_READ_RECOGNIZED_TEXT
+ assert preview.edit_handoff is not None
+ assert preview.edit_handoff.source_asset_key == preview.asset_key
+ assert preview.edit_handoff.handoff_state == HANDOFF_STATE_UNAVAILABLE
+ assert preview.edit_handoff.mutation_allowed is False
+ assert preview.edit_handoff.provider_write_executed is False
+ assert preview.edit_handoff.editable_document_payload is None
+ assert preview.provider_write_executed is False
diff --git a/backend/tests/test_repository_asset_preview.py b/backend/tests/test_repository_asset_preview.py
index 98c8bcfec..3b1f7533c 100644
--- a/backend/tests/test_repository_asset_preview.py
+++ b/backend/tests/test_repository_asset_preview.py
@@ -406,6 +406,16 @@ def test_preview_route_returns_recognized_hwpx_for_scoped_attachment() -> None:
assert data["error_code"] is None
assert data["next_action"] == NEXT_ACTION_READ_RECOGNIZED_TEXT
assert data["provider_write_executed"] is False
+ handoff = data["edit_handoff"]
+ assert handoff["source_asset_key"] == asset_key
+ assert handoff["handoff_state"] == "unavailable"
+ assert handoff["mutation_allowed"] is False
+ assert handoff["converts_source_to_plain_text"] is False
+ assert handoff["overwrites_original"] is False
+ assert handoff["provider_write_executed"] is False
+ assert handoff["editable_document_payload"] is None
+ assert handoff["next_action"] == "keep_reading_recognized_text"
+ assert handoff["error_code"] == "inkspan_hangul_capability_unavailable"
def test_preview_route_loads_only_the_matched_attachment_payload() -> None:
diff --git a/docs/doctoring/hwp-hwpx-attachment-recognition.md b/docs/doctoring/hwp-hwpx-attachment-recognition.md
index 856c23b5e..4c0618d3c 100644
--- a/docs/doctoring/hwp-hwpx-attachment-recognition.md
+++ b/docs/doctoring/hwp-hwpx-attachment-recognition.md
@@ -181,6 +181,7 @@ traceability, not a substitute for current-head gates.
| Parsed attachment text + graph provenance | shared content-graph landing path | HWPX worker + recognizer tests | Active stacked PR #1373 |
| Buyer-visible recognized HWPX paragraph preview | `backend/services/repository_asset_preview.py`, Data attachment view | `test_repository_asset_preview.py`, `RepositoryAssetPreviewPanel.test.tsx` | Active stacked preview PR |
| Mail-detail HWPX preview via the same read-only contract | `backend/api/emails.py`, `MailAttachmentPreview` | `test_email_attachment_preview.py`, `MailAttachmentPreview.test.tsx` | Active stacked mail preview PR |
+| Fail-closed Inkspan edit handoff for recognized HWPX | `backend/services/inkspan_edit_handoff.py`, preview panel | `test_inkspan_edit_handoff.py`, `MailAttachmentPreview.test.tsx` | Active stacked handoff PR |
| Binary HWP conversion | future sandboxed converter | none yet | Planned / out of this slice |
| HWPX tables, images, layout fidelity | future bounded recognizers | none yet | Planned / out of this slice |
| Protected-`develop` shipped HWP/HWPX recognition | protected branch | integrated release gates | Not yet shipped |
@@ -225,6 +226,24 @@ preview still means choose another file. Missing text is never presented as
empty document content. Unknown and cross-workspace resources remain one
indistinguishable 404.
+## Buyer-visible Inkspan edit handoff — stacked on PR #1406
+
+Reading recognized paragraphs is not an edit path. This slice adds an
+accessible **Inkspan에서 편집** control on recognized HWPX preview. The
+control preserves the exact opaque `asset_key` already authorized by the
+preview lookup and does not create a new document, overwrite the original
+attachment, or pass paragraph text to Markdown/HTML Inkspan.
+
+Released Inkspan remains a Markdown/HTML editor (ContextualWisdomLab, 2026).
+Bounded HWP/HWPX import/edit/export is owned by unreleased inkspan Draft #320
+and is frozen under inkspan #118, so the host adapter is absent. The handoff
+therefore fails closed and tells the buyer to keep reading the recognized
+text or choose another file. No write API is introduced.
+
+This capability probe does not change KS X 6101/OWPML recognition or the
+preview contract owned by PRs #1353, #1373, #1404, and #1406 (Korean Agency
+for Technology and Standards, 2024; Hancom Tech, 2025b, 2025c).
+
## Out of scope
This slice does not reconstruct HWPX tables, images, charts, layout, styles, or
@@ -265,6 +284,10 @@ https://tech.hancom.com/python-hwpx-parsing-1/
Hancom Tech. (2025d). *Parsing HWPX format with Python (Part 2)*.
https://tech.hancom.com/python-hwpx-parsing-2/
+ContextualWisdomLab. (2026). *Inkspan: commercial-grade Markdown + HTML WYSIWYG
+editor module* (Version 0.3.1) [Computer software].
+https://github.com/ContextualWisdomLab/inkspan/releases/tag/v0.3.1
+
PKWARE, Inc. (2024). *APPNOTE.TXT: .ZIP file format specification*.
https://pkware.cachefly.net/webdocs/casestudies/APPNOTE.TXT
diff --git a/frontend/src/components/EmailDetail.test.tsx b/frontend/src/components/EmailDetail.test.tsx
index 77dcc9cb6..8baaf6306 100644
--- a/frontend/src/components/EmailDetail.test.tsx
+++ b/frontend/src/components/EmailDetail.test.tsx
@@ -1314,6 +1314,20 @@ describe("EmailDetail", () => {
next_action: "read_recognized_text",
error_code: null,
provider_write_executed: false,
+ edit_handoff: {
+ source_asset_key: "asset_mail_hwpx_recognized",
+ source_asset_type: "email_attachment",
+ parser_family: "hwpx",
+ handoff_state: "unavailable",
+ editor_capability_name: "inkspan_hangul_document_engine",
+ mutation_allowed: false,
+ converts_source_to_plain_text: false,
+ overwrites_original: false,
+ provider_write_executed: false,
+ next_action: "keep_reading_recognized_text",
+ error_code: "inkspan_hangul_capability_unavailable",
+ editable_document_payload: null,
+ },
}));
}
throw new Error(`Unexpected fetch: ${url}`);
@@ -1342,6 +1356,10 @@ describe("EmailDetail", () => {
expect(container.textContent).toContain("Quarterly decision record");
expect(container.textContent).toContain("Approve the next action.");
expect(container.textContent).toContain("인식된 본문");
+ expect(container.textContent).toContain("Inkspan에서 편집");
+ expect(container.textContent).toContain(
+ "설치된 Inkspan에 HWPX 편집 기능이 없습니다. 인식된 본문을 계속 읽거나 다른 파일을 선택하세요.",
+ );
expect(container.textContent).not.toContain("본문이 없습니다");
expect(container.textContent).not.toContain("asset_mail_hwpx_recognized");
});
diff --git a/frontend/src/components/MailAttachmentPreview.test.tsx b/frontend/src/components/MailAttachmentPreview.test.tsx
index 72d5aded6..a9f937650 100644
--- a/frontend/src/components/MailAttachmentPreview.test.tsx
+++ b/frontend/src/components/MailAttachmentPreview.test.tsx
@@ -63,6 +63,20 @@ describe("MailAttachmentPreview", () => {
next_action: "read_recognized_text",
error_code: null,
provider_write_executed: false,
+ edit_handoff: {
+ source_asset_key: "asset_mail_hwpx_recognized",
+ source_asset_type: "email_attachment",
+ parser_family: "hwpx",
+ handoff_state: "unavailable",
+ editor_capability_name: "inkspan_hangul_document_engine",
+ mutation_allowed: false,
+ converts_source_to_plain_text: false,
+ overwrites_original: false,
+ provider_write_executed: false,
+ next_action: "keep_reading_recognized_text",
+ error_code: "inkspan_hangul_capability_unavailable",
+ editable_document_payload: null,
+ },
}));
}
throw new Error(`Unexpected fetch: ${url}`);
@@ -92,6 +106,69 @@ describe("MailAttachmentPreview", () => {
expect(container?.textContent).not.toContain("본문이 없습니다");
expect(container?.textContent).not.toContain("asset_mail_hwpx_recognized");
expect(container?.textContent).not.toContain("99");
+ const editButton = container?.querySelector
+ {getInkspanEditHandoffUnavailableReason(editHandoff.error_code)} +
+