hypgraph 0.7.9__tar.gz → 0.7.11__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 (104) hide show
  1. {hypgraph-0.7.9 → hypgraph-0.7.11}/CHANGELOG.md +44 -0
  2. {hypgraph-0.7.9 → hypgraph-0.7.11}/PKG-INFO +1 -1
  3. {hypgraph-0.7.9 → hypgraph-0.7.11}/docs/spec.md +5 -0
  4. {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/__init__.py +1 -1
  5. {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/blind.py +45 -15
  6. {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/coord.py +15 -6
  7. {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/graph.py +81 -2
  8. {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/mcp.py +10 -6
  9. {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/schema.py +4 -0
  10. {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/ui/hypgraph.css +2 -1
  11. {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_blind.py +46 -0
  12. hypgraph-0.7.11/tests/test_blind_oracles.py +191 -0
  13. {hypgraph-0.7.9 → hypgraph-0.7.11}/.gitignore +0 -0
  14. {hypgraph-0.7.9 → hypgraph-0.7.11}/AGENTS.md +0 -0
  15. {hypgraph-0.7.9 → hypgraph-0.7.11}/CITATION.cff +0 -0
  16. {hypgraph-0.7.9 → hypgraph-0.7.11}/DISCLAIMER.md +0 -0
  17. {hypgraph-0.7.9 → hypgraph-0.7.11}/LICENSE +0 -0
  18. {hypgraph-0.7.9 → hypgraph-0.7.11}/NOTICE +0 -0
  19. {hypgraph-0.7.9 → hypgraph-0.7.11}/README.md +0 -0
  20. {hypgraph-0.7.9 → hypgraph-0.7.11}/docs/PLAN.md +0 -0
  21. {hypgraph-0.7.9 → hypgraph-0.7.11}/docs/decisions/ADR-001-token-bound-authors.md +0 -0
  22. {hypgraph-0.7.9 → hypgraph-0.7.11}/docs/decisions/ADR-002-governance-profiles.md +0 -0
  23. {hypgraph-0.7.9 → hypgraph-0.7.11}/docs/profile-workflows-plan.md +0 -0
  24. {hypgraph-0.7.9 → hypgraph-0.7.11}/docs/review-2026-10-24.md +0 -0
  25. {hypgraph-0.7.9 → hypgraph-0.7.11}/docs/site-plan.md +0 -0
  26. {hypgraph-0.7.9 → hypgraph-0.7.11}/docs/ui-plan.md +0 -0
  27. {hypgraph-0.7.9 → hypgraph-0.7.11}/docs/workflows/README.md +0 -0
  28. {hypgraph-0.7.9 → hypgraph-0.7.11}/docs/workflows/knowledge.md +0 -0
  29. {hypgraph-0.7.9 → hypgraph-0.7.11}/docs/workflows/optimization.md +0 -0
  30. {hypgraph-0.7.9 → hypgraph-0.7.11}/docs/workflows/scientific.md +0 -0
  31. {hypgraph-0.7.9 → hypgraph-0.7.11}/docs/workflows/software.md +0 -0
  32. {hypgraph-0.7.9 → hypgraph-0.7.11}/docs/workflows/trading.md +0 -0
  33. {hypgraph-0.7.9 → hypgraph-0.7.11}/pyproject.toml +0 -0
  34. {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/__main__.py +0 -0
  35. {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/bundle.py +0 -0
  36. {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/checks.py +0 -0
  37. {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/cli.py +0 -0
  38. {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/demos/__init__.py +0 -0
  39. {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/demos/broad_street.py +0 -0
  40. {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/derive.py +0 -0
  41. {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/language.py +0 -0
  42. {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/plan.py +0 -0
  43. {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/policy.py +0 -0
  44. {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/profiles.py +0 -0
  45. {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/references.py +0 -0
  46. {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/refusals.py +0 -0
  47. {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/report_check.py +0 -0
  48. {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/server.py +0 -0
  49. {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/skill/SKILL.md +0 -0
  50. {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/stats.py +0 -0
  51. {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/store.py +0 -0
  52. {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/ui/__init__.py +0 -0
  53. {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/ui/atlas.css +0 -0
  54. {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/ui/atlas.js +0 -0
  55. {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/ui/hypgraph.js +0 -0
  56. {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/ui/hypgraph.svg +0 -0
  57. {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/ui/index.html +0 -0
  58. {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/ui/vendor/LICENSES.md +0 -0
  59. {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/ui/vendor/cose-base.js +0 -0
  60. {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/ui/vendor/cytoscape-fcose.js +0 -0
  61. {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/ui/vendor/cytoscape.min.js +0 -0
  62. {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/ui/vendor/layout-base.js +0 -0
  63. {hypgraph-0.7.9 → hypgraph-0.7.11}/src/hypgraph/workflow.py +0 -0
  64. {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_accept_work_kind.py +0 -0
  65. {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_bound_support.py +0 -0
  66. {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_bundle_telemetry.py +0 -0
  67. {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_cancel_releases_lease.py +0 -0
  68. {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_cited_and_failed_support.py +0 -0
  69. {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_coord.py +0 -0
  70. {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_d10.py +0 -0
  71. {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_engine.py +0 -0
  72. {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_engine_fixes.py +0 -0
  73. {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_incremental.py +0 -0
  74. {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_link_translate.py +0 -0
  75. {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_log_and_check.py +0 -0
  76. {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_measure_questions.py +0 -0
  77. {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_opposed_answers.py +0 -0
  78. {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_option_lead.py +0 -0
  79. {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_order_detail.py +0 -0
  80. {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_profiles.py +0 -0
  81. {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_profiles_041.py +0 -0
  82. {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_questions_not_assertions.py +0 -0
  83. {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_references.py +0 -0
  84. {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_refusal_friction.py +0 -0
  85. {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_refusal_results.py +0 -0
  86. {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_refusals.py +0 -0
  87. {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_relation_words.py +0 -0
  88. {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_report_answers.py +0 -0
  89. {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_rerunnable.py +0 -0
  90. {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_restates_scale.py +0 -0
  91. {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_server_auth.py +0 -0
  92. {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_server_hosts.py +0 -0
  93. {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_shape_refusals.py +0 -0
  94. {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_stats_not_backtests.py +0 -0
  95. {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_stats_values.py +0 -0
  96. {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_surfaces.py +0 -0
  97. {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_telemetry_stream.py +0 -0
  98. {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_ui_app.py +0 -0
  99. {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_ui_engine.py +0 -0
  100. {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_uncorrected_effect.py +0 -0
  101. {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_unnamed_inputs.py +0 -0
  102. {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_versions_not_figures.py +0 -0
  103. {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_vocabulary_friction.py +0 -0
  104. {hypgraph-0.7.9 → hypgraph-0.7.11}/tests/test_workflows.py +0 -0
@@ -1,5 +1,49 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.11 — 2026-10-30: a sealed checker can't learn the answer by asking
4
+
5
+ An adversarial test on a local HypMesh planted a 5-digit reading and 12-digit tags in a lead's sealed nodes and
6
+ attacked every route as the blind checker. Nothing leaked directly, but the checker could tell a right guess from a
7
+ wrong one, and 60 requests recovered the reading exactly. Fixed:
8
+
9
+ - **Errors no longer mark a right guess.** Answers scrubbed only the numbers the sealed material carries, so a route
10
+ that echoes its input (`resolve`, `node`, `why`, `order`, `check_work`, a refused write) answered "no node
11
+ '[sealed]'" for a right guess and "no node '48220'" for a wrong one. While a reader is sealed, every number in an
12
+ error is now blanked, secret or not; the MCP seat does the same.
13
+ - **A write is answered with the view after it.** The checker's own new numbers were scrubbed with the view from
14
+ before the write, so a result carrying the right reading came back "[sealed]".
15
+ - **No check reads sealed material for a sealed writer.** A claim bound to the sealed result was refused with an
16
+ unbound number for a wrong guess and passed that check for the right one, and the auto-bind looked the guess up
17
+ in the sealed results without any id given. A sealed writer may now link to a sealed node only to say its own work
18
+ supports, challenges or tests a hypothesis; binding to, resting on, reviewing, revising or ordering work on sealed
19
+ nodes is refused with one message before any check runs, and a seat holding a blind order issues no orders.
20
+ `Graph.sealed_from(hide)` lets a host apply its reader's view to writes, so writing under another name doesn't
21
+ escape the seal.
22
+ - **The leak check at issue skips what is sealed from the issuer**, which would otherwise answer the issuer's guesses.
23
+
24
+ `tests/test_blind_oracles.py` asks every request with the right and a wrong guess on fresh records and requires the
25
+ same answer; 14 of its 23 tests fail on 0.7.10.
26
+
27
+ ## 0.7.10 — 2026-10-30: fixes from the first live blind run
28
+
29
+ Claude led a question on a HypMesh and issued a blind order; Codex took the order through its seat and counted the
30
+ primes below 1,000,000 whose digits sum to 23 with its own sieve. The record marked its result written blind and
31
+ agreeing blind with Claude's, and nothing sealed reached Codex. Four fixes from that run and a phone-width check:
32
+
33
+ - **Investigations are part of the problem statement a blind checker reads.** Only the order's own investigation
34
+ and the curators' were. A lead whose framing lived in a second investigation had it sealed, the numbers that
35
+ define the problem (1,000,000; 23) read as secrets, and an honest blind order was refused. Questions and
36
+ investigations say what is being found out; don't put answers in them.
37
+ - **`my_work` without an investigation lists your orders in every investigation.** It asked "which
38
+ investigation?" when a record held two, and the seat's first call failed.
39
+ - **`produced_by`, `generated_by`, `output_of`, `executed_by` and `tested_by` are read as the relation they
40
+ invert.** The node types fix the direction, so the link is turned round. Codex's first result was refused for
41
+ `produced_by`.
42
+ - **On a phone, every tab of the embedded app can be reached.** The embed's `overflow: visible` beat the narrow
43
+ rule that makes the nav a scrolling strip. On hypdmz.com at 375 px, four of the eight tabs (Evidence, Work
44
+ orders, Audit log, Report) sat past the edge with no way to scroll to them. The nav now scrolls, and the embed
45
+ clips sideways, so the closed drawer can't widen a host page that doesn't clip it.
46
+
3
47
  ## 0.7.9 — 2026-10-30: blind work orders
4
48
 
5
49
  - **A checker can measure without seeing the answer it checks.** `issue_work(..., blind=True)` (MCP `issue_work`
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hypgraph
3
- Version: 0.7.9
3
+ Version: 0.7.11
4
4
  Summary: The evidence layer for AI research agents: a hash-chained research record that checks every claim as it is written and derives how well each is known.
5
5
  Project-URL: Homepage, https://hypgraph.com
6
6
  Project-URL: Documentation, https://hypgraph.com/engineers/
@@ -162,6 +162,11 @@ Implementations must derive these, never store them:
162
162
  - Nobody officially reviews their own node.
163
163
  - Writes into an `archived` or `published` investigation are refused (reserved authors excepted).
164
164
  - Only the importer may set a write's time. Everyone else's comes from the writer's clock.
165
+ - A writer sealed from nodes (it holds an open blind work order, or a host passes its reader's view with
166
+ `Graph.sealed_from`) may link to a sealed node only to say its own work `supports`, `challenges` or `tests` a
167
+ hypothesis. It may not bind a measurement to a sealed result, rest a claim on sealed work, review or revise a sealed
168
+ node, or issue a work order on sealed work, and a seat holding a blind order issues no orders. These are refused
169
+ with one message, before any check reads the sealed material, so that no answer depends on what is sealed.
165
170
 
166
171
  ## 7. Claim checks
167
172
 
@@ -16,7 +16,7 @@ Standing is derived on every read, never stored; every write is an event in a SH
16
16
  their numbers; reviews pin versions; independence is counted, not assumed. See https://hypgraph.com.
17
17
  """
18
18
 
19
- __version__ = "0.7.9"
19
+ __version__ = "0.7.11"
20
20
 
21
21
  from .bundle import verify_bundle # noqa: E402
22
22
  from .graph import Author, Graph, GraphError, file_sha256 # noqa: E402
@@ -2,8 +2,8 @@
2
2
  Blind work orders: a checker measures without seeing the answer it is checking.
3
3
 
4
4
  A work order issued with ``blind=True`` seals the record from its assignee until the order is returned. While it is
5
- open, the assignee reads the problem statement (the investigation the order belongs to, the curators' investigations,
6
- the questions) and its own order; everything else other authors wrote — hypotheses and their predictions, results,
5
+ open, the assignee reads the problem statement (the investigations and questions: what is being found out, never what
6
+ was found) and its own order; everything else other authors wrote — hypotheses and their predictions, results,
7
7
  claims, reviews, runs and their code, sources, other orders, other teams' attempts — is sealed: shown as a placeholder,
8
8
  left out of search, its numbers scrubbed wherever else they appear.
9
9
 
@@ -21,6 +21,11 @@ The record enforces what it can see:
21
21
  wrote a node. ``hypgraph serve`` and the Python API read the whole record: a file is only as blind as whoever
22
22
  holds it.
23
23
 
24
+ A sealed reader can't learn what is sealed by asking either: an error that echoes its request has every number
25
+ blanked (not only the secret ones, which would mark a right guess), a write is answered with the view after it, and a
26
+ sealed writer's writes may not rest on sealed nodes, so no check reads them on its behalf (``graph.sealed_from``).
27
+ ``tests/test_blind_oracles.py`` asks every request with a right and a wrong guess and requires the same answer.
28
+
24
29
  It cannot see reads. A seat that read the record before its order was issued has already seen what is now sealed;
25
30
  give a checker its order first.
26
31
  """
@@ -39,7 +44,10 @@ _NUM = re.compile(r"(?<![\w.,])-?(?:\d{1,3}(?:,\d{3})+|\d+)(?:\.\d+)?(?![\w])")
39
44
  READ_ROUTES = frozenset({"investigations", "orders", "order", "resolve", "subgraph", "node", "why", "impact",
40
45
  "neighborhood", "search", "events", "verify", "profiles", "profile", "references"})
41
46
  POST_ROUTES = frozenset({"check_work"})
42
- VISIBLE_TYPES = frozenset({"question"})
47
+ # The problem statement every reader keeps. An investigation says what is being found out; a lead whose framing
48
+ # lived in a second investigation had it sealed, and the numbers that define the problem (1,000,000; 23) then read
49
+ # as secrets, so an honest blind order was refused (first live run, 2026-10-30). Don't put answers in them.
50
+ VISIBLE_TYPES = frozenset({"question", "investigation"})
43
51
  # A blind order asks for a measurement. A review or a report has to read what it judges.
44
52
  BLIND_PREDICATES = frozenset({"run_registered", "result_exists", "claim_made", "within_budget"})
45
53
  SEALED = "sealed"
@@ -228,10 +236,8 @@ def _derived(s: D.Snap, x: str, limit: int = 60) -> set[str]:
228
236
 
229
237
  # ── what is sealed, and from whom ──────────────────────────────────
230
238
  def public(s: D.Snap, n: dict, w: Window) -> bool:
231
- """The problem statement every reader keeps: questions, the curators' investigations, the order's own
232
- investigation, and the order chain itself."""
233
- return (n["type"] in VISIBLE_TYPES
234
- or (n["type"] == "investigation" and (n["created_by"] in s.policy.curators or n["id"] == w.inv)))
239
+ """The problem statement every reader keeps: questions and investigations (the order chain is kept apart)."""
240
+ return n["type"] in VISIBLE_TYPES
235
241
 
236
242
 
237
243
  def scope(s: D.Snap, w: Window) -> set[str]:
@@ -274,6 +280,26 @@ class View:
274
280
  return [self.scrub(v) for v in x]
275
281
  return x
276
282
 
283
+ def blank(self, x: Any) -> Any:
284
+ """Every significant number replaced, secret or not: for answers that may echo what the reader sent (errors,
285
+ refusals). Scrubbing only the secret ones there would mark a right guess as "[sealed]" and leave a wrong one
286
+ as written — an oracle (an adversarial run found six routes that did, 2026-10-30)."""
287
+ if not self:
288
+ return x
289
+ if isinstance(x, str):
290
+ return _NUM.sub(lambda m: "[number]" if _significant(canon(m.group(0))) else m.group(0), x)
291
+ if isinstance(x, dict):
292
+ return {k: self.blank(v) for k, v in x.items()}
293
+ if isinstance(x, list):
294
+ return [self.blank(v) for v in x]
295
+ return x
296
+
297
+ def answer(self, status: int, out: Any) -> Any:
298
+ """A response as this reader gets it: redacted, and with every number blanked when it is an error."""
299
+ if not self:
300
+ return out
301
+ return self.blank(self._walk(out)) if status >= 400 else self.apply(out)
302
+
277
303
  def node(self, d: dict) -> dict:
278
304
  """A sealed node as a reader sees it: what it is and where, not what it says."""
279
305
  code = self.hide.get(d.get("id"), "")
@@ -377,26 +403,30 @@ def dispatch(g, method: str, path: str, *, query: dict | None = None, body: dict
377
403
  parts = [p for p in path.strip("/").split("/") if p]
378
404
  head, arg = (parts[0] if parts else ""), (parts[1] if len(parts) > 1 else "")
379
405
  if method == "POST" and head == "write":
380
- status, out = plain(g, method, path, query=query, body=body, **kw)
381
- return status, v.apply(out)
406
+ # the write is checked as this reader: nothing it may not see is read on its behalf (graph.sealed_from); its
407
+ # answer is redacted with the view after the write, in which its own new numbers are no longer secret
408
+ with g.sealed_from(v.hide):
409
+ status, out = plain(g, method, path, query=query, body=body, **kw)
410
+ return status, (view_for(g.snapshot(), keep) or v).answer(status, out)
382
411
  if not v.allows(method, head):
383
- return 409, {"detail": v.message(f"/{head}"), SEALED: v.because()}
412
+ return 409, v.blank({"detail": v.message(f"/{head}"), SEALED: v.because()})
384
413
  if head in ("order", "node", "why", "impact", "neighborhood") and arg:
385
414
  try:
386
415
  nid = g.resolve(arg)
387
416
  except Exception:
388
417
  nid = arg
389
418
  if head == "order" and nid in v.hide:
390
- return 409, {"detail": v.message(f"order {arg}"), SEALED: v.because()}
419
+ return 409, v.blank({"detail": v.message(f"order {arg}"), SEALED: v.because()})
391
420
  status, out = plain(g, method, path, query=query, body=body, **kw)
392
421
  if head == "search" and status == 200 and isinstance(out, dict) and isinstance(out.get("results"), list):
393
422
  out = out | {"results": v.search(out["results"])}
394
- return status, v.apply(out)
423
+ return status, v.answer(status, out)
395
424
 
396
425
 
397
- def leaks(s: D.Snap, w: Window, texts: list[str]) -> dict[str, list[str]]:
398
- """Numbers an order's own text quotes that only the material it would seal carries: number → codes holding it."""
399
- hide = scope(s, w)
426
+ def leaks(s: D.Snap, w: Window, texts: list[str], skip: dict | set | None = None) -> dict[str, list[str]]:
427
+ """Numbers an order's own text quotes that only the material it would seal carries: number → codes holding it.
428
+ ``skip``: material sealed from the issuer, left out (comparing against it would answer the issuer's guesses)."""
429
+ hide = scope(s, w) - set(skip or ())
400
430
  visible: set[str] = set()
401
431
  for i, n in s.nodes.items():
402
432
  if i not in hide:
@@ -371,6 +371,11 @@ def issue_work(g: "Graph", *, author: str, inv: str, target: str, assignee: str,
371
371
  raise GraphError(str(e).strip("'\"") + " — a target is a code the record has (Q1, H2, X1, CL3; find lists "
372
372
  "them) or the investigation's id; add a hypothesis or experiment first if you need a new "
373
373
  "one") from None
374
+ from . import blind as B
375
+ own = B.view_for_author(s, author)
376
+ if own: # a seat holding a blind order measures; it issues orders once it has returned it
377
+ raise g._sealed(own.hide, "a work order")
378
+ hidden = g._hidden(s, author)
374
379
  if D.out_of_play(s, t):
375
380
  raise GraphError(f"{_code(s, t)} is {D.lifecycle(s, t)}{' and fallen' if D.fallen(s, t) else ''} — "
376
381
  "nothing to work on")
@@ -386,9 +391,6 @@ def issue_work(g: "Graph", *, author: str, inv: str, target: str, assignee: str,
386
391
  preds = [norm_pred(p) for p in (done_when or profiles.default_done(s, inv, s.nodes[t]["type"])
387
392
  or DEFAULT_DONE.get(s.nodes[t]["type"], [{"p": "result_exists"}]))]
388
393
  _known_predicates(preds)
389
- if blind:
390
- _blind_ok(s, inv=inv, author=author, assignee=assignee, target=t, preds=preds, prediction=prediction,
391
- texts=[objective, method, note, role])
392
394
  if premises is None:
393
395
  prem = [a for a, _r, _d in D.ancestors(s, t, limit=40) if s.nodes[a]["type"] not in ("artifact", "task")]
394
396
  if s.nodes[t]["type"] == "experiment":
@@ -396,6 +398,11 @@ def issue_work(g: "Graph", *, author: str, inv: str, target: str, assignee: str,
396
398
  else:
397
399
  prem = prem_ids
398
400
  prem = [p for p in dict.fromkeys(prem) if p in s.nodes and p != t][:30]
401
+ if hidden and (t in hidden or any(p in hidden for p in prem)): # its text would quote sealed work
402
+ raise g._sealed(hidden, "a work order on sealed work")
403
+ if blind:
404
+ _blind_ok(s, inv=inv, author=author, assignee=assignee, target=t, preds=preds, prediction=prediction,
405
+ texts=[objective, method, note, role], skip=hidden)
399
406
  fields = {"objective": objective, "assignee": assignee, "role": role, "done_when": preds,
400
407
  "prediction": prediction, "method": method, "budget": budget or {}, "note": note,
401
408
  "title": objective[:300]}
@@ -410,8 +417,10 @@ def issue_work(g: "Graph", *, author: str, inv: str, target: str, assignee: str,
410
417
 
411
418
 
412
419
  def _blind_ok(s: D.Snap, *, inv: str, author: str, assignee: str, target: str, preds: list[dict], prediction: str,
413
- texts: list[str]) -> None:
414
- """A blind order's checks at issue: what the record can see of whether the assignee will really work blind."""
420
+ texts: list[str], skip: dict | None = None) -> None:
421
+ """A blind order's checks at issue: what the record can see of whether the assignee will really work blind.
422
+ ``skip``: what is sealed from the issuer itself, left out of the leak check (comparing against it would tell the
423
+ issuer whether a number it guessed is in there)."""
415
424
  from .graph import GraphError
416
425
  from . import blind as B
417
426
  if prediction.strip():
@@ -431,7 +440,7 @@ def _blind_ok(s: D.Snap, *, inv: str, author: str, assignee: str, target: str, p
431
440
  raise GraphError(f"{assignee} has already written in this record ({', '.join(wrote)}), so it has read what a "
432
441
  "blind order would seal. Give the blind order to a seat that hasn't")
433
442
  w = B.Window("", [], assignee, inv, s.seq, None, sealing=True, fresh=True)
434
- found = B.leaks(s, w, [x for x in texts if x])
443
+ found = B.leaks(s, w, [x for x in texts if x], skip=skip)
435
444
  if found:
436
445
  said = "; ".join(f"{k} (in {', '.join(v[:3])})" for k, v in sorted(found.items())[:6])
437
446
  raise GraphError(f"the order quotes what it would seal: {said}. Say what to measure and how, not what it "
@@ -170,6 +170,25 @@ def clean_body(ntype: str, body: dict, *, partial_ok: bool = False) -> dict:
170
170
 
171
171
  class _Writer(threading.local):
172
172
  task = ""
173
+ hide: Optional[dict] = None
174
+
175
+
176
+ class sealed_from:
177
+ """``with g.sealed_from(hide):`` — the writes inside come from a reader these nodes are sealed from (a host's blind
178
+ view: node id → the order that seals it, ``blind.view_for(...).hide``). They may not rest on those nodes, and no
179
+ check reads them on the writer's behalf: a check that did would answer a right guess differently from a wrong one.
180
+ An author holding a blind order is sealed this way without it; a host passes its reader's view so that writing
181
+ under another name doesn't escape the seal."""
182
+ def __init__(self, graph: "Graph", hide: dict | None):
183
+ self.graph = graph
184
+ self.hide = dict(hide or {})
185
+
186
+ def __enter__(self):
187
+ self.prev, self.graph._writer.hide = self.graph._writer.hide, self.hide
188
+ return self
189
+
190
+ def __exit__(self, *exc):
191
+ self.graph._writer.hide = self.prev
173
192
 
174
193
 
175
194
  class writing_for:
@@ -259,6 +278,42 @@ class Graph:
259
278
  def writing_for(self, task: str | None) -> writing_for:
260
279
  return writing_for(self, task)
261
280
 
281
+ def sealed_from(self, hide: dict | None) -> sealed_from:
282
+ return sealed_from(self, hide)
283
+
284
+ def _hidden(self, s: D.Snap, author: str) -> dict:
285
+ """What is sealed from this write's author: its own open blind orders' scope, and the host's view if given."""
286
+ from . import blind
287
+ out = dict(self._writer.hide or {})
288
+ for i, c in blind.view_for_author(s, author).hide.items():
289
+ out.setdefault(i, c)
290
+ return out
291
+
292
+ @staticmethod
293
+ def _sealed(hidden: dict, what: str) -> GraphError:
294
+ """The one refusal a sealed writer gets, the same whatever it guessed."""
295
+ codes = ", ".join(sorted({c for c in hidden.values() if c})) or "a blind work order"
296
+ return GraphError(f"{what}: that work is sealed from you while {codes} is open, so nothing you write may rest on "
297
+ "it or be checked against it. Record your own measurement, return the order (return_work), "
298
+ "and build on the rest once it opens.",
299
+ [{"code": "SEALED", "message": what}])
300
+
301
+ def _sealed_measurements(self, s: D.Snap, hidden: dict, body: dict, inv: str) -> None:
302
+ for m in body.get("measurements") or []:
303
+ ref = str(m.get("result") or "") if isinstance(m, dict) else ""
304
+ hits = [ref] if ref in s.nodes else [i for i, n in s.nodes.items()
305
+ if ref and n["code"].lower() == ref.lower() and n["inv"] == inv]
306
+ if any(h in hidden for h in hits):
307
+ raise self._sealed(hidden, f"a measurement bound to {ref}")
308
+
309
+ def _sealed_links(self, s: D.Snap, hidden: dict, links: list[tuple[str, str, str]]) -> None:
310
+ """A sealed writer's links may reach a sealed node only to say its own work supports, challenges or tests a
311
+ hypothesis — what a blind order targets; a claim, result or anything else would be read on its behalf."""
312
+ for a, rel, b in links:
313
+ for x in (a, b):
314
+ if x in hidden and not (s.nodes[x]["type"] == "hypothesis" and rel in ("supports", "challenges", "tests")):
315
+ raise self._sealed(hidden, f"a {rel} link to {s.nodes[x]['code'] or x}")
316
+
262
317
  def author(self, name: str) -> "Author":
263
318
  """A handle that writes as ``name`` — convenient in notebooks and scripts."""
264
319
  return Author(self, name)
@@ -707,12 +762,17 @@ class Graph:
707
762
  pending = [(a, rel, b) for a, rel, b, _f in oriented]
708
763
  flipped = [f"{s.nodes[a].get('code') or a} -{rel}-> {s.nodes[b].get('code') or b}"
709
764
  for a, rel, b, f in oriented if f]
765
+ hidden = self._hidden(s, author)
766
+ if hidden:
767
+ self._sealed_links(s, hidden, pending)
710
768
  for a, rel, b in pending:
711
769
  self._check_link(s, a, rel, b)
712
770
  auto_notes: list[str] = []
713
771
  if type == "claim":
714
772
  body = self._bind_measurements(s, body, inv)
715
773
  staged["body"] = body
774
+ if hidden:
775
+ self._sealed_measurements(s, hidden, body, inv)
716
776
  # a measurement names the result its number comes from: that result supports the claim, so the
717
777
  # standing counts it as a line of support without a separate link from the writer
718
778
  for m in body.get("measurements") or []:
@@ -729,7 +789,9 @@ class Graph:
729
789
  if type == "claim" and author not in P.curators:
730
790
  problems = claim_issues(s, body, self._support_ids(s, nid, pending), inv)
731
791
  loose = [w for p in problems if p.code == "UNBOUND_NUMBER" for w in p.words]
732
- if loose: # repair before refusing: bind what the record holds
792
+ # repair before refusing: bind what the record holds — not for a sealed writer, whose numbers
793
+ # would be looked up in what is sealed from it
794
+ if loose and not hidden:
733
795
  body, extra, auto_notes = self._auto_bind(s, body, inv, who,
734
796
  self._support_ids(s, nid, pending), loose)
735
797
  if auto_notes:
@@ -976,15 +1038,22 @@ class Graph:
976
1038
  raise
977
1039
  extra: list[str] = []
978
1040
  auto_notes: list[str] = []
1041
+ hidden = self._hidden(s, author)
1042
+ if hidden and node in hidden:
1043
+ raise self._sealed(hidden, f"a revision of {n['code'] or node}")
979
1044
  if n["type"] == "claim":
980
1045
  body = self._bind_measurements(s, body, n["inv"])
1046
+ if hidden:
1047
+ self._sealed_measurements(s, hidden, body, n["inv"])
1048
+ if set(self._support_ids(s, node, [])) & set(hidden):
1049
+ raise self._sealed(hidden, f"a revision of {n['code'] or node}, which rests on sealed work")
981
1050
  if n["type"] == "run" and body.get("kind") != n["body"].get("kind") and author != P.operator:
982
1051
  raise GraphError("a run's kind (lab / agent / external) is where its record came from; it doesn't "
983
1052
  "change")
984
1053
  if n["type"] == "claim" and author not in P.curators:
985
1054
  problems = claim_issues(s, body, self._support_ids(s, node, []), n["inv"])
986
1055
  loose = [w for p in problems if p.code == "UNBOUND_NUMBER" for w in p.words]
987
- if loose:
1056
+ if loose and not hidden:
988
1057
  body, extra, auto_notes = self._auto_bind(s, body, n["inv"], n["created_by"],
989
1058
  self._support_ids(s, node, []), loose)
990
1059
  if auto_notes:
@@ -1032,6 +1101,13 @@ class Graph:
1032
1101
  if translated:
1033
1102
  data = {**(data or {}), "read_as": given} # what the author wrote
1034
1103
  src, rel, dst, flipped = self._orient(s, src, rel, dst)
1104
+ hidden = self._hidden(s, author)
1105
+ if hidden:
1106
+ self._sealed_links(s, hidden, [(src, rel, dst)])
1107
+ for x in (src, dst): # a claim of its own may not come to rest on sealed work
1108
+ if x in s.nodes and s.nodes[x]["type"] == "claim" and \
1109
+ set(self._support_ids(s, x, [(src, rel, dst)])) & set(hidden):
1110
+ raise self._sealed(hidden, f"a link that rests {s.nodes[x]['code'] or x} on sealed work")
1035
1111
  if src in s.nodes:
1036
1112
  self._fixed_guard(s, author, src)
1037
1113
  eid = self._transaction(lambda con: self._insert_edge(con, s, src, rel, dst, data or {}, why, author,
@@ -1146,6 +1222,9 @@ class Graph:
1146
1222
  raise GraphError(f"no node {target}")
1147
1223
  if n["type"] not in RELS["reviews"][1]:
1148
1224
  raise GraphError(f"a {n['type']} isn't reviewed")
1225
+ hidden = self._hidden(s, author)
1226
+ if hidden and target in hidden:
1227
+ raise self._sealed(hidden, f"a review of {n['code'] or target}")
1149
1228
  official = author in D.board(s, target) and verdict != "comment"
1150
1229
  if official and author == n["created_by"] and author not in P.curators:
1151
1230
  who = ", ".join(sorted(D.board(s, target) - P.curators - {author})[:6]) or "another reviewer"
@@ -186,10 +186,12 @@ def build(graph: Graph | str, author: Optional[str] = None, files_root: str | No
186
186
  try:
187
187
  out = fn(*a, **kw)
188
188
  except ToolError as e:
189
- if v:
190
- raise ToolError(v.scrub(str(e))) from None
189
+ after = sealed() or v
190
+ if after: # every number blanked: scrubbing only the secret ones would mark a right guess
191
+ raise ToolError(after.blank(str(e))) from None
191
192
  raise
192
- return v.scrub(out) if v and isinstance(out, str) else out
193
+ after = sealed() # after a write its own new numbers are no longer secret
194
+ return after.scrub(out) if after and isinstance(out, str) else out
193
195
  return mcp.tool(wrapped)
194
196
 
195
197
  def fail(e: Exception):
@@ -551,10 +553,12 @@ def build(graph: Graph | str, author: Optional[str] = None, files_root: str | No
551
553
 
552
554
  @tool
553
555
  def my_work(investigation: str = "") -> str:
554
- """Your open work orders, each with its full text. An order you have returned is no longer yours to work:
555
- it waits for acceptance, and comes back as a new order if it is reworked."""
556
+ """Your open work orders, each with its full text, in every investigation unless you name one. An order you
557
+ have returned is no longer yours to work: it waits for acceptance, and comes back as a new order if it is
558
+ reworked."""
556
559
  try:
557
- allmine = [o for o in g.orders(inv_of(investigation)) if o["assignee"] == me]
560
+ invs = [g.resolve(investigation)] if investigation else [i["id"] for i in g.investigations()]
561
+ allmine = [o for inv in invs for o in g.orders(inv) if o["assignee"] == me]
558
562
  except (GraphError, KeyError) as e:
559
563
  fail(e)
560
564
  mine = [o for o in allmine if o["status"] in ("open", "overdue")]
@@ -148,6 +148,10 @@ REL_SYNONYMS: dict[str, str] = {
148
148
  "correct": "corrects", "corrected": "corrects", "require": "requires", "required": "requires",
149
149
  "depends_on": "requires", "part": "part_of", "belongs_to": "part_of", "motivated": "motivated_by",
150
150
  "rests": "rests_on", "works": "works_on", "blocked": "blocked_by", "settle": "settles", "settled": "settles",
151
+ # inverses whose node types fix the direction (a result can't produce a run): turned round by _orient. Not
152
+ # supported_by / challenged_by: a claim can sit at either end of those.
153
+ "produced_by": "produced", "generated_by": "produced", "output_of": "produced", "executed_by": "executes",
154
+ "tested_by": "tests",
151
155
  }
152
156
 
153
157
  SYNONYMS: dict[tuple[str, str], dict[str, str]] = {
@@ -92,7 +92,7 @@
92
92
  .hg.hg-embed .hg-app { height: auto; grid-template-rows: 52px auto; }
93
93
  .hg.hg-embed .hg-main, .hg.hg-embed .hg-nav { overflow: visible; }
94
94
  .hg.hg-embed .feed { max-height: none; overflow: visible; }
95
- .hg.hg-embed { overflow: visible; }
95
+ .hg.hg-embed { overflow-x: clip; overflow-y: visible; } /* the closed drawer sits off to the right: never widen the page */
96
96
  .hg .kpi { padding: 12px 14px; } .hg .kpi .k { color: var(--ink2); font-size: 12px; }
97
97
  .hg .kpi .v { font-size: 22px; font-weight: 650; letter-spacing: -.02em; margin-top: 2px; font-variant-numeric: tabular-nums; }
98
98
  .hg .kpi .s { color: var(--ink3); font-size: 11.5px; margin-top: 2px; }
@@ -247,6 +247,7 @@
247
247
  .hg .hg-nav button { width: auto; flex: none; white-space: nowrap; padding: 6px 10px; }
248
248
  .hg .hg-nav .n { margin-left: 4px; } .hg .hg-nav .inv .s { display: none; }
249
249
  .hg .hg-main { padding: 16px 16px 32px; }
250
+ .hg.hg-embed .hg-nav { overflow-x: auto; overflow-y: hidden; } /* beats the embed's overflow: visible: every tab reachable */
250
251
  .hg .trace .tr { grid-template-columns: minmax(90px, 40%) minmax(0, 1fr) 48px; }
251
252
  }
252
253
  @media (prefers-reduced-motion: reduce) { .hg *, .hg *::before { animation: none !important; transition: none !important; } }
@@ -166,3 +166,49 @@ def test_mcp_seat_holding_a_blind_order(env, tmp_path):
166
166
  ov = text(await c.call_tool("overview", {"investigation": inv}))
167
167
  assert "Primes below" in ov
168
168
  asyncio.run(go())
169
+
170
+
171
+ # ── from the first live run (Claude leading, Codex checking on HypMesh, 2026-10-30) ──
172
+ def test_a_framing_investigation_is_the_problem_statement_and_my_work_finds_the_order():
173
+ fastmcp = pytest.importorskip("fastmcp")
174
+ from hypgraph.mcp import build
175
+ g = Graph(clock=Clock())
176
+ lead = g.author("lead")
177
+ frame = lead.investigation("The question", "Count the primes below 1,000,000 whose digits sum to 23.")["id"]
178
+ inv = lead.investigation("Lead's attempt", "Count them, and have the count checked blind.")["id"]
179
+ h = lead.add("hypothesis", inv, statement="There are 5503 primes below 1,000,000 whose digits sum to 23.",
180
+ predictions=["an independent count finds 5503"])["id"]
181
+ o = g.issue_work(author="lead", inv=inv, target=h, assignee="checker", blind=True,
182
+ objective="Count the primes below 1,000,000 whose digits sum to 23, with your own code.")
183
+ v = B.view_for_author(g.snapshot(), "checker")
184
+ assert frame not in v.hide and h in v.hide and "5503" in v.secrets and "23" not in v.secrets
185
+ with pytest.raises(GraphError, match="quotes what it would seal: 5503"):
186
+ h2 = lead.add("hypothesis", inv, statement="Most of them are below 900,000.",
187
+ predictions=["more than half fall below 900,000"])["id"]
188
+ g.issue_work(author="lead", inv=inv, target=h2, assignee="other", blind=True,
189
+ objective="Check that the count is 5503.")
190
+
191
+ async def go():
192
+ async with fastmcp.Client(build(g, author="checker")) as c:
193
+ r = await c.call_tool("my_work", {}) # no investigation named, two in the record
194
+ assert o["code"] in r.content[0].text and "BLIND" in r.content[0].text
195
+ asyncio.run(go())
196
+
197
+
198
+ def test_inverse_relation_words_are_turned_round(env):
199
+ g, inv, h1, rs1 = env
200
+ a = g.author("lead")
201
+ run = a.add("run", inv, kind="agent", name="second sieve", state="succeeded")["id"]
202
+ rs = a.add("result", inv, summary="The second sieve counts 1230 primes below 10,000; the largest is 9967.",
203
+ outcome="positive", values={"count": 1230, "largest": 9967},
204
+ links=[{"rel": "produced_by", "to": run}])
205
+ assert any(e["rel"] == "produced" and e["src"] == run for e in rs["in"])
206
+
207
+
208
+ def test_the_embedded_app_never_widens_its_page():
209
+ from pathlib import Path
210
+ import hypgraph.ui as ui
211
+ css = (Path(ui.__file__).parent / "hypgraph.css").read_text(encoding="utf-8")
212
+ assert ".hg.hg-embed { overflow-x: clip;" in css
213
+ narrow = css.split("@container hg (max-width: 760px)", 1)[1].split("\n}\n", 1)[0]
214
+ assert ".hg.hg-embed .hg-nav { overflow-x: auto;" in narrow
@@ -0,0 +1,191 @@
1
+ """A sealed checker can't learn what is sealed by asking: every request it can make answers the same whether its guess
2
+ of a sealed value is right or wrong (an adversarial test on a live mesh, 2026-10-30, found six routes that marked a
3
+ right guess as "[sealed]", and claim checks that accepted a right guess and refused a wrong one).
4
+
5
+ Each case runs on its own fresh record twice, once with the sealed reading and once with a wrong one, through
6
+ ``blind.dispatch`` as a host serves the checker (its own nodes kept), and the two answers must be identical once the
7
+ guess itself and fresh ids are blanked."""
8
+ import re
9
+ from datetime import datetime, timedelta
10
+
11
+ import pytest
12
+
13
+ from hypgraph import Graph, Policy
14
+ from hypgraph import blind as B
15
+
16
+ R, WRONG = 48213, 48220
17
+ WORD = "quillotine"
18
+
19
+
20
+ class Clock:
21
+ def __init__(self):
22
+ self.t = datetime(2026, 1, 1, 9, 0, 0)
23
+
24
+ def __call__(self):
25
+ self.t += timedelta(seconds=1)
26
+ return self.t.isoformat(timespec="seconds")
27
+
28
+
29
+ def fixture():
30
+ """The lead read the instrument (R) and holds it in a hypothesis, a result and a bound claim; the checker has a
31
+ blind order and its own run."""
32
+ g = Graph(policy=Policy(reviewers={"critic"}), clock=Clock())
33
+ lead = g.author("lead")
34
+ inv = lead.investigation("Read the instrument", "Record the instrument's reading and have it checked blind.")["id"]
35
+ h1 = lead.add("hypothesis", inv, statement=f"The instrument reads {R}, codeword {WORD}.",
36
+ predictions=[f"A second reading gives {R}."])["id"]
37
+ run = lead.add("run", inv, kind="agent", name=f"read by {WORD}", state="succeeded")["id"]
38
+ rs1 = lead.add("result", inv, summary=f"The instrument read {R} ({WORD}).", outcome="positive",
39
+ values={"reading": R},
40
+ links=[{"rel": "produced", "from": run}, {"rel": "supports", "to": h1, "why": "the reading it predicts"}])["id"]
41
+ lead.add("claim", inv, statement=f"The instrument reads {R}.", kind="finding",
42
+ scope="One reading of the instrument, taken by the lead.",
43
+ measurements=[{"result": rs1, "key": "reading"}], links=[{"rel": "supports", "from": rs1, "why": "it measured it"}])
44
+ o = g.issue_work(author="lead", inv=inv, target="H1", assignee="checker", blind=True,
45
+ objective="Take your own reading of the instrument and record it as a result named reading.")
46
+ mine = g.author("checker").add("run", inv, kind="agent", name="my own reading", state="succeeded")["id"]
47
+ return g, {"inv": inv, "h1": h1, "rs1": rs1, "run": run, "order": o["order"], "mine": mine}
48
+
49
+
50
+ def own(g):
51
+ return lambda i, n: n["created_by"] == "checker"
52
+
53
+
54
+ def ask(g, method, path, body=None, query=None):
55
+ return B.dispatch(g, method, path, query=query, body=body, keep=own(g), writable=True)
56
+
57
+
58
+ def write(g, body):
59
+ return ask(g, "POST", "/write", {"author": "checker", **body})
60
+
61
+
62
+ # every request a checker might make with a guess in it: (name, fn(g, ids, x) -> (status, answer))
63
+ CASES = {
64
+ "resolve": lambda g, i, x: ask(g, "GET", "/resolve", query={"ref": str(x)}),
65
+ "node": lambda g, i, x: ask(g, "GET", f"/node/{x}"),
66
+ "why": lambda g, i, x: ask(g, "GET", f"/why/{x}"),
67
+ "order": lambda g, i, x: ask(g, "GET", f"/order/{x}"),
68
+ "search": lambda g, i, x: ask(g, "GET", "/search", query={"text": str(x)}),
69
+ "check_work text": lambda g, i, x: ask(g, "POST", "/check_work", {"order": i["order"], "text": f"I read {x}."}),
70
+ "check_work order": lambda g, i, x: ask(g, "POST", "/check_work", {"order": str(x)}),
71
+ "bad type": lambda g, i, x: write(g, {"op": "create", "type": f"t {x}", "inv": i["inv"], "fields": {}}),
72
+ "bad field": lambda g, i, x: write(g, {"op": "create", "type": "result", "inv": i["inv"],
73
+ "fields": {"summary": f"read {x}", "outcome": "positive", "zz": f"{x} x"}}),
74
+ "claim bound to the sealed result": lambda g, i, x: write(g, {
75
+ "op": "create", "type": "claim", "inv": i["inv"],
76
+ "fields": {"statement": f"The instrument reads {x}.", "scope": "My reading of the instrument, taken once.",
77
+ "kind": "finding", "measurements": [{"result": i["rs1"], "key": "reading"}]},
78
+ "links": [{"rel": "supports", "from": i["rs1"], "why": "the reading it measured"}]}),
79
+ "claim with an unbound number": lambda g, i, x: write(g, {
80
+ "op": "create", "type": "claim", "inv": i["inv"],
81
+ "fields": {"statement": f"The instrument reads {x}.", "scope": "My reading of the instrument, taken once.",
82
+ "kind": "finding"}}),
83
+ "claim by code": lambda g, i, x: write(g, {
84
+ "op": "create", "type": "claim", "inv": i["inv"],
85
+ "fields": {"statement": f"The instrument reads {x}.", "scope": "My reading of the instrument, taken once.",
86
+ "kind": "finding", "measurements": [{"result": "RS1", "key": "reading"}]}}),
87
+ "result supporting the target": lambda g, i, x: write(g, {
88
+ "op": "create", "type": "result", "inv": i["inv"],
89
+ "fields": {"summary": f"My reading is {x}.", "outcome": "positive", "values": {"reading": x}},
90
+ "links": [{"rel": "produced", "from": i["mine"]}, {"rel": "supports", "to": i["h1"], "why": "the reading it predicts"}]}),
91
+ "result challenging the target": lambda g, i, x: write(g, {
92
+ "op": "create", "type": "result", "inv": i["inv"],
93
+ "fields": {"summary": f"My reading is {x}.", "outcome": "positive", "values": {"reading": x}},
94
+ "links": [{"rel": "produced", "from": i["mine"]}, {"rel": "challenges", "to": i["h1"], "why": "a different reading"}]}),
95
+ "result replicating the sealed one": lambda g, i, x: write(g, {
96
+ "op": "create", "type": "result", "inv": i["inv"],
97
+ "fields": {"summary": f"My reading is {x}.", "outcome": "positive", "values": {"reading": x}},
98
+ "links": [{"rel": "produced", "from": i["mine"]}, {"rel": "replicates", "to": i["rs1"], "why": "same reading"}]}),
99
+ "review of the sealed result": lambda g, i, x: write(g, {
100
+ "op": "review", "target": i["rs1"], "verdict": "pass", "method": "re-read the instrument",
101
+ "notes": f"I read {x}, the same."}),
102
+ "review of the target": lambda g, i, x: write(g, {
103
+ "op": "review", "target": i["h1"], "verdict": "pass", "method": "re-read the instrument",
104
+ "notes": f"I read {x}, the same."}),
105
+ "blind order to another seat": lambda g, i, x: write(g, {
106
+ "op": "issue_work", "inv": i["inv"], "target": i["mine"], "assignee": "third", "blind": True,
107
+ "objective": f"Check that the instrument reads {x}, with a reading of your own."}),
108
+ "order on the target": lambda g, i, x: write(g, {
109
+ "op": "issue_work", "inv": i["inv"], "target": i["h1"], "assignee": "third",
110
+ "objective": f"Check that the instrument reads {x}, with a reading of your own."}),
111
+ }
112
+
113
+
114
+ def run_case(fn, x):
115
+ g, ids = fixture()
116
+ status, out = fn(g, ids, x)
117
+ # the checker's own new node as it then reads it, with its derived state (a right guess must not show there)
118
+ after = []
119
+ if status == 200 and isinstance(out, dict) and out.get("id") in g.snapshot().nodes:
120
+ after = ask(g, "GET", f"/node/{out['id']}")
121
+ return status, out, after
122
+
123
+
124
+ def norm(x, guess):
125
+ t = repr(x)
126
+ t = t.replace(f"{guess:,}", "<X>").replace(str(guess), "<X>")
127
+ t = re.sub(r"\bn[0-9a-f]{12}\b", "<N>", t)
128
+ t = re.sub(r"\binv-[0-9a-f]{8}\b", "<INV>", t)
129
+ t = re.sub(r"\d{4}-\d\d-\d\dT\d\d:\d\d:\d\d", "<T>", t)
130
+ return t
131
+
132
+
133
+ @pytest.mark.parametrize("name", list(CASES))
134
+ def test_a_right_guess_answers_like_a_wrong_one(name):
135
+ right, wrong = run_case(CASES[name], R), run_case(CASES[name], WRONG)
136
+ assert norm(right, R) == norm(wrong, WRONG), f"{name}: the answer tells a right guess from a wrong one"
137
+
138
+
139
+ def test_no_answer_carries_sealed_words():
140
+ for name, fn in CASES.items():
141
+ for x in (R, WRONG):
142
+ assert WORD not in repr(run_case(fn, x)), f"{name} quotes sealed text"
143
+
144
+
145
+ def test_a_sealed_checker_still_does_its_work():
146
+ """The fixes refuse nothing the order needs: a result of its own that supports or tests the target."""
147
+ for name in ("result supporting the target", "result challenging the target"):
148
+ status, out, _ = run_case(CASES[name], WRONG)
149
+ assert status == 200, (name, out)
150
+
151
+
152
+ def test_a_host_view_seals_writes_under_another_name():
153
+ """A host seals by reader (HypMesh: the agent token), and a token may write under several names. The write is
154
+ checked against the reader's view, so a name with no blind order of its own learns no more."""
155
+ def as_alias(x):
156
+ g, i = fixture()
157
+ return ask(g, "POST", "/write", {"author": "alias", "op": "create", "type": "claim", "inv": i["inv"],
158
+ "fields": {"statement": f"The instrument reads {x}.", "kind": "finding",
159
+ "scope": "My reading of the instrument, taken once.",
160
+ "measurements": [{"result": i["rs1"], "key": "reading"}]}})
161
+ right, wrong = as_alias(R), as_alias(WRONG)
162
+ assert right[0] == 400 and "sealed from you" in right[1]["detail"]
163
+ assert norm(right, R) == norm(wrong, WRONG)
164
+ g, i = fixture() # with no host view, plain hypgraph takes the name at its word
165
+ with pytest.raises(Exception, match="link the evidence|refused"):
166
+ g.create(author="alias", type="claim", inv=i["inv"], fields={
167
+ "statement": f"The instrument reads {WRONG}.", "kind": "finding", "scope": "A reading, taken once.",
168
+ "measurements": [{"result": i["rs1"], "key": "reading"}]})
169
+
170
+
171
+ def test_the_mcp_seat_answers_a_right_guess_like_a_wrong_one(tmp_path):
172
+ import asyncio
173
+ fastmcp = pytest.importorskip("fastmcp")
174
+ from hypgraph.mcp import build
175
+
176
+ def seat(x):
177
+ g, i = fixture()
178
+ out = []
179
+
180
+ async def go():
181
+ async with fastmcp.Client(build(g, author="checker")) as c:
182
+ for name, args in (("show", {"node": str(x)}),
183
+ ("add", {"type": "result", "investigation": i["inv"],
184
+ "fields": {"summary": f"My reading is {x}.", "outcome": "positive",
185
+ "values": {"reading": x}},
186
+ "links": [{"rel": "supports", "to": "H1", "why": "the reading it predicts"}]})):
187
+ r = await c.call_tool(name, args, raise_on_error=False)
188
+ out.append((r.is_error, r.content[0].text))
189
+ asyncio.run(go())
190
+ return out
191
+ assert norm(seat(R), R) == norm(seat(WRONG), WRONG)
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes