Skip to content

Commit 9ccf0ce

Browse files
committed
feat(workflows-mcp): add validate/invalidate_knowledge tools with enum .value bugfix
Adds validate_knowledge and invalidate_knowledge MCP tools and ops, enabling the full USER_VALIDATED authority lifecycle: store → validate → invalidate → forget. - validate_knowledge: promotes existing propositions to USER_VALIDATED (archive-immune) - invalidate_knowledge: revokes USER_VALIDATED, demoting back to AGENT; forget then works normally - Fix: all Authority/LifecycleState enum refs in f-string SQL now use .value to prevent Python 3.11+ str-enum repr ("Authority.AGENT") being written to the DB instead of "AGENT" - Tests: TestValidateOperation, TestInvalidateOperation, TestUserValidatedImmunity (137 passing) - Docs: README updated with block ops 6-7 and full MCP tool reference
1 parent 7a49dd2 commit 9ccf0ce

4 files changed

Lines changed: 303 additions & 7 deletions

File tree

README.md

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -670,7 +670,30 @@ Returns clean content only (no metadata) in `context_text`, ready for LLM prompt
670670
source_name: "deprecated-docs"
671671
created_before: "2025-01-01"
672672
```
673-
Returns `archived_count` and `skipped_count` in the output.
673+
Returns `archived_count` and `skipped_count`. `USER_VALIDATED` propositions are immune and counted in `skipped_count`.
674+
675+
**6. Validate — Grant a proposition permanent archive immunity:**
676+
```yaml
677+
- id: pin_finding
678+
type: Knowledge
679+
inputs:
680+
op: validate
681+
proposition_ids:
682+
- "uuid-of-reviewed-fact"
683+
```
684+
Promotes `authority` to `USER_VALIDATED` in-place, preserving the original UUID, creator, and category associations. Returns `validated_count`.
685+
686+
**7. Invalidate — Revoke USER_VALIDATED immunity:**
687+
```yaml
688+
- id: revoke_outdated
689+
type: Knowledge
690+
inputs:
691+
op: invalidate
692+
proposition_ids:
693+
- "uuid-of-outdated-fact"
694+
reason: "superseded by new measurement from 2026-03-21"
695+
```
696+
Demotes authority back to `AGENT` and logs an `INVALIDATED` audit entry with the reason. After this, `forget` can archive the proposition normally. Returns `invalidated_count`.
674697

675698
**6. Chaining Operations — Store, search, and cleanup in one workflow:**
676699
```yaml
@@ -839,6 +862,8 @@ When you configure workflows-mcp, your AI assistant gets these tools:
839862

840863
- **store_knowledge** - Persist a new fact with auto-computed embedding
841864
- `content`, `source`, `confidence` (default 0.8), `categories`
865+
- `authority`: `AGENT` (default), `EXTRACTED`, `COMMUNITY_SUMMARY`, or `USER_VALIDATED`
866+
- `lifecycle_state`: `ACTIVE` (default) or `QUARANTINED`
842867

843868
- **recall_knowledge** - Filter-based retrieval (no semantic search)
844869
- `source`, `categories`, `lifecycle_state`, `min_confidence`, `limit`, `order`
@@ -848,6 +873,18 @@ When you configure workflows-mcp, your AI assistant gets these tools:
848873
- By filter: `source` (exact or prefix `*`), `created_before`, `created_after`
849874
- At least one of `proposition_ids` or `source` must be provided
850875
- `reason` (optional, for audit trail)
876+
- `USER_VALIDATED` propositions are immune and reported in `skipped_count`
877+
878+
- **validate_knowledge** - Grant a proposition permanent archive immunity
879+
- `proposition_ids` (required): UUIDs to promote to `USER_VALIDATED` authority
880+
- In-place update — preserves UUID, creator, timestamp, and category associations
881+
- Returns `validated_count`
882+
883+
- **invalidate_knowledge** - Revoke USER_VALIDATED immunity
884+
- `proposition_ids` (required): UUIDs to demote back to `AGENT` authority
885+
- `reason` (optional, for audit trail)
886+
- After invalidation, `forget_knowledge` can archive the proposition normally
887+
- Returns `invalidated_count`
851888

852889
- **knowledge_context** - Token-budgeted context assembly for LLM prompts
853890
- `query`, `source`, `categories`, `max_tokens`, `diversity`

src/workflows_mcp/engine/executors_knowledge.py

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -117,7 +117,7 @@ class KnowledgeInput(BlockInput):
117117
```
118118
"""
119119

120-
op: Literal["search", "store", "recall", "forget", "context", "validate"] = Field(
120+
op: Literal["search", "store", "recall", "forget", "context", "validate", "invalidate"] = Field(
121121
description="Operation to perform",
122122
)
123123

@@ -278,6 +278,8 @@ def validate_op_fields(self) -> KnowledgeInput:
278278
)
279279
if self.op == "validate" and not self.proposition_ids:
280280
raise ValueError("'proposition_ids' is required for op='validate'")
281+
if self.op == "invalidate" and not self.proposition_ids:
282+
raise ValueError("'proposition_ids' is required for op='invalidate'")
281283
if self.path is not None and not self.source:
282284
raise ValueError("'source' is required when 'path' is provided")
283285
return self
@@ -319,8 +321,9 @@ class KnowledgeOutput(BlockOutput):
319321
archived_count: int = Field(default=0, description="Number archived")
320322
skipped_count: int = Field(default=0, description="Number skipped (immune)")
321323

322-
# Validate output
324+
# Validate / Invalidate output
323325
validated_count: int = Field(default=0, description="Number promoted to USER_VALIDATED")
326+
invalidated_count: int = Field(default=0, description="Number demoted from USER_VALIDATED")
324327

325328
# Context output
326329
context_text: str = Field(default="", description="Clean content assembled text")
@@ -401,6 +404,7 @@ async def execute( # type: ignore[override]
401404
"recall": self._op_recall,
402405
"forget": self._op_forget,
403406
"validate": self._op_validate,
407+
"invalidate": self._op_invalidate,
404408
"context": self._op_context,
405409
}
406410
handler = op_handlers[inputs.op]
@@ -1005,7 +1009,7 @@ async def _op_forget(
10051009
archived_by = ${len(ids) + 1}::uuid,
10061010
archive_reason = ${len(ids) + 2}
10071011
WHERE id IN ({placeholders})
1008-
AND authority != '{Authority.USER_VALIDATED}'
1012+
AND authority != '{Authority.USER_VALIDATED.value}'
10091013
RETURNING id
10101014
"""
10111015
result = await backend.query(
@@ -1055,7 +1059,7 @@ async def _op_forget(
10551059
archived_by = ${param_offset + 1}::uuid,
10561060
archive_reason = ${param_offset + 2}
10571061
WHERE {where_sql}
1058-
AND authority != '{Authority.USER_VALIDATED}'
1062+
AND authority != '{Authority.USER_VALIDATED.value}'
10591063
RETURNING id
10601064
"""
10611065
params.extend([str(archived_by) if archived_by else None, archive_reason])
@@ -1114,9 +1118,9 @@ async def _op_validate(
11141118
placeholders = ", ".join(f"${i + 1}::uuid" for i in range(len(ids)))
11151119
update_sql = f"""
11161120
UPDATE knowledge_propositions
1117-
SET authority = '{Authority.USER_VALIDATED}'
1121+
SET authority = '{Authority.USER_VALIDATED.value}'
11181122
WHERE id IN ({placeholders})
1119-
AND lifecycle_state != '{LifecycleState.ARCHIVED}'
1123+
AND lifecycle_state != '{LifecycleState.ARCHIVED.value}'
11201124
RETURNING id
11211125
"""
11221126
result = await backend.query(update_sql, tuple(ids))
@@ -1137,6 +1141,59 @@ async def _op_validate(
11371141

11381142
return KnowledgeOutput(success=True, validated_count=validated)
11391143

1144+
async def _op_invalidate(
1145+
self,
1146+
inputs: KnowledgeInput,
1147+
context: Execution,
1148+
backend: Any,
1149+
) -> KnowledgeOutput:
1150+
"""Revoke USER_VALIDATED authority, demoting proposition to AGENT trust level.
1151+
1152+
This is the deliberate counterpart to _op_validate. After invalidation the
1153+
proposition is no longer archive-immune and can be archived via _op_forget.
1154+
Demotes to AGENT (not deleted) so the content and audit trail are preserved.
1155+
1156+
SECURITY: Records invalidated_by and logs INVALIDATED to audit table.
1157+
"""
1158+
ids: list[str] = []
1159+
if isinstance(inputs.proposition_ids, str):
1160+
ids = [s.strip() for s in inputs.proposition_ids.split(",") if s.strip()]
1161+
elif isinstance(inputs.proposition_ids, list):
1162+
ids = inputs.proposition_ids
1163+
1164+
if not ids:
1165+
return KnowledgeOutput(success=True, invalidated_count=0)
1166+
1167+
invalidated_by = _get_audit_user_id(context)
1168+
auth_method = _get_auth_method(context)
1169+
user_string = _get_user_string_id(context)
1170+
1171+
placeholders = ", ".join(f"${i + 1}::uuid" for i in range(len(ids)))
1172+
update_sql = f"""
1173+
UPDATE knowledge_propositions
1174+
SET authority = '{Authority.AGENT.value}'
1175+
WHERE id IN ({placeholders})
1176+
AND authority = '{Authority.USER_VALIDATED.value}'
1177+
RETURNING id
1178+
"""
1179+
result = await backend.query(update_sql, tuple(ids))
1180+
invalidated = len(result.rows) if result and result.rows else 0
1181+
1182+
if invalidated > 0:
1183+
invalidated_ids = [row["id"] for row in result.rows]
1184+
for prop_id in invalidated_ids:
1185+
await self._log_audit_entry(
1186+
backend=backend,
1187+
proposition_id=prop_id,
1188+
action="INVALIDATED",
1189+
performed_by=invalidated_by,
1190+
auth_method=auth_method,
1191+
user_string=user_string,
1192+
metadata={"reason": inputs.reason, "method": "explicit_ids"},
1193+
)
1194+
1195+
return KnowledgeOutput(success=True, invalidated_count=invalidated)
1196+
11401197
async def _op_context(
11411198
self,
11421199
inputs: KnowledgeInput,

src/workflows_mcp/tools_knowledge.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -621,6 +621,66 @@ async def validate_knowledge(
621621

622622
return _json_response({"validated_count": result.validated_count})
623623

624+
@mcp_server.tool(
625+
annotations=ToolAnnotations(
626+
title="Invalidate Knowledge",
627+
readOnlyHint=False,
628+
destructiveHint=False,
629+
idempotentHint=True,
630+
openWorldHint=True,
631+
)
632+
)
633+
async def invalidate_knowledge(
634+
proposition_ids: Annotated[
635+
list[str],
636+
Field(description="UUIDs of USER_VALIDATED propositions to revoke"),
637+
],
638+
reason: Annotated[
639+
str | None,
640+
Field(
641+
description="Reason for revoking validation (for audit trail)",
642+
default=None,
643+
),
644+
],
645+
*,
646+
ctx: AppContextType,
647+
) -> CallToolResult:
648+
"""
649+
Revoke USER_VALIDATED authority, demoting propositions back to AGENT trust level.
650+
651+
WHEN TO USE: When a previously human-validated fact is no longer true or has
652+
been superseded. After invalidation the proposition loses archive immunity and
653+
can be removed with forget_knowledge. The content and full audit trail are
654+
preserved — the audit log records that this was once USER_VALIDATED and when
655+
and why that was revoked.
656+
657+
Only propositions currently holding USER_VALIDATED authority are affected.
658+
Propositions with any other authority are silently skipped (idempotent).
659+
660+
PARAMETERS:
661+
- proposition_ids: List of proposition UUIDs to invalidate
662+
- reason: Optional reason for revocation (stored in audit trail)
663+
664+
RETURNS: {invalidated_count: N}
665+
666+
SEE ALSO: validate_knowledge (grant immunity), forget_knowledge (archive after invalidation)
667+
"""
668+
from .engine.executors_knowledge import KnowledgeExecutor, KnowledgeInput
669+
670+
execution = _create_knowledge_execution(ctx)
671+
executor = KnowledgeExecutor()
672+
inputs = KnowledgeInput(
673+
op="invalidate",
674+
proposition_ids=proposition_ids,
675+
reason=reason,
676+
)
677+
result = await executor.execute(inputs, context=execution)
678+
679+
if not result.success:
680+
return _json_response({"status": "failure", "error": result.error})
681+
682+
return _json_response({"invalidated_count": result.invalidated_count})
683+
624684
@mcp_server.tool(
625685
annotations=ToolAnnotations(
626686
title="Knowledge Context",

0 commit comments

Comments
 (0)