graphddb-runtime 0.7.9__tar.gz → 0.8.0__tar.gz

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.
Files changed (50) hide show
  1. {graphddb_runtime-0.7.9 → graphddb_runtime-0.8.0}/PKG-INFO +2 -2
  2. {graphddb_runtime-0.7.9 → graphddb_runtime-0.8.0}/graphddb_runtime/runtime.py +60 -13
  3. {graphddb_runtime-0.7.9 → graphddb_runtime-0.8.0}/graphddb_runtime/transactions.py +155 -0
  4. {graphddb_runtime-0.7.9 → graphddb_runtime-0.8.0}/graphddb_runtime.egg-info/PKG-INFO +2 -2
  5. {graphddb_runtime-0.7.9 → graphddb_runtime-0.8.0}/graphddb_runtime.egg-info/SOURCES.txt +1 -0
  6. {graphddb_runtime-0.7.9 → graphddb_runtime-0.8.0}/graphddb_runtime.egg-info/requires.txt +1 -1
  7. {graphddb_runtime-0.7.9 → graphddb_runtime-0.8.0}/pyproject.toml +2 -2
  8. {graphddb_runtime-0.7.9 → graphddb_runtime-0.8.0}/tests/test_concurrency.py +7 -7
  9. {graphddb_runtime-0.7.9 → graphddb_runtime-0.8.0}/tests/test_description.py +2 -2
  10. {graphddb_runtime-0.7.9 → graphddb_runtime-0.8.0}/tests/test_description_jsdoc.py +1 -1
  11. graphddb_runtime-0.8.0/tests/test_guard_eval.py +146 -0
  12. {graphddb_runtime-0.7.9 → graphddb_runtime-0.8.0}/tests/test_integration.py +29 -29
  13. {graphddb_runtime-0.7.9 → graphddb_runtime-0.8.0}/tests/test_integration_edge_write.py +2 -2
  14. {graphddb_runtime-0.7.9 → graphddb_runtime-0.8.0}/tests/test_integration_middleware.py +11 -11
  15. {graphddb_runtime-0.7.9 → graphddb_runtime-0.8.0}/tests/test_integration_relations.py +9 -9
  16. {graphddb_runtime-0.7.9 → graphddb_runtime-0.8.0}/tests/test_middleware.py +44 -44
  17. {graphddb_runtime-0.7.9 → graphddb_runtime-0.8.0}/tests/test_relations.py +9 -9
  18. {graphddb_runtime-0.7.9 → graphddb_runtime-0.8.0}/tests/test_spec_version_guard.py +64 -6
  19. {graphddb_runtime-0.7.9 → graphddb_runtime-0.8.0}/tests/test_unit.py +46 -46
  20. {graphddb_runtime-0.7.9 → graphddb_runtime-0.8.0}/README.md +0 -0
  21. {graphddb_runtime-0.7.9 → graphddb_runtime-0.8.0}/graphddb_runtime/__init__.py +0 -0
  22. {graphddb_runtime-0.7.9 → graphddb_runtime-0.8.0}/graphddb_runtime/async_runtime.py +0 -0
  23. {graphddb_runtime-0.7.9 → graphddb_runtime-0.8.0}/graphddb_runtime/batch.py +0 -0
  24. {graphddb_runtime-0.7.9 → graphddb_runtime-0.8.0}/graphddb_runtime/concurrency.py +0 -0
  25. {graphddb_runtime-0.7.9 → graphddb_runtime-0.8.0}/graphddb_runtime/cursor.py +0 -0
  26. {graphddb_runtime-0.7.9 → graphddb_runtime-0.8.0}/graphddb_runtime/errors.py +0 -0
  27. {graphddb_runtime-0.7.9 → graphddb_runtime-0.8.0}/graphddb_runtime/filters.py +0 -0
  28. {graphddb_runtime-0.7.9 → graphddb_runtime-0.8.0}/graphddb_runtime/hydration.py +0 -0
  29. {graphddb_runtime-0.7.9 → graphddb_runtime-0.8.0}/graphddb_runtime/limits.py +0 -0
  30. {graphddb_runtime-0.7.9 → graphddb_runtime-0.8.0}/graphddb_runtime/middleware.py +0 -0
  31. {graphddb_runtime-0.7.9 → graphddb_runtime-0.8.0}/graphddb_runtime/per_key_cursor.py +0 -0
  32. {graphddb_runtime-0.7.9 → graphddb_runtime-0.8.0}/graphddb_runtime/relations.py +0 -0
  33. {graphddb_runtime-0.7.9 → graphddb_runtime-0.8.0}/graphddb_runtime/templates.py +0 -0
  34. {graphddb_runtime-0.7.9 → graphddb_runtime-0.8.0}/graphddb_runtime.egg-info/dependency_links.txt +0 -0
  35. {graphddb_runtime-0.7.9 → graphddb_runtime-0.8.0}/graphddb_runtime.egg-info/top_level.txt +0 -0
  36. {graphddb_runtime-0.7.9 → graphddb_runtime-0.8.0}/setup.cfg +0 -0
  37. {graphddb_runtime-0.7.9 → graphddb_runtime-0.8.0}/tests/test_contract_runtime.py +0 -0
  38. {graphddb_runtime-0.7.9 → graphddb_runtime-0.8.0}/tests/test_crosslang_python_cells.py +0 -0
  39. {graphddb_runtime-0.7.9 → graphddb_runtime-0.8.0}/tests/test_description_followups.py +0 -0
  40. {graphddb_runtime-0.7.9 → graphddb_runtime-0.8.0}/tests/test_integration_binding_contract.py +0 -0
  41. {graphddb_runtime-0.7.9 → graphddb_runtime-0.8.0}/tests/test_integration_command.py +0 -0
  42. {graphddb_runtime-0.7.9 → graphddb_runtime-0.8.0}/tests/test_integration_compose.py +0 -0
  43. {graphddb_runtime-0.7.9 → graphddb_runtime-0.8.0}/tests/test_integration_contract.py +0 -0
  44. {graphddb_runtime-0.7.9 → graphddb_runtime-0.8.0}/tests/test_integration_edge_derive.py +0 -0
  45. {graphddb_runtime-0.7.9 → graphddb_runtime-0.8.0}/tests/test_integration_events.py +0 -0
  46. {graphddb_runtime-0.7.9 → graphddb_runtime-0.8.0}/tests/test_integration_maintain.py +0 -0
  47. {graphddb_runtime-0.7.9 → graphddb_runtime-0.8.0}/tests/test_integration_referential.py +0 -0
  48. {graphddb_runtime-0.7.9 → graphddb_runtime-0.8.0}/tests/test_integration_unique.py +0 -0
  49. {graphddb_runtime-0.7.9 → graphddb_runtime-0.8.0}/tests/test_maintain.py +0 -0
  50. {graphddb_runtime-0.7.9 → graphddb_runtime-0.8.0}/tests/test_ttl.py +0 -0
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: graphddb-runtime
3
- Version: 0.7.9
3
+ Version: 0.8.0
4
4
  Summary: Thin DynamoDB executor for GraphDDB-generated Python repositories (single-operation core, issue #44).
5
5
  License: MIT
6
6
  Requires-Python: >=3.9
7
7
  Description-Content-Type: text/markdown
8
8
  Requires-Dist: boto3>=1.26
9
- Requires-Dist: behavior-contracts==0.1.2
9
+ Requires-Dist: behavior-contracts==0.1.3
10
10
  Provides-Extra: test
11
11
  Requires-Dist: pytest>=7.0; extra == "test"
12
12
 
@@ -57,13 +57,21 @@ from .templates import (
57
57
  validate_params,
58
58
  )
59
59
  from .transactions import MAX_TRANSACT_ITEMS, TransactionExpander
60
+ from .transactions import _guard_holds as _tx_guard_holds
60
61
  from .transactions import _when_holds as _tx_when_holds
61
62
 
62
63
  #: The operation-IR (spec) version this runtime implements — the newest
63
64
  #: ``manifest.json`` / ``operations.json`` document version whose vocabulary it
64
65
  #: fully understands. See :func:`_validate_spec_version` for the compatibility
65
66
  #: rule.
66
- SPEC_VERSION_SUPPORTED = "1.1"
67
+ #:
68
+ #: Phase 3 S6 (#266): bumped 1.1 -> 1.2 — the S6 half of the "one release column"
69
+ #: 1.2 bump (TS reached 1.2 in S5). This runtime now EVALUATES the 1.2 SCP
70
+ #: Expression IR ``guard`` vocabulary (see :func:`transactions._guard_holds`), so a
71
+ #: guard-bearing 1.2 bundle is ACCEPTED and executed on py/rust/go (matching TS).
72
+ #: Guard-free bundles remain stamped 1.1 by the generator (the conditional stamper
73
+ #: is unchanged), so an older runtime still runs them.
74
+ SPEC_VERSION_SUPPORTED = "1.2"
67
75
 
68
76
 
69
77
  def _validate_spec_version(document: Mapping[str, Any], label: str) -> None:
@@ -260,15 +268,15 @@ class GraphDDBRuntime:
260
268
  def use(self, mw: Any) -> None:
261
269
  """Register a host-side middleware (read R1–R5 / write W1–W5 hooks).
262
270
 
263
- Mirrors the TS ``DDBModel.use``: appended last, so it runs last in the
264
- FIFO ``before*`` order and first in the LIFO ``after`` / ``onError``
271
+ Mirrors the TS ``graphddb.config.use``: appended last, so it runs last in
272
+ the FIFO ``before*`` order and first in the LIFO ``after`` / ``onError``
265
273
  order. The middleware is never serialized.
266
274
  """
267
275
  self._middleware.use(mw)
268
276
 
269
277
  def clear_middleware(self) -> None:
270
278
  """Remove every registered middleware (teardown / tests). Mirrors the TS
271
- ``DDBModel.clearMiddleware``."""
279
+ ``graphddb.config.clearMiddleware``."""
272
280
  self._middleware.clear()
273
281
 
274
282
  def get_middleware(self) -> Tuple[Any, ...]:
@@ -581,6 +589,33 @@ class GraphDDBRuntime:
581
589
  opts = dict(params or {})
582
590
 
583
591
  is_array = isinstance(key_or_keys, (list, tuple))
592
+
593
+ # A **relation-traversal** contract read lowers to >1 operation (the root
594
+ # read + one leg per resolved relation; e.g. getUserByEmail → 4 ops,
595
+ # getGroupAccess → 3 ops). The single-op point/range executors below cannot
596
+ # assemble a multi-op result — that is the job of :meth:`execute_query`,
597
+ # which walks the whole ``operations`` list and merges the relation legs.
598
+ # So delegate a multi-op single-key read to the relation-assembly path (the
599
+ # exact path ``execute_query`` uses), keyed by the method's referenced
600
+ # operation id. An array of keys against such a method is an N+1 fan-out
601
+ # (one full traversal per key), forbidden by the same N+1 rule that rejects
602
+ # a range array — a single-key relation read is the only reachable shape.
603
+ if len(op_spec.get("operations", [])) > 1:
604
+ if is_array:
605
+ raise ContractArityError(
606
+ f"{contract_name}.{method_name}: a relation-traversal method "
607
+ f"(it lowers to multiple operations) was called with an array of "
608
+ f"keys. Each key is a full traversal, so an array would be an N+1 "
609
+ f"fan-out — forbidden by the contract's N+1 rule. Loop in "
610
+ f"application code for N independent reads."
611
+ )
612
+ validate_params(
613
+ key_or_keys,
614
+ op_spec.get("params", {}),
615
+ operation_id=f"{contract_name}.{method_name}",
616
+ )
617
+ return self.execute_query(method["operation"], key_or_keys, opts)
618
+
584
619
  if is_array:
585
620
  # Only a method whose ``inputArity`` is ``'either'`` coalesces a key
586
621
  # array into one batched call. A ``'single'`` method must reject an
@@ -668,12 +703,17 @@ class GraphDDBRuntime:
668
703
  def _contract_query_op(
669
704
  self, contract_name: str, method_name: str, method: Mapping[str, Any]
670
705
  ) -> Mapping[str, Any]:
671
- """The single referenced read op spec for a contract query method.
672
-
673
- A contract method resolves to exactly one declarative read op (#58). Any
674
- External Query compositions (#63) it declares are carried separately on
675
- the method spec (``compose``) and resolved by :meth:`_resolve_compositions`
676
- after the parent step they do **not** add operations to this op spec.
706
+ """The referenced read op spec for a contract query method.
707
+
708
+ A method's referenced operation carries **one or more** read operations: a
709
+ plain point/range read is a single operation, whereas a relation-traversal
710
+ read lowers to the root read PLUS one leg per resolved relation (e.g.
711
+ getUserByEmail 4 ops). ``execute_query_method`` routes a multi-op spec
712
+ through the relation-assembly path (:meth:`execute_query`); the single-op
713
+ point/range executors handle the ``len == 1`` case. Any External Query
714
+ compositions (#63) a method declares are carried separately on the method
715
+ spec (``compose``) — they do **not** add operations to this op spec (and a
716
+ composed child is always a ``point`` read, i.e. a single operation).
677
717
  """
678
718
  op_name = method["operation"]
679
719
  spec = self._queries.get(op_name)
@@ -683,10 +723,10 @@ class GraphDDBRuntime:
683
723
  f"not present in `queries`."
684
724
  )
685
725
  operations = spec.get("operations", [])
686
- if len(operations) != 1:
726
+ if len(operations) < 1:
687
727
  raise GraphDDBError(
688
- f"{contract_name}.{method_name}: a single-contract query method must "
689
- f"resolve to exactly one read operation, found {len(operations)}."
728
+ f"{contract_name}.{method_name}: a query method must resolve to at "
729
+ f"least one read operation, found {len(operations)}."
690
730
  )
691
731
  return spec
692
732
 
@@ -1409,6 +1449,9 @@ class GraphDDBRuntime:
1409
1449
  continue # ConditionCheck — not a logical write op.
1410
1450
  for_each = item.get("forEach")
1411
1451
  when = item.get("when")
1452
+ # Issue #266 (Phase 3 S6): a guard-false item is skipped here too, so
1453
+ # W1 does not fire for a write the expander drops (parity with `when`).
1454
+ guard = item.get("guard")
1412
1455
  if for_each:
1413
1456
  source = params.get(for_each["source"])
1414
1457
  if not isinstance(source, list):
@@ -1416,6 +1459,8 @@ class GraphDDBRuntime:
1416
1459
  for element in source:
1417
1460
  if when and not _tx_when_holds(when, params, element):
1418
1461
  continue
1462
+ if guard is not None and not _tx_guard_holds(guard, params, element):
1463
+ continue
1419
1464
  op_params = dict(params)
1420
1465
  op_params.update({f"item.{k}": v for k, v in element.items()})
1421
1466
  op_params.update(element)
@@ -1423,6 +1468,8 @@ class GraphDDBRuntime:
1423
1468
  else:
1424
1469
  if when and not _tx_when_holds(when, params, None):
1425
1470
  continue
1471
+ if guard is not None and not _tx_guard_holds(guard, params, None):
1472
+ continue
1426
1473
  yield kind, item, dict(params)
1427
1474
 
1428
1475
  # ── command-contract execution (issue #64) ──────────────────────────────
@@ -18,6 +18,9 @@ from __future__ import annotations
18
18
  import re
19
19
  from typing import Any, Dict, List, Mapping, Optional
20
20
 
21
+ from behavior_contracts import ExprFailure as _ExprFailure
22
+ from behavior_contracts import evaluate_expression as _evaluate_expression
23
+
21
24
  from .filters import compile_filter
22
25
 
23
26
  _PLACEHOLDER_RE = re.compile(r"\{[^{}]+\}")
@@ -168,6 +171,134 @@ def _when_holds(
168
171
  return left == right if when["op"] == "eq" else left != right
169
172
 
170
173
 
174
+ # ── SCP guard evaluation seam (issue #266, Phase 3 S6) ────────────────────────
175
+ #
176
+ # The branch-for-branch port of the TS ``evaluateGuard`` seam
177
+ # (``src/runtime/guard-eval.ts``, S5 / #265). A ``guard`` on a transaction item is
178
+ # an SCP Expression IR node — a boolean predicate over the transaction ``input``
179
+ # surface and, inside a ``forEach``, the current ``item`` element. S1 loud-rejected
180
+ # every guard-bearing bundle; S6 turns that reject into EVALUATION for the
181
+ # client-side ``input`` / ``item`` class, delegating the normative semantics to the
182
+ # shared ``behavior_contracts`` evaluator (``evaluate_expression``) — the SAME SSoT
183
+ # the TS/Rust/Go runtimes evaluate against, so a guard decides identically in every
184
+ # language: typed ``eq`` (int != string), i64 checked arithmetic, code-point string
185
+ # ordering, truncated-division ``mod`` sign.
186
+ #
187
+ # The two-condition-class rule (identical to TS): a ``guard`` references ONLY
188
+ # ``input`` / ``item`` (known client-side before the write), so guard-false skips
189
+ # the item — the same semantics the legacy ``when`` string-compare guard has, no
190
+ # DynamoDB round-trip. An expression that reads a DynamoDB attribute of the row
191
+ # being written is the ``scpExpr`` write-condition class (out of S6 scope) and must
192
+ # lower to a ``ConditionExpression``; :func:`_assert_guard_scopes` fail-closes a
193
+ # guard whose refs escape ``input`` / ``item`` so a client-side stale check can
194
+ # never happen.
195
+
196
+ _GUARD_SCOPE_ROOTS = frozenset(("input", "item"))
197
+
198
+
199
+ def _assert_guard_scopes(node: Any, context: str) -> None:
200
+ """Walk an expression IR node; reject any ``ref`` / ``refOpt`` whose ROOT segment
201
+ is not a client-side guard scope (``input`` / ``item``). Mirrors the TS
202
+ ``assertGuardScopes`` — a ``prop``-rooted guard is the write-condition class that
203
+ must lower to a ``ConditionExpression``; evaluating it client-side would be a
204
+ stale read, so it is loud-rejected (issue #266)."""
205
+ if not isinstance(node, Mapping):
206
+ return
207
+ ref = node.get("ref")
208
+ if ref is None:
209
+ ref = node.get("refOpt")
210
+ if isinstance(ref, list):
211
+ root = ref[0] if ref else None
212
+ if isinstance(root, str) and root not in _GUARD_SCOPE_ROOTS:
213
+ raise ValueError(
214
+ f"{context}: this guard references '{root}', but a client-side guard "
215
+ "may only reference the transaction 'input' / fan-out 'item' scopes. "
216
+ "An expression that reads a DynamoDB attribute must lower to a "
217
+ "ConditionExpression (write-condition class), not be evaluated "
218
+ "client-side (issue #266). Refusing to evaluate a stale check."
219
+ )
220
+ return
221
+ # Recurse every nested node (operator args, obj values, arr elements).
222
+ for value in node.values():
223
+ if isinstance(value, list):
224
+ for child in value:
225
+ _assert_guard_scopes(child, context)
226
+ elif isinstance(value, Mapping):
227
+ # ``{obj: {...}}`` construction — its values are nested nodes.
228
+ for child in value.values():
229
+ _assert_guard_scopes(child, context)
230
+
231
+
232
+ def _to_shared_value(v: Any) -> Any:
233
+ """Convert a plain graphddb runtime value into the ``behavior_contracts`` value
234
+ model, classifying by the VALUE's integrality — NOT by its declared numeric
235
+ subtype. This is the branch-for-branch mirror of the TS ``toSharedValue``
236
+ (#253): TS maps an integral JS ``number`` (``5`` == ``5.0``) to a ``bigint``
237
+ (int), so a scope value of ``5.0`` compares equal to an ``{int:"5"}`` literal.
238
+
239
+ graphddb's ``@number`` (R7) draws no int/float type distinction, so the only
240
+ cross-language-consistent rule is to classify by value integrality: an
241
+ integral-valued ``float`` (``5.0``) becomes a Python ``int`` (an int in the
242
+ shared model), a fractional ``float`` stays a ``float``. Without this, Python's
243
+ native ``isinstance(v, float)`` would classify ``5.0`` as float and an
244
+ ``input.n == {int:"5"}`` guard would TYPE_MISMATCH where TS returns True —
245
+ a cross-language divergence (S5 audit rule, issue #266)."""
246
+ if isinstance(v, bool):
247
+ # bool is an int subclass in Python; keep it a bool in the shared model.
248
+ return v
249
+ if isinstance(v, float):
250
+ # Integral-valued float -> int (matches TS Number.isInteger). NaN/Inf are
251
+ # non-integral, so they stay float and the shared core loud-rejects them.
252
+ if v.is_integer():
253
+ return int(v)
254
+ return v
255
+ if isinstance(v, list):
256
+ return [_to_shared_value(e) for e in v]
257
+ if isinstance(v, Mapping):
258
+ return {k: _to_shared_value(field) for k, field in v.items()}
259
+ return v
260
+
261
+
262
+ def _to_shared_params(params: Mapping[str, Any]) -> Dict[str, Any]:
263
+ return {name: _to_shared_value(value) for name, value in params.items()}
264
+
265
+
266
+ def _guard_holds(
267
+ guard: Mapping[str, Any],
268
+ params: Mapping[str, Any],
269
+ element: Optional[Mapping[str, Any]],
270
+ context: str = "declarative transaction guard",
271
+ ) -> bool:
272
+ """Evaluate a ``guard`` ExpressionSpec against the transaction ``input`` params
273
+ and the (optional) ``forEach`` element, returning its boolean truth value.
274
+ Delegates the normative semantics to the shared ``behavior_contracts`` evaluator
275
+ — the same SSoT every other-language runtime evaluates against (S5/S6/S7).
276
+
277
+ Raises ``ValueError`` if the guard escapes the ``input`` / ``item`` scopes
278
+ (:func:`_assert_guard_scopes`), if the shared evaluator reports a Failure (i64
279
+ overflow, type mismatch, unknown op, unknown exprVersion, …), or if the guard
280
+ does not evaluate to a boolean — all fail-closed / loud (issue #266)."""
281
+ expr = guard["expr"]
282
+ _assert_guard_scopes(expr, context)
283
+ scope: Dict[str, Any] = {"input": _to_shared_params(params)}
284
+ if element is not None:
285
+ scope["item"] = _to_shared_value(dict(element))
286
+ try:
287
+ result = _evaluate_expression(expr, scope)
288
+ except _ExprFailure as exc:
289
+ code = getattr(exc, "code", "?")
290
+ raise ValueError(
291
+ f"{context}: guard evaluation failed ({code}): {exc}"
292
+ ) from exc
293
+ if not isinstance(result, bool):
294
+ got = "null" if result is None else type(result).__name__
295
+ raise ValueError(
296
+ f"{context}: a guard expression must evaluate to a boolean, got {got} "
297
+ "(issue #266)."
298
+ )
299
+ return result
300
+
301
+
171
302
  def _apply_maintain_transform(op: str, args: List[Any], value: Any) -> Any:
172
303
  """Apply one maintenance projection transform to a resolved source value (#129,
173
304
  Epic #118) — the branch-for-branch mirror of the TS in-process
@@ -477,6 +608,19 @@ class TransactionExpander:
477
608
  values[alias] = self._serializer.serialize(resolved)
478
609
  target["ConditionExpression"] = condition["expression"]
479
610
  return
611
+ if kind == "scpExpr":
612
+ # Issue #266 (Phase 3 S6): the ``scpExpr`` write-condition class refs a
613
+ # DynamoDB attribute of the row being written, so it must lower to a
614
+ # ConditionExpression — NOT be evaluated client-side (a stale read is a
615
+ # #242-type silent drop). That lowering is out of S6 scope, so fail
616
+ # loudly rather than execute the write with its condition ignored.
617
+ # (Mirrors the TS ``applyCondition`` scpExpr reject.)
618
+ raise ValueError(
619
+ "declarative transaction: this runtime does not evaluate scpExpr "
620
+ "write conditions yet (spec 1.2 expression vocabulary, issue #261); "
621
+ "the write-condition class must lower to a ConditionExpression. "
622
+ "Refusing to execute the write with its condition ignored."
623
+ )
480
624
  clauses = []
481
625
  for i, (field, tmpl) in enumerate(condition["fields"].items()):
482
626
  n = f"#e{i}"
@@ -667,6 +811,13 @@ class TransactionExpander:
667
811
  for item in spec.get("items", []):
668
812
  for_each = item.get("forEach")
669
813
  when = item.get("when")
814
+ # Issue #266 (Phase 3 S6): a `guard` (SCP Expression IR over the
815
+ # `input` / fan-out `item` scopes) is EVALUATED via behavior_contracts
816
+ # — the normative SSoT every runtime shares. Guard-false skips the item
817
+ # (matching the legacy string-compare `when`); guard-true keeps it. An
818
+ # attribute-referencing guard is loud-rejected inside `_guard_holds`
819
+ # (write-condition class, out of scope). Matches the TS S5 seam.
820
+ guard = item.get("guard")
670
821
  if for_each:
671
822
  source = params.get(for_each["source"])
672
823
  if not isinstance(source, list):
@@ -677,9 +828,13 @@ class TransactionExpander:
677
828
  for element in source:
678
829
  if when and not _when_holds(when, params, element):
679
830
  continue
831
+ if guard is not None and not _guard_holds(guard, params, element):
832
+ continue
680
833
  expanded.append(self._build_item(item, params, element))
681
834
  else:
682
835
  if when and not _when_holds(when, params, None):
683
836
  continue
837
+ if guard is not None and not _guard_holds(guard, params, None):
838
+ continue
684
839
  expanded.append(self._build_item(item, params, None))
685
840
  return _collapse_same_key_items(expanded)
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: graphddb-runtime
3
- Version: 0.7.9
3
+ Version: 0.8.0
4
4
  Summary: Thin DynamoDB executor for GraphDDB-generated Python repositories (single-operation core, issue #44).
5
5
  License: MIT
6
6
  Requires-Python: >=3.9
7
7
  Description-Content-Type: text/markdown
8
8
  Requires-Dist: boto3>=1.26
9
- Requires-Dist: behavior-contracts==0.1.2
9
+ Requires-Dist: behavior-contracts==0.1.3
10
10
  Provides-Extra: test
11
11
  Requires-Dist: pytest>=7.0; extra == "test"
12
12
 
@@ -26,6 +26,7 @@ tests/test_crosslang_python_cells.py
26
26
  tests/test_description.py
27
27
  tests/test_description_followups.py
28
28
  tests/test_description_jsdoc.py
29
+ tests/test_guard_eval.py
29
30
  tests/test_integration.py
30
31
  tests/test_integration_binding_contract.py
31
32
  tests/test_integration_command.py
@@ -1,5 +1,5 @@
1
1
  boto3>=1.26
2
- behavior-contracts==0.1.2
2
+ behavior-contracts==0.1.3
3
3
 
4
4
  [test]
5
5
  pytest>=7.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "graphddb-runtime"
7
- version = "0.7.9"
7
+ version = "0.8.0"
8
8
  description = "Thin DynamoDB executor for GraphDDB-generated Python repositories (single-operation core, issue #44)."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -14,7 +14,7 @@ license = { text = "MIT" }
14
14
  # canonicalisation) are delegated to the extracted shared package
15
15
  # ``behavior-contracts`` (import name ``behavior_contracts``). The runtime
16
16
  # imports it unconditionally, so it is a HARD dependency.
17
- dependencies = ["boto3>=1.26", "behavior-contracts==0.1.2"]
17
+ dependencies = ["boto3>=1.26", "behavior-contracts==0.1.3"]
18
18
 
19
19
  [project.optional-dependencies]
20
20
  test = ["pytest>=7.0"]
@@ -166,7 +166,7 @@ def test_independent_relation_stage_runs_concurrently():
166
166
 
167
167
  # If the two sibling queries did NOT overlap, the barrier (timeout=3) would
168
168
  # raise BrokenBarrierError here. Reaching the assertions proves concurrency.
169
- out = rt.execute_query("getGroupAccess", {"groupId": "eng"})
169
+ out = rt.execute_query("GroupAccessMulti__getGroupAccess", {"groupId": "eng"})
170
170
 
171
171
  assert out["members"]["items"][0]["userId"] == "alice"
172
172
  assert out["permissions"]["items"][0]["resource"] == "codebase"
@@ -192,9 +192,9 @@ def test_stage_concurrency_is_bounded_by_the_declared_limit():
192
192
  client = _CountingGroupAccessClient()
193
193
  rt = GraphDDBRuntime(client, MULTI_MANIFEST, MULTI_OPS)
194
194
  # Force the declared bound to 1 on the loaded spec.
195
- rt._queries["getGroupAccess"]["executionPlan"]["concurrency"] = 1
195
+ rt._queries["GroupAccessMulti__getGroupAccess"]["executionPlan"]["concurrency"] = 1
196
196
 
197
- rt.execute_query("getGroupAccess", {"groupId": "eng"})
197
+ rt.execute_query("GroupAccessMulti__getGroupAccess", {"groupId": "eng"})
198
198
  # Bound 1 → strictly one query at a time.
199
199
  assert client.peak_in_flight == 1
200
200
  assert client.query_count == 2
@@ -215,9 +215,9 @@ def test_plan_absent_falls_back_to_sequential():
215
215
  client.barrier = threading.Barrier(1, timeout=3) # no overlap expected
216
216
  rt = GraphDDBRuntime(client, MULTI_MANIFEST, MULTI_OPS)
217
217
  # Strip the plan → exercise the backward-compatible (sequential) fallback.
218
- rt._queries["getGroupAccess"].pop("executionPlan", None)
218
+ rt._queries["GroupAccessMulti__getGroupAccess"].pop("executionPlan", None)
219
219
 
220
- out = rt.execute_query("getGroupAccess", {"groupId": "eng"})
220
+ out = rt.execute_query("GroupAccessMulti__getGroupAccess", {"groupId": "eng"})
221
221
  assert out["members"]["items"][0]["userId"] == "alice"
222
222
  assert out["permissions"]["items"][0]["resource"] == "codebase"
223
223
  # Plan-less → each op its own stage → strictly one query in flight at a time.
@@ -227,7 +227,7 @@ def test_plan_absent_falls_back_to_sequential():
227
227
  def test_relation_stages_reads_plan_groups():
228
228
  """Unit: `_relation_stages` derives stages from the plan (root dropped)."""
229
229
  rt = GraphDDBRuntime(_BarrierGroupAccessClient(), MULTI_MANIFEST, MULTI_OPS)
230
- spec = rt._queries["getGroupAccess"]
230
+ spec = rt._queries["GroupAccessMulti__getGroupAccess"]
231
231
  operations = spec["operations"]
232
232
  # Plan present: [[0],[1,2]] → relation stages [[1, 2]] (root index 0 dropped).
233
233
  assert rt._relation_stages(spec, operations) == [[1, 2]]
@@ -243,7 +243,7 @@ def test_relation_stages_reads_plan_groups():
243
243
  def test_deep_chain_plan_stages_are_sequential():
244
244
  """A result-dependent chain (getUserByEmail) stages into ordered single-op stages."""
245
245
  rt = GraphDDBRuntime(_BarrierGroupAccessClient(), MULTI_MANIFEST, MULTI_OPS)
246
- spec = rt._queries["getUserByEmail"]
246
+ spec = rt._queries["UserByEmailMulti__getUserByEmail"]
247
247
  # Plan [[0],[1],[2],[3]] → relation stages [[1],[2],[3]] (each depends on prior).
248
248
  assert rt._relation_stages(spec, spec["operations"]) == [[1], [2], [3]]
249
249
 
@@ -38,7 +38,7 @@ def test_manifest_carries_entity_and_field_description() -> None:
38
38
 
39
39
  def test_operations_carries_query_param_and_contract_description() -> None:
40
40
  ops = _operations()
41
- get_group = ops["queries"]["getGroup"]
41
+ get_group = ops["queries"]["GroupByIdRead__get"]
42
42
  assert get_group["description"] == "Fetch a single group by its id."
43
43
  assert get_group["params"]["groupId"]["description"] == "The group id to fetch."
44
44
  # The contract method description rides the `contracts` map.
@@ -48,7 +48,7 @@ def test_operations_carries_query_param_and_contract_description() -> None:
48
48
 
49
49
  def test_undescribed_query_has_no_description_key() -> None:
50
50
  # `getUser` declares no description — backward compatible.
51
- get_user = _operations()["queries"]["getUser"]
51
+ get_user = _operations()["queries"]["UserByIdRead__get"]
52
52
  assert "description" not in get_user
53
53
 
54
54
 
@@ -63,7 +63,7 @@ def test_manifest_carries_jsdoc_gsi_description() -> None:
63
63
 
64
64
 
65
65
  def test_operations_carry_jsdoc_query_and_param_description() -> None:
66
- get_user = _operations()["queries"]["getUserDocumented"]
66
+ get_user = _operations()["queries"]["UserDocumentedRead__getUserDocumented"]
67
67
  assert get_user["description"] == (
68
68
  "#165 JSDoc-as-default (query): fetch a single user by id (JSDoc source)."
69
69
  )
@@ -0,0 +1,146 @@
1
+ """SCP guard evaluation tests (issue #266, Phase 3 S6).
2
+
3
+ The py runtime evaluates a transaction-item ``guard`` (an SCP Expression IR node
4
+ over the ``input`` / fan-out ``item`` scopes) via the shared ``behavior_contracts``
5
+ evaluator — the SAME normative SSoT the TS S5 seam (``src/runtime/guard-eval.ts``)
6
+ evaluates against, so a guard decides IDENTICALLY across TS/py/rust/go. These
7
+ tests pin the adversarial matrix the S5 audit covered: typed ``eq`` (int != string),
8
+ i64 boundary, code-point string compare, truncated ``mod`` sign, non-bool loud,
9
+ unknown-op loud, scope-escape loud, and — the critical cross-language rule — an
10
+ integral-valued number classifies as int (matching the TS #253 adapter), NOT as
11
+ float -> TYPE_MISMATCH.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ from typing import Any, Optional
17
+
18
+ import pytest
19
+
20
+ from graphddb_runtime.transactions import _guard_holds, _to_shared_value
21
+
22
+
23
+ def _holds(expr: Any, params: dict, element: Optional[dict] = None) -> bool:
24
+ return _guard_holds({"exprVersion": 1, "expr": expr}, params, element)
25
+
26
+
27
+ # ── the integral-float classification rule (the crux, S5 audit) ───────────────
28
+
29
+
30
+ def test_integral_float_param_classifies_as_int():
31
+ # A param value of 5.0 must compare EQUAL to an {int:"5"} literal — matching
32
+ # the TS adapter (Number.isInteger(5.0) -> bigint). graphddb @number (R7) has
33
+ # no int/float distinction, so classification is by VALUE integrality.
34
+ assert _holds({"eq": [{"ref": ["input", "n"]}, {"int": "5"}]}, {"n": 5.0}) is True
35
+
36
+
37
+ def test_integral_int_param_classifies_as_int():
38
+ assert _holds({"eq": [{"ref": ["input", "n"]}, {"int": "5"}]}, {"n": 5}) is True
39
+
40
+
41
+ def test_fractional_float_param_mismatches_int_literal():
42
+ # 5.5 is genuinely a float; comparing it to an int literal is a TYPE_MISMATCH
43
+ # (int != float is normative) — matching TS.
44
+ with pytest.raises(ValueError, match="TYPE_MISMATCH"):
45
+ _holds({"eq": [{"ref": ["input", "n"]}, {"int": "5"}]}, {"n": 5.5})
46
+
47
+
48
+ def test_to_shared_value_classifies_by_integrality():
49
+ assert _to_shared_value(5.0) == 5 and isinstance(_to_shared_value(5.0), int)
50
+ assert isinstance(_to_shared_value(5.5), float)
51
+ # bool is an int subclass in Python but must stay a bool in the shared model.
52
+ assert _to_shared_value(True) is True
53
+ # nested integral floats reclassify recursively.
54
+ assert _to_shared_value({"a": 2.0, "b": [3.0, 4.5]}) == {"a": 2, "b": [3, 4.5]}
55
+
56
+
57
+ # ── typed eq (int != string) ──────────────────────────────────────────────────
58
+
59
+
60
+ def test_typed_eq_int_never_equals_string():
61
+ with pytest.raises(ValueError, match="TYPE_MISMATCH"):
62
+ _holds({"eq": [{"int": "5"}, "5"]}, {})
63
+
64
+
65
+ def test_string_eq_string_by_value():
66
+ assert _holds({"eq": [{"ref": ["input", "role"]}, "admin"]}, {"role": "admin"}) is True
67
+ assert _holds({"eq": [{"ref": ["input", "role"]}, "admin"]}, {"role": "user"}) is False
68
+
69
+
70
+ # ── i64 boundary ──────────────────────────────────────────────────────────────
71
+
72
+
73
+ def test_i64_max_boundary_ok():
74
+ # 2^63-1 is the max i64; adding within range is fine.
75
+ assert _holds(
76
+ {"eq": [{"add": [{"int": "9223372036854775806"}, {"int": "1"}]}, {"int": "9223372036854775807"}]},
77
+ {},
78
+ ) is True
79
+
80
+
81
+ def test_i64_overflow_is_loud():
82
+ with pytest.raises(ValueError, match="INT_OVERFLOW"):
83
+ _holds({"lt": [{"add": [{"int": "9223372036854775807"}, {"int": "1"}]}, {"int": "0"}]}, {})
84
+
85
+
86
+ # ── code-point string compare ─────────────────────────────────────────────────
87
+
88
+
89
+ def test_string_compare_is_code_point_order():
90
+ # 'Z' (0x5A) < 'a' (0x61) in code-point order.
91
+ assert _holds({"lt": ["Z", "a"]}, {}) is True
92
+
93
+
94
+ # ── truncated mod sign (sign of dividend) ─────────────────────────────────────
95
+
96
+
97
+ def test_mod_sign_follows_dividend_negative():
98
+ # -7 mod 3 == -1 (truncated division; sign follows dividend, NOT Python %).
99
+ assert _holds({"eq": [{"mod": [{"int": "-7"}, {"int": "3"}]}, {"int": "-1"}]}, {}) is True
100
+
101
+
102
+ def test_mod_sign_follows_dividend_positive():
103
+ assert _holds({"eq": [{"mod": [{"int": "7"}, {"int": "-3"}]}, {"int": "1"}]}, {}) is True
104
+
105
+
106
+ # ── non-bool result is loud ───────────────────────────────────────────────────
107
+
108
+
109
+ def test_non_bool_guard_is_loud():
110
+ with pytest.raises(ValueError, match="must evaluate to a boolean"):
111
+ _holds({"int": "5"}, {})
112
+
113
+
114
+ def test_null_guard_result_is_loud():
115
+ with pytest.raises(ValueError, match="must evaluate to a boolean"):
116
+ _holds({"refOpt": ["input", "missing"]}, {"missing": None})
117
+
118
+
119
+ # ── unknown op / unknown exprVersion is loud (fail-closed) ────────────────────
120
+
121
+
122
+ def test_unknown_op_is_loud():
123
+ with pytest.raises(ValueError, match="UNKNOWN_OP"):
124
+ _holds({"frobnicate": [1, 2]}, {})
125
+
126
+
127
+ # ── scope escape (prop-rooted guard) is loud ──────────────────────────────────
128
+
129
+
130
+ def test_prop_rooted_guard_is_rejected():
131
+ with pytest.raises(ValueError, match="only reference the transaction 'input'"):
132
+ _holds({"eq": [{"ref": ["prop", "status"]}, "closed"]}, {})
133
+
134
+
135
+ def test_nested_prop_ref_is_rejected():
136
+ with pytest.raises(ValueError, match="only reference the transaction 'input'"):
137
+ _holds({"and": [True, {"eq": [{"ref": ["prop", "x"]}, "y"]}]}, {})
138
+
139
+
140
+ # ── skip / keep semantics ─────────────────────────────────────────────────────
141
+
142
+
143
+ def test_guard_true_keeps_guard_false_skips():
144
+ expr = {"eq": [{"ref": ["input", "active"]}, True]}
145
+ assert _holds(expr, {"active": True}) is True
146
+ assert _holds(expr, {"active": False}) is False