From d9e467b539bf5ad5b6d187f63ceb6eb96871566a Mon Sep 17 00:00:00 2001 From: Nader Helmy Date: Fri, 29 May 2026 17:36:47 -0500 Subject: [PATCH] fix: align log cli API surface --- .printing-press-patches.json | 37 ++++++ .printing-press.json | 10 +- README.md | 10 +- SKILL.md | 79 +++++++----- internal/cli/api_discovery.go | 153 ++++++++++++++++------ internal/cli/promoted_entries.go | 104 +++++++++++++-- internal/cli/promoted_feed-json.go | 121 ++++++++++++++++++ internal/cli/promoted_log-pubkey.go | 88 +++++++++++++ internal/cli/promoted_proof.go | 102 +++++++++++++++ internal/cli/promoted_stream.go | 135 ++++++++++++++++++++ internal/cli/root.go | 8 +- internal/cli/which.go | 8 +- internal/cli/which_test.go | 2 +- internal/mcp/tools.go | 87 +++++++++++-- internal/store/schema_version_test.go | 2 +- internal/store/store.go | 4 +- spec.yaml | 177 ++++++++++++++++++++++++-- 17 files changed, 1007 insertions(+), 120 deletions(-) create mode 100644 .printing-press-patches.json create mode 100644 internal/cli/promoted_feed-json.go create mode 100644 internal/cli/promoted_log-pubkey.go create mode 100644 internal/cli/promoted_proof.go create mode 100644 internal/cli/promoted_stream.go diff --git a/.printing-press-patches.json b/.printing-press-patches.json new file mode 100644 index 0000000..37ef579 --- /dev/null +++ b/.printing-press-patches.json @@ -0,0 +1,37 @@ +{ + "schema_version": 1, + "applied_at": "2026-05-29", + "base_run_id": "20260517-103932", + "base_printing_press_version": "4.8.0", + "patches": [ + { + "id": "log-api-parity", + "summary": "Update the CLI and MCP surface for the current log-node API.", + "reason": "The public log API added proof recovery, signed-note vkey publication, JSON Feed, and SSE stream endpoints after this generated CLI was first published. POST /v1/entries also now accepts a bare signed atrib record and returns a proof bundle.", + "files": [ + "spec.yaml", + "README.md", + "SKILL.md", + "internal/cli/promoted_entries.go", + "internal/cli/promoted_feed-json.go", + "internal/cli/promoted_log-pubkey.go", + "internal/cli/promoted_proof.go", + "internal/cli/promoted_stream.go", + "internal/cli/root.go", + "internal/cli/which.go", + "internal/mcp/tools.go", + ".printing-press.json" + ], + "validated_outcome": "Dry-run generation parsed 13 resources and 14 endpoints. Local tests and live smoke checks cover the finite endpoints and SSE ready event." + }, + { + "id": "api-promoted-endpoints", + "summary": "Make api discovery list promoted endpoint commands.", + "reason": "The generated api command only listed hidden resource parents, so it missed top-level promoted endpoints such as checkpoint, pubkey, recent, and stats.", + "files": [ + "internal/cli/api_discovery.go" + ], + "validated_outcome": "atrib-log-pp-cli api lists promoted endpoints as interfaces." + } + ] +} diff --git a/.printing-press.json b/.printing-press.json index b1ff4a1..c325a9b 100644 --- a/.printing-press.json +++ b/.printing-press.json @@ -8,14 +8,14 @@ "owner": "nader-helmy", "printer": "creatornader", "printer_name": "Nader Helmy", - "spec_path": "./atrib-log-openapi.yaml", + "spec_path": "./spec.yaml", "spec_format": "openapi3", - "spec_checksum": "sha256:52f791859856a9ef338a8de9f1559e96e2a0b0d9b48e735fd0fa6bdc9f0b3257", + "spec_checksum": "sha256:3ed76c7644d78eb10a138a20600b28b4d466ec9a0b636abcafdb1f3075f0fc8f", "run_id": "20260517-103932", "mcp_binary": "atrib-log-pp-mcp", - "mcp_tool_count": 10, - "mcp_public_tool_count": 10, + "mcp_tool_count": 13, + "mcp_public_tool_count": 13, "mcp_ready": "full", "api_version": "1.0", "auth_type": "none" -} \ No newline at end of file +} diff --git a/README.md b/README.md index 7376aac..bacd214 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # atrib-log-pp-cli -Developer CLI for the [atrib](https://github.com/creatornader/atrib) transparency log at `log.atrib.dev`. Wraps the public log API (signed checkpoint, recent entries, lookup by hash, by context, by creator, Merkle tile retrieval) with local SQLite mirroring, full-text search, and agent-friendly defaults. +Developer CLI for the [atrib](https://github.com/creatornader/atrib) transparency log at `log.atrib.dev`. Wraps the public log API (signed checkpoint, log public keys, stats, recent entries, lookup by hash, inclusion-proof recovery, by context, by creator, JSON Feed, SSE stream, Merkle tile retrieval) with local SQLite mirroring, full-text search, and agent-friendly defaults. Generated by [Printing Press](https://github.com/mvanhorn/cli-printing-press) from a hand-authored OpenAPI spec for `log.atrib.dev`. @@ -8,7 +8,7 @@ Generated by [Printing Press](https://github.com/mvanhorn/cli-printing-press) fr **This is** the developer-facing CLI for direct interaction with `log.atrib.dev`. Use it for batch queries, verification, debugging, dogfooding the public log API, and one-off "show me the last N entries by signer X" type questions. -**This is NOT** a replacement for the [`@atrib/*`](https://github.com/creatornader/atrib) MCP cognitive primitives (`emit`, `annotate`, `revise`, `recall`, `trace`, `summarize`). Those are the agent-facing surface, designed around the 6 verbs an agent reasons in. This CLI is the operator-facing surface, designed around the HTTP API endpoints. They are complementary: +**This is NOT** a replacement for the [`@atrib/*`](https://github.com/creatornader/atrib) MCP cognitive primitives (`emit`, `annotate`, `revise`, `recall`, `trace`, `summarize`, `verify`). Those are the agent-facing surface, designed around the seven primitives an agent reasons in. This CLI is the operator-facing surface, designed around the HTTP API endpoints. They are complementary: - Agent doing cognitive work → use the MCP primitives - Human or script interacting with the log → use this CLI @@ -37,11 +37,15 @@ The binary lands in `$GOPATH/bin` (typically `~/go/bin/`). Ensure that's on your ```sh # Live API queries atrib-log-pp-cli checkpoint # signed tree head +atrib-log-pp-cli log-pubkey # C2SP signed-note vkey atrib-log-pp-cli stats # tree size + entries by event type atrib-log-pp-cli recent --limit 10 # most recent entries atrib-log-pp-cli lookup # entry by hash +atrib-log-pp-cli proof # recover inclusion proof atrib-log-pp-cli by-context # all entries in a session atrib-log-pp-cli by-creator # all entries by signer +atrib-log-pp-cli feed-json --limit 10 # JSON Feed 1.1 +atrib-log-pp-cli stream # raw Server-Sent Events # Local mirror + search atrib-log-pp-cli sync # mirror to local SQLite @@ -77,7 +81,7 @@ Configure in Claude Desktop's `claude_desktop_config.json`: } ``` -The MCP server exposes the same command surface as the CLI. Note: for *agent cognitive work* (emitting signed records, recalling past actions, etc.), the [`@atrib/*`](https://github.com/creatornader/atrib) MCP servers are the right surface, not this one. +The MCP server exposes the same finite command surface as the CLI. The raw SSE stream command stays CLI-only because MCP tool calls expect a bounded response. Note: for *agent cognitive work* (emitting signed records, recalling past actions, etc.), the [`@atrib/*`](https://github.com/creatornader/atrib) MCP servers are the right surface, not this one. ## Configuration diff --git a/SKILL.md b/SKILL.md index 36e417a..4b05235 100644 --- a/SKILL.md +++ b/SKILL.md @@ -1,6 +1,6 @@ --- name: pp-atrib-log -description: "Printing Press CLI for Atrib Log. atrib transparency log — Sigsum-style append-only Merkle log of signed agent action records. Each entry is a..." +description: "Printing Press CLI for Atrib Log. atrib transparency log - Sigsum-style append-only Merkle log of signed agent action records. Each entry is a..." author: "Nader Helmy" license: "Apache-2.0" argument-hint: " [args] | install cli|mcp" @@ -12,7 +12,7 @@ metadata: - atrib-log-pp-cli --- -# Atrib Log — Printing Press CLI +# Atrib Log - Printing Press CLI ## Prerequisites: Install the CLI @@ -29,48 +29,65 @@ If the `npx` install fails before this CLI has a public-library category, instal If `--version` reports "command not found" after install, the install step did not put the binary on `$PATH`. Do not proceed with skill commands until verification succeeds. -atrib transparency log — Sigsum-style append-only Merkle log of signed +atrib transparency log - Sigsum-style append-only Merkle log of signed agent action records. Each entry is a cryptographically signed record -of an LLM agent action (tool call, observation, annotation, revision). +of an LLM agent action (tool call, transaction, observation, annotation, +revision, or directory anchor). ## Command Reference -**by-context** — Manage by context +**by-context** - Manage by context -- `atrib-log-pp-cli by-context ` — All entries in a context (session) +- `atrib-log-pp-cli by-context ` - All entries in a context (session) -**by-creator** — Manage by creator +**by-creator** - Manage by creator -- `atrib-log-pp-cli by-creator ` — All entries by signer (creator key) +- `atrib-log-pp-cli by-creator ` - All entries by signer (creator key) -**checkpoint** — Manage checkpoint +**checkpoint** - Manage checkpoint -- `atrib-log-pp-cli checkpoint` — Returns the current signed checkpoint (tree size + root hash + log signature). Use this to anchor your local view of... +- `atrib-log-pp-cli checkpoint` - Returns the current signed checkpoint (tree size + root hash + log signature). Use this to anchor your local view of... -**entries** — Manage entries +**entries** - Manage entries -- `atrib-log-pp-cli entries` — Write path. Submit a signed record for inclusion in the log. Returns the assigned index + record_hash on success. +- `atrib-log-pp-cli entries` - Write path. Submit a signed record for inclusion in the log. Returns an inclusion-proof bundle on success. -**lookup** — Manage lookup +**feed-json** - Manage JSON Feed -- `atrib-log-pp-cli lookup ` — Lookup entry by record hash +- `atrib-log-pp-cli feed-json` - JSON Feed 1.1 companion for newest-first decoded log entries. -**pubkey** — Manage pubkey +**log-pubkey** - Manage C2SP log public key -- `atrib-log-pp-cli pubkey` — Get verification public key +- `atrib-log-pp-cli log-pubkey` - Get the log public key in signed-note vkey format. -**recent** — Manage recent +**lookup** - Manage lookup -- `atrib-log-pp-cli recent` — Most recent entries +- `atrib-log-pp-cli lookup ` - Lookup entry by record hash -**stats** — Manage stats +**proof** - Manage proof recovery -- `atrib-log-pp-cli stats` — Tree statistics +- `atrib-log-pp-cli proof ` - Recover an inclusion-proof bundle for a record already in the log. -**tile** — Manage tile +**pubkey** - Manage pubkey -- `atrib-log-pp-cli tile get` — Sigsum-style Merkle tile at level L, position N -- `atrib-log-pp-cli tile get-entries` — Get entries within a tile (leaf-level) +- `atrib-log-pp-cli pubkey` - Get verification public key + +**recent** - Manage recent + +- `atrib-log-pp-cli recent` - Most recent entries + +**stats** - Manage stats + +- `atrib-log-pp-cli stats` - Tree statistics + +**stream** - Manage SSE stream + +- `atrib-log-pp-cli stream` - Stream new decoded log entries as raw Server-Sent Events. + +**tile** - Manage tile + +- `atrib-log-pp-cli tile get` - Sigsum-style Merkle tile at level L, position N +- `atrib-log-pp-cli tile get-entries` - Get entries within a tile (leaf-level) ### Finding the right command @@ -81,7 +98,7 @@ When you know what you want to do but not which command does it, ask the CLI dir atrib-log-pp-cli which "" ``` -`which` resolves a natural-language capability query to the best matching command from this CLI's curated feature index. Exit code `0` means at least one match; exit code `2` means no confident match — fall back to `--help` or use a narrower query. +`which` resolves a natural-language capability query to the best matching command from this CLI's curated feature index. Exit code `0` means at least one match; exit code `2` means no confident match - fall back to `--help` or use a narrower query. ## Auth Setup @@ -93,16 +110,16 @@ Run `atrib-log-pp-cli doctor` to verify setup. Add `--agent` to any command. Expands to: `--json --compact --no-input --no-color --yes`. -- **Pipeable** — JSON on stdout, errors on stderr -- **Filterable** — `--select` keeps a subset of fields. Dotted paths descend into nested structures; arrays traverse element-wise. Critical for keeping context small on verbose APIs: +- **Pipeable** - JSON on stdout, errors on stderr +- **Filterable** - `--select` keeps a subset of fields. Dotted paths descend into nested structures; arrays traverse element-wise. Critical for keeping context small on verbose APIs: ```bash atrib-log-pp-cli by-context mock-value --agent --select id,name,status ``` -- **Previewable** — `--dry-run` shows the request without sending -- **Offline-friendly** — sync/search commands can use the local SQLite store when available -- **Non-interactive** — never prompts, every input is a flag -- **Explicit retries** — use `--idempotent` only when an already-existing create should count as success +- **Previewable** - `--dry-run` shows the request without sending +- **Offline-friendly** - sync/search commands can use the local SQLite store when available +- **Non-interactive** - never prompts, every input is a flag +- **Explicit retries** - use `--idempotent` only when an already-existing create should count as success ### Response envelope @@ -115,7 +132,7 @@ Commands that read from the local store or the API wrap output in a provenance e } ``` -Parse `.results` for data and `.meta.source` to know whether it's live or local. A human-readable `N results (live)` summary is printed to stderr only when stdout is a terminal AND no machine-format flag (`--json`, `--csv`, `--compact`, `--quiet`, `--plain`, `--select`) is set — piped/agent consumers and explicit-format runs get pure JSON on stdout. +Parse `.results` for data and `.meta.source` to know whether it's live or local. A human-readable `N results (live)` summary is printed to stderr only when stdout is a terminal AND no machine-format flag (`--json`, `--csv`, `--compact`, `--quiet`, `--plain`, `--select`) is set - piped/agent consumers and explicit-format runs get pure JSON on stdout. ## Agent Feedback diff --git a/internal/cli/api_discovery.go b/internal/cli/api_discovery.go index e4767ac..9a652db 100644 --- a/internal/cli/api_discovery.go +++ b/internal/cli/api_discovery.go @@ -34,65 +34,51 @@ Run 'api ' to see that interface's methods.`, if len(args) > 0 { target := strings.ToLower(args[0]) - for _, child := range root.Commands() { - if child.Hidden && strings.ToLower(child.Name()) == target { - methods := child.Commands() - // JSON envelope: {interface, short, methods: [{name, short}, ...]}. - if flags.asJSON { - methodList := make([]map[string]any, 0, len(methods)) - for _, method := range methods { - methodList = append(methodList, map[string]any{ - "name": method.Name(), - "short": method.Short, - }) - } - return printJSONFiltered(cmd.OutOrStdout(), map[string]any{ - "interface": child.Name(), - "short": child.Short, - "methods": methodList, - }, flags) - } - if len(methods) == 0 { - return child.Help() - } - fmt.Fprintf(cmd.OutOrStdout(), "%s — %s\n\nMethods:\n", child.Name(), child.Short) + // PATCH: promoted endpoint commands are not hidden interface + // parents, so collect by pp:endpoint annotation instead. + methods, short := collectAPIMethods(root, target) + if len(methods) > 0 { + if flags.asJSON { + methodList := make([]map[string]any, 0, len(methods)) for _, method := range methods { - fmt.Fprintf(cmd.OutOrStdout(), " %-50s %s\n", child.Name()+" "+method.Name(), method.Short) + methodList = append(methodList, map[string]any{ + "name": method.Name, + "short": method.Short, + "command": method.Command, + }) } - fmt.Fprintf(cmd.OutOrStdout(), "\nUse '%s-pp-cli %s --help' for details.\n", "atrib-log", child.Name()) - return nil + return printJSONFiltered(cmd.OutOrStdout(), map[string]any{ + "interface": target, + "short": short, + "methods": methodList, + }, flags) + } + fmt.Fprintf(cmd.OutOrStdout(), "%s - %s\n\nMethods:\n", target, short) + for _, method := range methods { + fmt.Fprintf(cmd.OutOrStdout(), " %-50s %s\n", method.Command, method.Short) } + fmt.Fprintf(cmd.OutOrStdout(), "\nUse '%s --help' for details.\n", root.Name()) + return nil } return fmt.Errorf("interface %q not found. Run '%s-pp-cli api' to list all interfaces", args[0], "atrib-log") } - // Pre-formatting human strings ahead of time would block the JSON - // path from emitting clean field values; build the typed slice and - // derive human format on print. - type ifaceEntry struct { - Name string `json:"name"` - Short string `json:"short"` - } - var ifaces []ifaceEntry - for _, child := range root.Commands() { - if child.Hidden { - ifaces = append(ifaces, ifaceEntry{Name: child.Name(), Short: child.Short}) - } - } - sort.Slice(ifaces, func(i, j int) bool { return ifaces[i].Name < ifaces[j].Name }) + // PATCH: include promoted one-shot endpoint commands in addition + // to hidden resource parents like tile. + ifaces := collectAPIInterfaces(root) // JSON envelope: {interfaces: [...], note?: "..."}. if flags.asJSON { out := map[string]any{"interfaces": ifaces} if len(ifaces) == 0 { - out["interfaces"] = []ifaceEntry{} - out["note"] = "No hidden API interfaces found." + out["interfaces"] = []apiInterfaceEntry{} + out["note"] = "No API interfaces found." } return printJSONFiltered(cmd.OutOrStdout(), out, flags) } if len(ifaces) == 0 { - fmt.Fprintln(cmd.OutOrStdout(), "No hidden API interfaces found.") + fmt.Fprintln(cmd.OutOrStdout(), "No API interfaces found.") return nil } @@ -107,3 +93,86 @@ Run 'api ' to see that interface's methods.`, return cmd } + +type apiInterfaceEntry struct { + Name string `json:"name"` + Short string `json:"short"` +} + +type apiMethodEntry struct { + Name string + Short string + Command string +} + +func collectAPIInterfaces(root *cobra.Command) []apiInterfaceEntry { + byName := map[string]apiInterfaceEntry{} + walkAPICommands(root, func(cmd *cobra.Command) { + endpoint := cmd.Annotations["pp:endpoint"] + if endpoint == "" { + return + } + name := apiInterfaceName(endpoint) + if name == "" { + return + } + short := cmd.Short + if cmd.Parent() != nil && cmd.Parent().Hidden && cmd.Parent().Name() == name { + short = cmd.Parent().Short + } + if _, exists := byName[name]; !exists { + byName[name] = apiInterfaceEntry{Name: name, Short: short} + } + }) + out := make([]apiInterfaceEntry, 0, len(byName)) + for _, e := range byName { + out = append(out, e) + } + sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name }) + return out +} + +func collectAPIMethods(root *cobra.Command, target string) ([]apiMethodEntry, string) { + var methods []apiMethodEntry + short := "" + walkAPICommands(root, func(cmd *cobra.Command) { + endpoint := cmd.Annotations["pp:endpoint"] + if apiInterfaceName(endpoint) != target { + return + } + methodName := endpoint + if dot := strings.Index(endpoint, "."); dot >= 0 && dot < len(endpoint)-1 { + methodName = endpoint[dot+1:] + } + if short == "" { + short = cmd.Short + if cmd.Parent() != nil && cmd.Parent().Hidden && cmd.Parent().Name() == target { + short = cmd.Parent().Short + } + } + methods = append(methods, apiMethodEntry{ + Name: methodName, + Short: cmd.Short, + Command: strings.TrimPrefix(cmd.CommandPath(), root.Name()+" "), + }) + }) + sort.Slice(methods, func(i, j int) bool { return methods[i].Name < methods[j].Name }) + return methods, short +} + +func apiInterfaceName(endpoint string) string { + if endpoint == "" { + return "" + } + if dot := strings.Index(endpoint, "."); dot >= 0 { + return endpoint[:dot] + } + return endpoint +} + +func walkAPICommands(cmd *cobra.Command, visit func(*cobra.Command)) { + for _, child := range cmd.Commands() { + visit(child) + walkAPICommands(child, visit) + } +} diff --git a/internal/cli/promoted_entries.go b/internal/cli/promoted_entries.go index b6e3c49..45eb1a0 100644 --- a/internal/cli/promoted_entries.go +++ b/internal/cli/promoted_entries.go @@ -12,16 +12,52 @@ import ( ) func newEntriesPromotedCmd(flags *rootFlags) *cobra.Command { + var bodyArgsHash string + var bodyChainRoot string + var bodyContentId string + var bodyContextId string + var bodyCreatorKey string + var bodyEventType string + var bodyInformedBy string + var bodyProvenanceToken string + var bodyResultHash string + var bodySessionToken string var bodySignature string - var bodySignedPayload string + var bodySpecVersion string + var bodyTimestamp int + var bodyToolName string cmd := &cobra.Command{ Use: "entries", - Short: "Write path. Submit a signed record for inclusion in the log. Returns the assigned index + record_hash on success.", - Long: "Shortcut for 'entries post-entry'. Write path. Submit a signed record for inclusion in the log. Returns the assigned index + record_hash on success.", - Example: " atrib-log-pp-cli entries", + Short: "Write path. Submit a signed record for inclusion in the log. Returns a fresh inclusion-proof bundle on success.", + Long: "Shortcut for 'entries post-entry'. Write path. Submit a signed record for inclusion in the log. Returns a fresh inclusion-proof bundle on success.", + Example: " atrib-log-pp-cli entries --chain-root example-value", Annotations: map[string]string{"pp:endpoint": "entries.post-entry", "pp:method": "POST", "pp:path": "/v1/entries"}, RunE: func(cmd *cobra.Command, args []string) error { + if !cmd.Flags().Changed("chain-root") && !flags.dryRun { + return fmt.Errorf("required flag \"%s\" not set", "chain-root") + } + if !cmd.Flags().Changed("content-id") && !flags.dryRun { + return fmt.Errorf("required flag \"%s\" not set", "content-id") + } + if !cmd.Flags().Changed("context-id") && !flags.dryRun { + return fmt.Errorf("required flag \"%s\" not set", "context-id") + } + if !cmd.Flags().Changed("creator-key") && !flags.dryRun { + return fmt.Errorf("required flag \"%s\" not set", "creator-key") + } + if !cmd.Flags().Changed("event-type") && !flags.dryRun { + return fmt.Errorf("required flag \"%s\" not set", "event-type") + } + if !cmd.Flags().Changed("signature") && !flags.dryRun { + return fmt.Errorf("required flag \"%s\" not set", "signature") + } + if !cmd.Flags().Changed("spec-version") && !flags.dryRun { + return fmt.Errorf("required flag \"%s\" not set", "spec-version") + } + if !cmd.Flags().Changed("timestamp") && !flags.dryRun { + return fmt.Errorf("required flag \"%s\" not set", "timestamp") + } c, err := flags.newClient() if err != nil { return err @@ -34,11 +70,51 @@ func newEntriesPromotedCmd(flags *rootFlags) *cobra.Command { // body-aware cached read helper is filed as #425 for when a // second store-backed POST-search consumer ships. body := map[string]any{} + if bodyArgsHash != "" { + body["args_hash"] = bodyArgsHash + } + if bodyChainRoot != "" { + body["chain_root"] = bodyChainRoot + } + if bodyContentId != "" { + body["content_id"] = bodyContentId + } + if bodyContextId != "" { + body["context_id"] = bodyContextId + } + if bodyCreatorKey != "" { + body["creator_key"] = bodyCreatorKey + } + if bodyEventType != "" { + body["event_type"] = bodyEventType + } + if bodyInformedBy != "" { + var parsedInformedBy any + if err := json.Unmarshal([]byte(bodyInformedBy), &parsedInformedBy); err != nil { + return fmt.Errorf("parsing --informed-by JSON: %w", err) + } + body["informed_by"] = parsedInformedBy + } + if bodyProvenanceToken != "" { + body["provenance_token"] = bodyProvenanceToken + } + if bodyResultHash != "" { + body["result_hash"] = bodyResultHash + } + if bodySessionToken != "" { + body["session_token"] = bodySessionToken + } if bodySignature != "" { body["signature"] = bodySignature } - if bodySignedPayload != "" { - body["signed_payload"] = bodySignedPayload + if bodySpecVersion != "" { + body["spec_version"] = bodySpecVersion + } + if bodyTimestamp != 0 { + body["timestamp"] = bodyTimestamp + } + if bodyToolName != "" { + body["tool_name"] = bodyToolName } data, _, err := c.PostWithParams(path, params, body) @@ -96,8 +172,20 @@ func newEntriesPromotedCmd(flags *rootFlags) *cobra.Command { return printOutputWithFlags(cmd.OutOrStdout(), data, flags) }, } - cmd.Flags().StringVar(&bodySignature, "signature", "", "Ed25519 signature, base64") - cmd.Flags().StringVar(&bodySignedPayload, "signed-payload", "", "base64-encoded signed record bytes") + cmd.Flags().StringVar(&bodyArgsHash, "args-hash", "", "Args hash") + cmd.Flags().StringVar(&bodyChainRoot, "chain-root", "", "Chain root") + cmd.Flags().StringVar(&bodyContentId, "content-id", "", "Content id") + cmd.Flags().StringVar(&bodyContextId, "context-id", "", "Context id") + cmd.Flags().StringVar(&bodyCreatorKey, "creator-key", "", "base64url-encoded Ed25519 public key") + cmd.Flags().StringVar(&bodyEventType, "event-type", "", "Event type") + cmd.Flags().StringVar(&bodyInformedBy, "informed-by", "", "Informed by") + cmd.Flags().StringVar(&bodyProvenanceToken, "provenance-token", "", "Provenance token") + cmd.Flags().StringVar(&bodyResultHash, "result-hash", "", "Result hash") + cmd.Flags().StringVar(&bodySessionToken, "session-token", "", "Session token") + cmd.Flags().StringVar(&bodySignature, "signature", "", "Ed25519 signature, base64url") + cmd.Flags().StringVar(&bodySpecVersion, "spec-version", "", "Spec version") + cmd.Flags().IntVar(&bodyTimestamp, "timestamp", 0, "Timestamp") + cmd.Flags().StringVar(&bodyToolName, "tool-name", "", "Tool name") // Wire sibling endpoints and sub-resources as subcommands diff --git a/internal/cli/promoted_feed-json.go b/internal/cli/promoted_feed-json.go new file mode 100644 index 0000000..5e48365 --- /dev/null +++ b/internal/cli/promoted_feed-json.go @@ -0,0 +1,121 @@ +// Copyright 2026 nader-helmy. Licensed under Apache-2.0. See LICENSE. +// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT. + +package cli + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/spf13/cobra" +) + +func newFeedJsonPromotedCmd(flags *rootFlags) *cobra.Command { + var flagCreatorKey string + var flagContextId string + var flagEventType string + var flagSince string + var flagLimit int + var flagOffset string + var flagAll bool + + cmd := &cobra.Command{ + Use: "feed-json", + Short: "JSON Feed 1.1 companion for consumers that cannot hold a long-lived Server-Sent Events connection. Items are...", + Long: "Shortcut for 'feed-json get-json-feed'. JSON Feed 1.1 companion for consumers that cannot hold a long-lived Server-Sent Events connection. Items are...", + Example: " atrib-log-pp-cli feed-json", + Annotations: map[string]string{"pp:endpoint": "feed-json.get-json-feed", "pp:method": "GET", "pp:path": "/v1/feed.json", "mcp:read-only": "true"}, + RunE: func(cmd *cobra.Command, args []string) error { + if cmd.Flags().Changed("event-type") { + allowedEventType := []string{"tool_call", "observation", "annotation", "revision", "transaction", "directory_anchor", "extension", "reserved"} + validEventType := false + for _, v := range allowedEventType { + if flagEventType == v { + validEventType = true + break + } + } + if !validEventType { + fmt.Fprintf(os.Stderr, "warning: --%s %q not in allowed set %v\n", "event-type", flagEventType, allowedEventType) + } + } + c, err := flags.newClient() + if err != nil { + return err + } + + path := "/v1/feed.json" + data, prov, err := resolvePaginatedRead(cmd.Context(), c, flags, "feed-json", path, map[string]string{ + "creator_key": fmt.Sprintf("%v", flagCreatorKey), + "context_id": fmt.Sprintf("%v", flagContextId), + "event_type": fmt.Sprintf("%v", flagEventType), + "since": fmt.Sprintf("%v", flagSince), + "limit": fmt.Sprintf("%v", flagLimit), + "offset": fmt.Sprintf("%v", flagOffset), + }, nil, flagAll, "offset", "", "") + if err != nil { + return classifyAPIError(err, flags) + } + // Unwrap API response envelopes (e.g. {"status":"success","data":[...]}) + // so output helpers see the inner data, not the wrapper. + data = extractResponseData(data) + + // Print provenance to stderr for human-facing output only. + // Machine-format flags (--json, --csv, --compact, --quiet, --plain, + // --select) and piped stdout suppress this line; the JSON envelope + // already carries meta.source for those consumers. + // SYNC: keep this gate aligned with command_endpoint.go.tmpl. + if wantsHumanTable(cmd.OutOrStdout(), flags) { + var countItems []json.RawMessage + if json.Unmarshal(data, &countItems) != nil { + // Single object, not an array + countItems = []json.RawMessage{data} + } + printProvenance(cmd, len(countItems), prov) + } + // For JSON output, wrap with provenance envelope. --select wins over + // --compact when both are set; --compact only runs when no explicit + // fields were requested. Explicit format flags (--csv, --quiet, --plain) + // opt out of the auto-JSON path so piped consumers that asked for a + // non-JSON format reach the standard pipeline below. + if flags.asJSON || (!isTerminal(cmd.OutOrStdout()) && !flags.csv && !flags.quiet && !flags.plain) { + filtered := data + if flags.selectFields != "" { + filtered = filterFields(filtered, flags.selectFields) + } else if flags.compact { + filtered = compactFields(filtered) + } + wrapped, wrapErr := wrapWithProvenance(filtered, prov) + if wrapErr != nil { + return wrapErr + } + return printOutput(cmd.OutOrStdout(), wrapped, true) + } + if wantsHumanTable(cmd.OutOrStdout(), flags) { + var items []map[string]any + if json.Unmarshal(data, &items) == nil && len(items) > 0 { + if err := printAutoTable(cmd.OutOrStdout(), items); err != nil { + return err + } + if len(items) >= 25 { + fmt.Fprintf(os.Stderr, "\nShowing %d results. To narrow: add --limit, --json --select, or filter flags.\n", len(items)) + } + return nil + } + } + return printOutputWithFlags(cmd.OutOrStdout(), data, flags) + }, + } + cmd.Flags().StringVar(&flagCreatorKey, "creator-key", "", "Creator key") + cmd.Flags().StringVar(&flagContextId, "context-id", "", "Context id") + cmd.Flags().StringVar(&flagEventType, "event-type", "", "Event type (one of: tool_call, observation, annotation, revision, transaction, directory_anchor, extension, reserved)") + cmd.Flags().StringVar(&flagSince, "since", "", "Since") + cmd.Flags().IntVar(&flagLimit, "limit", 20, "Limit") + cmd.Flags().StringVar(&flagOffset, "offset", "0", "Offset") + cmd.Flags().BoolVar(&flagAll, "all", false, "Fetch all pages") + + // Wire sibling endpoints and sub-resources as subcommands + + return cmd +} diff --git a/internal/cli/promoted_log-pubkey.go b/internal/cli/promoted_log-pubkey.go new file mode 100644 index 0000000..e7f5903 --- /dev/null +++ b/internal/cli/promoted_log-pubkey.go @@ -0,0 +1,88 @@ +// Copyright 2026 nader-helmy. Licensed under Apache-2.0. See LICENSE. +// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT. + +package cli + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/spf13/cobra" +) + +func newLogPubkeyPromotedCmd(flags *rootFlags) *cobra.Command { + + cmd := &cobra.Command{ + Use: "log-pubkey", + Short: "Returns the log public key in C2SP signed-note vkey format: ++.", + Long: "Shortcut for 'log-pubkey get'. Returns the log public key in C2SP signed-note vkey format: ++.", + Example: " atrib-log-pp-cli log-pubkey", + Annotations: map[string]string{"pp:endpoint": "log-pubkey.get", "pp:method": "GET", "pp:path": "/v1/log-pubkey", "mcp:read-only": "true"}, + RunE: func(cmd *cobra.Command, args []string) error { + c, err := flags.newClient() + if err != nil { + return err + } + + path := "/v1/log-pubkey" + params := map[string]string{} + data, prov, err := resolveRead(cmd.Context(), c, flags, "log-pubkey", false, path, params, nil) + if err != nil { + return classifyAPIError(err, flags) + } + // Unwrap API response envelopes (e.g. {"status":"success","data":[...]}) + // so output helpers see the inner data, not the wrapper. + data = extractResponseData(data) + + // Print provenance to stderr for human-facing output only. + // Machine-format flags (--json, --csv, --compact, --quiet, --plain, + // --select) and piped stdout suppress this line; the JSON envelope + // already carries meta.source for those consumers. + // SYNC: keep this gate aligned with command_endpoint.go.tmpl. + if wantsHumanTable(cmd.OutOrStdout(), flags) { + var countItems []json.RawMessage + if json.Unmarshal(data, &countItems) != nil { + // Single object, not an array + countItems = []json.RawMessage{data} + } + printProvenance(cmd, len(countItems), prov) + } + // For JSON output, wrap with provenance envelope. --select wins over + // --compact when both are set; --compact only runs when no explicit + // fields were requested. Explicit format flags (--csv, --quiet, --plain) + // opt out of the auto-JSON path so piped consumers that asked for a + // non-JSON format reach the standard pipeline below. + if flags.asJSON || (!isTerminal(cmd.OutOrStdout()) && !flags.csv && !flags.quiet && !flags.plain) { + filtered := data + if flags.selectFields != "" { + filtered = filterFields(filtered, flags.selectFields) + } else if flags.compact { + filtered = compactFields(filtered) + } + wrapped, wrapErr := wrapWithProvenance(filtered, prov) + if wrapErr != nil { + return wrapErr + } + return printOutput(cmd.OutOrStdout(), wrapped, true) + } + if wantsHumanTable(cmd.OutOrStdout(), flags) { + var items []map[string]any + if json.Unmarshal(data, &items) == nil && len(items) > 0 { + if err := printAutoTable(cmd.OutOrStdout(), items); err != nil { + return err + } + if len(items) >= 25 { + fmt.Fprintf(os.Stderr, "\nShowing %d results. To narrow: add --limit, --json --select, or filter flags.\n", len(items)) + } + return nil + } + } + return printOutputWithFlags(cmd.OutOrStdout(), data, flags) + }, + } + + // Wire sibling endpoints and sub-resources as subcommands + + return cmd +} diff --git a/internal/cli/promoted_proof.go b/internal/cli/promoted_proof.go new file mode 100644 index 0000000..4c94119 --- /dev/null +++ b/internal/cli/promoted_proof.go @@ -0,0 +1,102 @@ +// Copyright 2026 nader-helmy. Licensed under Apache-2.0. See LICENSE. +// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT. + +package cli + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/spf13/cobra" +) + +func newProofPromotedCmd(flags *rootFlags) *cobra.Command { + + cmd := &cobra.Command{ + Use: "proof ", + Short: "Returns a fresh inclusion-proof bundle for a record that is already included in the log. This is read-only and does...", + Long: "Shortcut for 'proof get-by-hash'. Returns a fresh inclusion-proof bundle for a record that is already included in the log. This is read-only and does...", + Example: " atrib-log-pp-cli proof example-value", + Annotations: map[string]string{"pp:endpoint": "proof.get-by-hash", "pp:method": "GET", "pp:path": "/v1/proof/{hex}", "mcp:read-only": "true"}, + RunE: func(cmd *cobra.Command, args []string) error { + c, err := flags.newClient() + if err != nil { + return err + } + + path := "/v1/proof/{hex}" + if len(args) < 1 { + // JSON envelope: {error, usage}. Written first; the + // usageErr return preserves exit code 2 across modes. + if flags.asJSON { + if printErr := printJSONFiltered(cmd.OutOrStdout(), map[string]any{ + "error": "hex is required", + "usage": fmt.Sprintf("%s <%s>", cmd.CommandPath(), "hex"), + }, flags); printErr != nil { + return printErr + } + } + return usageErr(fmt.Errorf("hex is required\nUsage: %s <%s>", cmd.CommandPath(), "hex")) + } + path = replacePathParam(path, "hex", args[0]) + params := map[string]string{} + data, prov, err := resolveRead(cmd.Context(), c, flags, "proof", false, path, params, nil) + if err != nil { + return classifyAPIError(err, flags) + } + // Unwrap API response envelopes (e.g. {"status":"success","data":[...]}) + // so output helpers see the inner data, not the wrapper. + data = extractResponseData(data) + + // Print provenance to stderr for human-facing output only. + // Machine-format flags (--json, --csv, --compact, --quiet, --plain, + // --select) and piped stdout suppress this line; the JSON envelope + // already carries meta.source for those consumers. + // SYNC: keep this gate aligned with command_endpoint.go.tmpl. + if wantsHumanTable(cmd.OutOrStdout(), flags) { + var countItems []json.RawMessage + if json.Unmarshal(data, &countItems) != nil { + // Single object, not an array + countItems = []json.RawMessage{data} + } + printProvenance(cmd, len(countItems), prov) + } + // For JSON output, wrap with provenance envelope. --select wins over + // --compact when both are set; --compact only runs when no explicit + // fields were requested. Explicit format flags (--csv, --quiet, --plain) + // opt out of the auto-JSON path so piped consumers that asked for a + // non-JSON format reach the standard pipeline below. + if flags.asJSON || (!isTerminal(cmd.OutOrStdout()) && !flags.csv && !flags.quiet && !flags.plain) { + filtered := data + if flags.selectFields != "" { + filtered = filterFields(filtered, flags.selectFields) + } else if flags.compact { + filtered = compactFields(filtered) + } + wrapped, wrapErr := wrapWithProvenance(filtered, prov) + if wrapErr != nil { + return wrapErr + } + return printOutput(cmd.OutOrStdout(), wrapped, true) + } + if wantsHumanTable(cmd.OutOrStdout(), flags) { + var items []map[string]any + if json.Unmarshal(data, &items) == nil && len(items) > 0 { + if err := printAutoTable(cmd.OutOrStdout(), items); err != nil { + return err + } + if len(items) >= 25 { + fmt.Fprintf(os.Stderr, "\nShowing %d results. To narrow: add --limit, --json --select, or filter flags.\n", len(items)) + } + return nil + } + } + return printOutputWithFlags(cmd.OutOrStdout(), data, flags) + }, + } + + // Wire sibling endpoints and sub-resources as subcommands + + return cmd +} diff --git a/internal/cli/promoted_stream.go b/internal/cli/promoted_stream.go new file mode 100644 index 0000000..b754be2 --- /dev/null +++ b/internal/cli/promoted_stream.go @@ -0,0 +1,135 @@ +// Copyright 2026 nader-helmy. Licensed under Apache-2.0. See LICENSE. +// Generated by CLI Printing Press (https://github.com/mvanhorn/cli-printing-press). DO NOT EDIT. + +package cli + +import ( + "bufio" + "fmt" + "io" + "net/http" + "net/url" + "os" + + "github.com/spf13/cobra" +) + +func newStreamPromotedCmd(flags *rootFlags) *cobra.Command { + var flagCreatorKey string + var flagContextId string + var flagEventType string + var flagSince string + + cmd := &cobra.Command{ + Use: "stream", + Short: "Stream new log entries as Server-Sent Events", + Long: "Opens /v1/stream and writes raw Server-Sent Events lines to stdout until the connection closes.", + Example: " atrib-log-pp-cli stream --event-type tool_call", + Annotations: map[string]string{ + "mcp:hidden": "true", + "pp:endpoint": "stream.log-entries", + "pp:method": "GET", + "pp:path": "/v1/stream", + }, + RunE: func(cmd *cobra.Command, args []string) error { + if flags.asJSON || flags.csv || flags.compact || flags.plain || flags.selectFields != "" { + return fmt.Errorf("stream emits raw Server-Sent Events; do not combine it with structured output flags") + } + if cmd.Flags().Changed("event-type") { + allowedEventType := []string{"tool_call", "observation", "annotation", "revision", "transaction", "directory_anchor", "extension", "reserved"} + validEventType := false + for _, v := range allowedEventType { + if flagEventType == v { + validEventType = true + break + } + } + if !validEventType { + fmt.Fprintf(os.Stderr, "warning: --%s %q not in allowed set %v\n", "event-type", flagEventType, allowedEventType) + } + } + + c, err := flags.newClient() + if err != nil { + return err + } + // PATCH: SSE is an infinite response, so the one-shot generated + // client reader would block until timeout. Stream lines directly. + if !cmd.Root().PersistentFlags().Changed("timeout") { + c.HTTPClient.Timeout = 0 + } + + u, err := url.Parse(c.BaseURL + "/v1/stream") + if err != nil { + return err + } + q := u.Query() + if flagCreatorKey != "" { + q.Set("creator_key", flagCreatorKey) + } + if flagContextId != "" { + q.Set("context_id", flagContextId) + } + if flagEventType != "" { + q.Set("event_type", flagEventType) + } + if flagSince != "" { + q.Set("since", flagSince) + } + u.RawQuery = q.Encode() + + req, err := http.NewRequestWithContext(cmd.Context(), http.MethodGet, u.String(), nil) + if err != nil { + return err + } + req.Header.Set("Accept", "text/event-stream") + req.Header.Set("User-Agent", "atrib-log-pp-cli/stream") + if c.Config != nil { + for k, v := range c.Config.Headers { + req.Header.Set(k, v) + } + if auth := c.Config.AuthHeader(); auth != "" { + req.Header.Set("Authorization", auth) + } + } + + resp, err := c.HTTPClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode >= 400 { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 4096)) + return classifyAPIError(&apiStatusError{method: http.MethodGet, path: "/v1/stream", status: resp.StatusCode, body: string(body)}, flags) + } + + scanner := bufio.NewScanner(resp.Body) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + for scanner.Scan() { + fmt.Fprintln(cmd.OutOrStdout(), scanner.Text()) + } + return scanner.Err() + }, + } + cmd.Flags().StringVar(&flagCreatorKey, "creator-key", "", "Creator key") + cmd.Flags().StringVar(&flagContextId, "context-id", "", "Context id") + cmd.Flags().StringVar(&flagEventType, "event-type", "", "Event type (one of: tool_call, observation, annotation, revision, transaction, directory_anchor, extension, reserved)") + cmd.Flags().StringVar(&flagSince, "since", "", "Since timestamp in milliseconds or ISO 8601 form") + + return cmd +} + +type apiStatusError struct { + method string + path string + status int + body string +} + +func (e *apiStatusError) Error() string { + return fmt.Sprintf("%s %s returned HTTP %d: %s", e.method, e.path, e.status, e.body) +} + +func (e *apiStatusError) StatusCode() int { + return e.status +} diff --git a/internal/cli/root.go b/internal/cli/root.go index 068f565..d12e4bc 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -87,7 +87,7 @@ func Execute() error { // missing required, etc.) never flow through usageErr() because // they originate inside rootCmd.Execute() before any user RunE // runs. Without this wrap, ExitCode() falls through to the - // default and emits 1 — clobbering the conventional code-2 for + // default and emits 1 - clobbering the conventional code-2 for // usage errors that the helpers.go contract already promises. return usageErr(err) } @@ -229,10 +229,16 @@ Run 'atrib-log-pp-cli doctor' to verify auth and connectivity.`, rootCmd.AddCommand(newByCreatorPromotedCmd(flags)) rootCmd.AddCommand(newCheckpointPromotedCmd(flags)) rootCmd.AddCommand(newEntriesPromotedCmd(flags)) + // PATCH: keep promoted log-node endpoints in the command tree until + // Printing Press can safely regenerate inside an existing git checkout. + rootCmd.AddCommand(newFeedJsonPromotedCmd(flags)) + rootCmd.AddCommand(newLogPubkeyPromotedCmd(flags)) rootCmd.AddCommand(newLookupPromotedCmd(flags)) + rootCmd.AddCommand(newProofPromotedCmd(flags)) rootCmd.AddCommand(newPubkeyPromotedCmd(flags)) rootCmd.AddCommand(newRecentPromotedCmd(flags)) rootCmd.AddCommand(newStatsPromotedCmd(flags)) + rootCmd.AddCommand(newStreamPromotedCmd(flags)) rootCmd.AddCommand(newVersionCliCmd()) return rootCmd diff --git a/internal/cli/which.go b/internal/cli/which.go index 7766716..46711bd 100644 --- a/internal/cli/which.go +++ b/internal/cli/which.go @@ -30,11 +30,17 @@ var whichIndex = []whichEntry{ {Command: "by-context list", Description: "All entries in a context (session)", Group: "by-context"}, {Command: "by-creator list", Description: "All entries by signer (creator key)", Group: "by-creator"}, {Command: "checkpoint get", Description: "Returns the current signed checkpoint (tree size + root hash +\nlog signature). Use this to anchor your local view of the log\nand verify inclusion proofs.", Group: "checkpoint"}, - {Command: "entries post-entry", Description: "Write path. Submit a signed record for inclusion in the log.\nReturns the assigned index + record_hash on success.", Group: "entries"}, + {Command: "entries post-entry", Description: "Write path. Submit a signed record for inclusion in the log.\nReturns a fresh inclusion-proof bundle on success.", Group: "entries"}, + // PATCH: advertise current log-node endpoints that were added after the + // original generated CLI was published. + {Command: "feed-json get-json-feed", Description: "JSON Feed 1.1 companion for consumers that cannot hold a long-lived\nServer-Sent Events connection. Items are newest-first and carry the\ndecoded log entry in `_atrib`.", Group: "feed-json"}, + {Command: "log-pubkey get", Description: "Get the log public key in C2SP signed-note vkey format.", Group: "log-pubkey"}, {Command: "lookup by-hash", Description: "Lookup entry by record hash", Group: "lookup"}, + {Command: "proof get-by-hash", Description: "Recover an inclusion-proof bundle for a record already included in the log.", Group: "proof"}, {Command: "pubkey get", Description: "Get verification public key", Group: "pubkey"}, {Command: "recent list", Description: "Most recent entries", Group: "recent"}, {Command: "stats get", Description: "Tree statistics", Group: "stats"}, + {Command: "stream log-entries", Description: "Stream new decoded log entries as Server-Sent Events.", Group: "stream"}, {Command: "tile get", Description: "Sigsum-style Merkle tile at level L, position N", Group: "tile"}, {Command: "tile get-entries", Description: "Get entries within a tile (leaf-level)", Group: "tile"}, } diff --git a/internal/cli/which_test.go b/internal/cli/which_test.go index 64b22f7..92a4eb1 100644 --- a/internal/cli/which_test.go +++ b/internal/cli/which_test.go @@ -21,7 +21,7 @@ var whichTestIndex = []whichEntry{ } // Happy path: a query that matches a command by keyword returns that -// command first. This is the load-bearing promise of `which`. +// command first. This is the decision-critical promise of `which`. func TestRankWhich_ExactTokenMatchWins(t *testing.T) { got := rankWhich(whichTestIndex, "search", 3) if len(got) == 0 { diff --git a/internal/mcp/tools.go b/internal/mcp/tools.go index 45dbc1e..a72dd4d 100644 --- a/internal/mcp/tools.go +++ b/internal/mcp/tools.go @@ -57,13 +57,50 @@ func RegisterTools(s *server.MCPServer) { ) s.AddTool( mcplib.NewTool("entries_post-entry", - mcplib.WithDescription("Write path. Submit a signed record for inclusion in the log. Returns the assigned index + record_hash on success. Optional: signature, signed_payload."), - mcplib.WithString("signature", mcplib.Description("Ed25519 signature, base64")), - mcplib.WithString("signed_payload", mcplib.Description("base64-encoded signed record bytes")), + mcplib.WithDescription("Write path. Submit a signed record for inclusion in the log. Returns a fresh inclusion-proof bundle on success. Required: chain_root, content_id, context_id, creator_key, event_type, signature, spec_version, timestamp. Optional: args_hash, provenance_token, result_hash, session_token, tool_name."), + mcplib.WithString("args_hash", mcplib.Description("Args hash")), + mcplib.WithString("chain_root", mcplib.Required(), mcplib.Description("Chain root")), + mcplib.WithString("content_id", mcplib.Required(), mcplib.Description("Content id")), + mcplib.WithString("context_id", mcplib.Required(), mcplib.Description("Context id")), + mcplib.WithString("creator_key", mcplib.Required(), mcplib.Description("base64url-encoded Ed25519 public key")), + mcplib.WithString("event_type", mcplib.Required(), mcplib.Description("Event type")), + mcplib.WithString("provenance_token", mcplib.Description("Provenance token")), + mcplib.WithString("result_hash", mcplib.Description("Result hash")), + mcplib.WithString("session_token", mcplib.Description("Session token")), + mcplib.WithString("signature", mcplib.Required(), mcplib.Description("Ed25519 signature, base64url")), + mcplib.WithString("spec_version", mcplib.Required(), mcplib.Description("Spec version")), + mcplib.WithNumber("timestamp", mcplib.Required(), mcplib.Description("Timestamp")), + mcplib.WithString("tool_name", mcplib.Description("Tool name")), mcplib.WithDestructiveHintAnnotation(false), mcplib.WithOpenWorldHintAnnotation(true), ), - makeAPIHandler("POST", "/v1/entries", false, []mcpParamBinding{{PublicName: "signature", WireName: "signature", Location: "body"}, {PublicName: "signed_payload", WireName: "signed_payload", Location: "body"}}, []string{}), + makeAPIHandler("POST", "/v1/entries", false, []mcpParamBinding{{PublicName: "args_hash", WireName: "args_hash", Location: "body"}, {PublicName: "chain_root", WireName: "chain_root", Location: "body"}, {PublicName: "content_id", WireName: "content_id", Location: "body"}, {PublicName: "context_id", WireName: "context_id", Location: "body"}, {PublicName: "creator_key", WireName: "creator_key", Location: "body"}, {PublicName: "event_type", WireName: "event_type", Location: "body"}, {PublicName: "provenance_token", WireName: "provenance_token", Location: "body"}, {PublicName: "result_hash", WireName: "result_hash", Location: "body"}, {PublicName: "session_token", WireName: "session_token", Location: "body"}, {PublicName: "signature", WireName: "signature", Location: "body"}, {PublicName: "spec_version", WireName: "spec_version", Location: "body"}, {PublicName: "timestamp", WireName: "timestamp", Location: "body"}, {PublicName: "tool_name", WireName: "tool_name", Location: "body"}}, []string{}), + ) + // PATCH: expose log-node endpoints added after the original generated MCP + // surface. Keep stream out of MCP because it is an infinite SSE response. + s.AddTool( + mcplib.NewTool("feed-json_get-json-feed", + mcplib.WithDescription("JSON Feed 1.1 companion for consumers that cannot hold a long-lived Server-Sent Events connection. Optional: creator_key, context_id, event_type, since, limit, offset."), + mcplib.WithString("creator_key", mcplib.Description("Creator key")), + mcplib.WithString("context_id", mcplib.Description("Context id")), + mcplib.WithString("event_type", mcplib.Description("Event type")), + mcplib.WithString("since", mcplib.Description("Since")), + mcplib.WithNumber("limit", mcplib.Description("Limit")), + mcplib.WithNumber("offset", mcplib.Description("Offset")), + mcplib.WithReadOnlyHintAnnotation(true), + mcplib.WithDestructiveHintAnnotation(false), + mcplib.WithOpenWorldHintAnnotation(true), + ), + makeAPIHandler("GET", "/v1/feed.json", false, []mcpParamBinding{{PublicName: "creator_key", WireName: "creator_key", Location: "query"}, {PublicName: "context_id", WireName: "context_id", Location: "query"}, {PublicName: "event_type", WireName: "event_type", Location: "query"}, {PublicName: "since", WireName: "since", Location: "query"}, {PublicName: "limit", WireName: "limit", Location: "query"}, {PublicName: "offset", WireName: "offset", Location: "query"}}, []string{}), + ) + s.AddTool( + mcplib.NewTool("log-pubkey_get", + mcplib.WithDescription("Get the log public key in C2SP signed-note vkey format."), + mcplib.WithReadOnlyHintAnnotation(true), + mcplib.WithDestructiveHintAnnotation(false), + mcplib.WithOpenWorldHintAnnotation(true), + ), + makeAPIHandler("GET", "/v1/log-pubkey", false, []mcpParamBinding{}, []string{}), ) s.AddTool( mcplib.NewTool("lookup_by-hash", @@ -75,6 +112,16 @@ func RegisterTools(s *server.MCPServer) { ), makeAPIHandler("GET", "/v1/lookup/{hex}", false, []mcpParamBinding{{PublicName: "hex", WireName: "hex", Location: "path"}}, []string{"hex"}), ) + s.AddTool( + mcplib.NewTool("proof_get-by-hash", + mcplib.WithDescription("Recover an inclusion-proof bundle for a record that is already included in the log. Required: hex."), + mcplib.WithString("hex", mcplib.Required(), mcplib.Description("SHA-256 hex (no `sha256:` prefix)")), + mcplib.WithReadOnlyHintAnnotation(true), + mcplib.WithDestructiveHintAnnotation(false), + mcplib.WithOpenWorldHintAnnotation(true), + ), + makeAPIHandler("GET", "/v1/proof/{hex}", false, []mcpParamBinding{{PublicName: "hex", WireName: "hex", Location: "path"}}, []string{"hex"}), + ) s.AddTool( mcplib.NewTool("pubkey_get", mcplib.WithDescription("Get verification public key."), @@ -125,7 +172,7 @@ func RegisterTools(s *server.MCPServer) { ), makeAPIHandler("GET", "/v1/tile/entries/{n}", false, []mcpParamBinding{{PublicName: "n", WireName: "n", Location: "path"}}, []string{"n"}), ) - // Search tool — faster than iterating list endpoints for finding specific items + // Search tool - faster than iterating list endpoints for finding specific items s.AddTool( mcplib.NewTool("search", mcplib.WithDescription("Full-text search across all synced data. Faster than paginating list endpoints. Requires sync first."), @@ -136,7 +183,7 @@ func RegisterTools(s *server.MCPServer) { ), handleSearch, ) - // SQL tool — ad-hoc analysis on synced data without API calls + // SQL tool - ad-hoc analysis on synced data without API calls s.AddTool( mcplib.NewTool("sql", mcplib.WithDescription("Run read-only SQL against local database. Use for ad-hoc analysis, aggregations, and joins across synced resources. Requires sync first."), @@ -147,7 +194,7 @@ func RegisterTools(s *server.MCPServer) { handleSQL, ) - // Context tool — front-loaded domain knowledge for agents. + // Context tool - front-loaded domain knowledge for agents. // Call this first to understand the API taxonomy, query patterns, and capabilities. s.AddTool( mcplib.NewTool("context", @@ -158,7 +205,7 @@ func RegisterTools(s *server.MCPServer) { handleContext, ) - // Runtime Cobra-tree mirror — exposes every user-facing command that is + // Runtime Cobra-tree mirror - exposes every user-facing command that is // not already covered by a typed endpoint or framework MCP tool. cobratree.RegisterAll(s, cli.RootCmd(), cobratree.SiblingCLIPath) } @@ -383,7 +430,7 @@ func handleSearch(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.Call // leading whitespace, line comments, block comments, and semicolons that // SQLite itself ignores before parsing. A naive HasPrefix check on a // keyword blocklist is bypassable by prefixing the dangerous statement with -// "/* x */" or "-- x\n" — TrimSpace strips outer whitespace but does not +// "/* x */" or "-- x\n" - TrimSpace strips outer whitespace but does not // understand SQL comment syntax. Combined with the empirical fact that // modernc.org/sqlite's mode=ro does NOT block VACUUM INTO (writes a snapshot // to a new file) or ATTACH DATABASE (opens a separate writable handle), @@ -474,9 +521,9 @@ func handleSQL(ctx context.Context, req mcplib.CallToolRequest) (*mcplib.CallToo func handleContext(_ context.Context, _ mcplib.CallToolRequest) (*mcplib.CallToolResult, error) { ctx := map[string]any{ "api": "atrib-log", - "description": "atrib transparency log — Sigsum-style append-only Merkle log of signed agent action records. Each entry is a...", + "description": "atrib transparency log - Sigsum-style append-only Merkle log of signed agent action records. Each entry is a...", "archetype": "generic", - "tool_count": 10, + "tool_count": 13, // tool_surface tells agents which surface a capability lives on. "tool_surface": "MCP exposes typed endpoint tools plus a runtime mirror of user-facing CLI commands. Endpoint tools keep typed schemas; command-mirror tools shell out to the companion atrib-log-pp-cli binary.", "resources": []map[string]any{ @@ -503,12 +550,30 @@ func handleContext(_ context.Context, _ mcplib.CallToolRequest) (*mcplib.CallToo "endpoints": []string{"post-entry"}, "searchable": true, }, + { + "name": "feed-json", + "description": "Manage feed json", + "endpoints": []string{"get-json-feed"}, + "syncable": true, + "searchable": true, + }, + { + "name": "log-pubkey", + "description": "Manage log pubkey", + "endpoints": []string{"get"}, + }, { "name": "lookup", "description": "Manage lookup", "endpoints": []string{"by-hash"}, "searchable": true, }, + { + "name": "proof", + "description": "Manage proof", + "endpoints": []string{"get-by-hash"}, + "searchable": true, + }, { "name": "pubkey", "description": "Manage pubkey", diff --git a/internal/store/schema_version_test.go b/internal/store/schema_version_test.go index 83a4e54..012b097 100644 --- a/internal/store/schema_version_test.go +++ b/internal/store/schema_version_test.go @@ -466,7 +466,7 @@ func TestOpenReadOnly_RejectsWrites(t *testing.T) { {"update", `UPDATE resources SET resource_type = 'hijacked' WHERE id = 'seed'`}, {"delete", `DELETE FROM resources WHERE id = 'seed'`}, {"replace", `REPLACE INTO resources (id, resource_type, data) VALUES ('seed', 'evil', '{}')`}, - // CTE-wrapped INSERT is load-bearing: it justifies leaving WITH + // CTE-wrapped INSERT is decision-critical: it justifies leaving WITH // out of the handleSQL blocklist so SELECT-form CTEs work. {"cte_insert", `WITH stale AS (SELECT id FROM resources) INSERT INTO resources (id, resource_type, data) SELECT id || '-evil', 'thing', '{}' FROM stale`}, } diff --git a/internal/store/store.go b/internal/store/store.go index 4601908..cdc9acf 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -70,7 +70,7 @@ func Open(dbPath string) (*Store, error) { // REPLACE, "WITH x AS (...) INSERT ...") at the driver level. Skips // MkdirAll and migrate; the file is expected to exist. // -// The file: URI prefix is load-bearing: modernc.org/sqlite only honors +// The file: URI prefix is decision-critical: modernc.org/sqlite only honors // SQLite's URI query parameters (mode, cache, etc.) when the DSN starts // with "file:". Without the prefix, "?mode=ro" is silently dropped and // the connection opens read-write. Underscore-prefixed driver pragmas @@ -338,7 +338,7 @@ func (s *Store) migrate(ctx context.Context) error { // contention at BEGIN/COMMIT time, so we retry both explicitly on // SQLITE_BUSY for up to migrationLockTimeout. return withMigrationLock(ctx, conn, deadline, func() error { - // Re-read user_version inside the lock. This is load-bearing, + // Re-read user_version inside the lock. This is decision-critical, // not paranoid: between the pre-lock read above and our // successful BEGIN IMMEDIATE, a newer-binary peer may have // committed a higher version stamp. Without this re-read, an diff --git a/spec.yaml b/spec.yaml index 8dbd6f4..5d35af9 100644 --- a/spec.yaml +++ b/spec.yaml @@ -3,9 +3,10 @@ info: title: log.atrib.dev version: "1.0" description: | - atrib transparency log — Sigsum-style append-only Merkle log of signed + atrib transparency log - Sigsum-style append-only Merkle log of signed agent action records. Each entry is a cryptographically signed record - of an LLM agent action (tool call, observation, annotation, revision). + of an LLM agent action (tool call, transaction, observation, annotation, + revision, or directory anchor). servers: - url: https://log.atrib.dev paths: @@ -30,6 +31,25 @@ paths: responses: "200": description: Log public key (Ed25519, base64) + content: + application/json: + schema: + type: object + properties: + origin: { type: string } + public_key: { type: string } + key_id: { type: string } + algorithm: { type: string } + /v1/log-pubkey: + get: + summary: Get C2SP signed-note public key + description: | + Returns the log public key in C2SP signed-note vkey format: + ++. + operationId: getLogPubkey + responses: + "200": + description: Log public key in signed-note vkey format content: text/plain: schema: { type: string } @@ -103,6 +123,28 @@ paths: schema: { $ref: "#/components/schemas/Entry" } "404": description: Hash not found in log + /v1/proof/{hex}: + get: + summary: Recover inclusion proof by record hash + description: | + Returns a fresh inclusion-proof bundle for a record that is already + included in the log. This is read-only and does not require the signed + record body. + operationId: getProofByHash + parameters: + - name: hex + in: path + required: true + description: SHA-256 hex (no `sha256:` prefix) + schema: { type: string, pattern: "^[0-9a-f]{64}$" } + responses: + "200": + description: Inclusion-proof bundle + content: + application/json: + schema: { $ref: "#/components/schemas/ProofBundle" } + "404": + description: Hash not found in log /v1/by-context/{hex}: get: summary: All entries in a context (session) @@ -190,29 +232,90 @@ paths: schema: type: array items: { $ref: "#/components/schemas/Entry" } + /v1/feed.json: + get: + summary: JSON Feed of log entries + description: | + JSON Feed 1.1 companion for consumers that cannot hold a long-lived + Server-Sent Events connection. Items are newest-first and carry the + decoded log entry in `_atrib`. + operationId: getJsonFeed + parameters: + - $ref: "#/components/parameters/CreatorKeyFilter" + - $ref: "#/components/parameters/ContextIdFilter" + - $ref: "#/components/parameters/EventTypeFilter" + - $ref: "#/components/parameters/SinceFilter" + - name: limit + in: query + schema: { type: integer, minimum: 1, maximum: 100, default: 20 } + - name: offset + in: query + schema: { type: integer, default: 0 } + responses: + "200": + description: JSON Feed 1.1 document + content: + application/feed+json: + schema: { $ref: "#/components/schemas/JsonFeed" } + /v1/stream: + get: + summary: Stream new log entries as Server-Sent Events + description: | + Opens a Server-Sent Events stream. The stream emits a ready event and + then log_entry events for new decoded log entries. + operationId: streamLogEntries + parameters: + - $ref: "#/components/parameters/CreatorKeyFilter" + - $ref: "#/components/parameters/ContextIdFilter" + - $ref: "#/components/parameters/EventTypeFilter" + - $ref: "#/components/parameters/SinceFilter" + responses: + "200": + description: Server-Sent Events stream + content: + text/event-stream: + schema: { type: string } /v1/entries: post: summary: Append a signed entry description: | Write path. Submit a signed record for inclusion in the log. - Returns the assigned index + record_hash on success. + Returns a fresh inclusion-proof bundle on success. operationId: postEntry requestBody: required: true content: application/json: - schema: { $ref: "#/components/schemas/EntrySubmission" } + schema: { $ref: "#/components/schemas/AtribRecord" } responses: - "201": - description: Entry accepted, included in next checkpoint + "200": + description: Entry accepted and proof bundle returned content: application/json: - schema: - type: object - properties: - index: { type: integer } - record_hash: { type: string } + schema: { $ref: "#/components/schemas/ProofBundle" } components: + parameters: + CreatorKeyFilter: + name: creator_key + in: query + schema: { type: string, pattern: "^[A-Za-z0-9_-]{43}$" } + ContextIdFilter: + name: context_id + in: query + schema: { type: string, pattern: "^[0-9a-f]{32}$" } + EventTypeFilter: + name: event_type + in: query + schema: + type: string + enum: [tool_call, observation, annotation, revision, transaction, directory_anchor, extension, reserved] + SinceFilter: + name: since + in: query + schema: + oneOf: + - { type: string, format: date-time } + - { type: string, pattern: "^[0-9]+$" } schemas: Entry: type: object @@ -226,8 +329,54 @@ components: type: string enum: [tool_call, observation, annotation, revision, transaction, directory_anchor, extension, reserved] event_type_byte: { type: integer } - EntrySubmission: + ProofBundle: + type: object + properties: + log_index: { type: integer } + checkpoint: { type: string } + inclusion_proof: + type: array + items: { type: string } + leaf_hash: { type: string } + JsonFeed: + type: object + properties: + version: { type: string } + title: { type: string } + home_page_url: { type: string } + feed_url: { type: string } + items: + type: array + items: + type: object + properties: + id: { type: string } + url: { type: string } + title: { type: string } + content_text: { type: string } + date_published: { type: string, format: date-time } + _atrib: { $ref: "#/components/schemas/Entry" } + _atrib: + type: object + additionalProperties: true + AtribRecord: type: object properties: - signed_payload: { type: string, description: "base64-encoded signed record bytes" } - signature: { type: string, description: "Ed25519 signature, base64" } + spec_version: { type: string, example: "atrib/1.0" } + event_type: { type: string, example: "https://atrib.dev/v1/types/tool_call" } + context_id: { type: string, pattern: "^[0-9a-f]{32}$" } + creator_key: { type: string, description: "base64url-encoded Ed25519 public key" } + chain_root: { type: string, example: "sha256:..." } + content_id: { type: string, example: "sha256:..." } + timestamp: { type: integer } + signature: { type: string, description: "Ed25519 signature, base64url" } + informed_by: + type: array + items: { type: string, example: "sha256:..." } + provenance_token: { type: string } + session_token: { type: string } + args_hash: { type: string, example: "sha256:..." } + result_hash: { type: string, example: "sha256:..." } + tool_name: { type: string } + required: [spec_version, event_type, context_id, creator_key, chain_root, content_id, timestamp, signature] + additionalProperties: true