mixle.substrate.spaces module

Team-scoped substrate views with explicit publishing, centralized access control, and immutable history.

The substrate tags every item with an access scope (“local”, a team id, or a shared scope like “public”), but the raw store filters on one scope at a time. A team’s real visibility is a union: its own items plus whatever has been shared into a common scope, and never another team’s private items. Space is that view. Construct one for a team and it answers retrieve / all over exactly the team’s visible set, so two teams querying the same substrate see different, correctly-isolated knowledge.

Sharing is explicit: nothing crosses a scope boundary until someone calls publish(). Publishing re-scopes an item into a shared scope and records who published it and from where.

Isolation is enforced, not caller convention (MXR-080-0264). Every read, write, and publish is checked against an AccessPolicy – the single, centralized decision of whether a principal may touch a scope. A principal always has full access to its own home scope (the scope sharing its name) and to PUBLIC, the explicit sharing commons; every OTHER scope, in particular another team’s private scope, is denied unless AccessPolicy.grant() says otherwise. Space validates shared against the policy at construction (and again, live, on every read, so a later revocation takes effect immediately); publish() checks both the item’s actual stored scope and the target; and merge_versions() requires the merging principal to hold write access to both sides. None of these trust a caller-supplied scope parameter as if it were already a validated permission.

Every publish and merge also persists an immutable, content-addressed snapshot of the state it is about to overwrite or delete (MXR-080-0265/0266). version_history entries reference those snapshots by digest; revision() recovers the complete prior item – text, payload, provenance, tags, links, scope, timestamp – from one, so “the prior state is recoverable” is an actual, checkable property, not just an audit label. merge_versions() additionally requires a typed MergeStrategy and authorized common lineage, and only performs its destructive step (deleting the merged-away item, whose full state remains recoverable via its snapshot) when the caller explicitly passes confirm=True.

exception AccessDeniedError[source]

Bases: PermissionError

Raised when a principal is not authorized for the scope it tried to read, write, or publish.

class AccessPolicy(_read=<factory>, _write=<factory>)[source]

Bases: object

The centralized access-policy decision point (MXR-080-0264): the one place that decides whether a principal may read or write a scope.

A principal always has read+write access to the scope sharing its own name (Space is constructed with team doubling as that team’s principal id, so a team’s own scope is always its home scope) and to PUBLIC, the explicit sharing commons – this module’s whole design is that nothing else crosses a boundary until someone deliberately publishes into a common scope. Every OTHER scope, in particular another team’s private scope, is denied until explicitly granted with grant_read() / grant_write() / grant(). Space, publish(), and merge_versions() all consult an AccessPolicy instead of trusting caller-supplied scope parameters (shared=, to=, from_scope=) as though they were already-validated permissions – closing the concrete leak where a team-A space configured with shared=("team-b",) simply read team-B’s private items with no check at all.

Parameters:
grant_read(principal, scope)[source]

Grant principal read access to scope beyond its home scope and PUBLIC (chainable).

Parameters:
Return type:

AccessPolicy

grant_write(principal, scope)[source]

Grant principal write access to scope beyond its home scope and PUBLIC (chainable).

Parameters:
Return type:

AccessPolicy

grant(principal, scope)[source]

Grant principal both read and write access to scope (chainable).

Parameters:
Return type:

AccessPolicy

revoke(principal, scope)[source]

Revoke both read and write grants of scope from principal (chainable).

A principal’s home scope and PUBLIC are intrinsic, not grant-dependent, so this can never revoke those – only a scope previously given via grant_read()/grant_write().

Parameters:
Return type:

AccessPolicy

can_read(principal, scope)[source]

Whether principal may read items in scope.

Parameters:
Return type:

bool

can_write(principal, scope)[source]

Whether principal may write (add or publish) items into scope.

Parameters:
Return type:

bool

require_read(principal, scope)[source]

Raise AccessDeniedError unless principal may read scope.

Parameters:
Return type:

None

require_write(principal, scope)[source]

Raise AccessDeniedError unless principal may write scope.

Parameters:
Return type:

None

visible_scopes(team, *, shared=(PUBLIC,))[source]

The scopes a team may read: its own id plus the shared scopes (never another team’s private one).

A pure helper – it does not itself enforce anything. Space is the enforcement point: it validates shared against an AccessPolicy before this ever runs.

Parameters:
Return type:

set[str]

revision(substrate, digest)[source]

The complete immutable snapshot recorded under content hash digest (MXR-080-0265), or None if no such revision was ever persisted. Returns the full prior SubstrateItem state as a JSON-compatible dict (kind, text, payload, provenance, tags, links, scope, created_at) – not just the who/from/to bookkeeping version_history alone would give you. The returned dict is independent of the stored snapshot (a deep copy), so a caller mutating it can never corrupt the persisted revision.

Parameters:
  • substrate (Substrate)

  • digest (str)

Return type:

dict[str, Any] | None

publish(substrate, ids, *, to=PUBLIC, by, from_scope=None, policy=None)[source]

Share items into a common scope, authorized (MXR-080-0264).

Re-scopes each item in ids to to and records published_by / published_from in its provenance. Returns the ids actually published; missing ids, ids whose scope doesn’t match from_scope (an additional, OPTIONAL caller assertion), and ids by is not authorized to move are all skipped rather than aborting the whole batch.

by must be a non-empty, authenticated principal, and policy (an AccessPolicy; a fresh, all-deny-but-home-scope-and-PUBLIC one when omitted) must authorize it to WRITE to – checked once, up front, for the whole call – AND to READ each item’s ACTUAL stored scope, checked per item. That second check is what closes the hole from_scope alone never did: it fires even when from_scope is omitted entirely, so a known item id from a scope by cannot read is never movable just by leaving the filter off.

Every published item’s pre-publish state is persisted as an immutable, content-addressed revision (MXR-080-0265) before it is overwritten; version_history entries carry that revision’s digest, recoverable with revision() – so “a re-published item never silently overwrites its predecessor, the prior state is always recoverable” is an enforced property, not just a claim.

Parameters:
  • substrate (Substrate)

  • ids (list[str])

  • to (str)

  • by (str)

  • from_scope (str | None)

  • policy (AccessPolicy | None)

Return type:

list[str]

version_of(item)[source]

The share version of an item (0 if never published) – a monotonic counter bumped by each publish.

Parameters:

item (Any)

Return type:

int

history(substrate, item_id)[source]

The full publish/merge history of an item: every version with who changed it, from where, and to where, plus (MXR-080-0265/0266) a "revision" (publish entries) or parents[]["revision"] (merge entries) content-hash digest that revision() resolves to the complete immutable prior item state – not just this bookkeeping.

Parameters:
  • substrate (Substrate)

  • item_id (str)

Return type:

list[dict[str, Any]]

class MergeStrategy(*values)[source]

Bases: StrEnum

Which side’s text/payload survives a merge – a closed, validated vocabulary (MXR-080-0266): an unrecognized strategy now raises instead of silently behaving like KEEP.

merge_versions(substrate, keep_id, other_id, *, by=None, prefer=MergeStrategy.LATEST, policy=None, confirm=False)[source]

Reconcile two versions of the same knowledge into one, keeping full lineage (no silent loss, P2).

Merges other_id into keep_id: unions tags and links, keeps the text/payload of whichever prefer (a MergeStrategy) selects, bumps the surviving item’s version, records BOTH parents in the history, and removes the merged-away item. Returns the surviving id, or None if either id is missing (checked first, before any authorization – a merge naming a nonexistent id is a no-op, not a permission question).

Authorized common lineage, required (MXR-080-0266): by must be a non-empty, authenticated principal, and policy (an AccessPolicy; a fresh, all-deny-but-home-scope-and-PUBLIC one when omitted) must authorize it to WRITE both keep’s and other’s current scopes – a caller can no longer reconcile, and thereby delete, another principal’s item merely by knowing its id. keep and other must also share the same kind: merging e.g. a text item into an image item is never a legitimate reconciliation of the same knowledge, so this is a hard reject with no override. This is deliberately not a full lineage-graph check (no links/common-ancestor requirement): P1’s own cross-team-fork use case reconciles items that were never formally linked to each other; kind agreement plus dual-scope write authorization is the minimum coherent gate that rejects genuinely unrelated items (different kind, unauthorized principal) without breaking that legitimate use case.

prefer (unrecognized values now raise ValueError instead of silently behaving like "keep") and both items’ pre-merge states are persisted as immutable, content-addressed revisions (MXR-080-0265) before keep is overwritten and other is deleted – recoverable with revision() even though other no longer exists as a live item. The delete is real and permanent as a LIVE item, so it requires an explicit confirm=True acknowledgment; without it this raises ValueError rather than silently performing the destructive step.

Parameters:
  • substrate (Substrate)

  • keep_id (str)

  • other_id (str)

  • by (str | None)

  • prefer (MergeStrategy | str)

  • policy (AccessPolicy | None)

  • confirm (bool)

Return type:

str | None

class Space(substrate, team, *, shared=(PUBLIC,), policy=None)[source]

Bases: object

A team’s scoped view over a shared substrate: its own items plus what has been shared to it.

team doubles as this space’s authorization principal (MXR-080-0264): its home scope, per AccessPolicy, is the scope of the same name. Construction validates every entry of shared against policy – PUBLIC always passes; anything else, including another team’s scope, needs an explicit grant, or construction raises AccessDeniedError rather than silently trusting the caller-supplied tuple. Reads re-check live (see scopes), so revoking a grant after construction takes effect immediately rather than requiring a new Space.

Parameters:
  • substrate (Substrate)

  • team (str)

  • shared (tuple[str, ...])

  • policy (AccessPolicy | None)

property scopes: set[str]

its own scope plus whichever of shared the policy currently authorizes – re-checked live (not just once at construction), so a grant revoked later stops being visible immediately rather than requiring a new Space.

Type:

Scopes actually visible to this team right now

all(*, kind=None)[source]

Every visible item (optionally of one kind) – never another team’s private knowledge.

Parameters:

kind (str | None)

Return type:

list[SubstrateItem]

add(*, scope=None, **kw)[source]

Add an item to this team’s own scope by default (pass scope=PUBLIC to share immediately, or any other scope this principal holds a write grant for). MXR-080-0264: no longer writes into an arbitrary scope – in particular another team’s – on the caller’s say-so alone.

Parameters:
Return type:

str

retrieve(query, *, k=8, **kw)[source]

Retrieve over exactly the team’s visible set (own scope ∪ shared), with cross-kind diversity.

Parameters:
Return type:

Any

publish(ids, *, to=PUBLIC, by=None)[source]

Share this team’s items into a common scope (audited). Only own-scope items are publishable.

by (default: self.team) is also the authorization principal for this call (MXR-080-0264): overriding it to a different principal requires that principal to hold its own grants on self.policy, since Space cannot vouch for an identity other than the team it represents.

Parameters:
Return type:

list[str]