hypgraph 0.7.12__tar.gz → 0.7.13__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 (112) hide show
  1. {hypgraph-0.7.12 → hypgraph-0.7.13}/CHANGELOG.md +30 -0
  2. {hypgraph-0.7.12 → hypgraph-0.7.13}/NOTICE +1 -1
  3. {hypgraph-0.7.12 → hypgraph-0.7.13}/PKG-INFO +5 -1
  4. {hypgraph-0.7.12 → hypgraph-0.7.13}/README.md +4 -0
  5. {hypgraph-0.7.12 → hypgraph-0.7.13}/docs/PLAN.md +1 -1
  6. {hypgraph-0.7.12 → hypgraph-0.7.13}/docs/RELEASE-READINESS.md +12 -0
  7. {hypgraph-0.7.12 → hypgraph-0.7.13}/docs/spec.md +38 -14
  8. {hypgraph-0.7.12 → hypgraph-0.7.13}/docs/verification.md +19 -4
  9. hypgraph-0.7.13/examples/frameworks/README.md +70 -0
  10. {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/__init__.py +1 -1
  11. {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/bundle.py +147 -50
  12. {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/cli.py +13 -2
  13. {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/coord.py +7 -4
  14. {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/graph.py +31 -15
  15. hypgraph-0.7.13/src/hypgraph/store.py +288 -0
  16. {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/ui/hypgraph.js +192 -35
  17. hypgraph-0.7.13/tests/test_framework_examples.py +50 -0
  18. {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_surfaces.py +1 -1
  19. hypgraph-0.7.13/tests/test_verify_complete.py +291 -0
  20. hypgraph-0.7.12/src/hypgraph/store.py +0 -139
  21. {hypgraph-0.7.12 → hypgraph-0.7.13}/.gitignore +0 -0
  22. {hypgraph-0.7.12 → hypgraph-0.7.13}/AGENTS.md +0 -0
  23. {hypgraph-0.7.12 → hypgraph-0.7.13}/CITATION.cff +0 -0
  24. {hypgraph-0.7.12 → hypgraph-0.7.13}/DISCLAIMER.md +0 -0
  25. {hypgraph-0.7.12 → hypgraph-0.7.13}/LICENSE +0 -0
  26. {hypgraph-0.7.12 → hypgraph-0.7.13}/docs/decisions/ADR-001-token-bound-authors.md +0 -0
  27. {hypgraph-0.7.12 → hypgraph-0.7.13}/docs/decisions/ADR-002-governance-profiles.md +0 -0
  28. {hypgraph-0.7.12 → hypgraph-0.7.13}/docs/profile-workflows-plan.md +0 -0
  29. {hypgraph-0.7.12 → hypgraph-0.7.13}/docs/review-2026-10-24.md +0 -0
  30. {hypgraph-0.7.12 → hypgraph-0.7.13}/docs/site-plan.md +0 -0
  31. {hypgraph-0.7.12 → hypgraph-0.7.13}/docs/ui-plan.md +0 -0
  32. {hypgraph-0.7.12 → hypgraph-0.7.13}/docs/workflows/README.md +0 -0
  33. {hypgraph-0.7.12 → hypgraph-0.7.13}/docs/workflows/knowledge.md +0 -0
  34. {hypgraph-0.7.12 → hypgraph-0.7.13}/docs/workflows/optimization.md +0 -0
  35. {hypgraph-0.7.12 → hypgraph-0.7.13}/docs/workflows/scientific.md +0 -0
  36. {hypgraph-0.7.12 → hypgraph-0.7.13}/docs/workflows/software.md +0 -0
  37. {hypgraph-0.7.12 → hypgraph-0.7.13}/docs/workflows/trading.md +0 -0
  38. {hypgraph-0.7.12 → hypgraph-0.7.13}/pyproject.toml +0 -0
  39. {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/__main__.py +0 -0
  40. {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/blind.py +0 -0
  41. {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/checks.py +0 -0
  42. {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/demos/__init__.py +0 -0
  43. {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/demos/broad_street.py +0 -0
  44. {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/derive.py +0 -0
  45. {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/language.py +0 -0
  46. {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/mcp.py +0 -0
  47. {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/plan.py +0 -0
  48. {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/policy.py +0 -0
  49. {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/profiles.py +0 -0
  50. {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/references.py +0 -0
  51. {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/refusals.py +0 -0
  52. {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/report_check.py +0 -0
  53. {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/schema.py +0 -0
  54. {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/server.py +0 -0
  55. {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/skill/SKILL.md +0 -0
  56. {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/stats.py +0 -0
  57. {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/ui/__init__.py +0 -0
  58. {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/ui/atlas.css +0 -0
  59. {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/ui/atlas.js +0 -0
  60. {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/ui/hypgraph.css +0 -0
  61. {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/ui/hypgraph.svg +0 -0
  62. {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/ui/index.html +0 -0
  63. {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/ui/vendor/LICENSES.md +0 -0
  64. {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/ui/vendor/cose-base.js +0 -0
  65. {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/ui/vendor/cytoscape-fcose.js +0 -0
  66. {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/ui/vendor/cytoscape.min.js +0 -0
  67. {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/ui/vendor/layout-base.js +0 -0
  68. {hypgraph-0.7.12 → hypgraph-0.7.13}/src/hypgraph/workflow.py +0 -0
  69. {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_accept_work_kind.py +0 -0
  70. {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_anchoring.py +0 -0
  71. {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_blind.py +0 -0
  72. {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_blind_oracles.py +0 -0
  73. {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_bound_support.py +0 -0
  74. {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_bundle_telemetry.py +0 -0
  75. {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_cancel_releases_lease.py +0 -0
  76. {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_cited_and_failed_support.py +0 -0
  77. {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_coord.py +0 -0
  78. {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_d10.py +0 -0
  79. {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_engine.py +0 -0
  80. {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_engine_fixes.py +0 -0
  81. {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_incremental.py +0 -0
  82. {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_link_translate.py +0 -0
  83. {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_log_and_check.py +0 -0
  84. {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_measure_questions.py +0 -0
  85. {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_opposed_answers.py +0 -0
  86. {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_option_lead.py +0 -0
  87. {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_order_detail.py +0 -0
  88. {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_profiles.py +0 -0
  89. {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_profiles_041.py +0 -0
  90. {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_questions_not_assertions.py +0 -0
  91. {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_references.py +0 -0
  92. {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_refusal_friction.py +0 -0
  93. {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_refusal_results.py +0 -0
  94. {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_refusals.py +0 -0
  95. {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_relation_words.py +0 -0
  96. {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_report_answers.py +0 -0
  97. {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_rerunnable.py +0 -0
  98. {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_restates_scale.py +0 -0
  99. {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_server_auth.py +0 -0
  100. {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_server_hosts.py +0 -0
  101. {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_shape_refusals.py +0 -0
  102. {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_stats_not_backtests.py +0 -0
  103. {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_stats_values.py +0 -0
  104. {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_telemetry_stream.py +0 -0
  105. {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_trust_boundary.py +0 -0
  106. {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_ui_app.py +0 -0
  107. {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_ui_engine.py +0 -0
  108. {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_uncorrected_effect.py +0 -0
  109. {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_unnamed_inputs.py +0 -0
  110. {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_versions_not_figures.py +0 -0
  111. {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_vocabulary_friction.py +0 -0
  112. {hypgraph-0.7.12 → hypgraph-0.7.13}/tests/test_workflows.py +0 -0
@@ -1,5 +1,35 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.13 — 2026-10-31: verification checks every row, and examples for agent frameworks
4
+
5
+ - **Verification checks every row, both ways.** A pre-launch audit found that `verify_bundle`, `verify_file` and
6
+ the browser's `verifyBundle` only checked that each row present matched an event. A bundle or graph file with a
7
+ withdrawal, a version, a link, a lease or a whole table removed still verified, so a withdrawn claim could come
8
+ back to life through `load()`; changed authors, times, link ends, reasons and duplicated rows passed too. Now every
9
+ row must be written by an event and match it field by field, every row an event wrote must be present, and no key
10
+ may appear twice, in Python, SQLite and the browser alike (`tests/test_verify_complete.py`, which also checks the
11
+ browser verifier agrees on every case).
12
+ - **Events hash every field of the row they write.** New events carry `reason_sha` (create, revise), `why_sha`
13
+ (link, retract, withdraw), `note_sha` (leases), a review's `code` and `inv`, and `as` when the importer credits
14
+ another author. Only the importer may. Older records still verify: what their events don't cover is reported as
15
+ `unchecked`, field by field, instead of passing silently.
16
+ - **Derived views are checked; standing is labelled.** A bundle's `derived` views must agree with the verified rows
17
+ on every fact the rows fix (nodes, live links, titles, authors, times, withdrawn or not); an edited view fails.
18
+ Standing can't be checked from the rows, so the result says `standing: {checked: false}`; `hypgraph verify
19
+ --standing` re-derives it with the same engine version and fails on a difference. The explorer shows a separate
20
+ "standing as exported" badge beside the verified one, and a banner over a record that doesn't verify.
21
+ - **The verify page takes a published head** and lists the fields an older record doesn't cover.
22
+
23
+ - **A LangChain example** (`examples/frameworks/langchain_agent.py`): `create_agent` with the record's tools from a
24
+ `MultiServerMCPClient` session, the same story as the others. It runs in the `frameworks` CI job.
25
+ - **Examples for agent frameworks** (`examples/frameworks/`): LangGraph, PydanticAI, CrewAI, the OpenAI Agents SDK
26
+ and the Claude Agent SDK, each connected to a record through `hypgraph mcp`. All five tell the same story: the
27
+ agent claims a 1.2-point difference is significant, the record refuses with the arithmetic, and the agent writes
28
+ the honest claim, which is accepted. They run offline with a scripted model through each framework's own
29
+ tool-calling path, or with `--real` on a real model. The README covers the one setting each framework needs so a
30
+ refusal reaches the model instead of ending the run. A new CI job runs them all (the Claude Agent SDK, which has no
31
+ scripted mode, gets a wiring check).
32
+
3
33
  ## 0.7.12 — 2026-10-31: the trust boundary, verification and docs, reviewed before launch
4
34
 
5
35
  A pre-launch review of who may write as whom, through every interface. Each fix has a test that failed first
@@ -1,5 +1,5 @@
1
1
  HypGraph
2
- Copyright 2026 Nicolas Houghtaling and VoodooMods LLC
2
+ Copyright 2026 VoodooMods LLC
3
3
 
4
4
  This product is licensed under the Apache License, Version 2.0 (see LICENSE).
5
5
  The record format specification and documentation are licensed under CC BY 4.0.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: hypgraph
3
- Version: 0.7.12
3
+ Version: 0.7.13
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/
@@ -180,6 +180,10 @@ the record from its checker until it is returned. The method is in [AGENTS.md](h
180
180
  [llms.txt](https://hypgraph.com/llms.txt). What the server enforces, and what it leaves to you, is in
181
181
  [SECURITY.md](https://github.com/voodoomods/hypgraph/blob/main/SECURITY.md).
182
182
 
183
+ Runnable examples for LangChain, LangGraph, PydanticAI, CrewAI, the OpenAI Agents SDK and the Claude Agent SDK, each
184
+ the same story wired a different way, are in
185
+ [examples/frameworks](https://github.com/voodoomods/hypgraph/tree/main/examples/frameworks).
186
+
183
187
  ## Wire it into your system
184
188
 
185
189
  The core is one Python package with no dependencies. You decide who is trusted to do what:
@@ -148,6 +148,10 @@ the record from its checker until it is returned. The method is in [AGENTS.md](h
148
148
  [llms.txt](https://hypgraph.com/llms.txt). What the server enforces, and what it leaves to you, is in
149
149
  [SECURITY.md](https://github.com/voodoomods/hypgraph/blob/main/SECURITY.md).
150
150
 
151
+ Runnable examples for LangChain, LangGraph, PydanticAI, CrewAI, the OpenAI Agents SDK and the Claude Agent SDK, each
152
+ the same story wired a different way, are in
153
+ [examples/frameworks](https://github.com/voodoomods/hypgraph/tree/main/examples/frameworks).
154
+
151
155
  ## Wire it into your system
152
156
 
153
157
  The core is one Python package with no dependencies. You decide who is trusted to do what:
@@ -109,7 +109,7 @@ claim's derived standing.
109
109
  Multi-writer sync between graphs (bundles cover sharing), authentication (the host's job; the HTTP server binds to
110
110
  loopback and is read-only unless started with `--writable`), and a hosted service.
111
111
 
112
- ## Status at 0.7.12 (2026-10-31)
112
+ ## Status at 0.7.13 (2026-10-31)
113
113
 
114
114
  All seven phases are done: the library, bundles, the server and UI, the MCP server, the website, Pegasus running on
115
115
  the package, and releases to PyPI from tags with trusted publishing. What came after the plan, by release, is in
@@ -4,6 +4,18 @@
4
4
  #61 (docs and release engineering). Everything marked as tested was run; the commands are in each PR. Nothing here
5
5
  was taken from a changelog without being checked against the code.*
6
6
 
7
+ > **Superseded in part (2026-10-31, later the same day).** A second audit found that verification checked rows
8
+ > against events in one direction only: a bundle or graph file with a withdrawal, a version, an edge or a whole
9
+ > table removed still verified, as did changed authors, times, link ends and reasons, and edited derived views. That
10
+ > was a confirmed integrity defect, so the GO below didn't hold for 0.7.12. The fix (exhaustive row checks in the
11
+ > Python, SQLite and browser verifiers, events that hash every row field, derived views checked against the rows,
12
+ > standing reported as not covered) is in the pull request that adds `tests/test_verify_complete.py`, and ships as
13
+ > 0.7.13. The recommendation stands again once that is merged with green CI and released.
14
+ >
15
+ > **Where that stands.** #66 is merged with green CI (17 of 17 checks); on main the suite is 386 passed, and
16
+ > hypswarm, hypmesh and hypdmz pass on it. Released as 0.7.13. The required-checks setting (condition 3 below) is
17
+ > still open, for the operator.
18
+
7
19
  ## Recommendation: CONDITIONAL GO
8
20
 
9
21
  HypGraph is ready for a public developer preview once #59, #60 and #61 are merged with green CI and released as one
@@ -78,19 +78,35 @@ where `prev` is the previous event's hash (64 zeros for the first). Kinds and pa
78
78
 
79
79
  | kind | ref | payload |
80
80
  |---|---|---|
81
- | `create` | the new node | `{type, code, inv, v: 1, body_sha}`; `body_sha` = sha256 of the version's stored body string |
82
- | `revise` | the node | `{v, reason, body_sha}` |
83
- | `review` | the reviewed node | `{review: <review node id>, verdict, on_v, official, body_sha}` |
84
- | `link` | the edge's source | `{edge, rel, dst, src_v, dst_v, data_sha}`; `data_sha` = sha256 of the stored edge data string |
85
- | `retract` | the edge's source | `{edge}` |
86
- | `withdraw` | the node | `{why}` |
87
- | `lease_take` / `lease_release` | the node | `{task, expires}` |
88
-
89
- Any event may carry `task` in its payload: the unit of work that wrote it.
90
-
91
- **Verifying** a record means recomputing every hash in order and checking each `prev`. Then check that every stored
92
- version body and edge matches the `body_sha` / `data_sha` of the event that wrote it, and that no row exists without
93
- its event.
81
+ | `create` | the new node | `{type, code, inv, v: 1, body_sha, reason_sha, as?}`; `body_sha` = sha256 of the version's stored body string, `reason_sha` of its stored reason |
82
+ | `revise` | the node | `{v, reason, body_sha, reason_sha}`; `reason` is the first 300 characters |
83
+ | `review` | the reviewed node | `{review: <review node id>, verdict, on_v, official, body_sha, code, inv, as?}` |
84
+ | `link` | the edge's source | `{edge, rel, dst, src_v, dst_v, data_sha, why_sha}`; `data_sha` = sha256 of the stored edge data string, `why_sha` of its stored reason |
85
+ | `retract` | the edge's source | `{edge, why_sha}` |
86
+ | `withdraw` | the node | `{why, why_sha, as?}`; `why` is the first 300 characters, `why_sha` covers the stored reason |
87
+ | `lease_take` / `lease_release` | the node | `{task, expires, note_sha, for?}`; `for` = the agent holding the lease when it isn't the actor |
88
+
89
+ Any event may carry `task` in its payload: the unit of work that wrote it. `as` appears only when the importer
90
+ credits a node or withdrawal to another author; from any other actor it is invalid.
91
+
92
+ **Verifying** a record means recomputing every hash in order and checking each `prev`, then checking the rows
93
+ against the events **both ways**: every row was written by an event and matches it field by field, every row an
94
+ event wrote is present, and no key appears twice. The fields each event fixes:
95
+
96
+ | table | key | must equal |
97
+ |---|---|---|
98
+ | `nodes` | `create.ref`, `review.payload.review` | `type`, `code`, `inv` from the payload (a review: type `review`); `created_at` = the event's `at`; `created_by` = the actor, or `as` |
99
+ | `versions` | node + `v` | `at`, `by` = the actor, sha256 of `body` = `body_sha`, sha256 of `reason` = `reason_sha` (a review's: empty) |
100
+ | `edges` | `payload.edge` | `src` = ref, `rel`, `dst`, `src_v`, `dst_v`, `at`, `by` = the actor, sha256 of `data` = `data_sha`, of `why` = `why_sha` |
101
+ | `withdrawals` | the node | `at`, `by` = the actor or `as`, sha256 of `why` = `why_sha` |
102
+ | `retractions` | `payload.edge` | `at`, `by` = the actor, sha256 of `why` = `why_sha` |
103
+ | `leases` | in id order, one per lease event in order | `node` = ref, `agent` = `for` or the actor, `task`, `kind` (`take` / `release`), `at`, `expires`, sha256 of `note` = `note_sha` |
104
+
105
+ Records written before these fields existed (before 0.7.13) carry events without `reason_sha`, `why_sha`,
106
+ `note_sha`, `as`, or a review's `code` and `inv`. A verifier checks what those events do carry (a revision's or a
107
+ withdrawal's first 300 characters, a lease note given in full, a review's investigation from its target) and
108
+ reports every other non-empty field as **not covered**, by table and field, rather than passing it silently. Without
109
+ `as`, a row the importer wrote may credit any author; that is reported too.
94
110
 
95
111
  ## 4. Bundles — `hypgraph/1-bundle`
96
112
 
@@ -108,7 +124,7 @@ strings and compares.
108
124
  "versions": [{"node": "…", "v": 1, "at": "…", "by": "…", "reason": "", "body": "<stored canonical string>"}],
109
125
  "edges": [{"id": 1, "src": "…", "src_v": 1, "rel": "…", "dst": "…", "dst_v": 1, "data": "<stored string>", "…": "…"}],
110
126
  "withdrawals": [], "retractions": [], "leases": [],
111
- "derived": {"…": "standing at export time, for viewers; never trusted by a verifier"}}
127
+ "derived": {"…": "views for viewers; their facts are checked against the rows, their standing is not"}}
112
128
  ```
113
129
 
114
130
  A **scoped** bundle (one investigation out of a shared record) replaces events outside it with
@@ -123,6 +139,14 @@ or a prefix of at least 16 hex characters) fails unless the chain ends there: `h
123
139
  `verify_bundle(b, head=HEX)`. A bundle without a `head` field is checked for consistency only. How a head is
124
140
  published and signed, and what each check does and doesn't show: [verification.md](verification.md).
125
141
 
142
+ **Derived views.** `derived` is a convenience for viewers, and a viewer shows it beside a verified chain, so a
143
+ verifier must check every fact in it that the rows fix: which investigations it covers (the scope's, or every
144
+ investigation node), which nodes each investigation's graph shows (all of them, unless capped at 5,000) and which
145
+ live (unretracted) links among them, each node's `type`, `code`, `inv`, `by`, `at`, `v`, `title` and `kind`, whether
146
+ it is shown as withdrawn, and each link's fields. **Standing** (review state, support, at-risk, the sentence) can't
147
+ be checked from the rows alone: it is what the exporting engine derived. A verifier reports it as not checked, or
148
+ re-derives it with the same engine version (`hypgraph verify FILE --standing`) and compares.
149
+
126
150
  ## 5. Derived standing (normative)
127
151
 
128
152
  Implementations must derive these, never store them:
@@ -8,7 +8,7 @@ somewhere its keeper can't change.
8
8
 
9
9
  | | What it shows | How | What it doesn't show |
10
10
  |---|---|---|---|
11
- | **1. Internal consistency** | The chain of events is unbroken, and every node, version, link, withdrawal and retraction matches the event that wrote it. | `hypgraph verify study.bundle.json` (or a graph file), `verify_bundle(b)` in Python, `HypGraph.verifyBundle` in the browser | That it wasn't rewritten. Whoever can write the record can change it, recompute every hash, or cut it short, and it still verifies. |
11
+ | **1. Internal consistency** | The chain of events is unbroken; every node, version, link, withdrawal, retraction and lease was written by an event and matches it field by field; no row an event wrote is missing; and the facts a viewer shows beside it (which nodes and links, titles, authors, times, withdrawn or not) agree with those rows. | `hypgraph verify study.bundle.json` (or a graph file), `verify_bundle(b)` in Python, `HypGraph.verifyBundle` in the browser | That it wasn't rewritten. Whoever can write the record can change it, recompute every hash, or cut it short, and it still verifies. |
12
12
  | **2. Not rewritten since it was published** | The record ends at the head that was published. Any change to anything before it, or a record cut short, ends somewhere else. | `hypgraph verify study.bundle.json --head <published head>`, `verify_bundle(b, head=...)`, `anchored(g.verify(), head)` | Who published that head. That's the job of where it was published (below). |
13
13
  | **3. Provenance of the evidence** | Who recorded what, and how: a lab-recorded run (only the instrument author files those) or an agent's report, its inputs and outputs by hash, which lines of support are independent. | The record's derived standing: `hypgraph show CL1 --why`, the report | That the instrument's own files are honest. A host decides who the instrument is (the policy). |
14
14
  | **4. That a claim is true** | Nothing. | Reviews, replications, separate implementations, new data: the evidence the record keeps and ranks | — |
@@ -24,11 +24,12 @@ claims in it stand exactly as well as the record derives, and no better.
24
24
  $ hypgraph demo && hypgraph export -o study.bundle.json
25
25
  Wrote study.bundle.json: 59 events, 24 nodes
26
26
  $ hypgraph verify study.bundle.json
27
- ✓ study.bundle.json: 59 events verified — chain intact, every row matches the event that wrote it.
27
+ ✓ study.bundle.json: 59 events verified — chain intact, every row matches the event that wrote it, and none is missing.
28
28
  head 565123f3a7ac06ed18ab555935fde0457ebb6a302dc188fc83dbf36af579623f
29
29
  consistent with itself; to rule out a rewrite, check it against a published head (--head)
30
+ standing not checked: standing (review state, support, at-risk, the sentence) is what the exporting engine derived: …
30
31
  $ hypgraph verify study.bundle.json --head 565123f3a7ac06ed
31
- ✓ study.bundle.json: 59 events verified — chain intact, every row matches the event that wrote it.
32
+ ✓ study.bundle.json: 59 events verified — chain intact, every row matches the event that wrote it, and none is missing.
32
33
  head 565123f3a7ac06ed18ab555935fde0457ebb6a302dc188fc83dbf36af579623f
33
34
  matches the anchored head: not rewritten or cut short since it was published
34
35
  ```
@@ -67,6 +68,18 @@ SHA-256 over strings and compares: no JSON canonicalisation to reimplement. The
67
68
  and the verifier's rules, is in [spec.md](spec.md). The site checks bundles in the browser with WebCrypto, from a
68
69
  separate implementation.
69
70
 
71
+ ## Standing, and older records
72
+
73
+ - **Standing** (review state, support, at-risk, the one-line summary) is derived, and a bundle carries the standing
74
+ its exporter derived. The chain doesn't cover it. `hypgraph verify study.bundle.json --standing` replays the
75
+ verified rows and derives it again; it fails if the result differs. Standing rules change between releases, so it
76
+ only compares a bundle exported by the same version, and says so otherwise. A graph file has no stored standing:
77
+ it is derived on every read.
78
+ - **Records written before 0.7.13** have events that don't hash a few row fields (a link's reason, a retraction's
79
+ reason, a lease note, a revision's full reason, the credited author of an importer's node). The verifier checks
80
+ what those events carry and lists the rest as *not covered*, with a count per field. Rewriting one of those fields
81
+ in an old record isn't caught; new writes cover them all.
82
+
70
83
  ## Scoped bundles and references
71
84
 
72
85
  - `hypgraph export --inv INV` writes one investigation. Events outside it are redacted to their hash, so the chain
@@ -79,5 +92,7 @@ separate implementation.
79
92
 
80
93
  `dev/e2e_install.py` installs HypGraph into a fresh environment and walks the whole path: a record, a claim accepted
81
94
  and one refused, a review, an export, and verification in another directory with no graph file, on its own and
82
- against the head, and failing on an edited copy. `tests/test_anchoring.py` rewrites a record (changing a claim and
95
+ against the head, and failing on an edited copy. `tests/test_verify_complete.py` drops, duplicates, empties and
96
+ edits every table and every field of a bundle and of a graph file, and edits the derived views: each one fails, in
97
+ Python and in the browser verifier alike. `tests/test_anchoring.py` rewrites a record (changing a claim and
83
98
  recomputing every hash) and cuts one short: both verify on their own, and both fail against the published head.
@@ -0,0 +1,70 @@
1
+ # HypGraph with agent frameworks
2
+
3
+ One story, told once per framework, so the difference between the files is only the wiring.
4
+
5
+ An eval harness has compared two models on the same 1,000 held-out questions: model A got 812 right, model B 800.
6
+ An agent, connected to the record over MCP as the author `analyst`, is asked whether A is more accurate. It writes
7
+ "significantly more accurate". The record refuses that with the arithmetic (p ≈ 0.5) and says what to write instead.
8
+ The agent writes the honest claim, and it is accepted. Afterwards the script reads the record in Python and prints
9
+ what was refused, what was accepted, and the refusal log.
10
+
11
+ The framework doesn't need to know HypGraph exists. It sees MCP tools, and the record does the checking.
12
+
13
+ | Framework | File | Install | MCP wiring |
14
+ |---|---|---|---|
15
+ | LangChain | [langchain_agent.py](langchain_agent.py) | `langchain langchain-mcp-adapters` | `MultiServerMCPClient` session → `create_agent` |
16
+ | LangGraph | [langgraph_agent.py](langgraph_agent.py) | `langgraph langchain-mcp-adapters` | `load_mcp_tools(session)` into a `ToolNode` |
17
+ | PydanticAI | [pydantic_ai_agent.py](pydantic_ai_agent.py) | `"pydantic-ai-slim[mcp]"` | `MCPToolset(StdioTransport(...))` |
18
+ | CrewAI | [crewai_agent.py](crewai_agent.py) | `crewai` | `Agent(mcps=[MCPServerStdio(...)])` |
19
+ | OpenAI Agents SDK | [openai_agents_sdk.py](openai_agents_sdk.py) | `openai-agents` | `Agent(mcp_servers=[MCPServerStdio(...)])` |
20
+ | Claude Agent SDK | [claude_sdk_agent.py](claude_sdk_agent.py) | `claude-agent-sdk` | `ClaudeAgentOptions(mcp_servers={...})` |
21
+
22
+ Every example also needs `pip install "hypgraph[mcp]"`. [_story.py](_story.py) holds what they share: the record
23
+ before the agent arrives, the task, the server command, and the check at the end.
24
+
25
+ ## Run them
26
+
27
+ ```bash
28
+ cd examples/frameworks
29
+ python langgraph_agent.py # scripted model: offline, free, the same every time
30
+ python langgraph_agent.py --real --model gpt-4.1-mini
31
+ ```
32
+
33
+ With no flags, a scripted model plays a careful agent's moves through the framework's own tool-calling path:
34
+ read the overview, overclaim, read the refusal, write the honest claim. That shows the wiring works, including the
35
+ part that matters most, a refused write getting back to the model. It can't show a model reasoning. `--real` uses a
36
+ real model (`OPENAI_API_KEY`, or whatever the framework's model string needs; `--model` or `MODEL` picks it). A real
37
+ model passes if it got a claim accepted and fixed every refusal, because a careful model may write the honest claim
38
+ the first time.
39
+
40
+ `--db FILE` keeps the record, so you can open it afterwards with `hypgraph --db FILE serve`.
41
+
42
+ The Claude Agent SDK drives Claude Code itself and has no scripted mode. `python claude_sdk_agent.py` is a real run
43
+ (Claude Code signed in, or `ANTHROPIC_API_KEY`; `--model` takes `haiku`, `sonnet` or `opus`). `--check` starts the
44
+ record's server from the exact options the SDK would use and confirms every allowed tool exists. That needs no model.
45
+
46
+ ## What to copy
47
+
48
+ - **One server per author.** `hypgraph --db FILE --as analyst mcp` writes as `analyst` and nothing else. The agent
49
+ can't pass another name, and its verdicts count only if the investigation names it as a reviewer. Give each agent
50
+ in a team its own server, with its own author.
51
+ - **Let a refusal reach the model.** A refused write is an MCP tool error whose text names the problem and the fix
52
+ (for example `[SIGNIFICANCE_NOT_SHOWN]`). If the framework ends the run on a tool error, the agent never sees it:
53
+ - LangChain: `load_mcp_tools(session, handle_tool_errors=True)`, the adapter's default, returns it as the tool's
54
+ output, so `create_agent` needs nothing more. Hold one `client.session(...)` for the run: `client.get_tools()`
55
+ alone starts a server process for every tool call.
56
+ - LangGraph: `ToolNode(tools, handle_tool_errors=True)`.
57
+ - PydanticAI: the default (`tool_error_behavior="retry"`) sends it back as a retry prompt. `max_retries` bounds it.
58
+ - CrewAI and the OpenAI Agents SDK hand it back as the tool's output by default.
59
+ - Claude Code shows tool errors to the model.
60
+ - **Keep the agent on the record.** The Claude example passes `tools=[]` (no shell, no file edits),
61
+ `strict_mcp_config=True` (no other MCP servers) and pre-approves only the record's tools.
62
+ - **CrewAI names MCP tools after the command line** (`<command>_<args>_<tool>`, hashed past 64 characters). The
63
+ example runs plain `hypgraph mcp` with `HYPGRAPH_DB` and `HYPGRAPH_AUTHOR` in its environment, so the tools come out
64
+ as `hypgraph_mcp_add` and so on.
65
+
66
+ Frameworks that take plain Python functions as tools can skip MCP. Call `Graph` directly and return the refusal text
67
+ from `GraphError`; see [hypgraph.com/engineers](https://hypgraph.com/engineers/#connect).
68
+
69
+ Tested with langchain 1.4, langgraph 1.2, pydantic-ai 2.x, crewai 1.15, openai-agents 0.20 and claude-agent-sdk 0.2 (the scripted
70
+ runs, and the Claude `--check`, run in CI).
@@ -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.12"
19
+ __version__ = "0.7.13"
20
20
 
21
21
  from .bundle import anchored, verify_bundle # noqa: E402
22
22
  from .graph import Author, Graph, GraphError, file_sha256 # noqa: E402
@@ -11,7 +11,8 @@ SHA-256 over strings plus a few lookups — any language can do it (the site doe
11
11
  "versions": [{"node", "v", "at", "by", "reason", "body"}], # body = the stored canonical string
12
12
  "edges": [{"id", "src", "src_v", "rel", "dst", "dst_v", "data", "at", "by", "why"}], # data likewise
13
13
  "withdrawals": [...], "retractions": [...], "leases": [...],
14
- "derived": {...}, # standing at export time, for viewers; never trusted
14
+ "derived": {...}, # views for viewers: facts checked against the rows,
15
+ # standing not (verify_bundle(..., standing=True) re-derives)
15
16
  "telemetry": {"source", "covered_by_chain": false, "events": [...]}} # optional: a swarm's telemetry sidecar
16
17
 
17
18
  A scoped bundle (one investigation out of a shared graph) redacts the events outside it to {seq, hash, prev}: their
@@ -33,7 +34,7 @@ from pathlib import Path
33
34
  from typing import TYPE_CHECKING, Any
34
35
 
35
36
  from . import __version__
36
- from .store import GENESIS, event_body, now
37
+ from .store import GENESIS, ROW_TABLES, check_rows, event_body, now
37
38
 
38
39
  if TYPE_CHECKING:
39
40
  from .graph import Graph
@@ -132,6 +133,7 @@ def derive_views(g: "Graph", invs: list[str]) -> dict:
132
133
  "profile": profiles.view(pid), "stages": g.stages(i), "completion": g.completion(i),
133
134
  "board": g.question_board(i), "orders": g.orders(i)}
134
135
  views["verify"] = g.verify()
136
+ views["at"] = g.clock() # the time standing was derived at (leases expire): re-deriving uses it
135
137
  return views
136
138
 
137
139
 
@@ -153,18 +155,32 @@ def anchored(res: dict, head: str) -> dict:
153
155
  return {**res, "anchored": True}
154
156
 
155
157
 
156
- def verify_bundle(b: dict, head: str = "") -> dict:
157
- """Check a bundle string-for-string: the chain, then every row against the event that wrote it. With ``head``,
158
- also that the chain ends at that published head (see ``anchored``)."""
159
- return anchored(_verify_bundle(b), head)
158
+ def verify_bundle(b: dict, head: str = "", *, standing: bool = False) -> dict:
159
+ """Check a bundle string-for-string: the chain; then the rows against the events, both ways (every row written by
160
+ an event, every event's row present, every recorded field equal); then the derived views against those rows. With
161
+ ``head``, also that the chain ends at that published head (see ``anchored``). With ``standing``, re-derive the
162
+ standing from the verified rows and compare it with what the bundle carries (``rederive``)."""
163
+ res = anchored(_verify_bundle(b), head)
164
+ if standing and res.get("ok"):
165
+ res = {**res, "standing": rederive(b)}
166
+ if res["standing"].get("problem"):
167
+ res = {**res, "ok": False, "problem": res["standing"]["problem"]}
168
+ return res
169
+
170
+
171
+ STANDING_NOTE = ("standing (review state, support, at-risk, the sentence) is what the exporting engine derived: the "
172
+ "chain doesn't cover it. Re-derive it from the verified rows with `hypgraph verify --standing`.")
160
173
 
161
174
 
162
175
  def _verify_bundle(b: dict) -> dict:
163
176
  if not isinstance(b, dict) or b.get("format") != BUNDLE_FORMAT:
164
177
  return {"ok": False, "problem": f"not a {BUNDLE_FORMAT} bundle"}
165
- prev, checked, red = GENESIS, 0, 0
166
- creates, revs, reviews, links, withdraws, retracts = {}, {}, {}, {}, set(), set()
178
+ prev, checked, red, seqs = GENESIS, 0, 0, set()
179
+ events: list[dict] = []
167
180
  for ev in sorted(b.get("events") or [], key=lambda e: e["seq"]):
181
+ if ev["seq"] in seqs:
182
+ return {"ok": False, "broken_at": ev["seq"], "problem": "two events share a sequence number"}
183
+ seqs.add(ev["seq"])
168
184
  if ev.get("redacted"):
169
185
  if ev.get("prev") != prev:
170
186
  return {"ok": False, "broken_at": ev["seq"], "problem": "chain broken (redacted event)"}
@@ -176,51 +192,19 @@ def _verify_bundle(b: dict) -> dict:
176
192
  if body.get("prev") != prev:
177
193
  return {"ok": False, "broken_at": ev["seq"], "problem": "chain broken (prev does not match)"}
178
194
  prev, checked = ev["hash"], checked + 1
179
- k, ref, p = body["kind"], body["ref"], body["payload"]
180
- if k == "create":
181
- creates[ref] = p
182
- revs[(ref, int(p.get("v", 1)))] = p.get("body_sha")
183
- elif k == "revise":
184
- revs[(ref, int(p.get("v")))] = p.get("body_sha")
185
- elif k == "review":
186
- reviews[p["review"]] = p
187
- revs[(p["review"], 1)] = p.get("body_sha")
188
- elif k == "link":
189
- links[int(p["edge"])] = p
190
- elif k == "withdraw":
191
- withdraws.add(ref)
192
- elif k == "retract":
193
- retracts.add(int(p["edge"]))
195
+ events.append(body)
194
196
  if b.get("head") and b["head"] != prev:
195
197
  return {"ok": False, "problem": "the chain does not end at the bundle's head"}
196
- for n in b.get("nodes") or []:
197
- c = creates.get(n["id"])
198
- if n["type"] == "review":
199
- if n["id"] not in reviews:
200
- return {"ok": False, "problem": f"review {n['id']} has no event"}
201
- elif not c or (c.get("type"), c.get("code"), c.get("inv")) != (n["type"], n["code"], n["inv"]):
202
- return {"ok": False, "problem": f"node {n['id']} differs from its create event"}
203
- for v in b.get("versions") or []:
204
- want = revs.get((v["node"], v["v"]))
205
- if want is None:
206
- return {"ok": False, "problem": f"version {v['node']} v{v['v']} has no event"}
207
- if _sha(v["body"]) != want:
208
- return {"ok": False, "problem": f"version {v['node']} v{v['v']} differs from its event"}
209
- for e in b.get("edges") or []:
210
- want = links.get(e["id"])
211
- if want is None:
212
- return {"ok": False, "problem": f"edge {e['id']} has no event"}
213
- if (want.get("rel"), want.get("dst"), want.get("dst_v")) != (e["rel"], e["dst"], e["dst_v"]) or \
214
- _sha(e["data"]) != want.get("data_sha"):
215
- return {"ok": False, "problem": f"edge {e['id']} differs from its event"}
216
- for w in b.get("withdrawals") or []:
217
- if w["node"] not in withdraws:
218
- return {"ok": False, "problem": f"withdrawal of {w['node']} has no event"}
219
- for r in b.get("retractions") or []:
220
- if int(r["edge"]) not in retracts:
221
- return {"ok": False, "problem": f"retraction of edge {r['edge']} has no event"}
198
+ tables = {t: b.get(t) or [] for t in ROW_TABLES}
199
+ rows = check_rows(events, tables, importer=str((b.get("policy") or {}).get("importer") or "importer"))
200
+ if "problem" in rows:
201
+ return {"ok": False, "problem": rows["problem"]}
202
+ problem = _derived_problem(b, tables)
203
+ if problem:
204
+ return {"ok": False, "problem": f"the derived views contradict the verified record: {problem}"}
222
205
  out = {"ok": True, "checked": checked, "head": prev, "complete": red == 0, "redacted": red,
223
- "nodes": len(b.get("nodes") or []), "edges": len(b.get("edges") or [])}
206
+ "nodes": len(tables["nodes"]), "edges": len(tables["edges"]), "unchecked": rows["unchecked"],
207
+ "standing": {"checked": False, "note": STANDING_NOTE}}
224
208
  if red:
225
209
  out["warning"] = (f"{red} event(s) outside this investigation are redacted: their content can't be checked, so "
226
210
  "a change hidden among them (a withdrawal, say) would not show. The full record rules that "
@@ -228,6 +212,119 @@ def _verify_bundle(b: dict) -> dict:
228
212
  return out
229
213
 
230
214
 
215
+ def _j(v: Any) -> str:
216
+ """A value in a problem, written as the browser's verifier writes it (JSON.stringify)."""
217
+ return json.dumps(v, ensure_ascii=False, separators=(",", ":"))
218
+
219
+
220
+ def _title(body: dict) -> str:
221
+ """A node's title as the engine shows it (Graph.brief)."""
222
+ t = (body.get("statement") or body.get("title") or body.get("text") or body.get("aim") or body.get("summary")
223
+ or body.get("choice") or body.get("citation") or body.get("name") or body.get("path") or body.get("verdict")
224
+ or "")
225
+ return str(t)[:300]
226
+
227
+
228
+ def _derived_problem(b: dict, tables: dict) -> str:
229
+ """The derived views are presentation, but a viewer shows them beside a verified chain: every fact in them that
230
+ the rows fix — which nodes and live links each investigation has, each node's type, code, author, time, version,
231
+ title and whether it is withdrawn — must agree with the verified rows. Standing can't be checked this way."""
232
+ d = b.get("derived")
233
+ if d is None:
234
+ return ""
235
+ if not isinstance(d, dict):
236
+ return "derived is not an object"
237
+ nodes = {n["id"]: n for n in tables["nodes"]}
238
+ latest: dict[str, tuple[int, dict]] = {}
239
+ for v in tables["versions"]:
240
+ if v["v"] > latest.get(v["node"], (0, {}))[0]:
241
+ latest[v["node"]] = (v["v"], json.loads(v["body"]))
242
+ withdrawn = {w["node"] for w in tables["withdrawals"]}
243
+ retracted = {int(r["edge"]) for r in tables["retractions"]}
244
+ live = {int(e["id"]): e for e in tables["edges"] if int(e["id"]) not in retracted}
245
+ scope = str((b.get("scope") or {}).get("inv") or "")
246
+ invs = {scope} if scope else {i for i, n in nodes.items() if n["type"] == "investigation"}
247
+
248
+ def facts(x: dict, where: str) -> str:
249
+ n = nodes.get(x.get("id")) if isinstance(x, dict) else None
250
+ if not n:
251
+ return f"{where} shows {x.get('id') if isinstance(x, dict) else _j(x)}, which the record doesn't hold"
252
+ v, body = latest.get(n["id"], (0, {}))
253
+ want = {"type": n["type"], "code": n["code"], "inv": n["inv"], "by": n["created_by"], "at": n["created_at"],
254
+ "v": v, "title": _title(body), "kind": body.get("kind") or body.get("verdict") or ""}
255
+ for k, w in want.items():
256
+ if k in x and x[k] != w:
257
+ return f"{where} shows {n['code'] or n['id']} with {k} {_j(x[k])}; the record has {_j(w)}"
258
+ life = (x.get("state") or {}).get("lifecycle")
259
+ if life is not None and (life == "withdrawn") != (n["id"] in withdrawn):
260
+ return (f"{where} shows {n['code'] or n['id']} as {life}; the record "
261
+ f"{'withdrew it' if n['id'] in withdrawn else 'has no withdrawal of it'}")
262
+ return ""
263
+ by_inv = d.get("by_inv") or {}
264
+ if set(by_inv) != invs:
265
+ return "the views don't cover the record's investigations"
266
+ for x in d.get("investigations") or []:
267
+ if x.get("id") not in invs:
268
+ return f"the investigation list shows {x.get('id')}, which isn't in the bundle's scope"
269
+ p = facts(x, "the investigation list")
270
+ if p:
271
+ return p
272
+ for inv, v in by_inv.items():
273
+ sg = (v or {}).get("subgraph") or {}
274
+ shown = sg.get("nodes") or []
275
+ for x in shown:
276
+ p = facts(x, f"the graph of {nodes[inv]['code'] or inv}")
277
+ if p:
278
+ return p
279
+ mine = {i for i, n in nodes.items() if n["inv"] == inv or i == inv}
280
+ ids = [x["id"] for x in shown]
281
+ if len(ids) != len(set(ids)) or (set(ids) != mine and not (len(mine) > len(ids) >= 5000)):
282
+ return f"the graph of {nodes[inv]['code'] or inv} doesn't show the nodes the record holds"
283
+ want = {i: e for i, e in live.items() if e["src"] in set(ids) and e["dst"] in set(ids)}
284
+ got = sg.get("edges") or []
285
+ if len(got) != len(want) or {int(e["id"]) for e in got} != set(want):
286
+ return f"the graph of {nodes[inv]['code'] or inv} doesn't show the live links the record holds"
287
+ for e in got:
288
+ r = want[int(e["id"])]
289
+ for k in ("src", "src_v", "rel", "dst", "dst_v", "at", "by", "why"):
290
+ if k in e and e[k] != r[k]:
291
+ return f"the graph shows link {e['id']} with {k} {_j(e[k])}; the record has {_j(r[k])}"
292
+ if "data" in e and e["data"] != json.loads(r["data"]): # as values: 1.0 and 1 are one number
293
+ return f"the graph shows link {e['id']} with other data than the record"
294
+ return ""
295
+
296
+
297
+ def rederive(b: dict) -> dict:
298
+ """Replay a complete bundle in memory and derive the standing again, at the time it was derived, with this
299
+ engine: {"checked": True} when it matches what the bundle carries, a "problem" when it doesn't. Standing rules
300
+ change between releases, so only a bundle exported by this version can be compared; others are reported as not
301
+ checked."""
302
+ from .graph import Graph
303
+ from .policy import Policy
304
+ d = b.get("derived")
305
+ if not isinstance(d, dict):
306
+ return {"checked": False, "note": "the bundle carries no derived views"}
307
+ if b.get("generator") != f"hypgraph {__version__}":
308
+ return {"checked": False, "note": f"exported by {b.get('generator') or 'an unknown version'}; standing rules "
309
+ f"differ between releases, so re-derive it with that version"}
310
+ if b.get("redacted_events") or any(e.get("redacted") for e in b.get("events") or []):
311
+ return {"checked": False, "note": "a scoped bundle can't be replayed; re-derive from the full record"}
312
+ at = str(d.get("at") or b.get("exported_at") or now())
313
+ g = Graph(None, policy=Policy.from_dict(b.get("policy") or {}), clock=lambda: at)
314
+ load(g, b)
315
+ fresh = json.loads(json.dumps(derive_views(g, list(d.get("by_inv") or {})), sort_keys=True))
316
+ old = json.loads(json.dumps(d, sort_keys=True))
317
+ for k in ("investigations", "by_inv"):
318
+ if fresh.get(k) != old.get(k):
319
+ where = k
320
+ if k == "by_inv":
321
+ where = next((f"{i}/{view}" for i in old.get(k) or {} for view in (old[k][i] or {})
322
+ if (fresh[k].get(i) or {}).get(view) != old[k][i][view]), k)
323
+ return {"checked": False, "problem": f"the standing the bundle carries differs from what its record "
324
+ f"derives ({where})"}
325
+ return {"checked": True}
326
+
327
+
231
328
  def load(g: "Graph", b: dict) -> None:
232
329
  """Replay a complete bundle into an empty graph, row for row — the result verifies exactly as the original."""
233
330
  v = verify_bundle(b)
@@ -276,15 +276,24 @@ def cmd_report(args) -> int:
276
276
  def cmd_verify(args) -> int:
277
277
  target = args.target or args.db
278
278
  if str(target).endswith(".json"):
279
- res = verify_bundle(json.loads(Path(target).read_text(encoding="utf-8")), head=args.head)
279
+ res = verify_bundle(json.loads(Path(target).read_text(encoding="utf-8")), head=args.head,
280
+ standing=args.standing)
280
281
  else:
281
282
  res = anchored(verify_file(target), args.head)
282
283
  ok = res.get("ok")
283
284
  text = (f"✓ {target}: {res.get('checked')} events verified — chain intact, every row matches the event that "
284
- f"wrote it.\n head {res.get('head', '')}" if ok else f"✗ {target}: {res.get('problem')}")
285
+ f"wrote it, and none is missing.\n head {res.get('head', '')}" if ok else f"✗ {target}: {res.get('problem')}")
285
286
  if ok:
286
287
  text += ("\n matches the anchored head: not rewritten or cut short since it was published" if res.get("anchored")
287
288
  else "\n consistent with itself; to rule out a rewrite, check it against a published head (--head)")
289
+ if ok and res.get("unchecked"):
290
+ text += ("\n not covered (written before events hashed these fields): "
291
+ + ", ".join(f"{f} ×{n}" for f, n in sorted(res["unchecked"].items())))
292
+ st = res.get("standing") or {}
293
+ if ok and st.get("checked"):
294
+ text += "\n standing re-derived from the verified rows: it matches what the bundle carries"
295
+ elif ok and st:
296
+ text += "\n standing not checked: " + str(st.get("note") or "")
288
297
  if res.get("warning"):
289
298
  text += "\n note: " + res["warning"]
290
299
  _out(args, res, text)
@@ -510,6 +519,8 @@ def build_parser() -> argparse.ArgumentParser:
510
519
  s.add_argument("target", nargs="?", default="")
511
520
  s.add_argument("--head", default="", help="the head published for this record (or its first 16+ hex characters): "
512
521
  "fail unless the chain ends there")
522
+ s.add_argument("--standing", action="store_true", help="bundles: re-derive the standing from the verified rows and "
523
+ "fail if it differs from what the bundle carries")
513
524
  s = cmd("export", cmd_export, "write a verifiable bundle")
514
525
  s.add_argument("--inv", default="", help="one investigation (events outside it are redacted)")
515
526
  s.add_argument("-o", "--output", default="")