From 2b3792978c0221dff43d7b684ecf10635add5eeb Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Mon, 17 Aug 2026 23:26:40 +0000
Subject: [PATCH 1/4] feat(mail): fail-closed Inkspan edit handoff for HWPX
Add an accessible Edit in Inkspan control on recognized HWPX preview
that preserves the exact asset identity, refuses plaintext conversion,
and stays read-only while the released Hangul engine is absent.
Co-authored-by: Seongho Bae
---
AGENTS.md | 8 +
backend/api/data.py | 37 ++++
backend/services/inkspan_edit_handoff.py | 149 +++++++++++++
backend/services/repository_asset_preview.py | 125 ++++++-----
backend/tests/test_inkspan_edit_handoff.py | 208 ++++++++++++++++++
.../tests/test_repository_asset_preview.py | 10 +
.../hwp-hwpx-attachment-recognition.md | 23 ++
frontend/src/components/EmailDetail.test.tsx | 18 ++
.../components/MailAttachmentPreview.test.tsx | 77 +++++++
.../src/components/MailAttachmentPreview.tsx | 1 +
.../RepositoryAssetPreviewPanel.test.tsx | 45 +++-
.../RepositoryAssetPreviewPanel.tsx | 26 +++
frontend/src/components/data-layout/types.ts | 22 ++
frontend/src/components/data-layout/utils.ts | 32 +++
frontend/tests/e2e/dashboard-flows.spec.ts | 2 +
frontend/tests/e2e/helpers.ts | 14 ++
16 files changed, 746 insertions(+), 51 deletions(-)
create mode 100644 backend/services/inkspan_edit_handoff.py
create mode 100644 backend/tests/test_inkspan_edit_handoff.py
diff --git a/AGENTS.md b/AGENTS.md
index 1a14c29bc..cb915bf89 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -339,6 +339,14 @@ in this repo.
responses must sanitize stored subject/body/snippet/address display fields
before returning them. Preserve message/thread identifiers separately from
UI-safe subject/body, address, and attachment display text.
+- Recognized HWPX mail/Data preview may expose a read-only Inkspan edit
+ handoff, but it must fail closed when a released/installed Hangul document
+ engine is absent. Do not hand recognized paragraph text to Markdown/HTML
+ Inkspan as an editable document, do not overwrite the original attachment,
+ do not invent a write API, and do not vendor unreleased inkspan Draft #320
+ HangulDocumentEngine code. Preserve the exact opaque `asset_key` and the
+ already authorized workspace scope; tell the buyer to keep reading the
+ recognized text or choose another file.
- Email file import must keep frontend file pickers, `/api/emails/import-files`,
and `services.email_import_service` in the same source-backed contract:
supported uploads are `.eml`, `.zip`, and `.mbox`; imported email and
diff --git a/backend/api/data.py b/backend/api/data.py
index a5655f4aa..48fa152a0 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):
@@ -2589,6 +2607,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,
@@ -2601,6 +2620,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..43621ef08
--- /dev/null
+++ b/backend/services/inkspan_edit_handoff.py
@@ -0,0 +1,149 @@
+"""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."""
+
+ families = _adapter_field(adapter, "accepted_source_families")
+ if not families:
+ 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 05459e4c7..11e6e1bb1 100644
--- a/backend/services/repository_asset_preview.py
+++ b/backend/services/repository_asset_preview.py
@@ -7,9 +7,13 @@
from __future__ import annotations
-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"]
@@ -66,6 +70,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:
@@ -154,6 +159,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,
@@ -165,30 +176,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(attachment)
@@ -197,21 +212,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"
- ),
)
@@ -223,29 +242,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..a15064309
--- /dev/null
+++ b/backend/tests/test_inkspan_edit_handoff.py
@@ -0,0 +1,208 @@
+"""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_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 6ac17f695..3fa8127f5 100644
--- a/backend/tests/test_repository_asset_preview.py
+++ b/backend/tests/test_repository_asset_preview.py
@@ -354,6 +354,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"
@pytest.mark.parametrize(
diff --git a/docs/doctoring/hwp-hwpx-attachment-recognition.md b/docs/doctoring/hwp-hwpx-attachment-recognition.md
index 30cf39a2e..db9095cb3 100644
--- a/docs/doctoring/hwp-hwpx-attachment-recognition.md
+++ b/docs/doctoring/hwp-hwpx-attachment-recognition.md
@@ -177,6 +177,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 |
@@ -221,6 +222,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
+#1353/#1373/#1404/#1406 preview contract (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
@@ -261,5 +280,9 @@ 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(
+ '[aria-label="decision.hwpx Inkspan에서 편집"]',
+ );
+ expect(editButton?.disabled).toBe(true);
+ expect(container?.textContent).toContain(
+ "설치된 Inkspan에 HWPX 편집 기능이 없습니다. 인식된 본문을 계속 읽거나 다른 파일을 선택하세요.",
+ );
+ });
+
+ it("does not POST or convert HWPX when the fail-closed Inkspan control is activated", async () => {
+ const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
+ const url = String(input);
+ if (url.endsWith("/api/data/repository-assets/asset_mail_hwpx_recognized/preview")) {
+ return Promise.resolve(jsonResponse({
+ 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: "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} ${init?.method ?? "GET"}`);
+ });
+ vi.stubGlobal("fetch", fetchMock);
+
+ renderPreview([recognizedAttachment]);
+ await act(async () => {
+ Array.from(container?.querySelectorAll("button") ?? []).find(
+ (button) => button.textContent?.includes("decision.hwpx"),
+ )?.click();
+ });
+ await flushAsyncWork();
+
+ const editButton = container?.querySelector(
+ '[aria-label="decision.hwpx Inkspan에서 편집"]',
+ );
+ expect(editButton?.disabled).toBe(true);
+ await act(async () => {
+ editButton?.click();
+ });
+ await flushAsyncWork();
+
+ expect(fetchMock.mock.calls.every(([, init]) => !init || !init.method || init.method === "GET")).toBe(true);
+ expect(container?.textContent).toContain("Quarterly decision record");
+ expect(container?.textContent).not.toContain("asset_mail_hwpx_recognized");
});
it("tells the buyer to wait when mail HWPX recognition is still pending", async () => {
diff --git a/frontend/src/components/MailAttachmentPreview.tsx b/frontend/src/components/MailAttachmentPreview.tsx
index c8402f7d4..b6ebcb3ed 100644
--- a/frontend/src/components/MailAttachmentPreview.tsx
+++ b/frontend/src/components/MailAttachmentPreview.tsx
@@ -88,6 +88,7 @@ export function MailAttachmentPreview({ attachments }: MailAttachmentPreviewProp
{selectedAttachment ? (
) : null}
diff --git a/frontend/src/components/data-layout/RepositoryAssetPreviewPanel.test.tsx b/frontend/src/components/data-layout/RepositoryAssetPreviewPanel.test.tsx
index 47fe677f5..c239c5630 100644
--- a/frontend/src/components/data-layout/RepositoryAssetPreviewPanel.test.tsx
+++ b/frontend/src/components/data-layout/RepositoryAssetPreviewPanel.test.tsx
@@ -4,7 +4,25 @@ import { createRoot, type Root } from "react-dom/client";
import { afterEach, describe, expect, it } from "vitest";
import { RepositoryAssetPreviewPanel } from "./RepositoryAssetPreviewPanel";
-import type { RepositoryAssetPreview } from "./types";
+import type { InkspanEditHandoff, RepositoryAssetPreview } from "./types";
+import { getInkspanEditHandoffNextActionLabel } from "./utils";
+
+function unavailableHwpxHandoff(): InkspanEditHandoff {
+ return {
+ source_asset_key: "asset_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,
+ };
+}
function recognizedPreview(): RepositoryAssetPreview {
return {
@@ -17,6 +35,7 @@ function recognizedPreview(): RepositoryAssetPreview {
next_action: "read_recognized_text",
error_code: null,
provider_write_executed: false,
+ edit_handoff: unavailableHwpxHandoff(),
};
}
@@ -95,6 +114,30 @@ describe("RepositoryAssetPreviewPanel", () => {
expect(container?.textContent).toContain("content and thread evidence ready");
});
+ it("offers a fail-closed Edit in Inkspan control for recognized HWPX", () => {
+ renderPanel({
+ currentDetailText: "content and thread evidence ready",
+ fileName: "decision.hwpx",
+ preview: recognizedPreview(),
+ });
+
+ const editButton = container?.querySelector(
+ '[aria-label="decision.hwpx Inkspan에서 편집"]',
+ );
+ expect(editButton).not.toBeNull();
+ expect(editButton?.disabled).toBe(true);
+ expect(editButton?.getAttribute("aria-disabled")).toBe("true");
+ expect(container?.textContent).toContain("Inkspan에서 편집");
+ expect(container?.textContent).toContain(
+ "설치된 Inkspan에 HWPX 편집 기능이 없습니다. 인식된 본문을 계속 읽거나 다른 파일을 선택하세요.",
+ );
+ expect(container?.textContent).not.toContain("asset_hwpx_recognized");
+ expect(container?.textContent).not.toContain("inkspan_hangul_document_engine");
+ expect(getInkspanEditHandoffNextActionLabel("keep_reading_recognized_text")).toBe(
+ "인식된 본문을 계속 읽거나 다른 파일을 선택하세요.",
+ );
+ });
+
it("tells the buyer to wait when HWPX recognition is still pending", () => {
renderPanel({
currentDetailText: "content extraction pending, canonical thread pending",
diff --git a/frontend/src/components/data-layout/RepositoryAssetPreviewPanel.tsx b/frontend/src/components/data-layout/RepositoryAssetPreviewPanel.tsx
index d72ae1665..db1138d09 100644
--- a/frontend/src/components/data-layout/RepositoryAssetPreviewPanel.tsx
+++ b/frontend/src/components/data-layout/RepositoryAssetPreviewPanel.tsx
@@ -2,6 +2,7 @@ import React from 'react';
import { toSafeReactText } from '@/lib/safe-text';
import type { RepositoryAssetPreview } from './types';
import {
+ getInkspanEditHandoffUnavailableReason,
getRepositoryAssetPreviewCopy,
isRecognizedRepositoryAssetPreview,
} from './utils';
@@ -9,12 +10,14 @@ import {
type RepositoryAssetPreviewPanelProps = {
currentDetailText: string;
preview: RepositoryAssetPreview | null;
+ fileName?: string;
};
/** Render recognized HWPX text, or an explicit next action when text is missing. */
export function RepositoryAssetPreviewPanel({
currentDetailText,
preview,
+ fileName,
}: RepositoryAssetPreviewPanelProps) {
const copy = preview
? getRepositoryAssetPreviewCopy(preview)
@@ -23,6 +26,8 @@ export function RepositoryAssetPreviewPanel({
status_label: '미리보기 확인',
};
const recognized = isRecognizedRepositoryAssetPreview(preview);
+ const editHandoff = preview?.edit_handoff ?? null;
+ const handoffFileName = toSafeReactText(fileName || '선택한 파일');
return (
@@ -43,6 +48,27 @@ export function RepositoryAssetPreviewPanel({
{copy.next_action_label}
)}
+ {editHandoff ? (
+
+
+
+ {getInkspanEditHandoffUnavailableReason(editHandoff.error_code)}
+
+
+ ) : null}
{toSafeReactText(currentDetailText)}
diff --git a/frontend/src/components/data-layout/types.ts b/frontend/src/components/data-layout/types.ts
index 9002691fa..7e232d16c 100644
--- a/frontend/src/components/data-layout/types.ts
+++ b/frontend/src/components/data-layout/types.ts
@@ -497,6 +497,27 @@ export type RepositoryAssetPreviewNextAction =
| 'wait_for_recognition'
| 'choose_another_file';
+export type InkspanEditHandoffState = 'unavailable';
+export type InkspanEditHandoffNextAction = 'keep_reading_recognized_text';
+export type InkspanEditHandoffErrorCode =
+ | 'inkspan_hangul_capability_unavailable'
+ | 'inkspan_edit_contract_unavailable';
+
+export type InkspanEditHandoff = {
+ source_asset_key: string;
+ source_asset_type: 'email_attachment' | 'workspace_document';
+ parser_family: string | null;
+ handoff_state: InkspanEditHandoffState;
+ editor_capability_name: string;
+ mutation_allowed: boolean;
+ converts_source_to_plain_text: boolean;
+ overwrites_original: boolean;
+ provider_write_executed: boolean;
+ next_action: InkspanEditHandoffNextAction;
+ error_code: InkspanEditHandoffErrorCode;
+ editable_document_payload: null;
+};
+
export type RepositoryAssetPreview = {
asset_key: string;
asset_type: 'email_attachment' | 'workspace_document';
@@ -507,6 +528,7 @@ export type RepositoryAssetPreview = {
next_action: RepositoryAssetPreviewNextAction;
error_code: string | null;
provider_write_executed: boolean;
+ edit_handoff?: InkspanEditHandoff | null;
};
export const duplicateImportCandidates = [
diff --git a/frontend/src/components/data-layout/utils.ts b/frontend/src/components/data-layout/utils.ts
index a419b16e7..9c860725a 100644
--- a/frontend/src/components/data-layout/utils.ts
+++ b/frontend/src/components/data-layout/utils.ts
@@ -5,6 +5,8 @@ import {
WebdavAccountLookup,
WebdavWritebackIntentResponse,
DataQualitySurfaceResponse,
+ InkspanEditHandoffErrorCode,
+ InkspanEditHandoffNextAction,
RepositoryAssetPreview,
RepositoryAssetPreviewNextAction,
RepositoryAssetPreviewState,
@@ -158,6 +160,36 @@ export function getRepositoryAssetPreviewNextActionLabel(
}
}
+/** Return the exact next action after a fail-closed Inkspan edit handoff. */
+export function getInkspanEditHandoffNextActionLabel(
+ nextAction: InkspanEditHandoffNextAction,
+) {
+ switch (nextAction) {
+ case 'keep_reading_recognized_text':
+ return '인식된 본문을 계속 읽거나 다른 파일을 선택하세요.';
+ default: {
+ const exhaustive: never = nextAction;
+ return exhaustive;
+ }
+ }
+}
+
+/** Return why Edit in Inkspan is unavailable without leaking capability ids. */
+export function getInkspanEditHandoffUnavailableReason(
+ errorCode: InkspanEditHandoffErrorCode,
+) {
+ switch (errorCode) {
+ case 'inkspan_hangul_capability_unavailable':
+ return '설치된 Inkspan에 HWPX 편집 기능이 없습니다. 인식된 본문을 계속 읽거나 다른 파일을 선택하세요.';
+ case 'inkspan_edit_contract_unavailable':
+ return 'Inkspan HWPX 편집 경로가 준비되지 않았습니다. 원본은 덮어쓰지 않습니다. 인식된 본문을 계속 읽으세요.';
+ default: {
+ const exhaustive: never = errorCode;
+ return exhaustive;
+ }
+ }
+}
+
/** True only when preview contains recognized paragraph text, never empty content. */
export function isRecognizedRepositoryAssetPreview(
preview: RepositoryAssetPreview | null | undefined,
diff --git a/frontend/tests/e2e/dashboard-flows.spec.ts b/frontend/tests/e2e/dashboard-flows.spec.ts
index b8bc053ba..ef9900a43 100644
--- a/frontend/tests/e2e/dashboard-flows.spec.ts
+++ b/frontend/tests/e2e/dashboard-flows.spec.ts
@@ -37,6 +37,8 @@ test('opens recognized HWPX paragraph text from the selected mail attachment', a
await page.getByRole('button', { name: 'decision.hwpx 인식된 본문 열기' }).click();
await expect(page.getByText('Quarterly decision record')).toBeVisible();
await expect(page.getByText('Approve the next action.')).toBeVisible();
+ await expect(page.getByRole('button', { name: 'decision.hwpx Inkspan에서 편집' })).toBeDisabled();
+ await expect(page.getByText('설치된 Inkspan에 HWPX 편집 기능이 없습니다. 인식된 본문을 계속 읽거나 다른 파일을 선택하세요.')).toBeVisible();
await expect(page.getByText('본문이 없습니다')).toHaveCount(0);
});
diff --git a/frontend/tests/e2e/helpers.ts b/frontend/tests/e2e/helpers.ts
index ae897f912..5f3324213 100644
--- a/frontend/tests/e2e/helpers.ts
+++ b/frontend/tests/e2e/helpers.ts
@@ -1348,6 +1348,20 @@ export async function mockDashboardApi(page: Page, onApiRequest?: (path: string,
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,
+ },
});
return;
}
From b5115d433eb44b741674a5f3e85c466b8ebe3cd3 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Tue, 18 Aug 2026 06:30:04 +0000
Subject: [PATCH 2/4] fix(mail): fail closed on malformed Inkspan handoff
metadata
Treat non-collection adapter family metadata as unavailable, show the
Edit in Inkspan control only for recognized HWPX, and fold PR numbers
into doctoring prose so the heading-hash lint does not fire.
Co-authored-by: Seongho Bae
---
backend/services/inkspan_edit_handoff.py | 8 +++-
backend/tests/test_inkspan_edit_handoff.py | 28 +++++++++++
.../hwp-hwpx-attachment-recognition.md | 4 +-
.../RepositoryAssetPreviewPanel.test.tsx | 47 +++++++++++++++++++
.../RepositoryAssetPreviewPanel.tsx | 7 ++-
5 files changed, 89 insertions(+), 5 deletions(-)
diff --git a/backend/services/inkspan_edit_handoff.py b/backend/services/inkspan_edit_handoff.py
index 43621ef08..585860ca6 100644
--- a/backend/services/inkspan_edit_handoff.py
+++ b/backend/services/inkspan_edit_handoff.py
@@ -72,10 +72,14 @@ def _adapter_field(adapter: object | None, field_name: str) -> object | None:
def _accepted_source_families(adapter: object | None) -> tuple[str, ...]:
- """Return the source families an adapter can open without conversion."""
+ """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 families:
+ if not isinstance(families, (list, tuple, set, frozenset)):
return ()
return tuple(str(family) for family in families)
diff --git a/backend/tests/test_inkspan_edit_handoff.py b/backend/tests/test_inkspan_edit_handoff.py
index a15064309..004275aff 100644
--- a/backend/tests/test_inkspan_edit_handoff.py
+++ b/backend/tests/test_inkspan_edit_handoff.py
@@ -97,6 +97,34 @@ def test_recognized_hwpx_handoff_fails_closed_without_hangul_capability() -> Non
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:
diff --git a/docs/doctoring/hwp-hwpx-attachment-recognition.md b/docs/doctoring/hwp-hwpx-attachment-recognition.md
index db9095cb3..47861702e 100644
--- a/docs/doctoring/hwp-hwpx-attachment-recognition.md
+++ b/docs/doctoring/hwp-hwpx-attachment-recognition.md
@@ -237,8 +237,8 @@ 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
-#1353/#1373/#1404/#1406 preview contract (Korean Agency for Technology and
-Standards, 2024; Hancom Tech, 2025b, 2025c).
+preview contract owned by PRs #1353, #1373, #1404, and #1406 (Korean Agency
+for Technology and Standards, 2024; Hancom Tech, 2025b, 2025c).
## Out of scope
diff --git a/frontend/src/components/data-layout/RepositoryAssetPreviewPanel.test.tsx b/frontend/src/components/data-layout/RepositoryAssetPreviewPanel.test.tsx
index b9d853970..834436fd9 100644
--- a/frontend/src/components/data-layout/RepositoryAssetPreviewPanel.test.tsx
+++ b/frontend/src/components/data-layout/RepositoryAssetPreviewPanel.test.tsx
@@ -138,6 +138,53 @@ describe("RepositoryAssetPreviewPanel", () => {
);
});
+ it("hides the Inkspan control on a pending HWPX preview even if a handoff is present", () => {
+ renderPanel({
+ currentDetailText: "content extraction pending, canonical thread pending",
+ fileName: "pending.hwpx",
+ preview: {
+ ...pendingPreview(),
+ edit_handoff: {
+ ...unavailableHwpxHandoff(),
+ source_asset_key: "asset_hwpx_pending",
+ },
+ },
+ });
+
+ const panel = container?.querySelector('[aria-label="선택한 자산 본문 미리보기"]');
+ expect(panel?.querySelector('[aria-label="pending.hwpx Inkspan에서 편집"]')).toBeNull();
+ expect(container?.textContent).not.toContain("Inkspan에서 편집");
+ expect(panel?.textContent).toContain("인식이 끝날 때까지 기다리거나 다른 파일을 선택하세요");
+ });
+
+ it("hides the Inkspan control on a recognized non-HWPX preview even if a handoff is present", () => {
+ renderPanel({
+ currentDetailText: "content and thread evidence ready",
+ fileName: "roadmap.pdf",
+ preview: {
+ asset_key: "asset_pdf_recognized",
+ asset_type: "email_attachment",
+ preview_state: "recognized",
+ parser_family: "pdf",
+ paragraph_texts: ["Extracted roadmap PDF text"],
+ preview_text: "Extracted roadmap PDF text",
+ next_action: "read_recognized_text",
+ error_code: null,
+ provider_write_executed: false,
+ edit_handoff: {
+ ...unavailableHwpxHandoff(),
+ source_asset_key: "asset_pdf_recognized",
+ parser_family: "pdf",
+ },
+ },
+ });
+
+ const panel = container?.querySelector('[aria-label="선택한 자산 본문 미리보기"]');
+ expect(panel?.textContent).toContain("Extracted roadmap PDF text");
+ expect(panel?.querySelector('[aria-label="roadmap.pdf Inkspan에서 편집"]')).toBeNull();
+ expect(container?.textContent).not.toContain("Inkspan에서 편집");
+ });
+
it("tells the buyer to wait when HWPX recognition is still pending", () => {
renderPanel({
currentDetailText: "content extraction pending, canonical thread pending",
diff --git a/frontend/src/components/data-layout/RepositoryAssetPreviewPanel.tsx b/frontend/src/components/data-layout/RepositoryAssetPreviewPanel.tsx
index fc4f7c6da..89b652a8d 100644
--- a/frontend/src/components/data-layout/RepositoryAssetPreviewPanel.tsx
+++ b/frontend/src/components/data-layout/RepositoryAssetPreviewPanel.tsx
@@ -29,6 +29,11 @@ export function RepositoryAssetPreviewPanel({
};
const recognized = isRecognizedRepositoryAssetPreview(preview);
const editHandoff = preview?.edit_handoff ?? null;
+ const showInkspanHandoff = Boolean(
+ editHandoff
+ && isRecognizedRepositoryAssetPreview(preview)
+ && preview.parser_family === 'hwpx',
+ );
const handoffFileName = toSafeReactText(fileName || '선택한 파일');
return (
@@ -50,7 +55,7 @@ export function RepositoryAssetPreviewPanel({
{copy.next_action_label}
)}
- {editHandoff ? (
+ {showInkspanHandoff && editHandoff ? (