From 5a77ea879c0d88f7e7f6c0aca52571a80f733c8d Mon Sep 17 00:00:00 2001 From: tegwick Date: Mon, 15 Jun 2026 09:00:49 +0200 Subject: [PATCH] feat(model): Identity/Placement/Span, Page, CapabilityProfile (WP-0007 T2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/shard_wiki/model/__init__.py | 47 ++++ src/shard_wiki/model/capability.py | 220 ++++++++++++++++++ src/shard_wiki/model/identity.py | 60 +++++ src/shard_wiki/model/page.py | 43 ++++ tests/test_model.py | 106 +++++++++ ...SHARD-WP-0007-foundation-implementation.md | 2 +- 6 files changed, 477 insertions(+), 1 deletion(-) create mode 100644 src/shard_wiki/model/__init__.py create mode 100644 src/shard_wiki/model/capability.py create mode 100644 src/shard_wiki/model/identity.py create mode 100644 src/shard_wiki/model/page.py create mode 100644 tests/test_model.py diff --git a/src/shard_wiki/model/__init__.py b/src/shard_wiki/model/__init__.py new file mode 100644 index 0000000..f487cba --- /dev/null +++ b/src/shard_wiki/model/__init__.py @@ -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", +] diff --git a/src/shard_wiki/model/capability.py b/src/shard_wiki/model/capability.py new file mode 100644 index 0000000..d686915 --- /dev/null +++ b/src/shard_wiki/model/capability.py @@ -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 diff --git a/src/shard_wiki/model/identity.py b/src/shard_wiki/model/identity.py new file mode 100644 index 0000000..99a7f05 --- /dev/null +++ b/src/shard_wiki/model/identity.py @@ -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 diff --git a/src/shard_wiki/model/page.py b/src/shard_wiki/model/page.py new file mode 100644 index 0000000..df1b911 --- /dev/null +++ b/src/shard_wiki/model/page.py @@ -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) diff --git a/tests/test_model.py b/tests/test_model.py new file mode 100644 index 0000000..b1169aa --- /dev/null +++ b/tests/test_model.py @@ -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 diff --git a/workplans/SHARD-WP-0007-foundation-implementation.md b/workplans/SHARD-WP-0007-foundation-implementation.md index c1e238d..129ebbb 100644 --- a/workplans/SHARD-WP-0007-foundation-implementation.md +++ b/workplans/SHARD-WP-0007-foundation-implementation.md @@ -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" ```