graphrefly 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- graphrefly/__init__.py +160 -0
- graphrefly/compat/__init__.py +18 -0
- graphrefly/compat/async_utils.py +228 -0
- graphrefly/compat/asyncio_runner.py +89 -0
- graphrefly/compat/trio_runner.py +81 -0
- graphrefly/core/__init__.py +142 -0
- graphrefly/core/clock.py +20 -0
- graphrefly/core/dynamic_node.py +749 -0
- graphrefly/core/guard.py +277 -0
- graphrefly/core/meta.py +149 -0
- graphrefly/core/node.py +963 -0
- graphrefly/core/protocol.py +460 -0
- graphrefly/core/runner.py +107 -0
- graphrefly/core/subgraph_locks.py +296 -0
- graphrefly/core/sugar.py +138 -0
- graphrefly/core/versioning.py +193 -0
- graphrefly/extra/__init__.py +313 -0
- graphrefly/extra/adapters.py +2149 -0
- graphrefly/extra/backoff.py +287 -0
- graphrefly/extra/backpressure.py +113 -0
- graphrefly/extra/checkpoint.py +307 -0
- graphrefly/extra/composite.py +303 -0
- graphrefly/extra/cron.py +133 -0
- graphrefly/extra/data_structures.py +707 -0
- graphrefly/extra/resilience.py +727 -0
- graphrefly/extra/sources.py +766 -0
- graphrefly/extra/tier1.py +1067 -0
- graphrefly/extra/tier2.py +1802 -0
- graphrefly/graph/__init__.py +31 -0
- graphrefly/graph/graph.py +2249 -0
- graphrefly/integrations/__init__.py +1 -0
- graphrefly/integrations/fastapi.py +767 -0
- graphrefly/patterns/__init__.py +5 -0
- graphrefly/patterns/ai.py +2132 -0
- graphrefly/patterns/cqrs.py +515 -0
- graphrefly/patterns/memory.py +639 -0
- graphrefly/patterns/messaging.py +553 -0
- graphrefly/patterns/orchestration.py +536 -0
- graphrefly/patterns/reactive_layout/__init__.py +81 -0
- graphrefly/patterns/reactive_layout/measurement_adapters.py +276 -0
- graphrefly/patterns/reactive_layout/reactive_block_layout.py +434 -0
- graphrefly/patterns/reactive_layout/reactive_layout.py +943 -0
- graphrefly/py.typed +1 -0
- graphrefly-0.1.0.dist-info/METADATA +253 -0
- graphrefly-0.1.0.dist-info/RECORD +47 -0
- graphrefly-0.1.0.dist-info/WHEEL +4 -0
- graphrefly-0.1.0.dist-info/licenses/LICENSE +21 -0
graphrefly/core/guard.py
ADDED
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
"""Actor context, capability guards, and policy builder (roadmap Phase 1.5)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Callable, Mapping
|
|
6
|
+
from typing import Any, Literal, TypedDict
|
|
7
|
+
|
|
8
|
+
type GuardAction = str
|
|
9
|
+
"""Known actions: ``"write"``, ``"signal"``, ``"observe"``.
|
|
10
|
+
|
|
11
|
+
Open type (plain ``str``) so callers can define domain-specific actions
|
|
12
|
+
(e.g. ``"admin"``, ``"delete"``) or use the wildcard ``"*"`` in
|
|
13
|
+
:func:`policy` rules. Aligned with the TS ``(string & {})`` pattern.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
#: ``(actor, action) -> bool`` — when ``False``, APIs raise :exc:`GuardDenied`.
|
|
17
|
+
GuardFn = Callable[["Actor", GuardAction], bool]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Actor(TypedDict, total=False):
|
|
21
|
+
"""Who is acting. Extra string keys are allowed at runtime (ABAC claims)."""
|
|
22
|
+
|
|
23
|
+
type: str
|
|
24
|
+
id: str
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def system_actor() -> Actor:
|
|
28
|
+
"""Default actor for mutations that do not pass an explicit context."""
|
|
29
|
+
return {"type": "system", "id": ""}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def normalize_actor(actor: Mapping[str, Any] | Actor | None) -> dict[str, Any]:
|
|
33
|
+
"""Merge with :func:`system_actor` defaults (missing ``type`` / ``id`` filled)."""
|
|
34
|
+
base: dict[str, Any] = {"type": "system", "id": ""}
|
|
35
|
+
if actor is not None:
|
|
36
|
+
base.update(dict(actor))
|
|
37
|
+
if not base.get("type"):
|
|
38
|
+
base["type"] = "system"
|
|
39
|
+
if "id" not in base:
|
|
40
|
+
base["id"] = ""
|
|
41
|
+
return base
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class GuardDenied(Exception):
|
|
45
|
+
"""Raised when a node's guard rejects an action."""
|
|
46
|
+
|
|
47
|
+
__slots__ = ("action", "actor", "node")
|
|
48
|
+
|
|
49
|
+
def __init__(
|
|
50
|
+
self,
|
|
51
|
+
actor: Mapping[str, Any],
|
|
52
|
+
node: str,
|
|
53
|
+
action: GuardAction,
|
|
54
|
+
) -> None:
|
|
55
|
+
self.actor = dict(actor)
|
|
56
|
+
self.node = node
|
|
57
|
+
self.action = action
|
|
58
|
+
super().__init__(f"guard denied {action!r} on node {node!r} for actor {self.actor!r}")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _normalize_actions(
|
|
62
|
+
action: GuardAction | list[GuardAction] | tuple[GuardAction, ...],
|
|
63
|
+
) -> frozenset[str]:
|
|
64
|
+
"""Accept a single action string or a sequence and return a frozen set."""
|
|
65
|
+
if isinstance(action, str):
|
|
66
|
+
return frozenset((action,))
|
|
67
|
+
return frozenset(action)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _matches_actions(actions: frozenset[str], guard_action: GuardAction) -> bool:
|
|
71
|
+
"""Check membership with ``"*"`` wildcard support (aligned with TS)."""
|
|
72
|
+
return guard_action in actions or "*" in actions
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _policy_rule_result(
|
|
76
|
+
*,
|
|
77
|
+
actions: frozenset[str],
|
|
78
|
+
where: Callable[[Actor], bool] | None,
|
|
79
|
+
actor: Mapping[str, Any],
|
|
80
|
+
guard_action: GuardAction,
|
|
81
|
+
) -> bool:
|
|
82
|
+
return _matches_actions(actions, guard_action) and (where is None or where(actor)) # type: ignore[arg-type]
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def policy(
|
|
86
|
+
build: Callable[
|
|
87
|
+
[
|
|
88
|
+
Callable[..., None],
|
|
89
|
+
Callable[..., None],
|
|
90
|
+
],
|
|
91
|
+
Any,
|
|
92
|
+
],
|
|
93
|
+
) -> GuardFn:
|
|
94
|
+
"""Build a guard function from declarative allow/deny rules.
|
|
95
|
+
|
|
96
|
+
Precedence: for a given ``(actor, action)``, any matching ``deny`` wins
|
|
97
|
+
(returns ``False``). Otherwise, any matching ``allow`` returns ``True``.
|
|
98
|
+
If no rule matches, the result is ``False``.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
build: Callable invoked with ``(allow, deny)`` accumulators. Each accumulator
|
|
102
|
+
accepts an ``action`` (str or list of str, supports ``"*"`` wildcard) and
|
|
103
|
+
an optional ``where`` predicate ``(actor) -> bool``.
|
|
104
|
+
|
|
105
|
+
Returns:
|
|
106
|
+
A :data:`GuardFn` ``(actor, action) -> bool``.
|
|
107
|
+
|
|
108
|
+
Example:
|
|
109
|
+
```python
|
|
110
|
+
from graphrefly import state, policy
|
|
111
|
+
g = policy(lambda allow, deny: [
|
|
112
|
+
allow("write", where=lambda a: a.get("role") == "admin"),
|
|
113
|
+
deny("write", where=lambda a: a.get("type") == "llm"),
|
|
114
|
+
])
|
|
115
|
+
x = state(0, guard=g)
|
|
116
|
+
```
|
|
117
|
+
"""
|
|
118
|
+
Rule = tuple[Literal["allow", "deny"], frozenset[str], Callable[[Actor], bool] | None]
|
|
119
|
+
rules: list[Rule] = []
|
|
120
|
+
|
|
121
|
+
def allow(
|
|
122
|
+
action: GuardAction | list[GuardAction] | tuple[GuardAction, ...],
|
|
123
|
+
*,
|
|
124
|
+
where: Callable[[Actor], bool] | None = None,
|
|
125
|
+
) -> None:
|
|
126
|
+
rules.append(("allow", _normalize_actions(action), where))
|
|
127
|
+
|
|
128
|
+
def deny(
|
|
129
|
+
action: GuardAction | list[GuardAction] | tuple[GuardAction, ...],
|
|
130
|
+
*,
|
|
131
|
+
where: Callable[[Actor], bool] | None = None,
|
|
132
|
+
) -> None:
|
|
133
|
+
rules.append(("deny", _normalize_actions(action), where))
|
|
134
|
+
|
|
135
|
+
build(allow, deny)
|
|
136
|
+
|
|
137
|
+
def guard(actor: Actor, guard_action: GuardAction) -> bool:
|
|
138
|
+
a = normalize_actor(actor)
|
|
139
|
+
denied = False
|
|
140
|
+
allowed = False
|
|
141
|
+
for kind, acts, where in rules:
|
|
142
|
+
matched = _policy_rule_result(
|
|
143
|
+
actions=acts,
|
|
144
|
+
where=where,
|
|
145
|
+
actor=a,
|
|
146
|
+
guard_action=guard_action,
|
|
147
|
+
)
|
|
148
|
+
if not matched:
|
|
149
|
+
continue
|
|
150
|
+
if kind == "deny":
|
|
151
|
+
denied = True
|
|
152
|
+
else:
|
|
153
|
+
allowed = True
|
|
154
|
+
if denied:
|
|
155
|
+
return False
|
|
156
|
+
return allowed
|
|
157
|
+
|
|
158
|
+
return guard
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def policy_from_rules(rules: list[dict[str, Any]]) -> GuardFn:
|
|
162
|
+
"""Rebuild a declarative guard from persisted rule data.
|
|
163
|
+
|
|
164
|
+
Rule schema:
|
|
165
|
+
- ``effect``: ``"allow"`` or ``"deny"``
|
|
166
|
+
- ``action``: str or list[str]
|
|
167
|
+
- ``actorType``: optional str or list[str]
|
|
168
|
+
- ``actorId``: optional str or list[str]
|
|
169
|
+
- ``claims``: optional dict[str, Any] exact-match constraints
|
|
170
|
+
"""
|
|
171
|
+
|
|
172
|
+
def build(
|
|
173
|
+
allow: Callable[..., None],
|
|
174
|
+
deny: Callable[..., None],
|
|
175
|
+
) -> None:
|
|
176
|
+
for rule in rules:
|
|
177
|
+
effect = str(rule.get("effect", "allow")).lower()
|
|
178
|
+
if effect not in ("allow", "deny"):
|
|
179
|
+
raise ValueError(f"policy_from_rules unknown effect {effect!r}")
|
|
180
|
+
action_value = rule.get("action", "write")
|
|
181
|
+
actor_type_raw = rule.get("actorType")
|
|
182
|
+
actor_id_raw = rule.get("actorId")
|
|
183
|
+
claims = rule.get("claims")
|
|
184
|
+
actor_types = (
|
|
185
|
+
None
|
|
186
|
+
if actor_type_raw is None
|
|
187
|
+
else set(actor_type_raw if isinstance(actor_type_raw, list) else [actor_type_raw])
|
|
188
|
+
)
|
|
189
|
+
actor_ids = (
|
|
190
|
+
None
|
|
191
|
+
if actor_id_raw is None
|
|
192
|
+
else set(actor_id_raw if isinstance(actor_id_raw, list) else [actor_id_raw])
|
|
193
|
+
)
|
|
194
|
+
claim_items = list(claims.items()) if isinstance(claims, dict) else []
|
|
195
|
+
|
|
196
|
+
def where(
|
|
197
|
+
actor: Actor,
|
|
198
|
+
_types: set[Any] | None = actor_types,
|
|
199
|
+
_ids: set[Any] | None = actor_ids,
|
|
200
|
+
_claims: list[tuple[Any, Any]] = claim_items,
|
|
201
|
+
) -> bool:
|
|
202
|
+
if _types is not None and actor.get("type") not in _types:
|
|
203
|
+
return False
|
|
204
|
+
if _ids is not None and actor.get("id", "") not in _ids:
|
|
205
|
+
return False
|
|
206
|
+
return all(actor.get(str(k)) == v for k, v in _claims)
|
|
207
|
+
|
|
208
|
+
if effect == "deny":
|
|
209
|
+
deny(action_value, where=where)
|
|
210
|
+
else:
|
|
211
|
+
allow(action_value, where=where)
|
|
212
|
+
|
|
213
|
+
return policy(build)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def compose_guards(*guards: GuardFn | None) -> GuardFn:
|
|
217
|
+
"""Compose multiple guard functions with AND logic; ``None`` entries are skipped.
|
|
218
|
+
|
|
219
|
+
Args:
|
|
220
|
+
*guards: Any number of :data:`GuardFn` callables or ``None``. ``None`` values
|
|
221
|
+
are silently filtered out.
|
|
222
|
+
|
|
223
|
+
Returns:
|
|
224
|
+
A :data:`GuardFn` that returns ``True`` only when every non-None guard
|
|
225
|
+
approves the ``(actor, action)`` pair.
|
|
226
|
+
|
|
227
|
+
Example:
|
|
228
|
+
```python
|
|
229
|
+
from graphrefly.core.guard import compose_guards, policy
|
|
230
|
+
g1 = policy(lambda allow, _: allow("write"))
|
|
231
|
+
g2 = policy(lambda allow, _: allow("observe"))
|
|
232
|
+
combined = compose_guards(g1, g2)
|
|
233
|
+
```
|
|
234
|
+
"""
|
|
235
|
+
gs = [g for g in guards if g is not None]
|
|
236
|
+
|
|
237
|
+
def composed(actor: Actor, action: GuardAction) -> bool:
|
|
238
|
+
return all(g(actor, action) for g in gs)
|
|
239
|
+
|
|
240
|
+
return composed
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
_STANDARD_WRITE_TYPES = ("human", "llm", "wallet", "system")
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def access_hint_for_guard(guard: GuardFn) -> str:
|
|
247
|
+
"""Best-effort ``meta.access`` string when a guard is present (roadmap 1.5)."""
|
|
248
|
+
allowed = [t for t in _STANDARD_WRITE_TYPES if guard({"type": t, "id": ""}, "write")]
|
|
249
|
+
if not allowed:
|
|
250
|
+
return "restricted"
|
|
251
|
+
if "human" in allowed and "llm" in allowed and set(allowed) <= {"human", "llm", "system"}:
|
|
252
|
+
return "both"
|
|
253
|
+
if len(allowed) == 1:
|
|
254
|
+
return allowed[0]
|
|
255
|
+
return "+".join(allowed)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def record_mutation(actor: Mapping[str, Any]) -> dict[str, Any]:
|
|
259
|
+
"""Snapshot for :attr:`~graphrefly.core.node.NodeImpl.last_mutation`."""
|
|
260
|
+
from graphrefly.core.clock import wall_clock_ns
|
|
261
|
+
|
|
262
|
+
return {"actor": dict(normalize_actor(actor)), "timestamp_ns": wall_clock_ns()}
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
__all__ = [
|
|
266
|
+
"Actor",
|
|
267
|
+
"GuardAction",
|
|
268
|
+
"GuardDenied",
|
|
269
|
+
"GuardFn",
|
|
270
|
+
"access_hint_for_guard",
|
|
271
|
+
"compose_guards",
|
|
272
|
+
"normalize_actor",
|
|
273
|
+
"policy",
|
|
274
|
+
"policy_from_rules",
|
|
275
|
+
"record_mutation",
|
|
276
|
+
"system_actor",
|
|
277
|
+
]
|
graphrefly/core/meta.py
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
"""Meta companion stores — graphrefly-ts ``src/core/meta.ts``."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from contextlib import suppress
|
|
6
|
+
from typing import TYPE_CHECKING, Any, Protocol
|
|
7
|
+
|
|
8
|
+
if TYPE_CHECKING:
|
|
9
|
+
from collections.abc import Mapping
|
|
10
|
+
|
|
11
|
+
from graphrefly.core.dynamic_node import DynamicNodeImpl
|
|
12
|
+
from graphrefly.core.guard import access_hint_for_guard
|
|
13
|
+
from graphrefly.core.node import NodeImpl # noqa: TC001 — runtime type for describe_node
|
|
14
|
+
from graphrefly.core.versioning import is_v1
|
|
15
|
+
|
|
16
|
+
__all__ = ["describe_node", "meta_snapshot"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _MetaNode(Protocol):
|
|
20
|
+
"""Parent node: read-only ``meta`` mapping (e.g. ``MappingProxyType``)."""
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def meta(self) -> Mapping[str, Any]: ...
|
|
24
|
+
|
|
25
|
+
def get(self) -> Any: ...
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def meta_snapshot(node: _MetaNode) -> dict[str, Any]:
|
|
29
|
+
"""Merge current meta field values into a plain dict for describe-style JSON.
|
|
30
|
+
|
|
31
|
+
Values come from each companion node's ``get()`` (last settled cache). If a meta
|
|
32
|
+
field is dirty (``DIRTY`` received, ``DATA`` pending), the snapshot holds the
|
|
33
|
+
previous value — check ``node.meta[key].status`` when freshness matters.
|
|
34
|
+
Keys whose companion ``get()`` raises are omitted so describe tooling keeps other
|
|
35
|
+
fields intact.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
node: Any object with a ``meta`` mapping of ``str -> Node`` and a ``get()`` method.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
A plain ``dict`` mapping each meta key to its last settled value.
|
|
42
|
+
|
|
43
|
+
Example:
|
|
44
|
+
```python
|
|
45
|
+
from graphrefly import state
|
|
46
|
+
from graphrefly.core.meta import meta_snapshot
|
|
47
|
+
x = state(0, meta={"version": 1})
|
|
48
|
+
snap = meta_snapshot(x)
|
|
49
|
+
# snap == {"version": 1}
|
|
50
|
+
```
|
|
51
|
+
"""
|
|
52
|
+
out: dict[str, Any] = {}
|
|
53
|
+
for key, child in node.meta.items():
|
|
54
|
+
with suppress(Exception):
|
|
55
|
+
out[key] = child.get()
|
|
56
|
+
return out
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _infer_describe_type(n: NodeImpl[Any]) -> str:
|
|
60
|
+
"""Best-effort ``type`` for GRAPHREFLY-SPEC describe node shape (§3.6, Appendix B)."""
|
|
61
|
+
if n._describe_kind is not None:
|
|
62
|
+
return n._describe_kind
|
|
63
|
+
if not n._has_deps:
|
|
64
|
+
return "state" if n._fn is None else "producer"
|
|
65
|
+
if n._fn is None:
|
|
66
|
+
return "derived"
|
|
67
|
+
if n._manual_emit_used:
|
|
68
|
+
return "operator"
|
|
69
|
+
return "derived"
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def describe_node(n: NodeImpl[Any] | DynamicNodeImpl[Any]) -> dict[str, Any]:
|
|
73
|
+
"""Build a single-node slice of ``Graph.describe()`` JSON (structure + ``meta`` snapshot).
|
|
74
|
+
|
|
75
|
+
The ``meta`` field uses :func:`meta_snapshot` (plain values), matching the
|
|
76
|
+
spec's describe shape (GRAPHREFLY-SPEC Appendix B). ``type`` is inferred from
|
|
77
|
+
factory configuration, optional ``describe_kind`` in node options, and the last
|
|
78
|
+
``_manual_emit_used`` hint (operator vs derived). Sugar constructors
|
|
79
|
+
(``effect``, ``producer``, ``derived``) set ``describe_kind`` automatically.
|
|
80
|
+
|
|
81
|
+
Args:
|
|
82
|
+
n: A :class:`~graphrefly.core.node.NodeImpl` or
|
|
83
|
+
:class:`~graphrefly.core.dynamic_node.DynamicNodeImpl` to describe.
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
A ``dict`` with keys ``type``, ``status``, ``deps``, ``meta``, and optionally
|
|
87
|
+
``name`` and ``value``.
|
|
88
|
+
|
|
89
|
+
Example:
|
|
90
|
+
```python
|
|
91
|
+
from graphrefly import state
|
|
92
|
+
from graphrefly.core.meta import describe_node
|
|
93
|
+
x = state(42, name="x")
|
|
94
|
+
d = describe_node(x)
|
|
95
|
+
assert d["type"] == "state"
|
|
96
|
+
assert d["value"] == 42
|
|
97
|
+
```
|
|
98
|
+
"""
|
|
99
|
+
if isinstance(n, DynamicNodeImpl):
|
|
100
|
+
out: dict[str, Any] = {
|
|
101
|
+
"type": n._describe_kind or "derived",
|
|
102
|
+
"status": n.status,
|
|
103
|
+
"deps": [],
|
|
104
|
+
"meta": meta_snapshot(n),
|
|
105
|
+
}
|
|
106
|
+
if n.name is not None:
|
|
107
|
+
out["name"] = n.name
|
|
108
|
+
with suppress(Exception):
|
|
109
|
+
out["value"] = n.get()
|
|
110
|
+
g = n._guard
|
|
111
|
+
if g is not None:
|
|
112
|
+
meta = dict(out.get("meta") or {})
|
|
113
|
+
if "access" not in meta:
|
|
114
|
+
meta["access"] = access_hint_for_guard(g)
|
|
115
|
+
out["meta"] = meta
|
|
116
|
+
# Versioning (GRAPHREFLY-SPEC §7)
|
|
117
|
+
if hasattr(n, "v") and n.v is not None:
|
|
118
|
+
out["v"] = _versioning_dict(n.v)
|
|
119
|
+
return out
|
|
120
|
+
|
|
121
|
+
out = {
|
|
122
|
+
"type": _infer_describe_type(n),
|
|
123
|
+
"status": n.status,
|
|
124
|
+
"deps": [d.name or "" for d in n._deps],
|
|
125
|
+
"meta": meta_snapshot(n),
|
|
126
|
+
}
|
|
127
|
+
if n.name is not None:
|
|
128
|
+
out["name"] = n.name
|
|
129
|
+
with suppress(Exception):
|
|
130
|
+
out["value"] = n.get()
|
|
131
|
+
g = n._guard
|
|
132
|
+
if g is not None:
|
|
133
|
+
meta = dict(out.get("meta") or {})
|
|
134
|
+
if "access" not in meta:
|
|
135
|
+
meta["access"] = access_hint_for_guard(g)
|
|
136
|
+
out["meta"] = meta
|
|
137
|
+
# Versioning (GRAPHREFLY-SPEC §7)
|
|
138
|
+
if n.v is not None:
|
|
139
|
+
out["v"] = _versioning_dict(n.v)
|
|
140
|
+
return out
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _versioning_dict(v: Any) -> dict[str, Any]:
|
|
144
|
+
"""Convert versioning info to a plain dict for JSON serialization."""
|
|
145
|
+
d: dict[str, Any] = {"id": v.id, "version": v.version}
|
|
146
|
+
if is_v1(v):
|
|
147
|
+
d["cid"] = v.cid
|
|
148
|
+
d["prev"] = v.prev
|
|
149
|
+
return d
|