Skip to content
Draft
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
14 changes: 0 additions & 14 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
37 changes: 37 additions & 0 deletions backend/api/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""

Expand All @@ -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):
Expand Down Expand Up @@ -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,
Expand All @@ -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,
)
),
)


Expand Down
153 changes: 153 additions & 0 deletions backend/services/inkspan_edit_handoff.py
Original file line number Diff line number Diff line change
@@ -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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.


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,
)
Loading