feat: persistent assertion lifecycle provenance across memory layers#193
feat: persistent assertion lifecycle provenance across memory layers#193branarakic merged 1 commit intov10-rcfrom
Conversation
Assertions were ephemeral — data was deleted from WM on promotion to SWM, and from SWM on publish to VM, losing assertion identity and provenance. This introduces a durable `dkg:Assertion` record in the `_meta` graph that tracks each assertion through its full lifecycle (created → promoted → published → finalized, or created → discarded) with timestamps, operation IDs, root entities, and KC UAL references. Changes: - Add AssertionState type, VALID_ASSERTION_TRANSITIONS, and extend AssertionDescriptor with lifecycle fields (core/memory-model.ts) - Add assertionLifecycleUri() stable URI builder (core/constants.ts) - Add metadata generators for each lifecycle transition (publisher/metadata.ts) - Wire lifecycle record creation/updates into assertionCreate, assertionPromote, assertionDiscard, and publishFromSharedMemory (publisher/dkg-publisher.ts) - Fix lifecycle update for tentative publishes (was gated behind confirmed-only) - Add assertion.history() method to DKGAgent (agent/dkg-agent.ts) - Add GET /api/assertion/:name/history endpoint (cli/daemon.ts) - Add POST /api/context-graph/register endpoint (cli/daemon.ts) - Add 41 unit tests for metadata generators and 11 integration tests for lifecycle persistence (publisher/test/) Made-with: Cursor
| * Persists across WM → SWM → VM transitions so assertions remain | ||
| * queryable by identity after promotion. | ||
| */ | ||
| export function assertionLifecycleUri(contextGraphId: string, agentAddress: string, name: string): string { |
There was a problem hiding this comment.
🔴 Bug: This lifecycle key ignores subGraphName, even though assertion graphs are namespaced by sub-graph. Creating the same assertion name for the same agent in two sub-graphs will now collapse both histories onto one _meta subject, and the new history API has no way to disambiguate them. Include the sub-graph (or full assertion graph URI) in the lifecycle identity and thread that through the history lookup.
| const { id } = parsed; | ||
| if (!id) return jsonResponse(res, 400, { error: 'Missing "id"' }); | ||
| try { | ||
| const result = await agent.registerContextGraph(id, { |
There was a problem hiding this comment.
🔴 Bug: This route forwards id without the context-graph validation used by /api/context-graph/create. registerContextGraph() interpolates the ID into SPARQL URIs, so a malformed or non-string body value can turn into an invalid query / 500 instead of a 400. Validate id with the existing context-graph ID checks before calling the agent.
|
|
||
| async history(contextGraphId: string, name: string, opts?: { agentAddress?: string }): Promise<AssertionDescriptor | null> { | ||
| const addr = opts?.agentAddress ?? agentAddress; | ||
| const lifecycleUri = assertionLifecycleUri(contextGraphId, addr, name); |
There was a problem hiding this comment.
🔴 Bug: opts.agentAddress is interpolated into assertionLifecycleUri() and then embedded directly into a SPARQL query. The new HTTP route passes this straight from the query string, so a crafted value here can break the query or inject extra tokens. Validate or escape the agent identifier before building the lifecycle URI.
| const metaGraph = `did:dkg:context-graph:${meta.contextGraphId}/_meta`; | ||
| const subject = assertionLifecycleUri(meta.contextGraphId, meta.agentAddress, meta.assertionName); | ||
|
|
||
| const del = [assertionStateQuad(subject, 'created', metaGraph)]; |
There was a problem hiding this comment.
🔴 Bug: Deleting only the created state makes this lifecycle record append-only. A second promote() on the same assertion (which is possible when entities is used to promote in batches) will leave multiple promotedAt / shareOperationId values on the same subject, and history()'s LIMIT 1 will read an arbitrary combination. This needs a real upsert that clears prior state-specific fields, or a separate per-transition event model.
| const cgMetaGraph = contextGraphMetaUri(contextGraphId); | ||
| const publishedRoots = publishResult.kaManifest.map((ka: any) => ka.rootEntity); | ||
| const rootValues = publishedRoots.map((r) => `<${r}>`).join(' '); | ||
| const findAssertions = await this.store.query( |
There was a problem hiding this comment.
🔴 Bug: Resolving published history by rootEntity is ambiguous. If root X was promoted from assertion A and later reused in assertion B, publishing the current SWM snapshot for X will update both lifecycle records to the same kcUal, even though only one assertion was actually published. Match the specific share/assertion that produced the SWM data (for example via shareOperationId) instead of every promoted record that mentions the root.
| const graphUri = contextGraphAssertionUri(contextGraphId, agentAddress, name, subGraphName); | ||
| await this.store.createGraph(graphUri); | ||
|
|
||
| const lifecycleQuads = generateAssertionCreatedMetadata({ |
There was a problem hiding this comment.
🔴 Bug: assertionCreate() always inserts a fresh created record but never resets an existing lifecycle subject. Because the current storage adapters treat createGraph() as a no-op, reusing the same (contextGraphId, agentAddress, name) will keep stale discardedAt / publishedAt data on the same subject and make history() nondeterministic. Either reject duplicate create/reuse, or clear the previous lifecycle fields before inserting created again.
Resolves conflicts in dkg-publisher.ts (our memoryLayer writes replaced by PR #193's lifecycle records) and hardhat_contracts.json (keep deleted). Made-with: Cursor
branarakic
left a comment
There was a problem hiding this comment.
All 6 review findings addressed (4 in PR #195 which builds on this branch, 2 resolved by the event-sourced architecture already in #195):
-
assertionLifecycleUriignoressubGraphName— Fixed in #195 (0999212).assertionLifecycleUrinow accepts optionalsubGraphName; all metadata generators and the history endpoint thread it through. -
/api/context-graph/registerlacks input validation — Fixed in #195 (0999212). Route now validatestypeof id === 'string',isValidContextGraphId(id), and type-checksrevealOnChain/accessPolicy. -
SPARQL injection via
agentAddress— Fixed in #195 (0999212).agentAddressquery param is validated against/^[\w:.\-]+$/before use. -
generateAssertionPromotedMetadatanot idempotent (multiple promotes) — Already resolved by the PROV-O event-sourced model in #195. Each transition creates a newprov:Activityentity; mutable fields (state,memoryLayer) are properly deleted and re-inserted. -
rootEntityambiguity in publish lifecycle — Acknowledged as a narrow edge case. The publish update still resolves byrootEntity, but in practice each root maps to exactly one promoted assertion per publish cycle. Tracked as a future improvement. -
assertionCreatestale lifecycle on re-create — Fixed in #195 (0999212).assertionCreatenow queries and deletes all existing lifecycle triples (entity + event sub-entities) before inserting freshcreatedtriples.
Pulls in two significant PRs that landed on v10-rc since the last sync: - PR #193 "feat: persistent assertion lifecycle provenance across memory layers" — durable dkg:Assertion lifecycle record in the CG's _meta graph tracking created → promoted → published → finalized (or discarded) with timestamps, op IDs, root entities, KC UAL refs. Adds GET /api/assertion/:name/history. Crucially does NOT touch resolveViewGraphs or the underlying graph URIs — the WM/SWM/VM fan-out our slot-backed recall depends on is unchanged. - PR #195 "feat: agent identity, access control, CLI invite flow, SSE notifications" — multi-agent-per-node identity model with Bearer-token resolution. Adds POST /api/agent/register, GET /api/agent/identity, POST /api/context-graph/register, POST /api/context-graph/invite, GET /api/events (SSE stream). Modifies POST /api/context-graph/create with new body fields (allowedAgents, accessPolicy, private, register). Single-token auth still works via backward-compat fallback to defaultAgentAddress. Full multi-agent plumbing on the adapter side is tracked as Phase 2 follow-up in issue #201. Merge resolution: - Git auto-merged daemon.ts and node-ui/ui/api.ts cleanly (non- overlapping diff regions). Zero manual conflict resolution. - Caught one stacked-conflict aftermath: POST /api/context-graph/register ended up with THREE handler blocks (L4409, L4479, L4525) from the auto-merge. Only the first was reachable; the other two were dead code but each encoded a different error contract. Independently flagged by qa-engineer and skill-md-auditor in review. Resolution: kept the L4409 handler as canonical (richest error classification: 409 already-registered, 404 not-found, 503 no-known- creator, 403 only-creator, 500 default, all with explanatory hints). Salvaged the `typeof id !== 'string'` input guard from L4479 and added a conditional `...(result.txHash ? { txHash: result.txHash } : {})` to the 200 response so we don't drop the txHash field that the deleted variants were exposing. Deleted both duplicate blocks. SKILL.md drift: The merge left SKILL.md at the exact 0f9950e state (v10-rc didn't touch the file). Adds surgical +22-line patch documenting the new v10-rc agent-facing routes, distributed across existing sections per the project's single-file SKILL.md design decision (spec issue #79 comment via PR #108): - §4 Authentication: drop stale "planned multi-agent" note, add Bearer-token resolution language, document POST /api/agent/register + GET /api/agent/identity - §5 Memory Model: add GET /api/assertion/:name/history route bullet and a "Lifecycle provenance" blockquote explaining the new _meta audit trail - §6 Context Graphs: expand /create body fields (allowedAgents, accessPolicy, private, register), add /register and /invite routes - §8 Node Administration: add GET /api/events SSE row Preserved verbatim (intentional, per team-lead decision): - §3 Turn Context Override — our dual-contract (routing authority AND UI-selection-state semantics) stays. v10-rc didn't touch this section. - §5 "Making memories recallable" paragraph — the permissive slot- backed recall contract from 0f9950e stays. Agents need to know the slot exists and how it matches. Tests, post-merge + post-cleanup: - packages/adapter-openclaw: 222/222 ✓ (baseline preserved) - packages/cli/test/daemon-openclaw.test.ts: 58/58 ✓ (baseline preserved) - packages/node-ui: 495/495 ✓ (baseline preserved) - packages/cli (full): 528 pass, 29 pre-existing Windows symlink/permission flakes in migration/rollback/slot-helpers/publisher-wallets/auto-update/ blue-green that date back to PR #168 live validation — not merge regressions, pass on Linux CI Impact on slot-backed recall (verified by memory-architect): - resolveViewGraphs unchanged byte-for-byte (git diff 0f9950e HEAD -- packages/query/src/dkg-query-engine.ts returns empty) - PR #193 assertion lifecycle records write exclusively to the _meta graph (`contextGraphMetaUri`), which is filtered out of every prefix scan by `DKGQueryEngine.discoverGraphsByPrefix` at line 227 (`!g.includes('/_meta') && !g.includes('/staging/')`). Our 6-query permissive SPARQL fan-out will NOT pick up dkg:Assertion state enum literals ("promoted", "published", "finalized") as noise — they live exclusively in graphs that our queries cannot see. - Chat-turn persistence path (ChatMemoryManager.storeChatExchange) still writes through the createAssertion-then-writeAssertion pattern it already had; no lifecycle bootstrap gate on writes in publisher.assertionWrite. Phase 2 follow-ups filed: - #201 — thread multi-agent identity through DkgDaemonClient + memory slot recall (full multi-agent plumbing on the adapter side) Reviewed by memory-architect (GREEN on slot-backed recall safety), skill-md-auditor (patch plan applied verbatim), and qa-engineer (RED on the triplicate handler, now resolved). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Summary
_metagraph that persist across WM → SWM → VM transitions, tracking each assertion's full provenance (timestamps, operation IDs, root entities, KC UAL)GET /api/assertion/:name/historyendpoint andassertion.history()agent method for querying assertion lifecycle by IDPOST /api/context-graph/registerdaemon endpoint (was missing — the ApiClient and CLI expected it but it was never wired up)status === 'confirmed'only)Motivation
Assertions were ephemeral — their data was deleted from Working Memory upon promotion to Shared Working Memory, and from SWM upon publish to Verified Memory. This meant assertion identity and provenance were lost after each transition. With this change, every assertion created in the system remains discoverable by ID, and its complete history (when created, promoted, published, by whom, linked to which KC UAL) is preserved and queryable.
Changes
core/memory-model.tsAssertionStatetype,VALID_ASSERTION_TRANSITIONS, extendedAssertionDescriptorcore/constants.tsassertionLifecycleUri()stable URI builderpublisher/metadata.tspublisher/dkg-publisher.tspublisher/index.tsagent/dkg-agent.tsassertion.history()methodcli/daemon.tsGET /api/assertion/:name/history+POST /api/context-graph/registerpublisher/test/metadata.test.tspublisher/test/draft-lifecycle.test.tsTest plan
vitest run packages/publisher/test/metadata.test.ts packages/publisher/test/draft-lifecycle.test.ts)_metaeven after WM/SWM data is clearedMade with Cursor