Skip to content

feat: persistent assertion lifecycle provenance across memory layers#193

Merged
branarakic merged 1 commit intov10-rcfrom
feat/assertion-lifecycle-provenance
Apr 15, 2026
Merged

feat: persistent assertion lifecycle provenance across memory layers#193
branarakic merged 1 commit intov10-rcfrom
feat/assertion-lifecycle-provenance

Conversation

@branarakic
Copy link
Copy Markdown
Contributor

Summary

  • Introduces durable assertion lifecycle records in the _meta graph that persist across WM → SWM → VM transitions, tracking each assertion's full provenance (timestamps, operation IDs, root entities, KC UAL)
  • Adds GET /api/assertion/:name/history endpoint and assertion.history() agent method for querying assertion lifecycle by ID
  • Adds POST /api/context-graph/register daemon endpoint (was missing — the ApiClient and CLI expected it but it was never wired up)
  • Fixes assertion lifecycle update being skipped for tentative publishes (was incorrectly gated behind 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

File What
core/memory-model.ts AssertionState type, VALID_ASSERTION_TRANSITIONS, extended AssertionDescriptor
core/constants.ts assertionLifecycleUri() stable URI builder
publisher/metadata.ts Metadata generators for created/promoted/published/discarded transitions
publisher/dkg-publisher.ts Lifecycle record writes at create, promote, discard, and publish
publisher/index.ts Export new metadata generators
agent/dkg-agent.ts assertion.history() method
cli/daemon.ts GET /api/assertion/:name/history + POST /api/context-graph/register
publisher/test/metadata.test.ts 25 new unit tests for lifecycle metadata generators
publisher/test/draft-lifecycle.test.ts 11 new integration tests for lifecycle persistence

Test plan

  • 215 unit/integration tests pass (vitest run packages/publisher/test/metadata.test.ts packages/publisher/test/draft-lifecycle.test.ts)
  • Devnet E2E verified: assertion created → promoted → published with full provenance at each step
  • Devnet E2E verified: discard path (created → discarded) with provenance persistence
  • History endpoint returns correct state and timestamps after each transition
  • Lifecycle record persists in _meta even after WM/SWM data is cleared

Made with Cursor

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 {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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, {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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)];
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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(
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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({
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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.

branarakic pushed a commit that referenced this pull request Apr 15, 2026
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
Copy link
Copy Markdown
Contributor Author

@branarakic branarakic left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All 6 review findings addressed (4 in PR #195 which builds on this branch, 2 resolved by the event-sourced architecture already in #195):

  1. assertionLifecycleUri ignores subGraphName — Fixed in #195 (0999212). assertionLifecycleUri now accepts optional subGraphName; all metadata generators and the history endpoint thread it through.

  2. /api/context-graph/register lacks input validation — Fixed in #195 (0999212). Route now validates typeof id === 'string', isValidContextGraphId(id), and type-checks revealOnChain/accessPolicy.

  3. SPARQL injection via agentAddress — Fixed in #195 (0999212). agentAddress query param is validated against /^[\w:.\-]+$/ before use.

  4. generateAssertionPromotedMetadata not idempotent (multiple promotes) — Already resolved by the PROV-O event-sourced model in #195. Each transition creates a new prov:Activity entity; mutable fields (state, memoryLayer) are properly deleted and re-inserted.

  5. rootEntity ambiguity in publish lifecycle — Acknowledged as a narrow edge case. The publish update still resolves by rootEntity, but in practice each root maps to exactly one promoted assertion per publish cycle. Tracked as a future improvement.

  6. assertionCreate stale lifecycle on re-create — Fixed in #195 (0999212). assertionCreate now queries and deletes all existing lifecycle triples (entity + event sub-entities) before inserting fresh created triples.

@branarakic branarakic merged commit 05c4b1c into v10-rc Apr 15, 2026
1 of 3 checks passed
Jurij89 pushed a commit that referenced this pull request Apr 15, 2026
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant