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:24 +00:00
commit deb0e251de
No known key found for this signature in database
7 changed files with 608 additions and 11 deletions

View file

@ -0,0 +1,341 @@
// Package metadatahist builds verae-meta-git/1 documents the same way as
// node-red-contrib-verae lib/metadata-history.js. Blank values do not delete
// fields. A document that already uses the schema is stored as sent.
package metadatahist
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"sort"
"time"
)
const Schema = "verae-meta-git/1"
func IsBlank(value any) bool {
if value == nil {
return true
}
switch v := value.(type) {
case string:
for _, r := range v {
if r != ' ' && r != '\t' && r != '\n' && r != '\r' {
return false
}
}
return true
case []any:
return len(v) == 0
case map[string]any:
return len(v) == 0
default:
return false
}
}
func isObject(value any) (map[string]any, bool) {
m, ok := value.(map[string]any)
return m, ok
}
func clone(value any) any {
switch v := value.(type) {
case []any:
out := make([]any, len(v))
for i := range v {
out[i] = clone(v[i])
}
return out
case map[string]any:
out := make(map[string]any, len(v))
for k, item := range v {
out[k] = clone(item)
}
return out
default:
return v
}
}
func jsonScalar(value any) string {
var buf bytes.Buffer
enc := json.NewEncoder(&buf)
enc.SetEscapeHTML(false)
_ = enc.Encode(value)
b := bytes.TrimRight(buf.Bytes(), "\n")
return string(b)
}
func canonical(value any) string {
switch v := value.(type) {
case []any:
parts := make([]string, len(v))
for i := range v {
parts[i] = canonical(v[i])
}
return "[" + join(parts) + "]"
case map[string]any:
keys := make([]string, 0, len(v))
for k := range v {
keys = append(keys, k)
}
sort.Strings(keys)
parts := make([]string, len(keys))
for i, k := range keys {
parts[i] = jsonScalar(k) + ":" + canonical(v[k])
}
return "{" + join(parts) + "}"
default:
return jsonScalar(value)
}
}
func join(parts []string) string {
out := ""
for i, p := range parts {
if i > 0 {
out += ","
}
out += p
}
return out
}
func same(a, b any) bool { return canonical(a) == canonical(b) }
func ExtendSnapshot(base, patch map[string]any) map[string]any {
out := map[string]any{}
if base != nil {
if cloned, ok := clone(base).(map[string]any); ok {
out = cloned
}
}
if patch == nil {
return out
}
for key, next := range patch {
if IsBlank(next) {
continue
}
prev, has := out[key]
prevObj, prevIsObj := isObject(prev)
nextObj, nextIsObj := isObject(next)
prevArr, prevIsArr := prev.([]any)
nextArr, nextIsArr := next.([]any)
switch {
case has && prevIsObj && nextIsObj:
out[key] = ExtendSnapshot(prevObj, nextObj)
case prevIsArr && nextIsArr:
extra := nonBlank(nextArr)
if len(extra) > 0 {
out[key] = append(append([]any{}, prevArr...), extra...)
}
case nextIsArr:
extra := nonBlank(nextArr)
if len(extra) == 0 {
continue
}
if prevIsArr {
out[key] = append(append([]any{}, prevArr...), extra...)
} else {
out[key] = extra
}
case nextIsObj:
nestedBase := map[string]any{}
if prevIsObj {
nestedBase = prevObj
}
nested := ExtendSnapshot(nestedBase, nextObj)
if !IsBlank(nested) {
out[key] = nested
}
default:
out[key] = clone(next)
}
}
return out
}
func nonBlank(items []any) []any {
out := make([]any, 0, len(items))
for _, item := range items {
if !IsBlank(item) {
out = append(out, clone(item))
}
}
return out
}
func ChangesBetween(before, after map[string]any) map[string]any {
added := map[string]any{}
extended := map[string]any{}
updated := map[string]any{}
if before == nil {
before = map[string]any{}
}
if after == nil {
after = map[string]any{}
}
keys := map[string]struct{}{}
for k := range before {
keys[k] = struct{}{}
}
for k := range after {
keys[k] = struct{}{}
}
ordered := make([]string, 0, len(keys))
for k := range keys {
ordered = append(ordered, k)
}
sort.Strings(ordered)
for _, key := range ordered {
next, hasNext := after[key]
if !hasNext {
continue
}
prev, hasPrev := before[key]
if !hasPrev {
added[key] = clone(next)
continue
}
if same(prev, next) {
continue
}
prevObj, prevIsObj := isObject(prev)
nextObj, nextIsObj := isObject(next)
prevArr, prevIsArr := prev.([]any)
nextArr, nextIsArr := next.([]any)
switch {
case prevIsObj && nextIsObj:
nested := ChangesBetween(prevObj, nextObj)
if !IsBlank(nested["added"]) || !IsBlank(nested["extended"]) || !IsBlank(nested["updated"]) {
extended[key] = nested
}
case prevIsArr && nextIsArr && len(nextArr) >= len(prevArr):
appended := nextArr[len(prevArr):]
if len(appended) > 0 {
extended[key] = map[string]any{"appended": clone(appended)}
}
default:
updated[key] = map[string]any{"from": clone(prev), "to": clone(next)}
}
}
return map[string]any{"added": added, "extended": extended, "updated": updated}
}
func commitID(commit map[string]any) string {
material := canonical(map[string]any{
"parent": commit["parent"],
"at": commit["at"],
"message": commit["message"],
"snapshot": commit["snapshot"],
})
sum := sha256.Sum256([]byte(material))
return hex.EncodeToString(sum[:])
}
func IsHistory(value any) bool {
m, ok := isObject(value)
if !ok || m["schema"] != Schema {
return false
}
if _, ok := m["commits"].([]any); !ok {
return false
}
_, ok = isObject(m["snapshot"])
return ok
}
func historyFromSnapshot(snapshot map[string]any, at string) map[string]any {
stored, _ := clone(snapshot).(map[string]any)
commit := map[string]any{
"parent": nil,
"at": at,
"message": "stamp",
"snapshot": stored,
"changes": ChangesBetween(map[string]any{}, stored),
}
commit["id"] = commitID(commit)
return map[string]any{
"schema": Schema,
"head": commit["id"],
"snapshot": clone(stored),
"commits": []any{commit},
}
}
func coerceHistory(existing any, at string) map[string]any {
if IsHistory(existing) {
cloned, _ := clone(existing).(map[string]any)
return cloned
}
if m, ok := isObject(existing); ok && !IsBlank(m) {
return historyFromSnapshot(m, at)
}
return map[string]any{
"schema": Schema,
"head": nil,
"snapshot": map[string]any{},
"commits": []any{},
}
}
func hasChanges(changes map[string]any) bool {
return !IsBlank(changes["added"]) || !IsBlank(changes["extended"]) || !IsBlank(changes["updated"])
}
// Append builds the next history document. A nil result means the caller
// should omit metadata. restamp with a blank patch still appends one commit.
func Append(existing any, patch map[string]any, restamp bool, at, priorAt, message string) map[string]any {
if at == "" {
at = time.Now().UTC().Format(time.RFC3339Nano)
}
blankPatch := patch == nil || IsBlank(patch) || IsBlank(ExtendSnapshot(map[string]any{}, patch))
if !restamp && blankPatch && !IsHistory(existing) && (existing == nil || IsBlank(existing)) {
return nil
}
if priorAt == "" {
priorAt = at
}
doc := coerceHistory(existing, priorAt)
head, _ := doc["head"].(string)
if !restamp && blankPatch {
if head != "" {
return doc
}
return nil
}
base, _ := doc["snapshot"].(map[string]any)
snapshot := ExtendSnapshot(base, patch)
changes := ChangesBetween(base, snapshot)
if !restamp && !hasChanges(changes) && head != "" {
return doc
}
msg := message
if msg == "" {
if head != "" {
msg = "restamp"
} else {
msg = "stamp"
}
}
var parent any
if head != "" {
parent = head
}
commit := map[string]any{
"parent": parent,
"at": at,
"message": msg,
"snapshot": snapshot,
"changes": changes,
}
commit["id"] = commitID(commit)
commits, _ := doc["commits"].([]any)
doc["commits"] = append(commits, commit)
doc["head"] = commit["id"]
doc["snapshot"] = snapshot
return doc
}

View file

@ -0,0 +1,52 @@
package metadatahist
import "testing"
func TestAppendMatchesNodeReference(t *testing.T) {
first := Append(nil, map[string]any{"title": "deed", "tags": []any{"a"}}, false, "2026-10-11T01:00:00.000Z", "", "stamp")
if first["head"] != "161ff3344ba776ae7201b73914c0e7500116ac9dde02e1967a761b7089c41fa7" {
t.Fatalf("head1=%v", first["head"])
}
second := Append(first, map[string]any{"title": "deed-2", "tags": []any{"b"}, "place": "office"}, true, "2026-10-11T02:00:00.000Z", "", "correct the title")
if second["head"] != "c606e41ccbf4c0f9f653b21759df83ecda25262621075db3a153b592270fd918" {
t.Fatalf("head2=%v", second["head"])
}
snap := second["snapshot"].(map[string]any)
if snap["title"] != "deed-2" || snap["place"] != "office" {
t.Fatalf("snapshot=%v", snap)
}
tags := snap["tags"].([]any)
if len(tags) != 2 || tags[0] != "a" || tags[1] != "b" {
t.Fatalf("tags=%v", tags)
}
commits := second["commits"].([]any)
tip := commits[1].(map[string]any)
if tip["parent"] != first["head"] {
t.Fatalf("parent=%v", tip["parent"])
}
if _, ok := tip["changes"].(map[string]any)["removed"]; ok {
t.Fatal("removed key")
}
}
func TestBlankRestampKeepsSnapshot(t *testing.T) {
base := map[string]any{"title": "deed", "note": "keep", "tags": []any{"a"}}
next := Append(base, map[string]any{"title": "", "note": nil, "tags": []any{}}, true, "2026-10-11T01:47:00.000Z", "2026-10-11T01:00:00.000Z", "")
snap := next["snapshot"].(map[string]any)
if snap["title"] != "deed" || snap["note"] != "keep" {
t.Fatalf("snapshot=%v", snap)
}
commits := next["commits"].([]any)
if len(commits) != 2 {
t.Fatalf("commits=%d", len(commits))
}
}
func TestOmitWhenEmpty(t *testing.T) {
if Append(nil, nil, false, "2026-10-11T01:00:00.000Z", "", "") != nil {
t.Fatal("expected omit")
}
if Append(nil, map[string]any{}, false, "2026-10-11T01:00:00.000Z", "", "") != nil {
t.Fatal("expected omit")
}
}

View file

@ -49,7 +49,11 @@ func (d *Deps) stamp(ctx context.Context, req *mcp.CallToolRequest, in StampArgs
if err != nil {
return nil, ReceiptsOut{}, err
}
rec, err := d.Global.Stamp(ctx, in.SHA256, tok)
rec, err := d.Global.StampWithMeta(ctx, in.SHA256, tok, veraetime.StampMeta{
Metadata: in.Metadata,
Restamp: in.Restamp,
Message: in.Message,
})
if err != nil {
return nil, ReceiptsOut{}, err
}
@ -61,6 +65,9 @@ func (d *Deps) stamp(ctx context.Context, req *mcp.CallToolRequest, in StampArgs
}
return nil, ReceiptsOut{Receipts: []ReceiptDTO{fromGlobal(rec)}}, nil
case "local", "shared":
if len(in.Metadata) > 0 || in.Restamp {
return nil, ReceiptsOut{}, fmt.Errorf("metadata history is stored on global stamps")
}
if d.Ledger == nil {
return nil, ReceiptsOut{}, fmt.Errorf("ledger not configured")
}
@ -98,7 +105,21 @@ func (d *Deps) stampBatch(ctx context.Context, req *mcp.CallToolRequest, in Stam
if err != nil {
return nil, StampBatchOut{}, err
}
br, err := d.Global.StampBatch(ctx, in.SHA256s, tok)
var metas []veraetime.StampMeta
if len(in.Metadata) > 0 || in.Restamp {
if len(in.Metadata) > 0 && len(in.Metadata) != len(in.SHA256s) {
return nil, StampBatchOut{}, fmt.Errorf("metadata must have one entry per digest")
}
metas = make([]veraetime.StampMeta, len(in.SHA256s))
for i := range in.SHA256s {
metas[i].Restamp = in.Restamp
metas[i].Message = in.Message
if i < len(in.Metadata) {
metas[i].Metadata = in.Metadata[i]
}
}
}
br, err := d.Global.StampBatchWithMeta(ctx, in.SHA256s, tok, metas)
if err != nil {
return nil, StampBatchOut{}, err
}
@ -108,6 +129,9 @@ func (d *Deps) stampBatch(ctx context.Context, req *mcp.CallToolRequest, in Stam
}
return nil, out, nil
case "local", "shared":
if len(in.Metadata) > 0 || in.Restamp {
return nil, StampBatchOut{}, fmt.Errorf("metadata history is stored on global stamps")
}
if d.Ledger == nil {
return nil, StampBatchOut{}, fmt.Errorf("ledger not configured")
}

View file

@ -27,7 +27,7 @@ func Register(s *mcp.Server, d *Deps) {
}
mcp.AddTool(s, &mcp.Tool{
Name: "stamp",
Description: "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.",
Description: "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.",
}, d.stamp)
mcp.AddTool(s, &mcp.Tool{
Name: "stamp_batch",

View file

@ -2,10 +2,13 @@ package tools
// StampArgs is the stamp tool input.
type StampArgs struct {
SHA256 string `json:"sha256" jsonschema:"SHA-256 digest (64 hex; optional sha256: prefix)"`
Scope string `json:"scope,omitempty" jsonschema:"local, shared, or global (default local)"`
CubeID string `json:"cube_id,omitempty" jsonschema:"optional cube id"`
OrgID string `json:"org_id,omitempty" jsonschema:"optional org id"`
SHA256 string `json:"sha256" jsonschema:"SHA-256 digest (64 hex; optional sha256: prefix)"`
Scope string `json:"scope,omitempty" jsonschema:"local, shared, or global (default local)"`
CubeID string `json:"cube_id,omitempty" jsonschema:"optional cube id"`
OrgID string `json:"org_id,omitempty" jsonschema:"optional org id"`
Metadata map[string]any `json:"metadata,omitempty" jsonschema:"optional plain object or a verae-meta-git/1 history. Blank values do not delete fields. Global scope only."`
Restamp bool `json:"restamp,omitempty" jsonschema:"when true, append metadata onto the previous global stamp for this digest"`
Message string `json:"message,omitempty" jsonschema:"optional history commit message"`
}
// VerifyArgs is the verify tool input.
@ -85,8 +88,11 @@ type CreateUserOut struct {
// StampBatchArgs is the stamp_batch tool input (DEC-659).
type StampBatchArgs struct {
SHA256s []string `json:"sha256s" jsonschema:"SHA-256 digests (64 hex each), 1..500"`
Scope string `json:"scope,omitempty" jsonschema:"global (default; charged per new digest to your Verae account) or local"`
SHA256s []string `json:"sha256s" jsonschema:"SHA-256 digests (64 hex each), 1..500"`
Scope string `json:"scope,omitempty" jsonschema:"global (default; charged per new digest to your Verae account) or local"`
Metadata []map[string]any `json:"metadata,omitempty" jsonschema:"optional history patch per digest, same length as sha256s. Global scope only."`
Restamp bool `json:"restamp,omitempty" jsonschema:"when true, append each metadata patch onto the previous global stamp"`
Message string `json:"message,omitempty" jsonschema:"optional history commit message for every item"`
}
// BatchItemDTO is one item of a stamp_batch answer.

View file

@ -10,6 +10,7 @@ import (
"strings"
"time"
"git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/internal/metadatahist"
"git.georgelambert.org/marchon/verae-mcp-timestamping-server-go/pkg/digest"
)
@ -155,8 +156,24 @@ func (c *Client) AuthPath() string { return c.authPath }
// BaseURL returns the API base URL.
func (c *Client) BaseURL() string { return c.baseURL }
// Stamp posts a SHA-256 digest with restamp=false.
// StampMeta is optional history for one stamp. The zero value keeps the
// digest-only body: restamp false and no metadata field.
type StampMeta struct {
Metadata map[string]any
Restamp bool
Message string
At string
}
// Stamp posts a SHA-256 digest with restamp=false and no metadata.
func (c *Client) Stamp(ctx context.Context, dig, bearer string) (*Receipt, error) {
return c.StampWithMeta(ctx, dig, bearer, StampMeta{})
}
// StampWithMeta posts a digest. Metadata or Restamp sends a verae-meta-git/1
// document. A second submit of an existing digest without Restamp, when
// metadata was sent, returns an error so the first history is left unchanged.
func (c *Client) StampWithMeta(ctx context.Context, dig, bearer string, meta StampMeta) (*Receipt, error) {
h, err := digest.NormalizeSHA256(dig)
if err != nil {
return nil, err
@ -167,6 +184,9 @@ func (c *Client) Stamp(ctx context.Context, dig, bearer string) (*Receipt, error
"hashAlg": "SHA256",
"restamp": false,
}
if err := c.attachHistory(ctx, h, bearer, meta, payload); err != nil {
return nil, err
}
status, body, err := c.doJSON(ctx, http.MethodPost, c.baseURL+"/api/timestamp", payload, bearer)
if err != nil {
return nil, err
@ -175,6 +195,9 @@ func (c *Client) Stamp(ctx context.Context, dig, bearer string) (*Receipt, error
return nil, authErr(status, body)
}
if status == http.StatusOK && (str(body, "certificate") != "" || truthy(body["existing"])) {
if payload["metadata"] != nil && !truthy(payload["restamp"]) {
return nil, fmt.Errorf("veraetime: digest already stamped; set restamp to append metadata")
}
cid := str(body, "certificate")
if cid != "" {
return &Receipt{
@ -641,15 +664,105 @@ type BatchResult struct {
Results []BatchItem `json:"results"`
}
func (c *Client) attachHistory(ctx context.Context, dig, bearer string, meta StampMeta, payload map[string]any) error {
if len(meta.Metadata) == 0 && !meta.Restamp && !metadatahist.IsHistory(meta.Metadata) {
return nil
}
var prior any
if meta.Restamp && !metadatahist.IsHistory(meta.Metadata) {
loaded, err := c.priorMetadata(ctx, dig, bearer)
if err != nil {
return err
}
prior = loaded
}
doc := metadatahist.Append(prior, meta.Metadata, meta.Restamp, meta.At, "", meta.Message)
if metadatahist.IsHistory(meta.Metadata) {
doc = meta.Metadata
}
if doc == nil {
return nil
}
payload["metadata"] = doc
if meta.Restamp {
payload["restamp"] = true
}
if commits, ok := doc["commits"].([]any); ok && len(commits) > 1 {
payload["restamp"] = true
}
return nil
}
func (c *Client) priorMetadata(ctx context.Context, dig, bearer string) (map[string]any, error) {
status, body, err := c.doJSON(ctx, http.MethodGet, c.baseURL+"/api/hash/"+dig, nil, bearer)
if err != nil {
return nil, err
}
if status == http.StatusNotFound {
return nil, nil
}
if status == http.StatusUnauthorized {
return nil, authErr(status, body)
}
if status >= 300 {
return nil, httpErr("hash report", status, body)
}
refs, _ := body["receipts"].([]any)
jobID := ""
for _, refAny := range refs {
ref, ok := refAny.(map[string]any)
if !ok {
continue
}
if id := str(ref, "jobId"); id != "" {
jobID = id
}
}
if jobID == "" {
return nil, nil
}
st, sb, err := c.doJSON(ctx, http.MethodGet, c.baseURL+"/api/status/"+jobID, nil, bearer)
if err != nil {
return nil, err
}
if st == http.StatusNotFound {
return nil, nil
}
if st == http.StatusUnauthorized {
return nil, authErr(st, sb)
}
if st >= 300 {
return nil, httpErr("status", st, sb)
}
meta, _ := sb["metadata"].(map[string]any)
return meta, nil
}
// StampBatch posts SHA-256 digests (restamp=false) in one batch; each new digest is charged to the key's customer.
func (c *Client) StampBatch(ctx context.Context, digests []string, bearer string) (*BatchResult, error) {
return c.StampBatchWithMeta(ctx, digests, bearer, nil)
}
// StampBatchWithMeta is StampBatch with one optional history per digest.
// A nil metas slice keeps every item digest-only. A non-nil slice must match
// len(digests).
func (c *Client) StampBatchWithMeta(ctx context.Context, digests []string, bearer string, metas []StampMeta) (*BatchResult, error) {
if metas != nil && len(metas) != len(digests) {
return nil, fmt.Errorf("veraetime: metadata entries (%d) must match digests (%d)", len(metas), len(digests))
}
items := make([]map[string]any, 0, len(digests))
for i, d := range digests {
h, err := digest.NormalizeSHA256(d)
if err != nil {
return nil, fmt.Errorf("sha256s[%d]: %w", i, err)
}
items = append(items, map[string]any{"data": h, "digest": h, "hashAlg": "SHA256", "restamp": false})
item := map[string]any{"data": h, "digest": h, "hashAlg": "SHA256", "restamp": false}
if metas != nil {
if err := c.attachHistory(ctx, h, bearer, metas[i], item); err != nil {
return nil, err
}
}
items = append(items, item)
}
status, body, err := c.doJSON(ctx, http.MethodPost, c.baseURL+"/api/batch/timestamp", map[string]any{"items": items}, bearer)
if err != nil {

View file

@ -381,6 +381,67 @@ func TestKeeper_UsesGlobalTokenEnv(t *testing.T) {
}
}
func TestStampWithMeta_SendsHistoryAndExtendsOnRestamp(t *testing.T) {
var posted []map[string]any
rt := roundTripFunc(func(r *http.Request) (*http.Response, error) {
switch {
case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/api/timestamp"):
posted = append(posted, readJSON(t, r))
return jsonResp(202, map[string]any{"jobId": "job-new"}), nil
case r.Method == http.MethodGet && strings.Contains(r.URL.Path, "/api/hash/"):
return jsonResp(200, map[string]any{
"receipts": []any{map[string]any{"jobId": "job-old", "certificate": "QmOld"}},
}), nil
case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/api/status/job-old"):
return jsonResp(200, map[string]any{
"status": "completed",
"metadata": map[string]any{
"title": "deed",
"tags": []any{"a"},
},
}), nil
case r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/api/status/job-new"):
return jsonResp(200, map[string]any{
"status": "completed",
"result": "QmNew",
"metadata": map[string]any{
"schema": "verae-meta-git/1",
},
}), nil
default:
t.Fatalf("unexpected %s %s", r.Method, r.URL.Path)
return nil, nil
}
})
c := veraetime.NewClient("https://api.example", veraetime.WithRoundTripper(rt), veraetime.WithPollInterval(time.Millisecond))
_, err := c.StampWithMeta(context.Background(), sha256Empty, "tok", veraetime.StampMeta{
Metadata: map[string]any{"place": "office"},
Restamp: true,
Message: "add the place",
At: "2026-10-11T02:00:00.000Z",
})
if err != nil {
t.Fatal(err)
}
if len(posted) != 1 {
t.Fatalf("posts=%d", len(posted))
}
if posted[0]["restamp"] != true {
t.Fatalf("restamp=%v", posted[0]["restamp"])
}
doc, ok := posted[0]["metadata"].(map[string]any)
if !ok || doc["schema"] != "verae-meta-git/1" {
t.Fatalf("metadata=%v", posted[0]["metadata"])
}
snap := doc["snapshot"].(map[string]any)
if snap["title"] != "deed" || snap["place"] != "office" {
t.Fatalf("snapshot=%v", snap)
}
if _, ok := posted[0]["metadata"]; !ok {
t.Fatal("missing metadata")
}
}
func TestKeeper_UsesFreshStoredToken(t *testing.T) {
dir := t.TempDir()
path := dir + "/auth.properties"