hypgraph 0.7.6__tar.gz → 0.7.8__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 (100) hide show
  1. {hypgraph-0.7.6 → hypgraph-0.7.8}/CHANGELOG.md +21 -0
  2. {hypgraph-0.7.6 → hypgraph-0.7.8}/PKG-INFO +1 -1
  3. {hypgraph-0.7.6 → hypgraph-0.7.8}/docs/spec.md +1 -1
  4. {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/__init__.py +3 -2
  5. {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/cli.py +3 -0
  6. {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/graph.py +15 -0
  7. hypgraph-0.7.8/src/hypgraph/references.py +173 -0
  8. {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/schema.py +4 -2
  9. {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/server.py +40 -5
  10. hypgraph-0.7.8/tests/test_references.py +156 -0
  11. hypgraph-0.7.8/tests/test_server_hosts.py +65 -0
  12. {hypgraph-0.7.6 → hypgraph-0.7.8}/.gitignore +0 -0
  13. {hypgraph-0.7.6 → hypgraph-0.7.8}/AGENTS.md +0 -0
  14. {hypgraph-0.7.6 → hypgraph-0.7.8}/CITATION.cff +0 -0
  15. {hypgraph-0.7.6 → hypgraph-0.7.8}/LICENSE +0 -0
  16. {hypgraph-0.7.6 → hypgraph-0.7.8}/NOTICE +0 -0
  17. {hypgraph-0.7.6 → hypgraph-0.7.8}/README.md +0 -0
  18. {hypgraph-0.7.6 → hypgraph-0.7.8}/docs/PLAN.md +0 -0
  19. {hypgraph-0.7.6 → hypgraph-0.7.8}/docs/decisions/ADR-001-token-bound-authors.md +0 -0
  20. {hypgraph-0.7.6 → hypgraph-0.7.8}/docs/decisions/ADR-002-governance-profiles.md +0 -0
  21. {hypgraph-0.7.6 → hypgraph-0.7.8}/docs/profile-workflows-plan.md +0 -0
  22. {hypgraph-0.7.6 → hypgraph-0.7.8}/docs/review-2026-10-24.md +0 -0
  23. {hypgraph-0.7.6 → hypgraph-0.7.8}/docs/site-plan.md +0 -0
  24. {hypgraph-0.7.6 → hypgraph-0.7.8}/docs/ui-plan.md +0 -0
  25. {hypgraph-0.7.6 → hypgraph-0.7.8}/docs/workflows/README.md +0 -0
  26. {hypgraph-0.7.6 → hypgraph-0.7.8}/docs/workflows/knowledge.md +0 -0
  27. {hypgraph-0.7.6 → hypgraph-0.7.8}/docs/workflows/optimization.md +0 -0
  28. {hypgraph-0.7.6 → hypgraph-0.7.8}/docs/workflows/scientific.md +0 -0
  29. {hypgraph-0.7.6 → hypgraph-0.7.8}/docs/workflows/software.md +0 -0
  30. {hypgraph-0.7.6 → hypgraph-0.7.8}/docs/workflows/trading.md +0 -0
  31. {hypgraph-0.7.6 → hypgraph-0.7.8}/pyproject.toml +0 -0
  32. {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/__main__.py +0 -0
  33. {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/bundle.py +0 -0
  34. {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/checks.py +0 -0
  35. {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/coord.py +0 -0
  36. {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/demos/__init__.py +0 -0
  37. {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/demos/broad_street.py +0 -0
  38. {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/derive.py +0 -0
  39. {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/language.py +0 -0
  40. {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/mcp.py +0 -0
  41. {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/plan.py +0 -0
  42. {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/policy.py +0 -0
  43. {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/profiles.py +0 -0
  44. {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/refusals.py +0 -0
  45. {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/report_check.py +0 -0
  46. {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/skill/SKILL.md +0 -0
  47. {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/stats.py +0 -0
  48. {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/store.py +0 -0
  49. {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/ui/__init__.py +0 -0
  50. {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/ui/atlas.css +0 -0
  51. {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/ui/atlas.js +0 -0
  52. {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/ui/hypgraph.css +0 -0
  53. {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/ui/hypgraph.js +0 -0
  54. {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/ui/hypgraph.svg +0 -0
  55. {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/ui/index.html +0 -0
  56. {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/ui/vendor/LICENSES.md +0 -0
  57. {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/ui/vendor/cose-base.js +0 -0
  58. {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/ui/vendor/cytoscape-fcose.js +0 -0
  59. {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/ui/vendor/cytoscape.min.js +0 -0
  60. {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/ui/vendor/layout-base.js +0 -0
  61. {hypgraph-0.7.6 → hypgraph-0.7.8}/src/hypgraph/workflow.py +0 -0
  62. {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_accept_work_kind.py +0 -0
  63. {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_bound_support.py +0 -0
  64. {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_bundle_telemetry.py +0 -0
  65. {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_cancel_releases_lease.py +0 -0
  66. {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_cited_and_failed_support.py +0 -0
  67. {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_coord.py +0 -0
  68. {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_d10.py +0 -0
  69. {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_engine.py +0 -0
  70. {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_engine_fixes.py +0 -0
  71. {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_incremental.py +0 -0
  72. {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_link_translate.py +0 -0
  73. {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_log_and_check.py +0 -0
  74. {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_measure_questions.py +0 -0
  75. {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_opposed_answers.py +0 -0
  76. {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_option_lead.py +0 -0
  77. {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_order_detail.py +0 -0
  78. {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_profiles.py +0 -0
  79. {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_profiles_041.py +0 -0
  80. {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_questions_not_assertions.py +0 -0
  81. {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_refusal_friction.py +0 -0
  82. {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_refusal_results.py +0 -0
  83. {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_refusals.py +0 -0
  84. {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_relation_words.py +0 -0
  85. {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_report_answers.py +0 -0
  86. {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_rerunnable.py +0 -0
  87. {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_restates_scale.py +0 -0
  88. {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_server_auth.py +0 -0
  89. {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_shape_refusals.py +0 -0
  90. {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_stats_not_backtests.py +0 -0
  91. {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_stats_values.py +0 -0
  92. {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_surfaces.py +0 -0
  93. {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_telemetry_stream.py +0 -0
  94. {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_ui_app.py +0 -0
  95. {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_ui_engine.py +0 -0
  96. {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_uncorrected_effect.py +0 -0
  97. {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_unnamed_inputs.py +0 -0
  98. {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_versions_not_figures.py +0 -0
  99. {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_vocabulary_friction.py +0 -0
  100. {hypgraph-0.7.6 → hypgraph-0.7.8}/tests/test_workflows.py +0 -0
@@ -1,4 +1,25 @@
1
1
  # Changelog
2
+
3
+ ## 0.7.8 — 2026-10-29: pinned references between records
4
+
5
+ - **A record can cite a node of another record and prove what it read.** A `source` takes four more fields,
6
+ `record` (the cited record's URL), `node`, `version` and `hash` (sha256 of that version's canonical body, the
7
+ same hash the cited record's chain holds), all four or none (`SHAPE_REFERENCE` otherwise). `Graph.pin(node,
8
+ record_url)` / `GET /api/graph/pin/{id}?record=` build them for one of this record's nodes, with the node's own
9
+ words in `citation` and `says`, so the citing record reads without the cited one. `Graph.references(inv)` /
10
+ `GET /api/graph/references` list the sources here that pin other records, with who cites them.
11
+ `hypgraph.verify_reference(fields, fetch=)` reads the cited record's node route (HTTP, or a host's own reader)
12
+ and reports whether the pinned version still hashes to the pin, whether it was revised since, and whether it
13
+ was withdrawn. The citing record never trusts the other's status; it trusts the hash it pinned. A record stays
14
+ one trust boundary; a host that holds many (a mesh) derives the network from these pins.
15
+
16
+ ## 0.7.7 — 2026-10-29: `hypgraph serve` inside a container
17
+
18
+ - **`hypgraph serve` bound to `0.0.0.0` answered 421 to everyone.** Its Host check knew only the bind address,
19
+ which no browser sends: inside a container with a published port, every request arrives as
20
+ `Host: 127.0.0.1:<host port>` and was refused as a possible DNS rebinding. A wildcard bind now accepts the
21
+ loopback names (the port was never part of the check), and `--allow-host NAME` (repeatable; `allow_hosts=` on
22
+ `make_server` / `serve`) adds a LAN or service name. A wildcard bind still never means "any Host".
2
23
 
3
24
  ## 0.7.6 — 2026-10-29: the swarm as swimlanes, and the UI walked on a real record
4
25
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hypgraph
3
- Version: 0.7.6
3
+ Version: 0.7.8
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/
@@ -28,7 +28,7 @@ correction is a new row, never an edited one.
28
28
  | `run` | RUN | **kind**\*, job_id, name, state, code, inputs, outputs, args, config, seed, env, started, finished, exit_code, log_sha256, note | One execution: code, inputs and outputs hashed, seed, state. `kind=lab` only from an instrument. |
29
29
  | `result` | RS | **summary**\*, kind, outcome, values, expected, limitations, power, evidence_note, legacy | What a run (or an observation, or a citation) found: values, outcome, power. |
30
30
  | `artifact` | A | **sha256**\*, path, size, format | Exact bytes, by sha256 — data, code, a model, a figure. |
31
- | `source` | S | **citation**\*, doi, url, passage, retrieved, says | Outside literature or data: citation, DOI, the passage that says it. |
31
+ | `source` | S | **citation**\*, doi, url, passage, retrieved, says, kind, reliability, record, node, version, hash | Outside literature or data: citation, DOI, the passage that says it. Or another record's node, pinned: `record` (its URL), `node`, `version` and `hash` (sha256 of that version's canonical body) — all four or none. |
32
32
  | `review` | RV | **verdict**\*, **method**\*, **notes**\*, official | A verdict on one version of a claim, result, run, experiment or hypothesis. |
33
33
  | `decision` | D | **choice**\*, alternatives, **reasons**\*, revisit_if, meeting | A choice made on the evidence; flagged when what it was based on changes. |
34
34
  | `task` | T | **kanban_id**\*, title | A unit of work in your tracker, linked to what it works on. |
@@ -16,13 +16,14 @@ 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.6"
19
+ __version__ = "0.7.8"
20
20
 
21
21
  from .bundle import verify_bundle # noqa: E402
22
22
  from .graph import Author, Graph, GraphError, file_sha256 # noqa: E402
23
23
  from .policy import Policy # noqa: E402
24
+ from .references import verify_reference # noqa: E402
24
25
  from .schema import FORMAT, describe # noqa: E402
25
26
  from .store import verify_file # noqa: E402
26
27
 
27
- __all__ = ["Graph", "GraphError", "Policy", "Author", "verify_bundle", "verify_file", "file_sha256", "describe",
28
+ __all__ = ["Graph", "GraphError", "Policy", "Author", "verify_bundle", "verify_file", "file_sha256", "describe", "verify_reference",
28
29
  "FORMAT", "__version__"]
@@ -425,6 +425,7 @@ def cmd_serve(args) -> int:
425
425
  if tokens and not args.writable:
426
426
  print("note: --token only matters with --writable (reads are open)")
427
427
  serve(args.db, host=args.host, port=args.port, writable=args.writable, open_browser=not args.no_browser,
428
+ allow_hosts=tuple(args.allow_host or ()),
428
429
  tokens=tokens or None, telemetry=(args.telemetry or "auto") if not args.no_telemetry else None)
429
430
  return 0
430
431
 
@@ -537,6 +538,8 @@ def build_parser() -> argparse.ArgumentParser:
537
538
  s = cmd("serve", cmd_serve, "the HypGraph UI and JSON API on localhost")
538
539
  s.add_argument("--host", default="127.0.0.1")
539
540
  s.add_argument("--port", type=int, default=8765)
541
+ s.add_argument("--allow-host", action="append", metavar="NAME",
542
+ help="a Host header name to answer to besides loopback (a LAN or service name); repeatable")
540
543
  s.add_argument("--writable", action="store_true", help="also accept writes (POST /api/graph/write)")
541
544
  s.add_argument("--no-browser", action="store_true")
542
545
  s.add_argument("--telemetry", default="", metavar="FILE",
@@ -28,6 +28,7 @@ from typing import Any, Callable, Optional
28
28
 
29
29
  from . import profiles
30
30
  from . import derive as D
31
+ from . import references as R
31
32
  from . import refusals, store
32
33
  from .checks import claim_issues, value_issues
33
34
  from .policy import Policy
@@ -158,6 +159,12 @@ def clean_body(ntype: str, body: dict, *, partial_ok: bool = False) -> dict:
158
159
  if len(canon(v)) > n:
159
160
  raise _shape("SHAPE_TOO_LONG", f"{k} is too long", words=[k])
160
161
  out[k] = v
162
+ if ntype == "source":
163
+ try:
164
+ out = R.check_fields(out)
165
+ except ValueError as e:
166
+ raise _shape("SHAPE_REFERENCE", str(e), "pin another record's node with record, node, version and hash "
167
+ "(its /api/graph/pin/<id>?record=<url> gives them)", ["record", "node", "version", "hash"])
161
168
  return out
162
169
 
163
170
 
@@ -1225,6 +1232,14 @@ class Graph:
1225
1232
  out["versions"] = list(s.versions.get(nid, []))
1226
1233
  return out
1227
1234
 
1235
+ def pin(self, nid: str, record: str) -> dict:
1236
+ """Source fields another record can cite this node by: its version and hash, pinned (references.pin)."""
1237
+ return R.pin(self, nid, record)
1238
+
1239
+ def references(self, inv: str = "") -> list[dict]:
1240
+ """The sources here that pin nodes of other records, with who cites them (references.references)."""
1241
+ return R.references(self, inv)
1242
+
1228
1243
  def resolve(self, ref: str, inv: str = "") -> str:
1229
1244
  """A node id from an id or a display code (CL3, H2, X4 …), within an investigation when given."""
1230
1245
  ref = str(ref or "").strip()
@@ -0,0 +1,173 @@
1
+ """Pinned references: one record citing a node of another record, so that records can form a network without
2
+ sharing a chain.
3
+
4
+ A record is one trust boundary: one hash chain, one policy, one board. Another record's claim can still be a line
5
+ of support here — as a ``source`` node, which is what a cited outside work already is — provided the reference
6
+ says *which version* it read and *what that version hashed to*. The fields on ``source``:
7
+
8
+ record the served record's base URL (what its /api/graph hangs off), e.g. https://mesh/q/demo-a
9
+ node the node id there
10
+ version the version that was read
11
+ hash sha256 of that version's canonical body — the same hash the record's own chain holds for it
12
+
13
+ ``pin`` builds those fields for one of this record's nodes; ``verify_reference`` fetches the cited record and checks
14
+ them: the hash still matches (nothing was rewritten), whether the node has been revised since (the pinned version
15
+ stands as read, but there is a newer one), and whether it has been withdrawn. A host that holds many records (a
16
+ mesh) uses the same functions against its own copies and derives cross-record standing from them. This record never
17
+ trusts another record's status; it trusts the hash it pinned."""
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import re
22
+ import urllib.error
23
+ import urllib.parse
24
+ import urllib.request
25
+ from datetime import datetime, timezone
26
+ from typing import TYPE_CHECKING, Callable, Optional
27
+
28
+ from . import store
29
+
30
+ if TYPE_CHECKING:
31
+ from .graph import Graph
32
+
33
+ _HEX64 = re.compile(r"^[0-9a-f]{64}$")
34
+ _URL = re.compile(r"^https?://[^\s/?#]+[^\s]*$", re.I)
35
+ Fetch = Callable[[str], dict]
36
+
37
+
38
+ def body_hash(body: dict) -> str:
39
+ """The hash a record's chain holds for a version: sha256 over the canonical JSON of its body."""
40
+ return store.sha(store.canon(body))
41
+
42
+
43
+ def normalize_record(url: str) -> str:
44
+ """A record URL without a trailing slash, fragment or query; refuses anything that is not http(s)."""
45
+ u = str(url or "").strip()
46
+ if not _URL.match(u):
47
+ raise ValueError(f"record must be the cited record's http(s) URL, not {u!r}")
48
+ p = urllib.parse.urlsplit(u)
49
+ return urllib.parse.urlunsplit((p.scheme.lower(), p.netloc, p.path.rstrip("/"), "", ""))
50
+
51
+
52
+ def check_fields(body: dict) -> dict:
53
+ """Validate the reference part of a source body (after the generic field cleaning). All four fields or none;
54
+ version a positive integer; hash 64 hex characters; record an http(s) URL. Returns the body with the fields
55
+ normalised."""
56
+ keys = ("record", "node", "version", "hash")
57
+ present = [k for k in keys if body.get(k) not in (None, "", 0)]
58
+ if not present:
59
+ return body
60
+ missing = [k for k in keys if k not in present]
61
+ if missing:
62
+ raise ValueError("a reference to another record names all of record, node, version and hash; missing: "
63
+ + ", ".join(missing))
64
+ out = dict(body)
65
+ out["record"] = normalize_record(body["record"])
66
+ out["node"] = str(body["node"]).strip()
67
+ v = body["version"]
68
+ try:
69
+ fv = float(v)
70
+ except (TypeError, ValueError):
71
+ raise ValueError(f"version must be a positive whole number, not {v!r}")
72
+ if not fv.is_integer() or fv < 1:
73
+ raise ValueError(f"version must be a positive whole number, not {v!r}")
74
+ out["version"] = int(fv)
75
+ h = str(body["hash"]).strip().lower()
76
+ if not _HEX64.match(h):
77
+ raise ValueError("hash must be the 64-hex sha256 of the cited version's canonical body")
78
+ out["hash"] = h
79
+ return out
80
+
81
+
82
+ def is_reference(body: dict) -> bool:
83
+ return bool(body.get("record")) and bool(body.get("hash"))
84
+
85
+
86
+ def pin(g: "Graph", nid: str, record: str, *, now: str = "") -> dict:
87
+ """Source fields that pin ``nid`` of ``g`` as it stands now, for another record to cite. ``record`` is the URL
88
+ this record is served at. The citation and ``says`` carry the node's own words, so the citing record reads
89
+ without the cited one."""
90
+ s = g.snapshot()
91
+ nid = g.resolve(nid)
92
+ n = s.nodes[nid]
93
+ rec = normalize_record(record)
94
+ brief = g.brief(s, nid)
95
+ title = brief["title"] or n["type"]
96
+ when = now or datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
97
+ return {"citation": f"{brief['code']} ({n['type']}) in {rec}, version {n['v']}: {title}"[:1000],
98
+ "url": f"{rec}/#node={nid}", "kind": "primary", "retrieved": when,
99
+ "says": str(title)[:1500],
100
+ "record": rec, "node": nid, "version": int(n["v"]), "hash": body_hash(n["body"])}
101
+
102
+
103
+ def references(g: "Graph", inv: str = "") -> list[dict]:
104
+ """Every source in ``g`` that pins a node of another record, with who cites it here. What a host walks to build
105
+ the network between records."""
106
+ s = g.snapshot()
107
+ out = []
108
+ for nid, n in s.nodes.items():
109
+ if n["type"] != "source" or not is_reference(n["body"]) or (inv and n["inv"] != inv):
110
+ continue
111
+ b = n["body"]
112
+ out.append({"id": nid, "code": n["code"], "inv": n["inv"], "record": b["record"], "node": b["node"],
113
+ "version": b["version"], "hash": b["hash"], "lifecycle": g.state(nid)["lifecycle"],
114
+ "cited_by": sorted({e["src"] for e in s.inn.get(nid, []) if e["rel"] == "cites"})})
115
+ out.sort(key=lambda r: (r["record"], r["node"], r["id"]))
116
+ return out
117
+
118
+
119
+ def _http_fetch(timeout: float) -> Fetch:
120
+ def fetch(url: str) -> dict:
121
+ req = urllib.request.Request(url, headers={"Accept": "application/json"})
122
+ with urllib.request.urlopen(req, timeout=timeout) as r:
123
+ return json.load(r)
124
+ return fetch
125
+
126
+
127
+ def verify_reference(fields: dict, *, fetch: Optional[Fetch] = None, timeout: float = 10.0) -> dict:
128
+ """Check a pinned reference against the record it points at.
129
+
130
+ ``fetch(url) -> dict`` answers ``GET <record>/api/graph/node/<node>?history=1``; the default is HTTP. A host
131
+ that holds the cited record passes its own reader. Returns::
132
+
133
+ {ok, problem, record, node, version, current_version, revised, withdrawn, lifecycle}
134
+
135
+ ``ok`` means the pinned version exists there and its body hashes to the pinned hash: what this record read is
136
+ what that record holds. ``revised`` (a newer version exists) and ``withdrawn`` are reported beside it — the
137
+ pin still stands as read, but the citing record's host decides what that means for its standing."""
138
+ try:
139
+ f = check_fields({k: fields.get(k) for k in ("record", "node", "version", "hash")})
140
+ except ValueError as e:
141
+ return {"ok": False, "problem": str(e)}
142
+ if not is_reference(f):
143
+ return {"ok": False, "problem": "not a reference: no record and hash pinned"}
144
+ url = f"{f['record']}/api/graph/node/{urllib.parse.quote(f['node'], safe='')}?history=1"
145
+ out = {"ok": False, "record": f["record"], "node": f["node"], "version": f["version"], "url": url}
146
+ try:
147
+ doc = (fetch or _http_fetch(timeout))(url)
148
+ except urllib.error.HTTPError as e:
149
+ return {**out, "problem": f"the record answered {e.code} for {f['node']}"}
150
+ except Exception as e: # unreachable, bad JSON, timeout: the reference is unverified, not wrong
151
+ return {**out, "problem": f"could not read the record: {type(e).__name__}"}
152
+ if not isinstance(doc, dict) or "versions" not in doc:
153
+ return {**out, "problem": "the record's answer carries no versions (is it a HypGraph node route with history=1?)"}
154
+ versions = {int(v.get("v", 0)): v for v in doc.get("versions") or [] if isinstance(v, dict)}
155
+ cur = max(versions) if versions else int(doc.get("v") or 0)
156
+ out |= {"current_version": cur, "revised": cur > f["version"], "withdrawn": bool(doc.get("withdrawn")),
157
+ "lifecycle": str((doc.get("state") or {}).get("lifecycle") or ("withdrawn" if doc.get("withdrawn") else ""))}
158
+ ver = versions.get(f["version"])
159
+ if ver is None:
160
+ return {**out, "problem": f"version {f['version']} does not exist there (it has {cur})"}
161
+ body = ver.get("body")
162
+ if isinstance(body, str):
163
+ try:
164
+ body = json.loads(body)
165
+ except json.JSONDecodeError:
166
+ return {**out, "problem": "the version's body is not JSON"}
167
+ got = body_hash(body) if isinstance(body, dict) else ""
168
+ if got != f["hash"]:
169
+ return {**out, "problem": f"version {f['version']} hashes to {got[:16]}…, not the pinned {f['hash'][:16]}…"}
170
+ return {**out, "ok": True}
171
+
172
+
173
+ __all__ = ["pin", "references", "verify_reference", "check_fields", "is_reference", "body_hash", "normalize_record"]
@@ -65,7 +65,9 @@ TYPES: dict[str, dict] = {
65
65
  "source": {"prefix": "S", "fields": {
66
66
  "citation": _f("text", 1000, True), "doi": _f("str", 200), "url": _f("str", 500), "passage": _f("text", 3000),
67
67
  "retrieved": _f("str", 40), "says": _f("text", 1500), "kind": _f("enum:primary|secondary|tertiary"),
68
- "reliability": _f("text", 1000)}},
68
+ "reliability": _f("text", 1000),
69
+ # a pinned reference to a node of another record (references.py): all four or none
70
+ "record": _f("str", 500), "node": _f("str", 80), "version": _f("num"), "hash": _f("str", 64)}},
69
71
  "review": {"prefix": "RV", "fields": {
70
72
  "verdict": _f("enum:" + "|".join(sorted(VERDICTS)), req=True), "method": _f("text", 1500, True),
71
73
  "notes": _f("text", 4000, True), "official": _f("bool")}},
@@ -179,7 +181,7 @@ ABOUT = {
179
181
  "run": "One execution: code, inputs and outputs hashed, seed, state. `kind=lab` only from an instrument.",
180
182
  "result": "What a run (or an observation, or a citation) found: values, outcome, power.",
181
183
  "artifact": "Exact bytes, by sha256 — data, code, a model, a figure.",
182
- "source": "Outside literature or data: citation, DOI, the passage that says it.",
184
+ "source": "Outside literature or data: citation, DOI, the passage that says it — or another record's node, pinned by version and hash.",
183
185
  "review": "A verdict on one version of a claim, result, run, experiment or hypothesis.",
184
186
  "decision": "A choice made on the evidence; flagged when what it was based on changes.",
185
187
  "task": "A unit of work linked to what it works on — or a work order: objective, assignee, done-when "
@@ -5,6 +5,8 @@
5
5
  GET /api/graph/investigations {investigations, stats}
6
6
  GET /api/graph/subgraph?inv=&types= nodes (with standing) and live edges
7
7
  GET /api/graph/node/{id}?history=1 one node, its links, its versions
8
+ GET /api/graph/pin/{id}?record=URL source fields another record cites this node by (version + hash pinned)
9
+ GET /api/graph/references?inv= the sources here that pin nodes of other records
8
10
  GET /api/graph/why/{id} what it rests on /impact/{id} what rests on it
9
11
  GET /api/graph/neighborhood/{id}?depth=
10
12
  GET /api/graph/search?text=&type=&inv=&limit=
@@ -160,6 +162,15 @@ def dispatch(g: Graph, method: str, path: str, query: dict | None = None, body:
160
162
  return 200, g.subgraph(inv, types=types)
161
163
  if head == "node" and arg:
162
164
  return 200, g.get(arg, history=q.get("history") in ("1", "true"))
165
+ if head == "pin" and arg:
166
+ if not q.get("record"):
167
+ return 400, {"detail": "record=<the URL this record is served at> is required"}
168
+ try:
169
+ return 200, g.pin(arg, q["record"])
170
+ except ValueError as e:
171
+ return 400, {"detail": str(e)}
172
+ if head == "references":
173
+ return 200, {"references": g.references(inv)}
163
174
  if head == "why" and arg:
164
175
  return 200, g.why(arg)
165
176
  if head == "impact" and arg:
@@ -236,6 +247,28 @@ def read_telemetry(path: Path | None, after: int = 0, inv: str = "", limit: int
236
247
  return out, after + end + 1
237
248
 
238
249
 
250
+ LOOPBACK_NAMES = frozenset({"localhost", "127.0.0.1", "::1"})
251
+ WILDCARD_BINDS = frozenset({"", "0.0.0.0", "::", "*"})
252
+
253
+
254
+ def accepted_hosts(host: str, allow_hosts: tuple[str, ...] | list[str] = ()) -> set[str]:
255
+ """The names a request's Host header may carry (port ignored); anything else is 421.
256
+
257
+ Bound to a loopback name, the server answers to every loopback name. Bound to a wildcard (``0.0.0.0``,
258
+ ``::``) — as inside a container whose port is published — it answers to the loopback names too, since the
259
+ browser reaches the published port as ``127.0.0.1:<host port>``; the bind address itself is never a name a
260
+ browser sends. ``allow_hosts`` adds names: a LAN hostname, a service name. The check is DNS-rebinding
261
+ defence (a page on another origin can't make the browser talk to this server by name), so a wildcard bind
262
+ never means "any Host"."""
263
+ names = {h.strip().lower() for h in allow_hosts if h and h.strip()}
264
+ h = (host or "").strip().lower()
265
+ if h in LOOPBACK_NAMES or h in WILDCARD_BINDS:
266
+ names |= LOOPBACK_NAMES
267
+ if h and h not in WILDCARD_BINDS:
268
+ names.add(h)
269
+ return names
270
+
271
+
239
272
  def make_handler(g: Graph, *, writable: bool, hosts: set[str], root: Path,
240
273
  tokens: dict[str, frozenset] | None = None, telemetry: Path | None = None):
241
274
  class Handler(BaseHTTPRequestHandler):
@@ -389,7 +422,8 @@ def make_handler(g: Graph, *, writable: bool, hosts: set[str], root: Path,
389
422
 
390
423
 
391
424
  def make_server(graph: Graph | str, host: str = "127.0.0.1", port: int = 8765, *, writable: bool = False,
392
- tokens: dict[str, frozenset] | None = None, telemetry: str | Path | None = "auto"):
425
+ tokens: dict[str, frozenset] | None = None, telemetry: str | Path | None = "auto",
426
+ allow_hosts: tuple[str, ...] | list[str] = ()):
393
427
  """``telemetry``: the swarm's sidecar to serve and stream — "auto" is ``<db>.telemetry.jsonl`` beside the record
394
428
  (it may appear later: a run started after the server is picked up), a path, or None for none."""
395
429
  g = graph if isinstance(graph, Graph) else Graph(graph)
@@ -399,19 +433,20 @@ def make_server(graph: Graph | str, host: str = "127.0.0.1", port: int = 8765, *
399
433
  tel = Path(g.path + ".telemetry.jsonl")
400
434
  elif telemetry:
401
435
  tel = Path(telemetry)
402
- hosts = {host.lower(), "localhost", "127.0.0.1", "::1"} if host in ("127.0.0.1", "localhost", "::1") else \
403
- {host.lower()}
436
+ hosts = accepted_hosts(host, allow_hosts)
404
437
  root = static_dir().resolve()
405
438
  return ThreadingHTTPServer((host, port), make_handler(g, writable=writable, hosts=hosts, root=root, tokens=tokens,
406
439
  telemetry=tel))
407
440
 
408
441
 
409
442
  def serve(db: str, host: str = "127.0.0.1", port: int = 8765, *, writable: bool = False,
410
- open_browser: bool = True, tokens: dict[str, frozenset] | None = None, telemetry: str | None = "auto") -> None:
443
+ open_browser: bool = True, tokens: dict[str, frozenset] | None = None, telemetry: str | None = "auto",
444
+ allow_hosts: tuple[str, ...] | list[str] = ()) -> None:
411
445
  if not Path(db).exists():
412
446
  print(f"{db} doesn't exist yet — `hypgraph demo` builds one to explore, `hypgraph init` starts your own.")
413
447
  return
414
- srv = make_server(db, host, port, writable=writable, tokens=tokens, telemetry=telemetry)
448
+ srv = make_server(db, host, port, writable=writable, tokens=tokens, telemetry=telemetry,
449
+ allow_hosts=allow_hosts)
415
450
  url = f"http://{host}:{srv.server_address[1]}/"
416
451
  auth = f", {len(tokens)} write token(s)" if tokens else (", writes unauthenticated" if writable else "")
417
452
  tel = telemetry if telemetry not in ("auto", None) else (db + ".telemetry.jsonl" if telemetry == "auto" else "")
@@ -0,0 +1,156 @@
1
+ """Pinned references between records: record B cites a node of record A as a source that names the version it read
2
+ and that version's hash. The pin is built by A (``pin``), checked against A (``verify_reference``), and listed by B
3
+ (``references``). A is read the way a host reads it: through the API's node route with history."""
4
+ import json
5
+ import threading
6
+ import urllib.request
7
+
8
+ import pytest
9
+
10
+ from hypgraph import Graph, GraphError, Policy, verify_reference
11
+ from hypgraph import derive as D
12
+ from hypgraph.references import body_hash, check_fields, normalize_record
13
+ from hypgraph.server import dispatch, make_server
14
+
15
+ RECORD_A = "https://mesh.example.org/q/demo-a"
16
+
17
+
18
+ @pytest.fixture
19
+ def a():
20
+ """Record A: one checked claim with a bound measurement."""
21
+ g = Graph(policy=Policy(reviewers={"critic"}, instrument="lab"))
22
+ me = g.author("ada")
23
+ inv = me.investigation("Treatment effect", "Does the treatment group score higher?")["id"]
24
+ run = me.add("run", inv, kind="agent", name="scores", state="succeeded")["id"]
25
+ r = me.add("result", inv, kind="computed", outcome="positive", summary="The treatment group scored higher in 41 of 50 pairs.",
26
+ values={"higher": {"k": 41, "n": 50}}, links=[{"rel": "produced", "from": run}])["id"]
27
+ c = me.add("claim", inv, statement="The treatment group scores higher in 41 of 50 matched pairs.",
28
+ scope="The 50 matched pairs in the pilot data.",
29
+ measurements=[{"name": "higher", "result": r, "key": "higher", "k": 41, "n": 50}],
30
+ links=[{"rel": "supports", "from": r, "why": "the measurement the claim states"}])["id"]
31
+ return g, inv, c
32
+
33
+
34
+ def reader_of(g):
35
+ """A fetch that reads ``g`` the way a host reads a served record: through the node route with history."""
36
+ def fetch(url):
37
+ assert url.startswith(RECORD_A + "/api/graph/"), url
38
+ path, _, query = url[len(RECORD_A + "/api/graph"):].partition("?")
39
+ q = {k: [v] for k, v in (kv.split("=", 1) for kv in query.split("&") if kv)}
40
+ status, obj = dispatch(g, "GET", path, query=q)
41
+ assert status == 200, (status, obj)
42
+ return json.loads(json.dumps(obj, default=str))
43
+ return fetch
44
+
45
+
46
+ def test_pin_carries_version_hash_and_the_nodes_own_words(a):
47
+ g, inv, c = a
48
+ p = g.pin("CL1", RECORD_A + "/") # a display code resolves; the trailing slash goes
49
+ assert p["record"] == RECORD_A and p["node"] == c and p["version"] == 1
50
+ assert p["hash"] == body_hash(g.get(c)["body"]) and len(p["hash"]) == 64
51
+ assert "41 of 50" in p["says"] and p["citation"].startswith("CL1 (claim) in " + RECORD_A)
52
+ assert p["url"] == f"{RECORD_A}/#node={c}" and p["kind"] == "primary" and p["retrieved"]
53
+
54
+
55
+ def test_b_cites_a_and_the_reference_is_a_line_of_support(a):
56
+ g_a, _, c = a
57
+ g_b = Graph()
58
+ bob = g_b.author("bob")
59
+ inv = bob.investigation("Follow-up", "Does the effect hold at scale?")["id"]
60
+ s = bob.add("source", inv, **g_a.pin(c, RECORD_A))
61
+ assert s["body"]["version"] == 1 and isinstance(s["body"]["version"], int)
62
+ cl = bob.add("claim", inv, statement="A pilot found the treatment group higher in 41 of 50 matched pairs.",
63
+ scope="The pilot's 50 matched pairs, as recorded in demo-a.", links=[{"rel": "cites", "to": s["id"]}])
64
+ st = D.state(g_b.snapshot(), cl["id"])
65
+ assert st["independent_lines"] == 1 and st["evidence"] == "external"
66
+ refs = g_b.references()
67
+ assert len(refs) == 1 and refs[0]["record"] == RECORD_A and refs[0]["node"] == c and refs[0]["cited_by"] == [cl["id"]]
68
+ assert g_b.references("inv-nothing") == []
69
+
70
+
71
+ def test_verify_against_the_cited_record(a):
72
+ g_a, _, c = a
73
+ fields = g_a.pin(c, RECORD_A)
74
+ v = verify_reference(fields, fetch=reader_of(g_a))
75
+ assert v["ok"] and v["current_version"] == 1 and not v["revised"] and not v["withdrawn"] and v["lifecycle"] == "active"
76
+
77
+ g_a.author("ada").revise(c, "the scope was too narrow", scope="The 50 matched pairs in the pilot data, both sites.")
78
+ v = verify_reference(fields, fetch=reader_of(g_a))
79
+ assert v["ok"] and v["revised"] and v["current_version"] == 2 # what was read still stands as read
80
+
81
+ g_a.author("ada").withdraw(c, "the pairs were matched after the scores were seen")
82
+ v = verify_reference(fields, fetch=reader_of(g_a))
83
+ assert v["ok"] and v["withdrawn"] and v["lifecycle"] == "withdrawn"
84
+
85
+
86
+ def test_a_rewritten_body_or_a_wrong_pin_fails(a):
87
+ g_a, _, c = a
88
+ fields = g_a.pin(c, RECORD_A)
89
+ bad = {**fields, "hash": "0" * 64}
90
+ v = verify_reference(bad, fetch=reader_of(g_a))
91
+ assert not v["ok"] and "hashes to" in v["problem"]
92
+ v = verify_reference({**fields, "version": 7}, fetch=reader_of(g_a))
93
+ assert not v["ok"] and "version 7 does not exist" in v["problem"]
94
+ v = verify_reference({**fields, "node": "n-nowhere"}, fetch=reader_of(g_a))
95
+ assert not v["ok"] and "problem" in v
96
+
97
+ def down(url):
98
+ raise OSError("connection refused")
99
+ v = verify_reference(fields, fetch=down)
100
+ assert not v["ok"] and v["problem"].startswith("could not read the record")
101
+
102
+
103
+ def test_the_schema_refuses_half_a_reference(a):
104
+ g_a, _, c = a
105
+ g_b = Graph()
106
+ bob = g_b.author("bob")
107
+ inv = bob.investigation("Follow-up", "Does the effect hold at scale?")["id"]
108
+ p = g_a.pin(c, RECORD_A)
109
+ with pytest.raises(GraphError) as e:
110
+ bob.add("source", inv, **{k: v for k, v in p.items() if k != "hash"})
111
+ assert "missing: hash" in str(e.value)
112
+ with pytest.raises(GraphError) as e:
113
+ bob.add("source", inv, **{**p, "hash": "nope"})
114
+ assert "64-hex" in str(e.value)
115
+ with pytest.raises(GraphError) as e:
116
+ bob.add("source", inv, **{**p, "version": 1.5})
117
+ assert "whole number" in str(e.value)
118
+ with pytest.raises(GraphError):
119
+ bob.add("source", inv, **{**p, "record": "ftp://mesh/q/a"})
120
+ plain = bob.add("source", inv, citation="A plain outside source, cited by its DOI.", doi="10.1000/xyz")
121
+ assert "record" not in plain["body"] and g_b.references() == []
122
+
123
+
124
+ def test_check_fields_and_normalize_record():
125
+ assert normalize_record("HTTPS://Mesh.example.org/q/a/?x=1#node=n") == "https://Mesh.example.org/q/a"
126
+ assert check_fields({"citation": "x"}) == {"citation": "x"}
127
+ out = check_fields({"record": "https://m/q/a", "node": " n1 ", "version": 2.0, "hash": "A" * 64})
128
+ assert out["version"] == 2 and out["node"] == "n1" and out["hash"] == "a" * 64
129
+
130
+
131
+ def test_the_routes_pin_and_list(a, tmp_path):
132
+ g_a, inv, c = a
133
+ srv = make_server(g_a, "127.0.0.1", 0)
134
+ threading.Thread(target=srv.serve_forever, daemon=True).start()
135
+ base = f"http://127.0.0.1:{srv.server_address[1]}"
136
+ try:
137
+ with urllib.request.urlopen(f"{base}/api/graph/pin/{c}?record={RECORD_A}") as r:
138
+ p = json.load(r)
139
+ assert p["hash"] == body_hash(g_a.get(c)["body"]) and p["version"] == 1
140
+ try:
141
+ urllib.request.urlopen(f"{base}/api/graph/pin/{c}")
142
+ assert False, "record= is required"
143
+ except urllib.error.HTTPError as e:
144
+ assert e.code == 400
145
+ g_b = Graph(str(tmp_path / "b.hypgraph"))
146
+ bob = g_b.author("bob")
147
+ inv_b = bob.investigation("Follow-up", "Does the effect hold at scale?")["id"]
148
+ bob.add("source", inv_b, **p)
149
+ status, obj = dispatch(g_b, "GET", "/references", query={"inv": [inv_b]})
150
+ assert status == 200 and [x["node"] for x in obj["references"]] == [c]
151
+ # verified over real HTTP, the way a mesh or another host does it
152
+ v = verify_reference({**p, "record": base})
153
+ assert v["ok"] and not v["revised"]
154
+ finally:
155
+ srv.shutdown()
156
+ g_b.close()
@@ -0,0 +1,65 @@
1
+ """Which Host headers `serve` answers to. Bound to loopback: loopback names. Bound to a wildcard (a container
2
+ with a published port): loopback names too — the browser reaches the published port as 127.0.0.1:<host port>, and
3
+ the bind address is never a name a browser sends. Anything else is 421, unless --allow-host names it."""
4
+ import threading
5
+ import urllib.error
6
+ import urllib.request
7
+
8
+ import pytest
9
+
10
+ from hypgraph import Graph
11
+ from hypgraph.server import accepted_hosts, make_server
12
+
13
+
14
+ def _get(url, host_header):
15
+ req = urllib.request.Request(f"{url}/api/graph/investigations", headers={"Host": host_header})
16
+ try:
17
+ with urllib.request.urlopen(req) as r:
18
+ return r.status
19
+ except urllib.error.HTTPError as e:
20
+ return e.code
21
+
22
+
23
+ @pytest.fixture
24
+ def serve_on(tmp_path):
25
+ servers = []
26
+
27
+ def start(host, **kw):
28
+ g = Graph(str(tmp_path / f"{len(servers)}.hypgraph"))
29
+ srv = make_server(g, host, port=0, **kw)
30
+ threading.Thread(target=srv.serve_forever, daemon=True).start()
31
+ servers.append(srv)
32
+ return f"http://127.0.0.1:{srv.server_address[1]}"
33
+
34
+ yield start
35
+ for srv in servers:
36
+ srv.shutdown()
37
+
38
+
39
+ def test_accepted_hosts_rules():
40
+ assert accepted_hosts("127.0.0.1") == {"127.0.0.1", "localhost", "::1"}
41
+ assert accepted_hosts("0.0.0.0") == {"127.0.0.1", "localhost", "::1"} # the bind address is not a name
42
+ assert accepted_hosts("::") == {"127.0.0.1", "localhost", "::1"}
43
+ assert accepted_hosts("lab.local") == {"lab.local"} # a real name: that name only
44
+ assert accepted_hosts("0.0.0.0", ["Swarm.Internal", " "]) == {"127.0.0.1", "localhost", "::1", "swarm.internal"}
45
+
46
+
47
+ def test_loopback_bind_answers_loopback_names_and_refuses_others(serve_on):
48
+ url = serve_on("127.0.0.1")
49
+ assert _get(url, "127.0.0.1:9") == 200 # the port is not part of the check
50
+ assert _get(url, "localhost") == 200
51
+ assert _get(url, "evil.example") == 421
52
+
53
+
54
+ def test_wildcard_bind_answers_loopback_through_a_published_port(serve_on):
55
+ url = serve_on("0.0.0.0")
56
+ assert _get(url, "127.0.0.1:8100") == 200 # the host side of a container's published port
57
+ assert _get(url, "localhost:8100") == 200
58
+ assert _get(url, "0.0.0.0:8000") == 421 # no browser sends the bind address; a rebinding page might
59
+ assert _get(url, "evil.example") == 421
60
+
61
+
62
+ def test_allow_host_adds_a_name(serve_on):
63
+ url = serve_on("0.0.0.0", allow_hosts=["swarm.internal"])
64
+ assert _get(url, "swarm.internal:8000") == 200
65
+ assert _get(url, "other.internal") == 421
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
File without changes
File without changes