Send global stamps as a verae-meta-git/1 history.

Optional metadata or restamp builds the same commit history as node-red. A call that omits both stays digest-only with restamp false. Local and shared stamps reject metadata.
This commit is contained in:
Cursor Agent 2026-10-11 05:37:25 +00:00
commit 394c46ebfc
No known key found for this signature in database
8 changed files with 469 additions and 21 deletions

View file

@ -14,8 +14,9 @@ info:
arguments named `content`, `bytes`, `file`, `data`, `path`, `body`, `payload`,
`blob`, or `archive`.
**Global stamp:** VeraeTime body uses `restamp: false` by default (reuse existing
certificate). Explicit restamp is out of scope unless added later.
**Global stamp:** VeraeTime body uses `restamp: false` and omits metadata unless
the caller passes `metadata` or `restamp`. Those calls send a `verae-meta-git/1`
history. Local and shared stamps reject metadata.
## Mapping appendix (MCP ↔ VeraeTime ↔ Application API)
@ -52,8 +53,9 @@ paths:
summary: Stamp a SHA-256 digest
description: |
Request a timestamp for a digest. Scope `local` or `shared` goes to MCP tool
`stamp`; scope `global` goes to VeraeTime `POST /api/timestamp` with
`restamp: false` by default.
`stamp`; scope `global` goes to VeraeTime `POST /api/timestamp`. The default
body is digest-only with `restamp: false`. `metadata` or `restamp` sends a
`verae-meta-git/1` history.
tags: [timestamping]
requestBody:
required: true
@ -250,13 +252,27 @@ components:
minLength: 64
scope:
type: string
description: Default local. global → VeraeTime with restamp false.
description: Default local. global → VeraeTime. Metadata history is global only.
enum: [local, shared, global]
default: local
cube_id:
type: string
org_id:
type: string
metadata:
type: object
description: >
Plain object or a verae-meta-git/1 document. Blank values do not delete
fields. Omitted on a digest-only stamp.
additionalProperties: true
restamp:
type: boolean
description: >
When true, append metadata onto the previous global stamp for this digest.
default: false
message:
type: string
description: History commit message.
StampResponse:
type: object
required: [receipts]

View file

@ -360,7 +360,16 @@ class GlobalClient:
headers["Authorization"] = f"Bearer {self.token}"
return headers
def submit(self, digest: str, cube_id: str = "") -> Receipt:
def submit(
self,
digest: str,
cube_id: str = "",
*,
metadata: dict | None = None,
restamp: bool = False,
message: str = "",
at: str = "",
) -> Receipt:
override = request_token.get()
saved = self.token
if override:
@ -368,22 +377,75 @@ class GlobalClient:
else:
self.ensure_token()
try:
return self._submit(digest, cube_id)
return self._submit(digest, cube_id, metadata=metadata, restamp=restamp, message=message, at=at)
finally:
if override:
self.token = saved
def _submit(self, digest: str, cube_id: str = "") -> Receipt:
def _history_document(self, digest: str, metadata, restamp: bool, message: str, at: str):
from verae_mcp_timestamping_server.metadata_history import append_metadata, is_history
if not metadata and not restamp:
return None
if is_history(metadata):
return metadata
prior = None
if restamp:
prior = self._prior_metadata(digest)
return append_metadata(prior, metadata if isinstance(metadata, dict) else None, restamp=restamp, at=at or None, message=message or None)
def _prior_metadata(self, digest: str):
status, body = self._request_json("GET", f"{self.base_url}/api/hash/{digest}", None)
if status == 404 or not isinstance(body, dict):
return None
if status >= 300:
raise ValueError(f"global timestamp HTTP {status}: {body}")
job_id = ""
for ref in body.get("receipts") or []:
if isinstance(ref, dict) and ref.get("jobId"):
job_id = str(ref["jobId"])
if not job_id:
return None
st, sb = self._request_json("GET", f"{self.base_url}/api/status/{job_id}", None)
if st == 404 or not isinstance(sb, dict):
return None
if st >= 300:
raise ValueError(f"global timestamp HTTP {st}: {sb}")
meta = sb.get("metadata")
return meta if isinstance(meta, dict) else None
def _request_json(self, method: str, url: str, payload):
res = self.http.request(method, url, payload={} if payload is None else payload, headers=self._headers())
parsed = res.json() if hasattr(res, "json") else {}
return res.status_code, parsed
def _submit(
self,
digest: str,
cube_id: str = "",
*,
metadata: dict | None = None,
restamp: bool = False,
message: str = "",
at: str = "",
) -> Receipt:
url = f"{self.base_url}/api/timestamp"
payload = {
"data": digest,
"digest": digest,
"hashAlg": "SHA256",
"restamp": False,
}
doc = self._history_document(digest, metadata, restamp, message, at)
if doc is not None:
payload["metadata"] = doc
commits = doc.get("commits") if isinstance(doc, dict) else None
if restamp or (isinstance(commits, list) and len(commits) > 1):
payload["restamp"] = True
res = self.http.request(
"POST",
url,
payload={
"data": digest,
"digest": digest,
"hashAlg": "SHA256",
"restamp": False,
},
payload=payload,
headers=self._headers(),
)
parsed = res.json() if hasattr(res, "json") else {}
@ -394,6 +456,8 @@ class GlobalClient:
if res.status_code == 200 and (
parsed.get("certificate") or parsed.get("existing")
):
if payload.get("metadata") is not None and not payload.get("restamp"):
raise ValueError("digest already stamped; set restamp to append metadata")
cid = str(parsed.get("certificate") or "")
if cid:
return Receipt(
@ -560,10 +624,21 @@ class GlobalLedger:
scope: str = "global",
cube_id: str = "",
org_id: str = "",
*,
metadata: dict | None = None,
restamp: bool = False,
message: str = "",
) -> list[Receipt]:
del org_id
digest = validate_sha256(sha256)
receipt = await asyncio.to_thread(self.client.submit, digest, cube_id)
receipt = await asyncio.to_thread(
self.client.submit,
digest,
cube_id,
metadata=metadata,
restamp=restamp,
message=message,
)
return [receipt]
async def verify(self, receipt_id: str, want_scope: str = "") -> VerifyResult:

View file

@ -45,6 +45,10 @@ class Ledger(Protocol):
scope: str = "local",
cube_id: str = "",
org_id: str = "",
*,
metadata: dict | None = None,
restamp: bool = False,
message: str = "",
) -> list[Receipt]: ...
async def verify(self, receipt_id: str, want_scope: str = "") -> VerifyResult: ...
@ -92,7 +96,14 @@ class MemoryLedger:
scope: str = "local",
cube_id: str = "",
org_id: str = "",
*,
metadata: dict | None = None,
restamp: bool = False,
message: str = "",
) -> list[Receipt]:
del message
if metadata or restamp:
raise ValueError("metadata history is stored on global stamps")
digest = validate_sha256(sha256)
if scope not in _SCOPE_RANK:
raise ValueError("scope must be local, shared, or global")
@ -192,9 +203,21 @@ class FileLedger(MemoryLedger):
scope: str = "local",
cube_id: str = "",
org_id: str = "",
*,
metadata: dict | None = None,
restamp: bool = False,
message: str = "",
) -> list[Receipt]:
before = set(self._by_id)
receipts = await super().stamp(sha256, scope=scope, cube_id=cube_id, org_id=org_id)
receipts = await super().stamp(
sha256,
scope=scope,
cube_id=cube_id,
org_id=org_id,
metadata=metadata,
restamp=restamp,
message=message,
)
if receipts[0].receipt_id not in before:
self._save()
return receipts
@ -217,13 +240,29 @@ class RoutingLedger:
scope: str = "local",
cube_id: str = "",
org_id: str = "",
*,
metadata: dict | None = None,
restamp: bool = False,
message: str = "",
) -> list[Receipt]:
if scope == "global":
return await self.global_ledger.stamp(
sha256, scope=scope, cube_id=cube_id, org_id=org_id
sha256,
scope=scope,
cube_id=cube_id,
org_id=org_id,
metadata=metadata,
restamp=restamp,
message=message,
)
return await self.local.stamp(
sha256, scope=scope, cube_id=cube_id, org_id=org_id
sha256,
scope=scope,
cube_id=cube_id,
org_id=org_id,
metadata=metadata,
restamp=restamp,
message=message,
)
async def verify(self, receipt_id: str, want_scope: str = "") -> VerifyResult:

View file

@ -0,0 +1,207 @@
"""verae-meta-git/1 documents, matching node-red-contrib-verae lib/metadata-history.js."""
from __future__ import annotations
import hashlib
import json
from datetime import datetime, timezone
from typing import Any
SCHEMA = "verae-meta-git/1"
def is_plain_object(value: Any) -> bool:
return isinstance(value, dict)
def is_blank(value: Any) -> bool:
if value is None:
return True
if isinstance(value, str):
return value.strip() == ""
if isinstance(value, list):
return len(value) == 0
if isinstance(value, dict):
return len(value) == 0
return False
def _clone(value: Any) -> Any:
if isinstance(value, list):
return [_clone(item) for item in value]
if isinstance(value, dict):
return {key: _clone(item) for key, item in value.items()}
return value
def _scalar(value: Any) -> str:
if isinstance(value, bool) or value is None or isinstance(value, str):
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
if isinstance(value, int) and not isinstance(value, bool):
return json.dumps(value)
if isinstance(value, float):
if value.is_integer():
return json.dumps(int(value))
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
def canonical(value: Any) -> str:
if isinstance(value, list):
return "[" + ",".join(canonical(item) for item in value) + "]"
if isinstance(value, dict):
keys = sorted(value)
return "{" + ",".join(_scalar(key) + ":" + canonical(value[key]) for key in keys) + "}"
return _scalar(value)
def _same(a: Any, b: Any) -> bool:
return canonical(a) == canonical(b)
def extend_snapshot(base: dict | None, patch: dict | None) -> dict:
out = _clone(base) if isinstance(base, dict) else {}
if not isinstance(patch, dict):
return out
for key, nxt in patch.items():
if is_blank(nxt):
continue
prev = out.get(key, None)
has = key in out
if has and isinstance(prev, dict) and isinstance(nxt, dict):
out[key] = extend_snapshot(prev, nxt)
elif isinstance(prev, list) and isinstance(nxt, list):
extra = [_clone(item) for item in nxt if not is_blank(item)]
if extra:
out[key] = list(prev) + extra
elif isinstance(nxt, list):
extra = [_clone(item) for item in nxt if not is_blank(item)]
if not extra:
continue
out[key] = (list(prev) + extra) if isinstance(prev, list) else extra
elif isinstance(nxt, dict):
nested = extend_snapshot(prev if isinstance(prev, dict) else {}, nxt)
if not is_blank(nested):
out[key] = nested
else:
out[key] = _clone(nxt)
return out
def changes_between(before: dict | None, after: dict | None) -> dict:
added: dict[str, Any] = {}
extended: dict[str, Any] = {}
updated: dict[str, Any] = {}
prior = before if isinstance(before, dict) else {}
nxt = after if isinstance(after, dict) else {}
for key in sorted(set(prior) | set(nxt)):
if key not in nxt:
continue
if key not in prior:
added[key] = _clone(nxt[key])
continue
if _same(prior[key], nxt[key]):
continue
if isinstance(prior[key], dict) and isinstance(nxt[key], dict):
nested = changes_between(prior[key], nxt[key])
if not is_blank(nested["added"]) or not is_blank(nested["extended"]) or not is_blank(nested["updated"]):
extended[key] = nested
elif (
isinstance(prior[key], list)
and isinstance(nxt[key], list)
and len(nxt[key]) >= len(prior[key])
):
appended = nxt[key][len(prior[key]) :]
if appended:
extended[key] = {"appended": _clone(appended)}
else:
updated[key] = {"from": _clone(prior[key]), "to": _clone(nxt[key])}
return {"added": added, "extended": extended, "updated": updated}
def _commit_id(commit: dict) -> str:
material = canonical(
{
"parent": commit["parent"],
"at": commit["at"],
"message": commit["message"],
"snapshot": commit["snapshot"],
}
)
return hashlib.sha256(material.encode()).hexdigest()
def is_history(value: Any) -> bool:
return (
isinstance(value, dict)
and value.get("schema") == SCHEMA
and isinstance(value.get("commits"), list)
and isinstance(value.get("snapshot"), dict)
)
def _history_from_snapshot(snapshot: dict, at: str) -> dict:
stored = _clone(snapshot)
commit = {
"parent": None,
"at": at,
"message": "stamp",
"snapshot": stored,
"changes": changes_between({}, stored),
}
commit["id"] = _commit_id(commit)
return {
"schema": SCHEMA,
"head": commit["id"],
"snapshot": _clone(stored),
"commits": [commit],
}
def _coerce(existing: Any, at: str) -> dict:
if is_history(existing):
return _clone(existing)
if isinstance(existing, dict) and not is_blank(existing):
return _history_from_snapshot(existing, at)
return {"schema": SCHEMA, "head": None, "snapshot": {}, "commits": []}
def _has_changes(changes: dict) -> bool:
return not is_blank(changes["added"]) or not is_blank(changes["extended"]) or not is_blank(changes["updated"])
def append_metadata(
existing: Any,
patch: dict | None,
*,
restamp: bool = False,
at: str | None = None,
prior_at: str | None = None,
message: str | None = None,
) -> dict | None:
if not at:
at = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
blank_patch = not isinstance(patch, dict) or is_blank(patch) or is_blank(extend_snapshot({}, patch))
if not restamp and blank_patch and not is_history(existing) and (existing is None or is_blank(existing)):
return None
doc = _coerce(existing, prior_at or at)
head = doc.get("head")
if not restamp and blank_patch:
return doc if head else None
snapshot = extend_snapshot(doc.get("snapshot") or {}, patch if isinstance(patch, dict) else {})
changes = changes_between(doc.get("snapshot") or {}, snapshot)
if not restamp and not _has_changes(changes) and head:
return doc
msg = message or ("restamp" if head else "stamp")
commit = {
"parent": head,
"at": at,
"message": msg,
"snapshot": snapshot,
"changes": changes,
}
commit["id"] = _commit_id(commit)
doc["commits"] = list(doc.get("commits") or []) + [commit]
doc["head"] = commit["id"]
doc["snapshot"] = snapshot
return doc

View file

@ -33,7 +33,14 @@ class NatsLedger:
scope: str = "local",
cube_id: str = "",
org_id: str = "",
*,
metadata: dict | None = None,
restamp: bool = False,
message: str = "",
) -> list[Receipt]:
if metadata or restamp:
raise ValueError("metadata history is stored on global stamps")
del message
digest = validate_sha256(sha256)
body = await self._ask(
SUBJECT_REQUEST,

View file

@ -138,13 +138,25 @@ def _tool_error(exc: ValueError) -> ToolError:
@mcp.tool()
async def stamp(
sha256: str, scope: str = "local", cube_id: str = "", org_id: str = ""
sha256: str,
scope: str = "local",
cube_id: str = "",
org_id: str = "",
metadata: dict | None = None,
restamp: bool = False,
message: str = "",
) -> Receipts:
"""Commit a SHA-256. scope=local is the leaf file ledger; scope=global posts digest and restamp=false to api.veraetime.net and reuses the first CID."""
"""Commit a SHA-256. scope=local is the leaf file ledger. scope=global posts the digest to api.veraetime.net and reuses the first CID unless restamp is true. Optional metadata is sent as a verae-meta-git/1 history; blank values do not delete fields."""
try:
with _caller_token():
receipts = await _ledger.stamp(
sha256, scope=scope, cube_id=cube_id, org_id=org_id
sha256,
scope=scope,
cube_id=cube_id,
org_id=org_id,
metadata=metadata,
restamp=restamp,
message=message,
)
except ValueError as exc:
raise _tool_error(exc) from exc

View file

@ -380,3 +380,36 @@ async def test_global_hash_report_uses_api_hash():
assert methods_urls[0] == ("GET", f"https://api.veraetime.net/api/hash/{SHA256_EMPTY}")
assert methods_urls[1][0] == "POST"
assert methods_urls[1][1].endswith("/api/verify")
async def test_restamp_sends_verae_meta_git_history():
class HistoryHttp(FakeHttp):
def request(self, method, url, payload, headers):
self.calls.append((method, url, payload, headers))
if url.endswith(f"/api/hash/{SHA256_EMPTY}"):
return _Resp(200, {"receipts": [{"jobId": "job-old", "certificate": "QmOld"}]})
if url.endswith("/api/status/job-old"):
return _Resp(200, {"status": "completed", "metadata": {"title": "deed", "tags": ["a"]}})
if url.endswith("/api/timestamp"):
return _Resp(202, {"jobId": "job-new"})
if url.endswith("/api/status/job-new"):
return _Resp(200, {"status": "completed", "result": "QmNew", "metadata": {"schema": "verae-meta-git/1"}})
raise AssertionError(f"unexpected {method} {url}")
http = HistoryHttp()
client = GlobalClient("https://api.veraetime.net", token="secret", http=http)
rec = client.submit(
SHA256_EMPTY,
metadata={"place": "office"},
restamp=True,
message="add the place",
at="2026-10-11T02:00:00.000Z",
)
post = next(call for call in http.calls if call[1].endswith("/api/timestamp"))
body = post[2]
assert body["restamp"] is True
assert body["metadata"]["schema"] == "verae-meta-git/1"
assert body["metadata"]["snapshot"]["title"] == "deed"
assert body["metadata"]["snapshot"]["place"] == "office"
assert body["metadata"]["snapshot"]["tags"] == ["a"]
assert rec.receipt_id == "QmNew"

View file

@ -0,0 +1,59 @@
"""Golden verae-meta-git/1 ids shared with node-red-contrib-verae."""
from verae_mcp_timestamping_server.metadata_history import append_metadata
HEAD_1 = "161ff3344ba776ae7201b73914c0e7500116ac9dde02e1967a761b7089c41fa7"
HEAD_2 = "c606e41ccbf4c0f9f653b21759df83ecda25262621075db3a153b592270fd918"
def test_append_matches_node_reference():
first = append_metadata(
None,
{"title": "deed", "tags": ["a"]},
restamp=False,
at="2026-10-11T01:00:00.000Z",
message="stamp",
)
assert first["head"] == HEAD_1
second = append_metadata(
first,
{"title": "deed-2", "tags": ["b"], "place": "office"},
restamp=True,
at="2026-10-11T02:00:00.000Z",
message="correct the title",
)
assert second["head"] == HEAD_2
assert second["snapshot"] == {"title": "deed-2", "tags": ["a", "b"], "place": "office"}
assert second["commits"][1]["parent"] == HEAD_1
assert "removed" not in second["commits"][1]["changes"]
def test_blank_restamp_keeps_snapshot():
nxt = append_metadata(
{"title": "deed", "note": "keep", "tags": ["a"]},
{"title": "", "note": None, "tags": []},
restamp=True,
at="2026-10-11T01:47:00.000Z",
prior_at="2026-10-11T01:00:00.000Z",
)
assert nxt["snapshot"]["title"] == "deed"
assert nxt["snapshot"]["note"] == "keep"
assert nxt["snapshot"]["tags"] == ["a"]
assert len(nxt["commits"]) == 2
def test_omit_when_empty():
assert append_metadata(None, None, restamp=False, at="2026-10-11T01:00:00.000Z") is None
assert append_metadata(None, {}, restamp=False, at="2026-10-11T01:00:00.000Z") is None
def test_history_document_is_not_rewritten_by_append_of_itself():
first = append_metadata(
None,
{"title": "deed", "tags": ["a"]},
restamp=False,
at="2026-10-11T01:00:00.000Z",
message="stamp",
)
assert first["schema"] == "verae-meta-git/1"
assert first["commits"][0]["parent"] is None