From 786ecbed41ae28bcea44886672d9f8f3d286f4b0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 20 Apr 2026 16:54:07 +0000 Subject: [PATCH] =?UTF-8?q?feat(mesh):=20C-287=20HIVE=20contract=20?= =?UTF-8?q?=E2=80=94=20ingest=20sources,=20jobs,=20seal-memory=20workflow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extend mobius.yaml with ingest.sources, jobs.seal-memory, governance; add GET /api/oaa/kv/latest for latest-by-key journal slices; shared buildOaaMemoryIngestBody; seal-memory GitHub Action + script; fix OAA_MEMORY_PATH resolution per call for tests. --- .github/workflows/seal-memory.yml | 22 +++++++ docs/mobius_yaml_v1.md | 10 +++- lib/kv/store.ts | 30 ++++++++-- lib/ledger/bridge.ts | 12 +--- lib/ledger/oaaProof.ts | 15 +++++ mobius.yaml | 28 +++++++++ pages/api/oaa/kv/latest.ts | 37 ++++++++++++ scripts/oaa/seal-memory.mjs | 97 +++++++++++++++++++++++++++++++ tests/oaa-kv-latest.test.ts | 61 +++++++++++++++++++ 9 files changed, 295 insertions(+), 17 deletions(-) create mode 100644 .github/workflows/seal-memory.yml create mode 100644 lib/ledger/oaaProof.ts create mode 100644 pages/api/oaa/kv/latest.ts create mode 100644 scripts/oaa/seal-memory.mjs create mode 100644 tests/oaa-kv-latest.test.ts diff --git a/.github/workflows/seal-memory.yml b/.github/workflows/seal-memory.yml new file mode 100644 index 0000000..d39baf0 --- /dev/null +++ b/.github/workflows/seal-memory.yml @@ -0,0 +1,22 @@ +# C-287 HIVE mesh execution — forward recent OAA journal entries to Civic ledger ingest +name: Seal OAA Memory + +on: + schedule: + - cron: "*/10 * * * *" + workflow_dispatch: + +jobs: + seal-memory: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Seal recent memory entries + env: + CIVIC_LEDGER_URL: ${{ secrets.CIVIC_LEDGER_URL }} + LEDGER_BASE_URL: ${{ secrets.LEDGER_BASE_URL }} + CIVIC_LEDGER_TOKEN: ${{ secrets.CIVIC_LEDGER_TOKEN }} + LEDGER_ADMIN_TOKEN: ${{ secrets.LEDGER_ADMIN_TOKEN }} + OAA_SEAL_LOOKBACK: "50" + run: node scripts/oaa/seal-memory.mjs diff --git a/docs/mobius_yaml_v1.md b/docs/mobius_yaml_v1.md index 33a23de..9d7ef35 100644 --- a/docs/mobius_yaml_v1.md +++ b/docs/mobius_yaml_v1.md @@ -18,7 +18,9 @@ KV / runtime state |--------|--------| | `mesh` | Node identity, tier, role, repository, discovery | | `pulse` | What the node exposes for health, feeds, snapshots; lanes; `authoritative_for`; `emits` flags | -| `ingest` | How the node participates in writes: `mode`, optional `write_url` / `auth` / `accepts`, and/or `targets[]` for client forwarding | +| `ingest` | How the node participates in writes: `mode`, optional `write_url` / `auth` / `accepts`, `targets[]` for client forwarding, and optional **`sources`** (read URLs other nodes use when composing world / pulse) | +| `jobs` | **Declaration only** — maps workflow ids to `.github/workflows/*.yml` plus `trigger` / `cron` (execution is GitHub Actions, not this file) | +| `governance` | Agent PR rules, merge gates, reviewer handles (policy text; enforce in branch protection + CODEOWNERS) | | `mcp` | MCP server and discovery URLs when the node exposes tools | | `policy` | Canonical ledger node, hashing, mirroring, local truth | @@ -76,6 +78,8 @@ Declare under `ingest.targets[].accepts` or ledger `ingest.accepts` as appropria ## This repository (`OAA-API-Library`) -Root [`mobius.yaml`](../mobius.yaml) declares **OAA** as a **`service_node`**: append-only memory (`/api/oaa/kv`), optional warm **KV bridge** (`/api/kv-bridge/*`), and **client** forwarding of proofs to **`civic-protocol-core`** (`/mesh/ingest`). Relative URLs in `pulse` are resolved against the deployed hub base URL. +Root [`mobius.yaml`](../mobius.yaml) declares **OAA** as a **`service_node`**: append-only memory (`/api/oaa/kv`), latest journal slices (**`GET /api/oaa/kv/latest`** for HIVE / mesh consumers), optional warm **KV bridge** (`/api/kv-bridge/*`), **`ingest.sources`** pointers for cross-node reads, **`jobs`** listing **`seal-memory`** (`.github/workflows/seal-memory.yml`), and **client** forwarding of proofs to **`civic-protocol-core`** (`/mesh/ingest`). The CI script [`scripts/oaa/seal-memory.mjs`](../scripts/oaa/seal-memory.mjs) re-forwards recent `OAA_MEMORY_ENTRY_V1` rows when secrets are present. -For the full cross-repo rollout (Terminal writer + Substrate spec authority + Civic `mobius.yaml`), apply the same schema in those repositories on their own branches. +Relative URLs in `pulse` / `ingest.sources.sovereign_memory` are resolved against the deployed hub base URL. + +For the full cross-repo rollout (Substrate `mesh-sync`, Terminal `publish-cycle-state`, HIVE `world-update`, browser-shell renderers), apply the same schema and workflows in those repositories on their own branches. diff --git a/lib/kv/store.ts b/lib/kv/store.ts index 27c9a50..686c116 100644 --- a/lib/kv/store.ts +++ b/lib/kv/store.ts @@ -4,8 +4,9 @@ import type { OaaMemoryEntry } from "../../types/oaa-kv"; import { canonicalJson } from "../crypto/canonicalJson"; import { sha256Hex } from "../crypto/hmac"; -const MEMORY_PATH = - process.env.OAA_MEMORY_PATH || path.join(process.cwd(), "OAA_MEMORY.json"); +function getMemoryPath(): string { + return process.env.OAA_MEMORY_PATH || path.join(process.cwd(), "OAA_MEMORY.json"); +} type MemoryFile = { version?: string; @@ -19,7 +20,7 @@ type MemoryFile = { async function readMemoryFile(): Promise { try { - const raw = await fs.readFile(MEMORY_PATH, "utf8"); + const raw = await fs.readFile(getMemoryPath(), "utf8"); return JSON.parse(raw) as MemoryFile; } catch { return { @@ -36,7 +37,7 @@ async function readMemoryFile(): Promise { async function writeMemoryFile(data: MemoryFile): Promise { data.updatedAt = new Date().toISOString(); - await fs.writeFile(MEMORY_PATH, JSON.stringify(data, null, 2), "utf8"); + await fs.writeFile(getMemoryPath(), JSON.stringify(data, null, 2), "utf8"); } function isOaaMemoryEntry(n: unknown): n is OaaMemoryEntry { @@ -100,3 +101,24 @@ export async function getLatestHash(): Promise { } return null; } + +/** Latest `OAA_MEMORY_ENTRY_V1` per `data.key` (newest by `ts` wins). */ +export async function getLatestJournalEntriesByKeys( + keys: string[] +): Promise> { + const db = await readMemoryFile(); + const notes = Array.isArray(db.notes) ? db.notes : []; + const journal = notes.filter(isOaaMemoryEntry).sort((a, b) => b.ts - a.ts); + + const out: Record = {}; + for (const k of keys) { + out[k] = null; + } + for (const e of journal) { + const dk = e.data.key; + if (keys.includes(dk) && out[dk] === null) { + out[dk] = e; + } + } + return out; +} diff --git a/lib/ledger/bridge.ts b/lib/ledger/bridge.ts index 242dcc4..792a4a4 100644 --- a/lib/ledger/bridge.ts +++ b/lib/ledger/bridge.ts @@ -1,4 +1,5 @@ import type { OaaMemoryEntry } from "../../types/oaa-kv"; +import { buildOaaMemoryIngestBody } from "./oaaProof"; const LEDGER_URL = process.env.CIVIC_LEDGER_URL || process.env.LEDGER_BASE_URL || ""; @@ -20,15 +21,6 @@ export async function sealToLedger(entry: OaaMemoryEntry): Promise { Authorization: `Bearer ${LEDGER_TOKEN}`, "X-MNS-Node": "oaa-api-library" }, - body: JSON.stringify({ - type: "OAA_MEMORY_ENTRY_V1", - agent: entry.agent, - cycle: entry.cycle, - key: entry.data.key, - intent: entry.intent ?? null, - hash: entry.hash, - previous_hash: entry.previous_hash, - timestamp: new Date(entry.ts).toISOString() - }) + body: JSON.stringify(buildOaaMemoryIngestBody(entry)) }); } diff --git a/lib/ledger/oaaProof.ts b/lib/ledger/oaaProof.ts new file mode 100644 index 0000000..d889ae8 --- /dev/null +++ b/lib/ledger/oaaProof.ts @@ -0,0 +1,15 @@ +import type { OaaMemoryEntry } from "../../types/oaa-kv"; + +/** Civic Core /mesh/ingest body for OAA journal entries. */ +export function buildOaaMemoryIngestBody(entry: OaaMemoryEntry): Record { + return { + type: "OAA_MEMORY_ENTRY_V1", + agent: entry.agent, + cycle: entry.cycle, + key: entry.data.key, + intent: entry.intent ?? null, + hash: entry.hash, + previous_hash: entry.previous_hash, + timestamp: new Date(entry.ts).toISOString() + }; +} diff --git a/mobius.yaml b/mobius.yaml index 6d1bdf8..48af012 100644 --- a/mobius.yaml +++ b/mobius.yaml @@ -52,6 +52,18 @@ ingest: enabled: true mode: "client_of_other_node" + # Read-only sources other nodes use when composing world / pulse (HIVE mesh contract) + sources: + terminal_snapshot: + node_id: "mobius-terminal" + read_url: "https://mobius-civic-ai-terminal.vercel.app/api/terminal/snapshot-lite" + cycle_state: + node_id: "mobius-substrate" + read_url: "https://raw.githubusercontent.com/kaizencycle/Mobius-Substrate/main/ledger/mobius-pulse.json" + sovereign_memory: + node_id: "oaa-api-library" + read_url: "/api/oaa/kv/latest" + targets: - node_id: "civic-protocol-core" purpose: "durable_ledger" @@ -65,6 +77,22 @@ ingest: - MOBIUS_PULSE_V1 - EPICON_ENTRY_V1 +# Execution fabric: declared jobs (GitHub Actions / cron) — this repo runs seal-memory only +jobs: + enabled: true + workflows: + - id: "seal-memory" + file: ".github/workflows/seal-memory.yml" + trigger: "schedule" + cron: "*/10 * * * *" + +governance: + agent_prs_allowed: true + auto_merge_allowed: false + required_reviewers: + - "ZEUS" + - "ATLAS" + mcp: enabled: false diff --git a/pages/api/oaa/kv/latest.ts b/pages/api/oaa/kv/latest.ts new file mode 100644 index 0000000..5569334 --- /dev/null +++ b/pages/api/oaa/kv/latest.ts @@ -0,0 +1,37 @@ +import type { NextApiRequest, NextApiResponse } from "next"; +import { getLatestJournalEntriesByKeys } from "../../../lib/kv/store"; + +const DEFAULT_KEYS = [ + "vault:status", + "mic:readiness", + "heartbeat:terminal", + "GI_STATE", + "SIGNAL_SNAPSHOT" +]; + +export default async function handler(req: NextApiRequest, res: NextApiResponse) { + if (req.method !== "GET") { + return res.status(405).json({ ok: false, error: "Method not allowed" }); + } + + const raw = req.query.keys; + let keys: string[]; + if (typeof raw === "string" && raw.trim()) { + keys = raw + .split(",") + .map((k) => k.trim()) + .filter(Boolean); + } else { + keys = [...DEFAULT_KEYS]; + } + + const entries = await getLatestJournalEntriesByKeys(keys); + + return res.status(200).json({ + ok: true, + schema: "OAA_KV_LATEST_V1", + keys_requested: keys, + latest: entries, + timestamp: new Date().toISOString() + }); +} diff --git a/scripts/oaa/seal-memory.mjs b/scripts/oaa/seal-memory.mjs new file mode 100644 index 0000000..242be5b --- /dev/null +++ b/scripts/oaa/seal-memory.mjs @@ -0,0 +1,97 @@ +#!/usr/bin/env node +/** + * C-287 / mesh workflow: forward recent OAA_MEMORY_ENTRY_V1 rows to Civic Core /mesh/ingest. + * Uses OAA_MEMORY.json on disk (CI checkout). Set CIVIC_LEDGER_URL + CIVIC_LEDGER_TOKEN (or LEDGER_*). + */ +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const repoRoot = path.join(__dirname, "..", ".."); +const MEMORY_PATH = process.env.OAA_MEMORY_PATH || path.join(repoRoot, "OAA_MEMORY.json"); + +const LEDGER_URL = (process.env.CIVIC_LEDGER_URL || process.env.LEDGER_BASE_URL || "").replace( + /\/$/, + "" +); +const LEDGER_TOKEN = process.env.CIVIC_LEDGER_TOKEN || process.env.LEDGER_ADMIN_TOKEN || ""; +const LOOKBACK = Math.max(1, parseInt(process.env.OAA_SEAL_LOOKBACK || "50", 10) || 50); + +function isOaaMemoryEntry(n) { + return ( + n && + typeof n === "object" && + n.type === "OAA_MEMORY_ENTRY_V1" && + typeof n.hash === "string" && + typeof n.ts === "number" && + n.data && + typeof n.data === "object" && + typeof n.data.key === "string" + ); +} + +function buildBody(entry) { + return { + type: "OAA_MEMORY_ENTRY_V1", + agent: entry.agent, + cycle: entry.cycle, + key: entry.data.key, + intent: entry.intent ?? null, + hash: entry.hash, + previous_hash: entry.previous_hash, + timestamp: new Date(entry.ts).toISOString() + }; +} + +async function main() { + if (!LEDGER_URL || !LEDGER_TOKEN) { + console.log("[seal-memory] skip: CIVIC_LEDGER_URL/LEDGER_BASE_URL or token not set"); + process.exit(0); + } + + let mem; + try { + mem = JSON.parse(fs.readFileSync(MEMORY_PATH, "utf8")); + } catch (e) { + console.error("[seal-memory] cannot read memory:", e.message); + process.exit(1); + } + + const notes = Array.isArray(mem.notes) ? mem.notes : []; + const journal = notes.filter(isOaaMemoryEntry); + const slice = journal.slice(-LOOKBACK); + + let ok = 0; + let fail = 0; + const ingestUrl = `${LEDGER_URL}/mesh/ingest`; + + for (const entry of slice) { + try { + const res = await fetch(ingestUrl, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${LEDGER_TOKEN}`, + "X-MNS-Node": "oaa-api-library" + }, + body: JSON.stringify(buildBody(entry)) + }); + if (res.ok) ok += 1; + else { + fail += 1; + const t = await res.text(); + console.warn(`[seal-memory] ${entry.hash.slice(0, 8)}… HTTP ${res.status}: ${t.slice(0, 200)}`); + } + } catch (e) { + fail += 1; + console.warn(`[seal-memory] ${entry.hash.slice(0, 8)}…`, e.message); + } + } + + console.log(`[seal-memory] forwarded ${ok}/${slice.length} entries (${fail} failed)`); + // Do not fail the workflow on partial ingest errors (ledger may be down); operators use logs. + process.exit(0); +} + +main(); diff --git a/tests/oaa-kv-latest.test.ts b/tests/oaa-kv-latest.test.ts new file mode 100644 index 0000000..cd804d5 --- /dev/null +++ b/tests/oaa-kv-latest.test.ts @@ -0,0 +1,61 @@ +import fs from "fs"; +import os from "os"; +import path from "path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { getLatestJournalEntriesByKeys } from "../lib/kv/store"; + +describe("getLatestJournalEntriesByKeys", () => { + let tmp: string; + let prev: string | undefined; + + beforeEach(() => { + tmp = path.join(os.tmpdir(), `oaa-memory-${Date.now()}.json`); + prev = process.env.OAA_MEMORY_PATH; + process.env.OAA_MEMORY_PATH = tmp; + }); + + afterEach(() => { + if (prev === undefined) delete process.env.OAA_MEMORY_PATH; + else process.env.OAA_MEMORY_PATH = prev; + try { + fs.unlinkSync(tmp); + } catch { + /* ignore */ + } + }); + + it("returns newest entry per key", async () => { + const mem = { + version: "v1", + notes: [ + { + ts: 100, + note: "old", + tag: "t", + agent: "A", + cycle: "C-1", + data: { key: "k1", value: 1 }, + hash: "h1", + previous_hash: null, + type: "OAA_MEMORY_ENTRY_V1" + }, + { + ts: 200, + note: "new", + tag: "t", + agent: "A", + cycle: "C-1", + data: { key: "k1", value: 2 }, + hash: "h2", + previous_hash: "h1", + type: "OAA_MEMORY_ENTRY_V1" + } + ] + }; + fs.writeFileSync(tmp, JSON.stringify(mem), "utf8"); + + const r = await getLatestJournalEntriesByKeys(["k1", "missing"]); + expect(r.k1?.data.value).toBe(2); + expect(r.missing).toBeNull(); + }); +});