feat(model): Identity/Placement/Span, Page, CapabilityProfile (WP-0007 T2)

Identity = stable shard-scoped handle (not a fingerprint); Placement separate;
Span carries a layered provenance delta. Page model (PageShape incl. the four
computational shapes). CapabilityProfile with orthogonal-core axes + validate()
applying §6.5 implication rules that reject impossible profiles. Imports only
provenance/. 8 tests green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-15 09:00:49 +02:00
parent aca9bf30f9
commit 5a77ea879c
6 changed files with 477 additions and 1 deletions

View File

@@ -0,0 +1,47 @@
"""model/ — the backend-neutral page model and capability types (the top waist).
Imports only the ``provenance`` leaf; nothing backend-specific lives here.
"""
from shard_wiki.model.capability import (
AccessGrant,
Addressing,
AttachmentMode,
CapabilityProfile,
ContentOpacity,
History,
MergeModel,
NativeQuery,
NotSupported,
OperationalEnvelope,
ProfileError,
Substrate,
Translation,
Verb,
WriteGranularity,
)
from shard_wiki.model.identity import Identity, Placement, Span
from shard_wiki.model.page import Page, PageShape
__all__ = [
"Identity",
"Placement",
"Span",
"Page",
"PageShape",
"CapabilityProfile",
"Verb",
"Substrate",
"AttachmentMode",
"WriteGranularity",
"ContentOpacity",
"OperationalEnvelope",
"AccessGrant",
"History",
"MergeModel",
"Addressing",
"NativeQuery",
"Translation",
"ProfileError",
"NotSupported",
]

View File

@@ -0,0 +1,220 @@
"""Capability profile — capability-as-data (CoreArchitectureBlueprint §6, I-3).
A binding's abilities are a *position on each capability spectrum*, not a boolean checklist.
Descriptively there are fifteen spectra (§6.1); operationally they reduce to a small
**orthogonal core** plus **implied** axes, with **implication rules that forbid impossible
profiles** (§6.5). ``CapabilityProfile.validate()`` enforces those rules; a profile that
violates one is rejected (the structural half of "capability-as-data is sound" — the behavioural
half is the conformance suite, T4).
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from shard_wiki.provenance import Liveness
__all__ = [
"Verb",
"Substrate",
"AttachmentMode",
"WriteGranularity",
"ContentOpacity",
"OperationalEnvelope",
"AccessGrant",
"History",
"MergeModel",
"Addressing",
"NativeQuery",
"Translation",
"CapabilityProfile",
"ProfileError",
"NotSupported",
]
CONTRACT_VERSION = "0.1"
class ProfileError(ValueError):
"""Raised when a capability profile is internally impossible (violates §6.5 rules)."""
class NotSupported(Exception):
"""Raised by an adapter when an operation verb is not in its capability profile."""
class Verb(Enum):
READ = "read"
WRITE = "write"
DIFF = "diff"
MERGE = "merge"
LOCK = "lock"
VERSION = "version"
PUBLISH = "publish"
NOTIFY = "notify"
TRANSCLUDE_SOURCE = "transclude-source"
TRANSLATE_SYNTAX = "translate-syntax"
STRUCTURED_PAYLOAD = "structured-payload"
DERIVE_PROJECTION = "derive-projection" # gated, off by default (T18)
EXECUTE = "execute" # gated, off by default (T18)
# --- orthogonal core axes (§6.5a) ---
class Substrate(Enum):
GIT = "git"
FILES = "files"
RELATIONAL_DB = "relational-db"
EXTERNAL_API = "external-api"
CRDT = "crdt"
class WriteGranularity(Enum):
NONE = "none" # read-only
WHOLE_FILE = "whole-file"
SECTION = "section"
PER_PAGE = "per-page"
PER_BLOCK = "per-block"
class ContentOpacity(Enum):
TRANSPARENT = "transparent"
STRUCTURED_REEVALUABLE = "structured-re-evaluable"
ENCRYPTED = "encrypted"
PER_ITEM = "per-item"
PROPRIETARY_LOSSY = "proprietary-lossy"
class OperationalEnvelope(Enum):
LOCAL_UNBOUNDED = "local-unbounded"
REALTIME = "realtime"
RATE_LIMITED = "rate-limited"
class AccessGrant(Enum):
OPEN = "open"
TOKEN = "token"
OAUTH_SCOPED = "oauth-scoped"
P2P_KEY = "p2p-key"
ENTERPRISE_ACL = "enterprise-acl"
# --- implied axes (§6.5b): may be derived from the core, validated for consistency ---
class History(Enum):
NONE = "none"
INTERNAL_ONLY = "internal-only"
CRDT_LOG = "crdt-log"
OPEN_FILE = "open-file"
GIT_NATIVE = "git-native"
class MergeModel(Enum):
NONE = "none"
GIT_TEXT = "git-text"
KEEP_BOTH = "keep-both"
NATIVE_CRDT = "native-crdt"
COEXIST_RANK = "coexist-rank"
class AttachmentMode(Enum):
# NB: there is deliberately no IMAGE mode — an image/live-memory blob is never an attach
# target (I-12). It participates only via export→files.
FILE_STORE = "file-store"
GIT_IS_STORE = "git-is-store"
IN_ENGINE_HOST = "in-engine-host"
LOCAL_REST = "local-rest"
EXTERNAL_API = "external-api"
DIRECT_DB = "direct-db"
CRDT_REPLICA = "crdt-replica"
P2P = "p2p"
class Addressing(Enum):
NONE = "none"
PATH = "path"
PAGE_ID = "page-id"
SPAN = "span"
BLOCK_ID = "block-id"
STORE_UUID = "store-uuid"
TUMBLER = "tumbler"
class NativeQuery(Enum):
NONE = "none"
TEXT = "text"
DERIVED_INDEX = "derived-index"
DATALOG_GRAPH = "datalog-graph"
DB_QUERY = "db-query"
SPARQL = "sparql"
class Translation(Enum):
NATIVE = "native"
LOSSLESS = "lossless"
LOSSY_WITH_REPORT = "lossy-with-report"
@dataclass(frozen=True, slots=True)
class CapabilityProfile:
"""A verified-at-registration position on each capability axis, plus supported verbs."""
# orthogonal core
substrate: Substrate
attachment_mode: AttachmentMode
write_granularity: WriteGranularity
content_opacity: ContentOpacity
operational_envelope: OperationalEnvelope
access_grant: AccessGrant
liveness: Liveness # computational/liveness axis (15th)
# implied
history: History
merge_model: MergeModel
addressing: Addressing
native_query: NativeQuery
translation: Translation
supported_verbs: frozenset[Verb] = field(default_factory=frozenset)
contract_version: str = CONTRACT_VERSION
def supports(self, verb: Verb) -> bool:
return verb in self.supported_verbs
def validate(self) -> CapabilityProfile:
"""Apply the §6.5 implication rules; raise :class:`ProfileError` on an impossible
combination. Returns ``self`` so it can be used fluently at construction."""
rules: list[tuple[bool, str]] = [
(
Verb.READ in self.supported_verbs,
"every shard must support READ (the capability floor)",
),
(
self.attachment_mode is not AttachmentMode.GIT_IS_STORE
or (self.substrate is Substrate.GIT and self.history is History.GIT_NATIVE),
"git-is-store ⟹ substrate=git ∧ history=git-native",
),
(
self.content_opacity is not ContentOpacity.ENCRYPTED
or self.native_query is NativeQuery.NONE,
"encrypted opacity ⟹ native-query=none",
),
(
self.merge_model is not MergeModel.NATIVE_CRDT
or (
self.history is History.CRDT_LOG
and self.operational_envelope is OperationalEnvelope.REALTIME
),
"native-crdt merge ⟹ history=crdt-log ∧ envelope=realtime",
),
(
(self.write_granularity is WriteGranularity.NONE)
== (Verb.WRITE not in self.supported_verbs),
"write-granularity=none ⟺ WRITE not supported (read-only consistency)",
),
(
Verb.MERGE not in self.supported_verbs or self.merge_model is not MergeModel.NONE,
"MERGE verb ⟹ merge-model ≠ none",
),
]
broken = [msg for ok, msg in rules if not ok]
if broken:
raise ProfileError("; ".join(broken))
return self

View File

@@ -0,0 +1,60 @@
"""Identity, Placement, Span — three distinct concepts (CoreArchitectureBlueprint §7.2).
- **Identity** is a *stable handle* assigned/minted by a shard; it survives edits. It is NEVER
derived from content (a content fingerprint identifies a *version*, not a *page*).
- **Placement** is *where* an identity sits (path in a shard); one identity may have many
placements (a DAG). Placement can change without changing identity.
- **Span** is a sub-page address within an identity, optionally carrying a provenance delta.
Equivalence (detecting that two *distinct* identities hold the same content) is a separate
mechanism and lives in ``union/`` — not here.
"""
from __future__ import annotations
from dataclasses import dataclass
from shard_wiki.provenance import SpanProvenanceDelta
__all__ = ["Identity", "Placement", "Span"]
@dataclass(frozen=True, slots=True)
class Identity:
"""A shard-scoped, stable page handle. Value-equal; stable across content edits.
``shard`` scopes the handle so native ids survive projection and never collide across
shards. ``key`` is the backend's stable handle (page name / native uid) — not a fingerprint.
"""
shard: str
key: str
def __str__(self) -> str:
return f"{self.shard}:{self.key}"
@dataclass(frozen=True, slots=True)
class Placement:
"""Where an identity sits: a path within a shard. Mutable over an identity's life."""
shard: str
path: str
def __str__(self) -> str:
return f"{self.shard}/{self.path}"
@dataclass(frozen=True, slots=True)
class Span:
"""A sub-page address within a page identity, with an optional provenance delta.
``ref`` is a native span id where the backend mints one (Roam ``:block/uid``, Logseq
``id::``), else a positional/fingerprint address. ``delta`` overrides the page envelope
only where this span genuinely differs (effective-vs-own, blueprint §7.3).
"""
ref: str
start: int | None = None
end: int | None = None
delta: SpanProvenanceDelta | None = None

View File

@@ -0,0 +1,43 @@
"""The backend-neutral wiki page model — the top narrow waist (CoreArchitectureBlueprint §7).
Markdown-first but stretchable: every shape reduces to
``(content|source, structure, provenance envelope, [derivation rule])``.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import Enum
from shard_wiki.model.identity import Identity, Placement, Span
from shard_wiki.provenance import ProvenanceEnvelope
__all__ = ["PageShape", "Page"]
class PageShape(Enum):
"""The page-model shapes (blueprint §7.1). The slice handles PROSE; the rest are declared
so adapters can tag content faithfully (union without erasure)."""
PROSE = "prose"
TYPED_RECORD = "typed-record"
TYPED_GRAPH = "typed-graph"
INLINE_EMBEDDED = "inline-embedded"
NON_MARKDOWN_ASSET = "non-markdown-asset"
ONE_SOURCE_MANY_PROJECTIONS = "one-source-many-projections"
NOTEBOOK = "notebook"
PROGRAM_AS_PAGE = "program-as-page"
LIVE_TEMPORAL = "live-temporal"
@dataclass(frozen=True, slots=True)
class Page:
"""A page in the union: a stable identity, content, the page-level provenance envelope,
its placements, and its spans (whose deltas layer over the page envelope, §7.3)."""
identity: Identity
body: str
envelope: ProvenanceEnvelope
shape: PageShape = PageShape.PROSE
placements: tuple[Placement, ...] = ()
spans: tuple[Span, ...] = field(default_factory=tuple)

106
tests/test_model.py Normal file
View File

@@ -0,0 +1,106 @@
"""Tests for the page model + capability profile (SHARD-WP-0007 T2)."""
import pytest
from shard_wiki.model import (
AccessGrant,
Addressing,
AttachmentMode,
CapabilityProfile,
ContentOpacity,
History,
Identity,
MergeModel,
NativeQuery,
OperationalEnvelope,
Page,
PageShape,
ProfileError,
Substrate,
Translation,
Verb,
WriteGranularity,
)
from shard_wiki.provenance import Liveness, ProvenanceEnvelope
def _read_only_folder_profile(**over) -> CapabilityProfile:
base = dict(
substrate=Substrate.FILES,
attachment_mode=AttachmentMode.FILE_STORE,
write_granularity=WriteGranularity.NONE,
content_opacity=ContentOpacity.TRANSPARENT,
operational_envelope=OperationalEnvelope.LOCAL_UNBOUNDED,
access_grant=AccessGrant.OPEN,
liveness=Liveness.STATIC,
history=History.NONE,
merge_model=MergeModel.NONE,
addressing=Addressing.PATH,
native_query=NativeQuery.NONE,
translation=Translation.NATIVE,
supported_verbs=frozenset({Verb.READ}),
)
base.update(over)
return CapabilityProfile(**base)
def test_identity_is_stable_across_content_and_value_equal():
a = Identity("shardA", "Home")
b = Identity("shardA", "Home")
assert a == b and hash(a) == hash(b)
assert str(a) == "shardA:Home"
# Identity does not depend on content: a Page edit reuses the same identity.
env = ProvenanceEnvelope(source_shard="shardA")
p1 = Page(a, "first body", env)
p2 = Page(a, "edited body", env)
assert p1.identity == p2.identity # stable handle, not a fingerprint
def test_read_only_folder_profile_validates():
assert _read_only_folder_profile().validate().supports(Verb.READ)
def test_missing_read_is_rejected():
with pytest.raises(ProfileError, match="READ"):
_read_only_folder_profile(supported_verbs=frozenset()).validate()
def test_git_is_store_requires_git_substrate_and_history():
with pytest.raises(ProfileError, match="git-is-store"):
_read_only_folder_profile(attachment_mode=AttachmentMode.GIT_IS_STORE).validate()
# consistent git-is-store profile validates
_read_only_folder_profile(
attachment_mode=AttachmentMode.GIT_IS_STORE,
substrate=Substrate.GIT,
history=History.GIT_NATIVE,
).validate()
def test_encrypted_forbids_native_query():
with pytest.raises(ProfileError, match="encrypted"):
_read_only_folder_profile(
content_opacity=ContentOpacity.ENCRYPTED, native_query=NativeQuery.DB_QUERY
).validate()
def test_write_verb_requires_write_granularity():
with pytest.raises(ProfileError, match="read-only consistency"):
_read_only_folder_profile(supported_verbs=frozenset({Verb.READ, Verb.WRITE})).validate()
# writable profile is consistent
_read_only_folder_profile(
write_granularity=WriteGranularity.PER_PAGE,
supported_verbs=frozenset({Verb.READ, Verb.WRITE}),
).validate()
def test_native_crdt_merge_requires_crdt_log_and_realtime():
with pytest.raises(ProfileError, match="native-crdt"):
_read_only_folder_profile(
merge_model=MergeModel.NATIVE_CRDT,
supported_verbs=frozenset({Verb.READ, Verb.MERGE}),
).validate()
def test_page_defaults_to_prose():
p = Page(Identity("s", "k"), "x", ProvenanceEnvelope(source_shard="s"))
assert p.shape is PageShape.PROSE

View File

@@ -56,7 +56,7 @@ delta). Frozen dataclasses, no tree deps. Tests: ⊕ identity (no delta), overri
```task
id: SHARD-WP-0007-T2
status: todo
status: done
priority: high
state_hub_task_id: "780ad01f-c3e1-4b49-9ae9-60e0324178a7"
```