diff --git a/docs/en/rfcs/2549-plugin-interfaces.md b/docs/en/rfcs/2549-plugin-interfaces.md
new file mode 100644
index 0000000000..fd35f6ccbe
--- /dev/null
+++ b/docs/en/rfcs/2549-plugin-interfaces.md
@@ -0,0 +1,1196 @@
+---
+title: Plugin interfaces
+description: Artifact plugins implement a few interfaces that PC Server calls when needed, with background calls in Supervisor Workers and synchronous calls in plugin host processes that core manages; callers use only the existing Source and Artifact APIs, and core manages only the two core data kinds and their consumption progress, without managing plugin lifecycle.
+---
+
+- Proposal Name: `plugin_interfaces`
+- Start Date: 2026-11-08
+- RFC PR: [oceanbase/powercontext#2549](https://github.com/oceanbase/powercontext/pull/2549)
+
+# Summary
+
+This proposal redefines PowerContext's third-party plugin model: plugins implement only a few fixed interfaces, and
+PC Server calls them when it needs them. Callers never call plugins directly; they use the existing Source and Artifact
+APIs exactly as they do for built-in capabilities.
+
+- **Artifact plugins**: a plugin owns one Artifact family, implements `generate` to turn unconsumed Sources into
+ Artifacts and `render_artifact` to give the default presentation, and may implement search and user changes. A plugin
+ may bring its own Source types to record what users submit to it.
+- **Plugin shape**: a plugin is a directory containing `plugin.py`, which assigns an `ArtifactPlugin` declaration object
+ to `plugin`. The declaration lives in code; there is no separate manifest file. The plugin reads
+ deployment-specific settings from environment variables.
+- **How callers use plugins**:
+ - Writing Sources of a plugin's own types uses the existing Source write API.
+ - Reading and searching Artifacts use the existing Artifact APIs.
+ - Creating, editing, merging, forgetting, and restoring plugin Artifacts also use the existing Artifact APIs. Core
+ handles authorization, version checks, and provenance, then synchronously calls the matching plugin method; the plugin
+ validates the content, maintains its own storage, and writes through the writer. An operation the plugin does not
+ implement returns 422.
+- **Execution**:
+ - `generate` is a background call. It runs in Worker child processes managed by the Supervisor, reusing its existing
+ isolation, timeouts, concurrency budgets, leader lease, and failure backoff.
+ - `resolve` and Projections during Source writes, presentation, search, and user changes to plugin Artifacts are
+ synchronous calls. They run in a long-lived **plugin host process** that core manages: each Server process starts one
+ host process for each loaded plugin, and deployers do not manage it.
+- **Writes**: an Artifact plugin performs its writes inside an `async with writer.transaction()` block. When the block
+ ends, core commits the writes, the consumption cursor, and the completion acknowledgment together; an exception rolls
+ everything back. Plugins can neither read nor change the cursor.
+- **What core owns**: only `pc_sources`, `pc_artifacts` (including heads, lineage, merge records, lifecycle, handles, and
+ tags), and their consumption progress. Plugins may have their own indexes and storage and manage them themselves. Core
+ gives each plugin a dedicated storage area on the database backend the deployment uses, and lets plugins use the models
+ Server has configured.
+- **Lifecycle**: plugins load from a directory at Server startup, and changes require a restart. Each Server process
+ records its active plugins only in memory and persists no plugin enabled state, registration generation, or
+ management phase.
+
+This proposal replaces the plugin contracts and management mechanisms of the
+[Artifact plugin framework RFC #2175](2175-artifact-plugin-system.md). The old framework shipped in v1.5, but no plugins
+for it have been published, so this proposal keeps no compatibility: the old plugin manifests, management APIs, and
+plugin-management tables are removed, and custom Artifact data written by old plugins is not migrated. So that no data is
+lost silently, the migration preflight refuses to upgrade while the old framework still holds data, and the deployer
+exports and cleans it up on the old version first (reference section 10).
+
+Standalone Source plugins, which only provide Source types or collect continuously from external systems (collection
+targets, checkpoints, collecting on a device and reporting to a central Server), are out of scope. A follow-up proposal
+defines them and replaces the [Source plugin framework RFC #2012](2012-source-plugin-system.md); until then the old Source
+plugin framework stays unchanged.
+
+This document describes the target design; it does not mean the mainline or a release already provides it. Interface
+names and code examples illustrate the contract; the implementation determines the final syntax.
+
+# Motivation
+
+The existing Artifact plugin framework makes PowerContext carry a large amount of lifecycle management for third-party
+plugins:
+
+| Framework | Work Server carries for plugins |
+| --- | --- |
+| Artifact plugins (#2175) | `desired_state`, `phase`, `binding_generation`, and `last_error` in `pc_artifact_processing_binding_states`; seven management states and their transitions; the `enable-all / reload / enable / disable / unload` management APIs with `expected_generation` concurrency checks; registration generations that fence old Worker commits; content schemas and index definitions pinned in `pc_artifact_family_schemas`, with 409 on change; the framework builds, rebuilds, and tracks availability of custom declarative indexes |
+
+Everyone pays for these mechanisms:
+
+- **Plugin authors** must understand framework concepts such as bindings, generations, schema pinning rules, and index
+ declarations before they can write a plugin.
+- **Deployers** must write manifest files and use management APIs to get plugins into a runnable state.
+- **Callers** must tell built-in capabilities from plugin capabilities and use different APIs for them.
+- **Maintainers** must maintain state machines, concurrency fencing, schema and index governance, and recovery paths for
+ third-party code; this code and its test surface are already larger than the plugins' own business logic.
+
+What a plugin actually needs is to be called at the right time, receive the input it needs, and hand its results back to
+core. Scheduling, isolation, consumption progress, and transactional commits are capabilities core already has; the
+Supervisor already provides them for built-in Artifacts. Plugin models such as Dify's also show that letting the host
+define the interfaces and when to call them, while plugins only implement the interfaces, is enough to support a large
+number of third-party extensions.
+
+Goals:
+
+1. A plugin author writes only `plugin.py`: one declaration object and a few methods, without learning the internals of
+ plugin management.
+2. Callers are unaware of plugins: the existing Source and Artifact APIs apply equally to data plugins produce.
+3. Plugin code runs only in child processes core manages (Workers or plugin host processes), so a plugin failure does
+ not affect Server or other plugins.
+4. Plugin results go into `pc_sources` / `pc_artifacts` and commit atomically with consumption progress.
+5. Core manages only these two core data kinds and their consumption progress; content structure, indexes, and other
+ storage belong to the plugin.
+6. Plugin writes to core data go only through a writer core provides, which covers every generic Artifact write
+ operation and keeps core's existing validation rules.
+7. User changes to plugin Artifacts go through the plugin, so plugin-owned storage is updated together with the change.
+8. Artifact generation can run on a schedule or be triggered by new Sources.
+9. Users deploy a single Server; plugins load when Server starts.
+
+# Guide-level explanation
+
+## 1. Overall design
+
+```mermaid
+flowchart LR
+ Caller["Application / Agent / Host"]
+ subgraph Server["PC Server (core)"]
+ SourceAPI["Existing Source APIs
write Sources"]
+ ArtifactAPI["Existing Artifact APIs
read, search, change"]
+ Sup["Supervisor
schedules / triggers / budgets / backoff"]
+ Core[("Core data
pc_sources / pc_artifacts / cursors")]
+ end
+ subgraph Host["Plugin host process (long-lived, managed by core)"]
+ Sync["resolve / Projections / render_artifact
search_artifacts / create_artifact / ..."]
+ end
+ subgraph AW["Worker: Artifact plugin"]
+ Generate["generate(request, writer)"]
+ Tx["writer.transaction()"]
+ Generate --> Tx
+ end
+ Caller --> SourceAPI
+ Caller --> ArtifactAPI
+ SourceAPI -->|"synchronous call"| Sync
+ ArtifactAPI -->|"reads"| Core
+ ArtifactAPI -->|"presentation, search, synchronous call"| Sync
+ ArtifactAPI -->|"changes, synchronous call"| Sync
+ Sync -->|"Source or Artifact writes, one commit"| Core
+ Sync -.-> Own
+ Core -->|"new Sources trigger"| Sup
+ Sup -->|"unconsumed Source window"| Generate
+ Tx -->|"Artifact writes, cursor, acknowledgment, one commit"| Core
+ Generate -.-> Own[("Plugin-owned storage / models")]
+```
+
+The diagram has two paths, and one call runs only one plugin:
+
+- **Generation path**: when new Sources enter the journal, Artifact plugins that consume them are marked as pending. The
+ Supervisor dispatches a Worker with the Scope's unconsumed Source window and a writer; the plugin writes Artifacts in a
+ write transaction block, and core commits them together with the cursor and the acknowledgment.
+- **Synchronous path**: when a caller writes a Source of a plugin type, presents or searches plugin Artifacts, or
+ changes a plugin Artifact, Server
+ checks authorization and versions and then hands the request to the plugin's host process. The plugin performs the
+ conversion or write there, and the result is committed in one transaction and returned to the caller.
+
+Callers deal only with core's existing APIs:
+
+| What the caller does | API | How the plugin takes part |
+| --- | --- | --- |
+| Submit a Source of a plugin type | Existing Source write API | The host process runs the plugin's `resolve` and Projections |
+| Browse or read Artifacts | Existing Artifact read APIs | Not involved |
+| Present or search plugin Artifacts | The presentation endpoint, the existing search API, Prepared Context | The host process runs `render_artifact` or `search_artifacts` |
+| Create, edit, merge, forget, restore, undo a merge | Existing Artifact create and edit APIs, and the generic merge APIs | The host process runs the matching plugin method; an unimplemented one returns 422 |
+| Tag, query by tag | Existing tag APIs | Not involved; tags are core data |
+
+Responsibilities:
+
+| Responsibility | Owner |
+| --- | --- |
+| Authentication, Scope authorization, public HTTP / MCP APIs, version checks and provenance for user changes | Core |
+| When to call: schedules, trigger coalescing, Worker budgets, timeouts, backoff, leader lease | Core (the existing Supervisor) |
+| Starting, reusing, terminating on timeout, and restarting host processes | Core |
+| Reading inputs, providing the constrained write transaction, committing core data / cursor / acknowledgment in one transaction | Core (the plugin executor in the Worker or host process) |
+| Allocating dedicated plugin storage, wrapping the models Server has configured | Core |
+| Definitions and Projection computation for the plugin's own Source types; Artifact content structure, generation algorithm, presentation, search, validating user-submitted content, plugin-owned indexes and storage and their consistency | Plugin |
+| Deploying plugin code, dependencies, and environment variables | Deployer |
+
+Plugin code does one thing: given an input, produce a result or raise. Raising means failure and core commits no core
+data; a background call retries the same input next time, and a synchronous call returns the error to the caller.
+
+## 2. Plugin shape
+
+Server loads plugins from `POWERCONTEXT_SERVER_PLUGINS_DIR`. Each direct subdirectory is one plugin, and the directory name
+is the plugin name. The directory must contain `plugin.py` and may contain other modules:
+
+```text
+/opt/powercontext/plugins/
+├── knowledge-base/
+│ ├── plugin.py
+│ └── parsers.py
+└── incident-report/
+ └── plugin.py
+```
+
+`plugin.py` assigns a declaration object to the module-level variable `plugin`:
+
+```python
+plugin = ArtifactPlugin(family="incident-report", ..., setup=setup)
+```
+
+The declaration answers what core needs to know before it runs any plugin code: what the plugin provides (an Artifact
+family and its own Source types), what it consumes, when to call it, and its resource limits. It is an ordinary Python object validated
+by Pydantic; there is no YAML or other manifest file.
+
+- **Deployment settings**: the plugin's own values that vary by deployment, such as credentials and external service
+ addresses, are read by the plugin from environment variables in `setup`. Server neither stores, passes, nor logs them.
+- **Resources core provides**: a plugin that needs a database or models does not configure them separately; it uses
+ objects core wraps for it through the context (see reference 1.2):
+ - `ctx.storage`: a dedicated area for the plugin on the database backend the deployment uses. On SQLite it is a
+ separate file in the data directory; on OceanBase and seekdb it is a separate database. The connection the plugin
+ gets points only to this area, not to core's tables.
+ - `ctx.models`: the generation and embedding models Server has configured. The plugin can only call them; it never
+ sees model addresses or credentials. Calls count toward the existing model usage statistics.
+- **Enabling and disabling**: if the directory exists and passes the check, the plugin is available; to disable it, move
+ it out of the directory and restart Server.
+- **Check**: at startup, Server imports `plugin.py` in a time-limited child process and validates the declaration without
+ calling `setup`. A failed check affects only that plugin.
+
+## 3. A plugin's own Source types
+
+An Artifact plugin can provide its own Source types in the `definitions` of its declaration (see 4.2). The plugin uses them
+to record what users asked for, such as uploading a document or rewriting a chunk, and then turns those Sources into
+Artifacts in `generate`. Every change thus has a matching Source and traceable provenance, without a separate Source
+plugin.
+
+```python
+plugin = ArtifactPlugin(
+ family="knowledge-base",
+ definitions=(DocumentUploadDefinition(),), # Source type kb-document
+ inputs=Inputs(source_types=("kb-document",), projection="knowledge-base.upload@1"),
+ on_sources=True,
+ setup=setup,
+)
+```
+
+- **Definitions**: the existing `SourceDefinition` applies: `input_class` validates the input, `resolve` turns it into a
+ Source, and Projections give the shapes consumers read.
+- **Writes**: callers submit through the existing Source write API (`POST /v1/scopes/{scope_id}/sources` with the plugin's
+ type as `source_type`), and the plugin's own code submits with `ctx.sources.submit`. Validation, `resolve`, and
+ Projections run in the plugin's host process, and saving follows the same semantics as built-in Sources.
+- **Append-only**: like every built-in Source, these have no updates or deletes; to correct content, submit a new Source.
+- **Large content**: `resolve` must be a pure function, and the existing Source write APIs accept only JSON. Files and
+ images come in through plugin-defined routes (see [RFC #2579](https://github.com/oceanbase/powercontext/pull/2579)); the
+ plugin stores them in its own storage, and the Source records only metadata such as the location, digest, and media type.
+- **Consumption**: the plugin consumes these types by listing them in `inputs.source_types`; other Artifact plugins can
+ consume them too.
+
+## 4. Artifact plugins
+
+### 4.1 Interfaces to implement
+
+| Interface | Required | Description |
+| --- | --- | --- |
+| `setup(ctx) -> ArtifactGenerator` | Yes | The factory of the plugin object (not an Artifact create); run once per invocation in a Worker, and only once and then reused in the host process |
+| `generate(request, writer) -> None` | When `schedule` or `on_sources` is set | Reads `request.sources` (the unconsumed Source window) and writes Artifacts in the write transaction block |
+| `render_artifact(request) -> Rendering` | Yes | The default presentation (Markdown or plain text) of an exact revision; see reference section 7 |
+| `search_artifacts(request) -> SearchResult` | No | Searches with the plugin's own indexes and returns the revisions and passages it matched; the generic search API and Prepared Context call it |
+| `create_artifact(request, writer) -> None` | No | A user create; core allocates `request.artifact_id` |
+| `update_artifact(request, writer) -> None` | No | A user edit of the given Artifact; `request.expected_revision` is the revision the caller saw |
+| `merge_artifacts(request, writer) -> None` | No | A user merge; core allocates the result ID |
+| `forget_artifact(request, writer) -> None` | No | A user forget |
+| `restore_artifact(request, writer) -> None` | No | A user restore, restore to an earlier revision, or undo of a merge |
+
+The last five methods handle user changes (see 4.4), and each is optional: for an operation the plugin does not
+implement, the matching API returns 422, meaning the family does not support it. In these methods the plugin validates
+the content the user submitted, updates its own storage, and performs through the writer the one write operation core
+requires.
+
+Core builds no index for plugin families and does not understand the structure of `content`: only the plugin knows how
+its content is chunked, whether it suits full-text or vector search, and how to present it. Presentation and search
+therefore belong to the plugin: `render_artifact` is required, `search_artifacts` is optional, and without it the generic
+search API and Prepared Context return 422 for the family.
+
+### 4.2 Declaration
+
+| Field | Default | Description |
+| --- | --- | --- |
+| `family` | Required | The Artifact family the plugin owns; must not duplicate a built-in family or another plugin's |
+| `inputs` | `None` | `Inputs(source_types=..., projection=..., projections={...}, max_sources=50)`: the Source types consumed, the Projection each Source is delivered in, and the maximum Sources per window; empty means the plugin consumes no Sources. `projection` applies to every type, `projections` names a different Projection per Source type, and every type needs one or the other |
+| `on_sources` | `False` | Trigger after new Sources arrive; requires `inputs` |
+| `schedule` | `None` | `Interval(seconds=...)` or `Cron(expression=..., timezone=...)`. With neither `schedule` nor `on_sources`, the plugin has no background generation and need not implement `generate`; its Artifacts are written only by user changes and plugin APIs (notes, for example) |
+| `scopes` | `None` | Scopes the plugin may serve; required for plugins that set `schedule` but consume no Sources |
+| `timeout_seconds` | 300 | Time limit for the whole invocation |
+| `transaction_timeout_seconds` | 30 | Time limit for the write transaction block, at most `timeout_seconds` |
+| `max_workers` | 1 | Concurrency limit across Scopes |
+| `change_timeout_seconds` | 30 | Time limit for one user-change call, including its write transaction block |
+| `definitions` | `()` | The plugin's own Source types, registered when the plugin loads; see section 3 and reference section 2 |
+| `setup` | Required | See the table above |
+
+### 4.3 Example
+
+The plugin below generates one report per ticket Source: it first uses its own vector index to find a similar existing
+report and revises it on a match, otherwise it creates a new one; it also writes the report into its own vector index.
+It also supports user edits of reports; create, merge, forget, and restore are not implemented and return 422 for this
+family. The vector index lives in the dedicated storage core allocates, and the models are the ones Server has
+configured.
+
+```python
+from powercontext.plugins import (
+ ArtifactPlugin, ArtifactWriter, GenerateRequest, Inputs, InvalidContent, PluginContext, Rendering, RenderRequest,
+ SearchHit, SearchRequest, SearchResult, UpdateRequest,
+)
+from powercontext.sources import TextEvidence
+
+
+class IncidentReports:
+ def __init__(self, index: VectorIndex, model: Summarizer) -> None:
+ self.index, self.model = index, model
+
+ async def generate(self, request: GenerateRequest, writer: ArtifactWriter) -> None:
+ # 1. Slow work outside the write transaction: model calls, vector computation
+ planned = []
+ for source in request.sources:
+ evidence = TextEvidence.model_validate(source.projection)
+ summary = await self.model.summarize(evidence.content)
+ planned.append((source, summary, await self.model.embed(summary)))
+
+ # 2. A short write transaction: reads inside the block see what the block has written
+ async with writer.transaction() as tx:
+ for source, summary, vector in planned:
+ match = await self.index.nearest(request.scope_id, vector, threshold=0.92)
+ current = await tx.get(match.artifact_id) if match else None
+ artifact_id = current.ref.artifact_id if current else writer.stable_id(
+ "report", source.ref.source_type, source.ref.source_id)
+ written = await tx.put(artifact_id, content={"title": "Incident report", "summary": summary},
+ source_refs=[source.ref])
+ # Write the owned index once the real artifact_id and revision are known; upsert by ID
+ await self.index.upsert(request.scope_id, written.ref.artifact_id, written.ref.revision, vector)
+ # The block ended normally: core commits the Artifact writes, the cursor advance, and the acknowledgment
+
+ async def update_artifact(self, request: UpdateRequest, writer: ArtifactWriter) -> None:
+ # A user edit: validate the content, recompute the vector, then write to core and the owned index
+ report = Report.model_validate(request.content) # raise InvalidContent on bad input; the API returns 422
+ vector = await self.model.embed(report.summary)
+ async with writer.transaction() as tx:
+ written = await tx.put(request.artifact_id, content=report.model_dump(),
+ expected_revision=request.expected_revision)
+ await self.index.upsert(request.scope_id, written.ref.artifact_id, written.ref.revision, vector)
+
+ async def render_artifact(self, request: RenderRequest) -> Rendering:
+ report = Report.model_validate(request.artifact.content)
+ return Rendering(text=f"# {report.title}\n\n{report.summary}")
+
+ async def search_artifacts(self, request: SearchRequest) -> SearchResult:
+ # The generic search request arrives unchanged; this plugin supports vector search only
+ if request.search.mode not in (None, "vector"):
+ raise InvalidContent("only vector search is supported", field="mode")
+ vector = await self.model.embed(request.search.query)
+ hits = await self.index.nearest_many(request.scope_id, vector, limit=request.search.limit)
+ return SearchResult(hits=[SearchHit(ref=hit.ref, text=hit.summary, score=hit.score) for hit in hits])
+
+
+async def setup(ctx: PluginContext) -> IncidentReports:
+ index = await ctx.enter_async_context(VectorIndex.connect(ctx.storage.engine()))
+ return IncidentReports(index, Summarizer(ctx.models))
+
+
+plugin = ArtifactPlugin(
+ family="incident-report",
+ inputs=Inputs(source_types=("example-ticket",), projection="powercontext.text-evidence@1"),
+ on_sources=True,
+ setup=setup,
+)
+```
+
+If the block raises, core rolls back every Artifact write in it, the cursor stays put, and the same window is retried.
+The plugin's own index is not part of core's transaction: the plugin upserts by stable ID, so a retry overwrites entries
+left by the previous attempt, and reads from the owned index defer to core's current data (see reference 3.5).
+
+### 4.4 How callers use it
+
+Callers access plugin families through the existing Artifact APIs. Read APIs read `pc_artifacts` directly and run no
+plugin code; for presentation, search, and change APIs, core checks authorization and then synchronously calls the
+matching plugin method:
+
+| Purpose | API | Plugin method |
+| --- | --- | --- |
+| Browse | `GET /v1/scopes/{scope_id}/artifacts/{family}` | None |
+| Read the current content, a given revision, revision history, tags | `GET .../{artifact_id}`, `GET .../{artifact_id}/revisions/{revision}`, `GET .../{artifact_id}/revisions`, `GET .../{artifact_id}/tags` | None |
+| Default presentation | `GET .../{artifact_id}/rendering` (optionally with `revision`) | `render_artifact` |
+| Search (the request is passed on unchanged; the plugin interprets the search controls) | `POST /v1/scopes/{scope_id}/artifacts/{family}/search` | `search_artifacts` |
+| Prepare context for an Agent | List the plugin family in `assembly.sections` of `POST /v1/context/prepare` | `search_artifacts` |
+| Create (submit only content; core allocates the ID) | `POST /v1/scopes/{scope_id}/artifacts` with body `{"family": ..., "content": {...}}` | `create_artifact` |
+| Edit (submit complete content; `If-Match` checks the current revision) | `PUT /v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}` | `update_artifact` |
+| Read the governance state (the `state_version` needed to forget or merge) | `GET .../{artifact_id}/governance` | None |
+| Merge | `POST /v1/scopes/{scope_id}/artifacts/{family}/merges` | `merge_artifacts` |
+| Forget | `POST .../{artifact_id}/forget` | `forget_artifact` |
+| Restore, restore an earlier revision, undo a merge | `POST .../{artifact_id}/restorations` | `restore_artifact` |
+| Change tags | `PUT .../{artifact_id}/tags`; query by tag with `POST /v1/scopes/{scope_id}/artifact-tags/query` | None; handled by core |
+
+Changes return their result synchronously. A plugin rejecting the content or request returns 422, a plugin that does not
+implement the method also returns 422, and a host process that times out or is unavailable returns 503.
+
+### 4.5 Methods at a glance
+
+| Method | Applies to | Required | When core calls it | Where it runs |
+| --- | --- | --- | --- | --- |
+| `setup(ctx)` | Every plugin | Yes | Once when the host process starts; once per Worker invocation | Host process, Worker |
+| Source Definitions: `input_class`, `resolve`, a Projection's `project`, `read` | Plugins that declare `definitions` | Yes | Source writes (the generic write API, `ctx.sources.submit`); computing Projections when delivering Sources to `generate` | Host process, Worker |
+| `generate` | Artifact plugins | When `schedule` or `on_sources` is set | On schedule, or when new Sources arrive | Worker |
+| `render_artifact` | Artifact plugins | Yes | The default presentation API | Host process |
+| `search_artifacts` | Artifact plugins | No | The generic search API; Prepared Context sections that list the family | Host process |
+| `create_artifact`, `update_artifact`, `merge_artifacts`, `forget_artifact`, `restore_artifact` | Artifact plugins | No | The matching generic change APIs | Host process |
+
+Listing, reading the current content or a given revision, revision history, the governance state, and tags call no
+plugin method; core serves them from the data it stores. The host process checks the required methods after running
+`setup`; a plugin missing one fails to load and is `failed`.
+
+## 5. Scheduling and triggers
+
+Scheduling and triggering of plugins' background calls (`generate`) are delegated to the existing Artifact
+processing Supervisor; synchronous calls are triggered by requests and do not go through the Supervisor. At startup each
+plugin registers as one Supervisor binding named `plugin:`, and the scheduling key is `(binding, Scope)`.
+
+Artifact plugins:
+
+| Declaration | When a Worker is dispatched | `request.reason` |
+| --- | --- | --- |
+| `schedule=Interval(...)` or `schedule=Cron(...)` | When due, for Scopes with pending work | `sources` when there are new Sources, otherwise `schedule` |
+| `on_sources=True` | When the Scope's Source journal passes the plugin's cursor, within about 2 seconds | `sources` |
+
+- A plugin with `inputs` receives the Scope's unconsumed Source window in every invocation; when the window has no matching
+ Source, the plugin is not called and the cursor is advanced and acknowledged directly.
+- A plugin without `inputs` (for example, one that tidies existing Artifacts once a day) consumes no Sources and is called
+ once for each Scope in `scopes` when due; the first version supports only `Interval`.
+- `Cron` is allowed only for plugins with `inputs` and cannot be combined with `on_sources`: each Supervisor binding
+ accepts a single interval or cron policy.
+
+At most one Worker runs per plugin per Scope. Triggers that arrive during a run are coalesced by the Supervisor into one
+invocation after it finishes.
+
+## 6. Operations
+
+- **Startup**: Server checks each plugin directory in turn. A failed check (no `plugin.py`, import failure, an invalid
+ declaration, or a family or Source type that conflicts with a built-in or another plugin) affects only that plugin;
+ Server and the other plugins start normally.
+- **Inspection** (all require `server.admin`, are read-only, and come from the startup check or the database, so every
+ Server role returns the same answer):
+ - `GET /v1/plugins`, `GET /v1/plugins/{name}`: each plugin's check result, kind, family or Source types, consumed Source
+ types, triggers, and Scope range.
+ - `GET /v1/plugins/{name}/scopes`: for each Scope, whether work is pending, the journal position consumed through, and
+ the current journal position.
+ - Reasons for failed calls are diagnosed through logs and metrics; the API does not store them.
+- **Changes**: restart Server after changing code. Server does not watch files or reload plugins at runtime.
+- **Disabling**: move the plugin out of the directory and restart. Saved Sources, Artifacts, and cursors are all kept, but the plugin's family and Source types are no longer registered: reading, searching,
+ or changing that family through the generic APIs returns 404, as for a family that does not exist. A plugin
+ Artifact's content usually records only digests or locations, while bodies, chunks, and files live in the plugin's own
+ storage and cannot be interpreted without the plugin, so there are no reads without the plugin. New writes of the plugin's
+ Source types return 422. Redeploying a plugin with the same name
+ restores reads and continues generation from the existing cursor. The plugin's dedicated storage is not deleted with
+ it; the deployer keeps or cleans it up.
+- **Host processes**: core starts, reuses, and restarts them on demand; deployers neither configure nor manage them.
+ Host process starts, terminations, and restarts appear only in logs and metrics.
+
+# Reference-level explanation
+
+## 1. Shared contract
+
+The public interfaces live in a new module, `powercontext.plugins`, replacing the existing
+`powercontext.artifact_plugins`. Interfaces are expressed as `typing.Protocol` and
+Pydantic models.
+
+### 1.1 Declarations and loading
+
+```python
+class Interval(BaseModel):
+ seconds: float # > 0
+
+class Cron(BaseModel):
+ expression: str
+ timezone: str = "Asia/Shanghai"
+
+class ArtifactPlugin(BaseModel): # fields in guide 4.2
+ family: str
+ inputs: Inputs | None = None
+ ...
+ setup: Callable[[PluginContext], Awaitable[ArtifactGenerator]]
+```
+
+- The plugin name comes from the directory name and must match `^[a-z][a-z0-9-]{0,63}$`.
+- A declaration is validated when constructed, including schedule combinations, the dependency between `inputs` and
+ `scopes`, time-limit relations, and family naming. `setup` must be an async function.
+- The parts of a declaration other than code objects such as `setup` and `definitions` are
+ serializable. Server obtains them from the check child process to register bindings and answer operational queries.
+- During the check, core records the plugin directory's **code fingerprint** (computed from file contents). Workers
+ and host processes check it before importing the plugin, and a mismatch fails the call and asks for a Server restart,
+ so calls in one Server process never mix old and new code.
+
+### 1.2 Context
+
+```python
+class PluginContext:
+ name: str # plugin name
+ scope_id: str | None # Scope of a Worker invocation; None in the host process, where each request carries it
+ plugin_dir: Path # plugin directory (read-only by convention)
+ logger: logging.Logger # logger bound to the plugin name
+ storage: PluginStorage # the plugin's dedicated storage
+ models: PluginModels | None # models Server has configured; None when none are configured
+
+ async def enter_async_context(self, cm: AbstractAsyncContextManager[T]) -> T: ...
+
+class PluginStorage(Protocol):
+ backend: Literal["sqlite", "oceanbase", "seekdb"]
+ def engine(self) -> AsyncEngine: ... # points only to this plugin's dedicated area
+
+class ChatMessage(BaseModel):
+ role: Literal["system", "user", "assistant"]
+ content: str
+
+class PluginModels(Protocol):
+ embedding_available: bool # whether Server has an embedding model
+ embedding_dimension: int | None # vector dimension; None without an embedding model
+ generation_available: bool # whether Server has a generation model
+ async def embed(self, texts: Sequence[str], /) -> Sequence[Sequence[float]]: ...
+ async def generate(self, messages: Sequence[ChatMessage], /, *, max_tokens: int | None = None) -> str: ...
+ # Calling a model Server lacks raises ModelUnavailable; synchronous calls return 503
+```
+
+- The factory runs once per Worker invocation, and instances are not reused across invocations; in the host process it
+ runs only once, and the instance serves requests for every Scope for as long as the host process lives, so resource use
+ does not grow with the number of Scopes. Each request in the host process carries its `scope_id`. Resources that need closing
+ (including connections to plugin-owned storage) are registered through `ctx.enter_async_context` and released by the
+ executor when the Worker invocation ends or the host process stops.
+- **Dedicated storage**: core uses Server's existing database connection settings to allocate an area for the plugin on
+ the same backend, with no additional database login:
+ - SQLite: `plugins/.sqlite3` in the data directory.
+ - OceanBase / seekdb: a database named `powercontext_plugin_` (with `-` replaced by `_`), created by core on
+ first use. When Server's database login cannot create databases, the deployer creates it in advance.
+ - The plugin manages its own tables and migrations; core does not read, migrate, or back up this area. It is not part
+ of core's transactions, and its consistency with core data follows 3.5.
+ - This area belongs to the whole plugin and is not isolated by Scope. The plugin partitions its data by `scope_id` and,
+ when reading, confirms that the data belongs to the request's Scope instead of returning data on a caller-supplied
+ ID alone.
+ - The plugin and Server use the same database login, so this split prevents a plugin from writing core's tables by
+ mistake but does not stop a malicious plugin; the plugin directory is treated as trusted code.
+- **Models**: `models` wraps Server's inference configuration; the plugin never sees model addresses, headers, or
+ credentials. `models` is None when Server configures neither model; when only one is configured, the plugin checks
+ `embedding_available` and `generation_available`. Plugins should record the embedding configuration, such as
+ `embedding_dimension`, with their indexes and rebuild them when it changes, so vectors from different models are never
+ compared. Calls
+ follow Server's timeout settings and count toward the existing model usage statistics. A plugin may also read its own
+ model configuration from environment variables and create its own clients.
+- Plugins get no connection to core's database and no interface for writing cursors. Plugins write core data through
+ exactly two paths: the return value of `resolve` in the host process, and the `ArtifactWriter`.
+
+## 2. Plugin Source type contract
+
+- **Definition registration**: the Source types and Projections in `definitions` are registered in the existing Source
+ definition manifests (`pc_source_definition_manifests`) when the plugin loads. A conflict with a built-in type, a type
+ registered by the old Source plugin framework, or another plugin fails the check.
+- **Writes**: core validates the input with `definition.input_class`, calls `resolve` to get the Source, computes each
+ Projection, and saves it, with the same semantics as built-in Source writes. Validation, `resolve`, and Projections run
+ synchronously in the plugin's host process under Server's synchronous call limit. `resolve` must be a pure function with
+ no side effects such as copying files.
+- **Source identity**: determined by `resolve`. Resubmitting the same content yields the same identity and follows the
+ existing deduplication rules, so no duplicate Source is created; the same identity with different content is a conflict.
+- **Permissions**: writes require Source write permission on the Scope, and the Scope must be within the plugin's
+ `scopes`.
+- **Discovery**: `GET /v1/capabilities` and `pc_source_capabilities` list the Source types of loaded plugins.
+
+## 3. Artifact plugin contract
+
+### 3.1 Request
+
+```python
+class ArtifactGenerator(Protocol):
+ # required when schedule or on_sources is set
+ async def generate(self, request: GenerateRequest, writer: ArtifactWriter, /) -> None: ...
+
+class GenerateRequest(BaseModel):
+ scope_id: str
+ reason: Literal["schedule", "sources"]
+ sources: Sequence[GenerationSource] # this window; empty when inputs is not declared
+
+class GenerationSource(BaseModel):
+ ref: SourceRef # exact Source reference
+ position: int # the Source's journal position, for processing in write order
+ projection: JsonValue # the Projection declared for this Source type
+```
+
+### 3.2 Writer and write transaction
+
+```python
+class ArtifactWriter(ArtifactReads):
+ family: str # fixed to the declared family
+ def stable_id(self, *parts: str) -> str: ...
+ def transaction(self) -> AbstractAsyncContextManager[ArtifactTransaction]: ...
+
+class ArtifactTransaction(ArtifactReads):
+ ... # reads (see 3.4) plus write operations (see the table below)
+```
+
+`generate` runs in two parts:
+
+- **Outside the transaction**: the plugin reads committed data through `writer` (each read uses its own short read-only
+ transaction) and does slow work such as model calls and vector computation.
+- **The write transaction block**: `async with writer.transaction() as tx:`.
+ - When the block starts, core opens a write transaction and re-checks the Supervisor lease and the invocation intent.
+ - Writes inside the block execute directly in that transaction and immediately return the written `ArtifactSnapshot`
+ (with the exact revision); reads inside the block see what the block has written.
+ - When the block ends normally, core advances the cursor by CAS, acknowledges the invocation, and commits, all in the
+ same transaction.
+ - If the block raises, exceeds `transaction_timeout_seconds`, or hits a commit conflict, the whole transaction rolls back
+ and the invocation fails.
+
+Rules:
+
+- Each invocation opens at most one write transaction block; opening a second one raises. The cursor and the
+ acknowledgment commit only with this transaction, so a window's core data is never partially committed.
+- If the plugin returns normally without opening a write transaction block, the window produces nothing, and the executor
+ commits the cursor advance and the acknowledgment on its own.
+- Once the write transaction block has committed, exceptions raised by later code in `generate` no longer affect the
+ committed result; the executor records them as post-commit errors and does not retry the window. Plugins should make the
+ write transaction block the last step of `generate`.
+- The block's time limit counts toward the invocation's `timeout_seconds`. Inside the block, plugins should only read,
+ write, and write their own storage, never call a model.
+
+### 3.3 Write operations
+
+The write transaction covers every generic Artifact write operation. Each reuses core's existing implementation and
+validation and raises immediately when a rule is violated:
+
+| Operation | Semantics |
+| --- | --- |
+| `put(artifact_id, *, content, source_refs=(), artifact_refs=(), expected_revision=None)` | Creates revision 1 if absent; otherwise appends a revision on the current head. With `expected_revision`, a different current revision is a conflict; without it, the revision this invocation read outside the transaction is used (see 3.5), and nothing is checked if it was not read. Identical content to the current revision writes nothing and returns the current snapshot, and the supplied `source_refs` and `artifact_refs` are not recorded (see the write constraints below). `put` in user changes is the exception, see 4.1 |
+| `revert(artifact_id, *, to_revision)` | Appends a new revision with a historical revision's content and records `restored_from_revision`; a forgotten or merge-frozen target is restored as well. Returns an `ArtifactChange`: the target's new snapshot plus exact references to the other Artifacts restored and retired with it |
+| `set_lifecycle(artifact_id, state, *, replacement_artifact_id=None)` | `deprecated` forgets, `active` restores, and `retired` is irreversible. Restoring uses shared restoration: it records a new revision with the same content, and an input frozen by a merge undoes that merge as one group. Forgetting and retiring append no revision. Returns the same as `revert`, so the plugin can update entries in its own index affected by the cascade |
+| `merge(inputs, *, into, content, source_refs=(), artifact_refs=())` | Follows the shared merge method ([RFC #1859](1859_artifact_merge.md)): takes the current revisions of `inputs`, creates `into`, records the merge, and freezes the inputs. `into` must be a new ID; when the same inputs are merged again after an undo, the plugin should include the input revisions in `stable_id` to avoid colliding with the retired result |
+| `preview_undo_merge(artifact_id)` / `undo_merge(artifact_id)` | Undoes the merge that produced the Artifact, restoring its inputs and retiring the result, and returns exact references to what was restored and retired |
+| `bind_handle(handle_key, ref)` | Binds a stable handle to the given revision |
+| `set_tags(artifact_id, tags)` | Replaces the Artifact's tags as a whole |
+
+Write constraints:
+
+- Writes apply only to Artifacts of the declared `family` in this Scope; plugins cannot write another family or Scope.
+- In `generate`, `source_refs` may only reference Sources in this invocation's window; in user changes they may only
+ reference Sources already in the target Artifact's current lineage (to keep the original inputs). `artifact_refs` may
+ reference existing revisions of any family in this Scope; `to_revision` and the target of `bind_handle` must be
+ revisions that exist in this family.
+- Unchanged content produces no revision, as in built-in Atomic Memory: when the model finds that an existing memory
+ already expresses a new Source, that memory gets no new revision and the new Source does not enter its lineage. A
+ plugin that needs a new Source recorded as evidence for an Artifact should change its content as well.
+- Core extracts no search text and builds no index for plugin families; plugins search with their own indexes (see
+ section 7).
+- Core records, in each revision's metadata, the name and version digest of the plugin that wrote it, so when several
+ Servers load different versions each revision can be traced to the version that wrote it (see section 10).
+- Each write transaction block has limits on operations, single content size, and total bytes; exceeding one raises and
+ rolls back, with no truncation or partial commit.
+- Core does not validate the business structure of `content`, nor store or pin a schema. The plugin owns how its content
+ structure evolves and may keep a version field in `content`.
+
+### 3.4 Reads
+
+`ArtifactWriter` (outside the transaction, committed data only) and `ArtifactTransaction` (inside the transaction, also
+seeing what the block has written) offer the same read methods, scoped by default to this Scope and this family.
+`reads(family)` returns a read-only view of another family in the same Scope with the same methods; it can read built-in
+families and other plugins' families (content is returned as JSON, as is) but cannot write. This lets one plugin work
+from another plugin's results, for example a knowledge base plugin reading the Artifacts a document plugin writes:
+
+```python
+class ArtifactSnapshot(BaseModel):
+ ref: ArtifactRef # exact revision
+ content: dict[str, JsonValue]
+ lineage: ArtifactLineage # directly referenced Sources and Artifacts
+ lifecycle_state: LifecycleState # active / deprecated / retired
+ merged_into_id: str | None # the merge result when frozen by a merge
+ replacement_artifact_id: str | None
+ governance_generation: int # governance state version, to detect changes after a read
+
+class ArtifactChange(BaseModel): # returned by revert / set_lifecycle
+ artifact: ArtifactSnapshot # the target's new snapshot
+ restored: Sequence[ArtifactRef] # other Artifacts restored with it
+ retired: Sequence[ArtifactRef] # other Artifacts retired with it
+
+class ArtifactReads(Protocol):
+ async def get(self, artifact_id: str, *, revision: int | None = None) -> ArtifactSnapshot | None: ...
+ async def revisions(self, artifact_id: str, *, cursor: str | None = None, limit: int = 50) -> ArtifactPage: ...
+ async def list(self, *, states: Collection[LifecycleState] = ("active",), tags: Collection[str] = (),
+ cursor: str | None = None, limit: int = 50) -> ArtifactPage: ...
+ async def by_source(self, ref: SourceRef) -> Sequence[ArtifactSnapshot]: ...
+ async def merge_inputs(self, artifact_id: str) -> Sequence[ArtifactRef]: ...
+ async def tags(self, artifact_id: str) -> Sequence[str]: ...
+ async def resolve_handle(self, handle_key: str) -> ArtifactRef | None: ...
+ def reads(self, family: str) -> ArtifactReads: ... # read-only view of another family in the same Scope
+```
+
+- A snapshot carries the exact revision, lineage, and current governance state together, so plugins need not read lineage
+ and lifecycle separately.
+- `get` reads the current content or a given revision; `revisions` lists the full history; `merge_inputs` reads the exact
+ inputs of a merge result.
+- `list` filters by lifecycle state and tags, including deprecated, retired, and merge-frozen Artifacts, for finding data
+ that already existed.
+- `by_source` finds the Artifacts whose lineage references a given Source, for locating Artifacts to revise after a Source
+ changes.
+- Every returned snapshot carries an exact revision and can be used directly as `artifact_refs` or `to_revision` in writes.
+
+### 3.5 Consistency and idempotency
+
+- **Core data**: each window's Artifact writes, cursor advance, and invocation acknowledgment commit in one transaction. On
+ failure core data is unchanged and the retry starts clean; on success the cursor has passed the window, so the same
+ window is never delivered again. Conflicts (for example, a head changed by a user after it was read) are reported when
+ the operation runs or at commit; the whole transaction rolls back, the Supervisor reschedules, and the next invocation
+ reads the changed state.
+- **Processing the same business object again**: scenarios such as scheduled tidying without Source inputs process objects
+ that were already processed. Plugins derive Artifact IDs from business identity with `writer.stable_id(...)`; `put` is a
+ no-op for identical content, `set_lifecycle` is a no-op when the target state equals the current one, and `merge` is a
+ no-op when every input is already merged into the same result. No duplicate Artifacts or meaningless revisions result.
+- **Writing after a read outside the transaction**: when a plugin reads an Artifact outside the transaction and then
+ `put`s it in the write transaction block without `expected_revision`, core uses the revision read outside the
+ transaction as the expected value. If a user changed the Artifact after the read, the write conflicts, the invocation
+ rolls back, and it is rescheduled. This matches built-in Atomic Memory, which checks before committing whether the
+ memories it read have changed, so a plugin write based on old content never silently overwrites a user change.
+- **Plugin-owned storage**: the plugin's indexes and other storage (the dedicated storage core allocates, or any database,
+ object store, or vector store the plugin connects to itself) are outside core's transaction. The plugin writes and
+ commits owned storage with its own clients; core takes no part and does not guarantee their consistency. Core's
+ transaction is the only commit point for core data: data in owned storage takes effect only once a committed revision
+ in core references it. This needs no two-phase commit; the following practice keeps them consistent:
+ 1. Write owned storage inside the write transaction block after receiving the `ArtifactSnapshot`, upserting by
+ `artifact_id`. A failed write to owned storage then rolls back the core writes too.
+ 2. If the write transaction fails to commit, owned storage may keep entries from that attempt. The retry processes the
+ same window, derives the same stable IDs, and overwrites them.
+ 3. Treat the owned index as a source of candidates: read each hit's current snapshot from core with `get`, and use that
+ snapshot's content and state. Entries left by rollbacks then cannot make the plugin read stale content. Plugins may
+ also rebuild their index from core data periodically.
+ 4. Store large content (bodies, chunks, file bytes) as immutable records keyed by content digest: write and commit them
+ to owned storage first, then commit an Artifact whose content carries that digest in the write transaction block.
+ Do not key them by `(artifact_id, revision)`: after a write transaction rolls back, the same revision number is
+ allocated again to a write with different content. With content-digest keys, a retry that writes the same content
+ gets the same key, and a failed commit leaves only records that no revision references, which the plugin cleans up
+ periodically as unreferenced.
+- **Requirements on plugin-owned storage**: the practice above does not depend on the kind of storage, but the storage
+ must meet three conditions, which the plugin confirms when choosing it and core does not check:
+ - It can write idempotently under a key the plugin chooses (upsert or insert-if-absent). With a system that only
+ assigns IDs itself, the plugin must settle the ID before writing.
+ - Data is durable when a write returns.
+ - Data is readable right after a write. With systems that have a refresh delay (for example, Elasticsearch's refresh
+ interval or vector stores that index asynchronously), core may already reference a record that owned storage cannot
+ yet return, and the plugin retries or treats it as temporarily missing when reading.
+- `generate` itself may run several times because of retries; model calls and external requests a plugin makes are not
+ exactly-once.
+
+## 4. User changes to plugin Artifacts
+
+Users change plugin Artifacts through core's generic Artifact APIs. After authorization and a version precheck, Server
+hands the request to the plugin's host process; the plugin method runs there and writes through the writer, and the
+result is committed in a short transaction and returned synchronously. User changes neither go through the Supervisor
+nor wait in a queue.
+
+### 4.1 Requests and methods
+
+```python
+class CreateRequest(BaseModel):
+ scope_id: str
+ artifact_id: str # allocated by core; the caller does not submit it
+ content: dict[str, JsonValue]
+
+class UpdateRequest(BaseModel):
+ scope_id: str
+ artifact_id: str
+ expected_revision: int
+ content: dict[str, JsonValue]
+
+class MergeRequest(BaseModel):
+ scope_id: str
+ inputs: Sequence[MergeInput] # each is artifact_id, revision, state_version
+ into: str # result ID allocated by core
+ content: dict[str, JsonValue]
+
+class ForgetRequest(BaseModel):
+ scope_id: str
+ artifact_id: str
+ expected_revision: int
+ expected_state_version: int
+
+class RestoreRequest(BaseModel):
+ scope_id: str
+ artifact_id: str
+ operation: Literal["restore", "undo_merge"]
+ revision: int | None = None # for restore, the revision to restore to
+
+class InvalidContent(Exception): # the plugin rejects the content; the API returns 422 with the message and field
+ def __init__(self, message: str, *, field: str | None = None) -> None: ...
+```
+
+| Method | Write operation the plugin must perform |
+| --- | --- |
+| `create_artifact(request, writer)` | Exactly one `put(request.artifact_id, ...)` on an Artifact that did not exist |
+| `update_artifact(request, writer)` | Exactly one `put(request.artifact_id, ..., expected_revision=request.expected_revision)` |
+| `merge_artifacts(request, writer)` | Exactly one `merge(input IDs, into=request.into, ...)` with the inputs from the request |
+| `forget_artifact(request, writer)` | Exactly one `set_lifecycle(request.artifact_id, "deprecated")` |
+| `restore_artifact(request, writer)` | For `restore`, exactly one `revert` or `set_lifecycle(..., "active")`; for `undo_merge`, exactly one `undo_merge(request.artifact_id)` |
+
+- The write transaction in these calls allows only the matching write operation from the table, plus `set_tags` on the
+ same Artifact; writing any other Artifact raises immediately. If the method returns normally without performing the
+ required write, core rolls back and returns 502, meaning the plugin did not complete the contract.
+- The plugin may normalize the content (for example, by filling default fields); the written content is what the plugin
+ submits.
+- `put` in `create_artifact` and `update_artifact` always appends a revision, even when the content equals the current
+ revision. This matches edits to built-in families: every user edit has a new `lineage_only` Content Source (see 4.2),
+ which needs a new revision to record it.
+- The plugin should update its own storage inside the write transaction block after receiving the snapshot, as in 3.5.
+
+### 4.2 What core does
+
+- **Authorization**: the same as for built-in families. Creating an Artifact or a merge result needs the Scope contribute
+ permission, and the caller owns the result; changing an existing Artifact needs write permission on it
+ (`artifact.write`). In these calls the plugin gets only the write scope the request needs.
+- **Version checks**: before calling the plugin, core checks `If-Match`, `revision`, and `state_version`, and returns 412
+ or 409 without calling the plugin when they differ. At commit it checks again inside the write transaction; if another
+ write changed the state in the meantime, it rolls back and returns 409.
+- **Provenance**: as for built-in families, on create, edit, restore, and undo merge core stores the submitted content as
+ an internal Content Source in the same transaction (`lineage_only`, with operation `artifact_create`,
+ `artifact_replace`, `artifact_restore`, or `artifact_undo_merge`) and records it in lineage as the new revision's
+ direct source. Core writes these Sources, and they never enter a plugin's input window.
+- **Merge registry**: the generic merge, forget, and restoration APIs sit on the registry of the shared merge engine
+ ([RFC #1859](1859_artifact_merge.md)) and work for every registered family. Experience and
+ Atomic Memory are the registered built-in families and use their own adapters; a plugin family is registered at load
+ and handed to the matching plugin method. Unregistered families return 422.
+- **No preview**: Atomic Memory's dedicated APIs can preview a restoration's effect and then execute it with a signed
+ token. The generic APIs offer no preview and therefore need no signing key; restoration and undo merge act on the state
+ at execution time, while merge and forget are checked by `revision` and `state_version`.
+- **Tags**: `GET` / `PUT .../{artifact_id}/tags` and `POST /v1/scopes/{scope_id}/artifact-tags/query` behave the same for
+ plugin and built-in families. Core handles them directly, without calling the plugin and without creating a content
+ revision.
+
+### 4.3 APIs
+
+| API | Rules |
+| --- | --- |
+| `POST /v1/scopes/{scope_id}/artifacts` | The body gains a plugin branch `{"family": "", "content": {...}}`, where family must not be a built-in family name; core allocates the ID; the response matches built-in creation, including `Location` and `ETag` |
+| `PUT /v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}` | The body is `{"content": {...}}`; `If-Match` that differs from the current revision returns 412; the Artifact must exist |
+| `GET .../{artifact_id}/governance` | Returns the current revision, lifecycle state, `state_version`, and merge target; handled by core |
+| `POST /v1/scopes/{scope_id}/artifacts/{family}/merges` | Takes at least two current Artifacts as `artifact_id`, `revision`, and `state_version`, plus the result's complete content; creates the result Artifact and freezes every input |
+| `POST .../{artifact_id}/forget` | Takes `revision` and `state_version`; marks the Artifact deprecated with no replacement, appends no revision, and removes it from search |
+| `POST .../{artifact_id}/restorations` | `operation` is `restore` (default; `revision` restores earlier content) or `undo_merge`; restores and retires as one group |
+
+| Situation | Response |
+| --- | --- |
+| The plugin does not implement the method | 422, saying the family does not support the operation |
+| The plugin raises `InvalidContent` | 422, with the plugin's message and field |
+| A version mismatch | 412 (`If-Match`) or 409 (`revision`, `state_version`) |
+| The plugin raises any other exception | 500; only the exception type is logged, and its message is not returned |
+| The plugin did not perform the required write | 502 |
+| `change_timeout_seconds` is exceeded, or the host process is unavailable or in restart backoff | 503 |
+
+- The plugin branch of the create request has no discriminator mapping: OpenAPI cannot map arbitrary plugin families. The
+ built-in branch keeps its discrimination by family, the plugin branch sits beside it, and the family name selects the
+ branch, so a built-in family's request is never accepted by the plugin branch. This matches the tag APIs, whose family
+ became a string.
+- A need that only means removal (for example, taking down a document) uses forget; core provides no physical delete. In
+ `forget_artifact` the plugin can clean the body and indexes out of its own storage, while the revisions and Sources in
+ core are kept.
+- **Concurrency**: user changes and background `generate` calls can overlap. When a `generate` writes an
+ Artifact it read outside the transaction, core checks the revision it read (see 3.5); a conflict rolls the whole
+ invocation back, and the next invocation decides again from the changed state, so a user change is never silently
+ overwritten by a plugin write based on old content.
+
+## 5. Consumption cursor
+
+Each background call of an Artifact plugin processes one Source window after the cursor in that Scope:
+
+- The window holds at most `inputs.max_sources` Sources. When it has no matching Source, the plugin is not loaded and the
+ cursor advances with an acknowledgment.
+- Sources in the window whose type does not match are filtered out, but the cursor still advances past the whole window;
+ a matching Source that lacks the required Projection fails the invocation instead of being skipped.
+- The cursor advances only by CAS in the executor's commit transaction; plugins never see its value and have no way to
+ write it. Even if leadership changes or the invocation is dispatched twice between reading the window and committing,
+ the same window commits only once.
+- Whether work is pending is decided from persisted data (the gap between the Source journal position and the cursor), so
+ pending input is not lost when Server restarts. Plugins with `on_sources` are dispatched within about 2 seconds of a new
+ Source write.
+- Without `scopes`, automatic scheduling covers every Scope whose journal is past the cursor; with `scopes`, only Scopes
+ on the list are accepted.
+
+## 6. Plugin host process
+
+Synchronous calls must return within the request's time limit, so they cannot start a Worker each time: every Worker is a
+fresh Python process, and importing core's execution modules takes seconds. Synchronous calls therefore go to a
+long-lived host process:
+
+| Call | Where it runs |
+| --- | --- |
+| Input validation, `resolve`, and Projections for Source writes | Host process |
+| `create_artifact`, `update_artifact`, `merge_artifacts`, `forget_artifact`, `restore_artifact` | Host process |
+| `render_artifact`, `search_artifacts` | Host process |
+| `generate` | Supervisor Worker |
+
+- **Count and startup**: each Server process keeps at most one host process for each loaded plugin, starts it in the
+ background once the plugin loads, and reuses it afterward. Every plugin implements `render_artifact`, so every
+ loaded plugin has a host process.
+- **Execution**: the host process imports `plugin.py` once at startup and compares the declaration, and the writer runs
+ inside the host process. One host process can serve several requests concurrently, up to a limit set in the Server
+ configuration.
+- **Timeouts and failures**: each call has its own time limit (Source writes, presentation, and search use
+ Server's synchronous call limit; user changes use `change_timeout_seconds`). On a timeout, crash, or loss of response, core
+ terminates the process, in-flight requests return 503, and the process restarts after a backoff. During the backoff
+ the plugin's synchronous calls return 503; background calls are unaffected.
+- **Code changes**: a host process does not re-import the plugin while it lives, and checks the code fingerprint when it
+ starts. Restart Server after changing a plugin, the same rule as for Workers.
+- **Isolation**: as with Workers, the host process isolates ordinary failures and is not a sandbox for untrusted code.
+
+**In-memory record of active plugins**: each Server process records in memory the plugins it has loaded: name,
+directory, kind, declaration digest, check result, and the current host process state (not started, running, or in
+restart backoff). Routing user requests, starting host processes, and dispatching Workers all use this record. It is not
+persisted or synchronized across processes, and is rebuilt from the plugin directory when Server restarts. The
+operational APIs return only the check results and declarations, which are the same on every process; host process state
+is visible through logs and metrics.
+
+## 7. Public reads, presentation, and search
+
+Core serves reads directly, without running plugin code:
+
+- list / get / revisions / governance / tags reuse the existing generic Artifact read paths. Plugin family `content` is
+ returned as the JSON the plugin wrote, without a schema in `pc_artifact_family_schemas`. Merge-frozen, deprecated, and
+ retired Artifacts are presented under the shared merge and lifecycle rules.
+- List items carry only metadata; `title` and `summary` are empty for plugin families, and content is read with get.
+
+A plugin may keep only digests or references in `content` and store large content in its own storage (a knowledge base
+document, for example, records only the digest of its chunk set). The `content` returned as is then cannot be presented.
+Presentation and search therefore belong to the plugin:
+
+```python
+class RenderRequest(BaseModel):
+ scope_id: str
+ artifact: ArtifactSnapshot # the exact revision to present
+ caller: Caller | None
+
+class Rendering(BaseModel):
+ format: Literal["markdown", "text"] = "markdown"
+ text: str
+
+class SearchRequest(BaseModel):
+ scope_id: str
+ search: dict[str, JsonValue] # the complete request of the generic search API as JSON, unchanged
+ max_bytes: int | None = None # from Prepared Context: the request's byte budget
+ caller: Caller | None
+
+class SearchHit(BaseModel):
+ ref: ArtifactRef # the exact revision matched
+ text: str # a passage to use as context, such as one chunk
+ score: float | None = None
+ locator: dict[str, JsonValue] | None = None # where the passage sits in the Artifact, such as a page or chunk number
+
+class SearchResult(BaseModel):
+ hits: Sequence[SearchHit]
+```
+
+- **Presentation**: `GET .../{artifact_id}/rendering` calls `render_artifact` and returns the plugin's default presentation;
+ read APIs still return the original `content` for programs. A revision's presentation does not change under the same
+ plugin version, so core may cache it by family, ID, revision, and plugin version. Built-in families do not offer this API.
+- **Search**: the generic search API passes every field of `SearchArtifactsRequest` (`query`, `query_vector`, `indexes`,
+ `mode`, `filters`, `admission`, `fusion`, `rerank`, `min_score`, and so on, except `include_scores`, which only decides
+ whether core returns scores) unchanged to `search_artifacts`, which
+ interprets them against the plugin's own indexes; a plugin raises `InvalidContent` for controls it does not support, and
+ the API returns 422. Core keeps only matches that are active current revisions the caller may read, returns the exact
+ revisions in the plugin's order, and adds the plugin's scores when `include_scores` is set. A plugin therefore cannot
+ return data the caller cannot see through search.
+- **Prepared Context**: `assembly.sections` may list plugin families. Core calls `search_artifacts` with Prepared Context's
+ `query`, the section's `limit`, and the request's byte budget, and fills the matched passages in section order as
+ context entries that, like built-in entries, cite exact revisions and count toward the byte budget and the receipt;
+ entries beyond the budget are truncated or left out. A section may carry an
+ optional `search` with the fields of a search request other than `query` and `limit`, passed on to the plugin
+ unchanged; built-in families that carry `search` return 422.
+- Review and Recall are not open to plugin families; those APIs return an explicit "not supported" error, and
+ `GET /v1/capabilities` lists plugin families.
+
+## 8. Loading and lifecycle
+
+- At startup, Server checks each direct subdirectory of `POWERCONTEXT_SERVER_PLUGINS_DIR` (no plugins load if it is
+ unset): it imports `plugin.py` in a time-limited child process and validates the declaration without calling `setup`.
+ Once the check passes, Server registers the Source types or family the plugin provides (reads, search, and the generic
+ merge APIs), registers a Supervisor binding when there is a schedule or trigger, and adds the plugin to this process's
+ record of active plugins. A failed check affects only that plugin.
+- When Server stops, it stops accepting synchronous calls and ends host processes first, then stops the Supervisor and
+ reclaims Workers through the existing flow.
+
+- A plugin has only two outcomes, `loaded` and `failed`, kept only in process memory. Server persists no plugin enabled
+ state, registration generation, or configuration snapshot; each startup re-checks the directory as it is. Plugin code
+ is imported only in the check child process, host processes, and Workers, never in the Server process.
+- With no hot reload, no registration generation is needed to fence old Workers; the existing Supervisor lease fence is
+ enough to fence stale commits after a leadership change.
+- A Worker re-imports plugin code on every invocation and checks the code fingerprint before importing (see 1.1). Once
+ the plugin directory is modified while Server runs, new Worker invocations and newly started host processes fail and
+ ask for a restart, so one Server never mixes old and new code. Deployers should restart Server after a change.
+- Server instances in the `api` role run no background work, but they also check plugins and register families and
+ Source types for reads, search, Source writes, and user changes; they start host processes on demand and do not
+ dispatch Workers. Instances in the `background` role receive no requests and start no host processes.
+- Adding or removing plugins at runtime (hot reload) and plugin-defined APIs are covered by
+ [RFC #2579](https://github.com/oceanbase/powercontext/pull/2579), built on host processes.
+
+## 9. Execution constraints
+
+- **Isolation**: `generate` runs in Worker child processes started by the Supervisor and synchronous calls run in
+ host processes, so crashes, memory leaks, and blocking do not affect Server. Child processes isolate ordinary failures;
+ they are not a sandbox for untrusted code: a plugin runs with the child process's operating-system privileges and can
+ access its files, network, and environment variables.
+- **Timeouts and budgets**: `timeout_seconds` is the total Worker limit, `transaction_timeout_seconds` is the write
+ transaction block limit, and `max_workers` is the plugin's concurrency limit across Scopes; each maps to an existing
+ Supervisor binding parameter or is enforced by the executor.
+- **Lock impact of the write transaction**: while the block is open it locks the heads and cursor rows involved; on SQLite a
+ write transaction blocks all other writes to the database. The block must therefore be short, and exceeding its limit
+ rolls it back.
+- **Leftover invocation intents**: after a plugin is removed, the invocation intents it left behind are no longer
+ processed and do not block the database readiness check.
+- **Failure and backoff**: a failed call commits no core data, and that plugin in that Scope enters the Supervisor's
+ existing backoff without affecting other Scopes or plugins. Core never skips input automatically because of repeated
+ failures; persistent failures require fixing the plugin or the data and restarting.
+- **Multi-instance deployments**: with several Server instances on OceanBase, dispatch happens only on the instance holding
+ the Supervisor leader lease; Source writes, reads, presentation, search, and user changes can run on any instance, each using
+ its own host processes. Plugin-owned storage must be reachable from every instance; the dedicated storage core allocates
+ lives on the shared database backend and meets this naturally. A SQLite deployment has a single Server process.
+
+## 10. Persistence
+
+| Data | Handling |
+| --- | --- |
+| `pc_sources`, Projections, the Source journal, `pc_source_definition_manifests` | Kept; definitions and written results of plugin Source types |
+| `pc_artifacts`, `pc_artifact_heads`, `pc_artifact_lineage_*`, merge records, handles, tags | Kept; Artifact plugin results |
+| `pc_source_cursors` | Kept; Artifact plugin consumption cursors, keyed by `(scope_id, plugin:)` |
+| Supervisor invocation intent, pending, and lease tables | Kept; plugin bindings share them with built-in bindings, and they back `GET /v1/plugins/{name}/scopes`. Plugin bindings start with `plugin:` and are not registered in the database |
+| The new `metadata` column of `pc_artifacts` | Added; nullable, stored as canonical JSON bytes like `content`, for additional information that is never searched. Revisions plugins write record the plugin name and version digest; built-in families leave it empty. It does not take part in content digests |
+| `pc_artifact_fts` and `pc_artifact_heads.searchable_text` | Used only by built-in families; plugin families do not write them |
+| Columns the old plugin frameworks added to `pc_artifact_processing_binding_states` (desired state, management phase, registration generation, and so on) | Removed |
+| `pc_artifact_family_schemas` | Removed |
+| Search state and vector index data for custom family declarative indexes | Removed; the implementation PR confirms built-in families do not depend on them before dropping the tables |
+| Artifacts, heads, and lineage written by old Artifact plugins | Not migrated; the upgrade is refused while any remain, see below |
+| Dedicated plugin storage (`plugins/.sqlite3` or the `powercontext_plugin_` database) | Not part of core's schema; core only allocates it, and the plugin and the deployer own its tables, migrations, and cleanup |
+| Other plugin-owned indexes and storage | Not part of core; managed by the plugin and the deployer |
+
+Additions and removals ship as **one new migration revision**; the migration resources of released versions stay
+unchanged. Its notes list the tables, columns, and data ranges added and removed. The migration preflight first confirms the old Artifact plugin
+framework left no data behind: Artifacts of old plugin families, and old plugin registrations and invocation intents.
+If it finds any, it refuses the upgrade, lists the plugin names, families, and
+row counts, and leaves the database unchanged; the deployer exports what they need, uninstalls the old plugins, and cleans
+up their data on the old version, then upgrades. The migration drops these tables only when they are empty.
+
+The readiness check of this revision,
+which Server runs against the current database at every startup, differs from the previous one in two ways:
+
+- Bindings of invocation intents that start with `plugin:` count as plugin bindings and need no database registration.
+ Intents a removed plugin left behind are not processed, are visible in the operational APIs, and do not stop Server
+ from starting; they are processed again once a plugin with the same name returns.
+- The full-text index check covers only built-in family data.
+
+The format version of Artifact revision records in export and restore (portable archives) increases by one and includes
+`metadata`. Restoring an export of the old format leaves `metadata` empty; older Servers cannot restore exports of the
+new format.
+
+## 11. API changes
+
+| Change | Content |
+| --- | --- |
+| Removed | `/v1/admin/artifact-plugins` and its subpaths; the old Artifact plugins' `plugin.yml` manifest |
+| Removed | Custom family declarative indexes (`ArtifactSearchDeclaration`, `TextIndex`, and so on), `ArtifactFamily` schema declarations, `ArtifactDraft`, and the old Worker plugin executor |
+| Added | `powercontext.plugins`: `ArtifactPlugin`, `Inputs`, `Interval`, `Cron`, `PluginContext`, `PluginStorage`, `PluginModels`, the writer and read interfaces, and the user-change request models and `InvalidContent`, and the presentation and search request and result models |
+| Changed | The body of `POST /v1/scopes/{scope_id}/artifacts` gains a plugin branch `{"family", "content"}`; core allocates the ID, and the plugin's `create_artifact` handles it. `CreateArtifactRequest` becomes a choice between the built-in branch `CreateBuiltinArtifactRequest` (the former union discriminated by family) and the plugin branch `CreatePluginArtifactRequest`, selected by family name: built-in names take the built-in branch with unchanged validation; other names take the plugin branch, and a family no plugin provides returns 422. In the generated Python client, built-in requests gain one wrapping level, and `ArtifactCreated.family` changes from the built-in family enum to a string |
+| Changed | The `source_type` of `POST /v1/scopes/{scope_id}/sources` changes from an enum allowing only `content` to a string (still defaulting to `content`). It may name a Source type a plugin defines; `content` is then that type's input, which the plugin validates and resolves into a Source. Unknown types return 404. The `SourceType` enum remains |
+| Added | `GET /v1/plugins`, `GET /v1/plugins/{name}`, `GET /v1/plugins/{name}/scopes` (`server.admin`, read-only) |
+| Added | `GET .../{artifact_id}/governance`, `POST /v1/scopes/{scope_id}/artifacts/{family}/merges`, `POST .../{artifact_id}/forget`, `POST .../{artifact_id}/restorations`: generic APIs on the shared merge engine registry that work for every registered family (Experience and Atomic Memory among built-ins, plus loaded plugin families, which the matching plugin methods handle). Atomic Memory's dedicated APIs are unchanged |
+| Changed | `PUT /v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}` accepts plugin families with a `{"content": {...}}` body, handled by the plugin's `update_artifact` |
+| Changed | The tag APIs accept plugin families: `TaggableArtifactFamily` changes from an enum to a string in the `ArtifactReadFamily` format, and Server accepts built-in families or loaded plugin families. The wire format is unchanged; generated clients lose the enum type |
+| Changed | The generic Artifact read APIs support plugin families; for plugin families, the generic search API passes the complete request to the plugin's `search_artifacts` and returns 422 when it is not implemented; `GET /v1/capabilities` lists plugin families |
+| Added | `GET /v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/rendering`: the default presentation of a plugin family Artifact, provided by the plugin's `render_artifact` |
+| Changed | `POST /v1/context/prepare`: `ContextAssemblySection.family` changes from an enum to a string that may name a loaded plugin family; an optional `search` (the fields of a search request other than `query` and `limit`) applies to plugin families only; the family of receipt entries changes from an enum to a string that may name a plugin family |
+| Changed | The format version of Artifact revision records in export and restore increases by one and adds `metadata` |
+| Changed | The environment variable `POWERCONTEXT_SERVER_PLUGINS_DIR` replaces `POWERCONTEXT_SERVER_ARTIFACT_PLUGINS_DIR` |
+
+After `openapi/powercontext.yaml` is updated per the table, `src/powercontext/http/_generated/` and each integration's
+operation table are regenerated.
+
+## 12. Observability
+
+Plugin calls reuse the Supervisor's and Worker's existing operation vocabulary, execution identities, and outcome fields,
+and no request ID is generated for scheduled calls. New fields identify the plugin name, the method called, where it ran
+(Worker or host process), the trigger, and the size of the writes; host process starts, terminations, and restarts each
+record one event.
+
+- Metric labels may use the plugin name, method, and execution location, never Scope IDs.
+- Logs go through `log_safely` and exclude Source content, Projections, Artifact content, and environment variable
+ values; plugin exceptions are logged only by type and known error category, never by message.
+- New fields are registered in the allowlists per the developer guide and documented in both locales' observability docs.
+- Call outcomes go only to logs and metrics and are not persisted, so the operational APIs report no "last call result".
+
+# Drawbacks
+
+- **Host processes hold resources**: each Server process gets one more long-lived process for each plugin that needs
+ synchronous calls, and the count grows with instances in multi-instance deployments. Synchronous calls add one
+ inter-process round trip and serialization.
+- **Synchronous calls depend on the plugin being available**: while a host process is crashed or in restart backoff, the
+ plugin's Source writes and user changes return 503; defects in plugin code show up directly as failed user requests.
+- **The write transaction holds locks**: the block holds database locks, which on SQLite block other writes. A plugin that
+ does slow work in the block slows every write in Server until the block times out and rolls back.
+- **Plugin-owned storage is not guaranteed consistent with core data**: they are not in the same transaction, so a failed
+ commit can leave entries in owned storage. Plugins must overwrite by stable ID, keep immutable records keyed by
+ content digest, clean up unreferenced records, and defer to core's current data; owned storage must also support
+ idempotent writes, be durable on write, and be readable right after a write.
+- **Every background call starts a Worker and loads the plugin**: process startup and factory initialization have a fixed
+ cost that rules out second-level high-frequency background calls, and plugins cannot reuse connections or caches across
+ Worker calls.
+- **Dedicated storage is isolated only by convention**: the plugin and Server use the same database login, so a plugin can
+ technically reach core's tables.
+- **Old and new ways to add Source types coexist**: until the follow-up proposal lands, Source types can come from a
+ plugin's own definitions in this proposal or from write plugins of the old Source plugin framework. Both register in
+ the same definition manifests, and names must not collide.
+- **Not a security sandbox**: Workers isolate ordinary failures but do not restrict a plugin's access to files, network, or
+ credentials, nor impose hard memory or CPU quotas.
+- **Dependencies must be compatible**: every plugin shares one Python environment with Server, and deployers resolve
+ dependency conflicts.
+- **Deploying the same code twice takes extra care**: the family is part of the declaration, so copying the same code into
+ two directories fails the check with a family conflict. A plugin that needs several deployments should read identifiers
+ such as its family from environment variables.
+- **Content structure is not governed by core**: core does not validate a `content` schema, and plugins validate
+ user-submitted content; plugins and applications must keep historical revisions interpretable.
+- **Presentation and search depend on the plugin**: presentation, search, and Prepared Context for plugin families go
+ through the host process and return 503 when the plugin is unavailable; without `search_artifacts`, a plugin's data cannot
+ be used through generic search or Prepared Context.
+- **Servers may load different plugin versions**: core records which plugin version wrote each revision but does not stop
+ instances from loading different versions; whether old and new versions can read and write the same data at once is the
+ plugin's responsibility. Summarizing the versions each instance has loaded needs an instance registry table, which is
+ left to the follow-up plugin installation proposal.
+- **Code changes require a restart**: without hot reload, Server is briefly unavailable while plugins update.
+
+# Rationale and alternatives
+
+| Option | Trade-off |
+| --- | --- |
+| Keep the current design and only simplify the interfaces | Limited improvement for plugin authors; the cost of maintaining lifecycle state machines, the management service, and schema pinning remains |
+| Describe plugins in a manifest file (YAML) | Plugins must maintain a manifest alongside code and keep their fields in sync, and variable substitution, config models, and export conventions in the manifest are another set of rules. Declarations in code are validated by the same Pydantic model, and plugin authors write only Python |
+| Run plugins in the Server process | Simplest to deploy and call, but a plugin failure can take Server down; the Supervisor already provides isolation and scheduling, so there is no reason to build another |
+| Plugins manage their own storage and cursors entirely | Core's read APIs could not be used directly, every plugin would reimplement window reads and idempotency, and cursors would be hard to commit atomically with results |
+| Plugins return drafts, or the writer stages operations in memory and commits at the end | Atomic, but plugins cannot see real revisions before the commit and struggle to write their own index; merge, undo merge, and lifecycle semantics would need reimplementing in memory; search would not see staged content |
+| Wrap the whole `generate` in one transaction | Model calls can take minutes, and holding locks that long blocks other writes, the whole database on SQLite; plugin-owned storage is another database that one transaction cannot cover anyway |
+| A short write transaction block inside `generate` (this proposal) | Slow work stays outside the block; inside it, core's write operations and validation are reused directly, real revisions are returned, and reads see the block's writes; at the end it commits with the cursor and acknowledgment, and an exception rolls back. The cost is holding locks inside the block, bounded by a time limit |
+| Queue user changes for the plugin's next background call | Callers can only wait asynchronously; a change that always fails blocks later generation in the Scope; and the request queue must be persisted, while core has no task management today |
+| Core executes user changes directly, without the plugin | Simple to implement, but the plugin never sees the submitted content: it cannot validate it, and its own storage and indexes catch up only after a later rebuild. Storage belongs to the plugin, so changes should go through the plugin too; built-in families likewise create and edit through their own family code |
+| One `write` method for both create and edit | A create carries no ID and core allocates one, while an edit must name the ID and the expected revision; the inputs and checks differ, and one method would make the plugin tell them apart itself |
+| Dispatch a Worker for each synchronous call | Reuses the existing mechanism, but each Worker starts a new Python process, and importing core modules alone takes seconds, which cannot sit on a user request path |
+| Run Source `resolve` and similar code in the Server process | Saves an inter-process round trip, but a blocking or crashing plugin affects Server; a plugin's sibling modules enter `sys.modules` under top-level names, so same-named modules of different plugins collide and cannot later be reloaded reliably |
+| A long-lived host process for synchronous calls and Workers for background calls (this proposal) | Synchronous calls need no process start each time, and the Server process imports no plugin code; background calls keep the Worker's per-call isolation and budgets. The cost is the resources and management of long-lived processes |
+| Plugin storage inside core's transactions (plugins create tables in core's database) | Atomic commits, but core would manage plugin migrations, upgrades, and permissions on three backends, and plugin data would mix into core's schema. Plugin storage belongs to the plugin and stays out of core |
+| Two-phase commit between plugin storage and core | Most plugin storage, such as object and vector stores, does not support XA, and core would need a coordinator for each kind of storage. Writing immutable content-addressed records first and making core's commit the only point where they take effect gives the consistency needed |
+| One plugin declaring several families | Could write several families in one transaction, but declarations, permissions, and merge registration would all need a multi-family design. Cooperating cases are served by two plugins plus cross-family reads |
+| Hand core's environment variables or database settings to plugins | Core's configuration does not all come from environment variables, and a plugin with core's database credentials could bypass the writer. Core provides the wrapped `ctx.storage` and `ctx.models` instead |
+| Plugin-specific trigger and management APIs | Callers would have to tell plugins from built-ins; the existing Source and Artifact APIs already cover the operations callers need |
+| Generic merge APIs for plugin families only | Needs a separate family allowlist in the APIs. The registry already decides which families can be changed, and a family without an adapter cannot register; built-in permission checks live in the engine, so both entry points apply the same checks |
+| Core builds a full-text index for plugin families from declared fields | Plugin authors need no index of their own, but core does not know how content is chunked or whether it suits full-text or vector search, so the index is often useless; it would also share index tables with built-in families, and the readiness check would have to tell them apart. Search belongs to the plugin, and core only filters by permission |
+| Reads also go through the plugin (for example a `read_artifact` that expands content) | Generic reads would return complete content, but the most frequent read path would enter the host process every time, and reads would fail whenever the plugin is unavailable. Reads stay with core, and presentation has its own API |
+| Register plugin bindings and families in the database (the old Artifact plugin framework's approach) | Detects another plugin taking over an existing family and leftover invocation intents, but core must store and maintain plugin state and provide a management operation to resolve conflicts. The new framework has no stable plugin identity, so a registry would catch little more; it is left to the proposal on plugin installation and distribution, to be designed with plugin identity |
+| Record the plugin version in a separate table, in `created_by`, or under a reserved key of `content` | A separate table must also join export and restore; changing `created_by` or `content` alters the meaning of existing fields and the content digest. A `metadata` column needs only one added column and an export format upgrade |
+| Hot reload in this proposal | Requires each instance to rescan on its own, fencing commits from old Workers, and switching host processes; a separate proposal is clearer |
+
+Reasons for this choice:
+
+- A plugin author writes only `plugin.py`: one declaration, one factory, and one or two methods.
+- Callers use only the existing Source and Artifact APIs and are unaware of plugins.
+- Isolation, scheduling, triggers, backoff, and leadership all reuse the Supervisor already in production.
+- Writes reuse core's existing implementation, so plugin writes and user changes follow the same semantics; user changes
+ go through the plugin, so plugin storage stays in step.
+- Sources and Artifacts stay in core's unified storage, so the existing read APIs, lineage, merge records, and access
+ control apply directly.
+- Core manages only the two core data kinds and their progress and leaves everything else to plugins.
+
+# Prior art
+
+- **Dify plugins**: interfaces are defined per plugin type, plugins implement the matching methods, and Dify calls them
+ when a workflow or application needs them; plugins run in a separate plugin daemon. This proposal keeps the idea that the
+ host defines the interfaces and when to call them: synchronous calls use long-lived host processes that core manages,
+ and background calls reuse PowerContext's existing Supervisor Workers.
+- **pytest plugins and setuptools entry points**: plugins declare what they provide as Python objects that the host reads
+ after importing them, with no separate manifest file.
+- **Transaction context managers**: interfaces such as SQLAlchemy's `async with engine.begin()` open a transaction when the
+ block starts, commit when it ends normally, and roll back on an exception. `writer.transaction()` follows the same usage
+ and also commits the cursor and acknowledgment.
+- **Existing PowerContext foundations**: scheduling, invocation intents, Workers, and lease fences of the
+ [Artifact processing Supervisor](1515_artifact_processing_supervisor.md); the [shared merge methods](1859_artifact_merge.md);
+ the generic Artifact read and write APIs and the `lineage_only` Content Source built-in families record on edits;
+ `SourceDefinition` / `SourceProjection`, the Source journal, and consumption cursors.
+
+# Unresolved questions
+
+| Question | Notes |
+| --- | --- |
+| Default limits for the write transaction block | Whether 30 seconds is right; whether SQLite and OceanBase need different defaults; per-block operation and byte limits |
+| Host process defaults | Default synchronous call limit, per-host-process concurrency limit, and restart backoff |
+| Permission to create the dedicated database | When Server's database login cannot create databases, whether to allow falling back to tables prefixed by plugin name in core's database, besides the deployer creating the database in advance |
+| Plugin families in Recall | Prepared Context is covered; whether Recall should be, and how its candidates are defined |
+| Passages in generic search results | The generic search API returns only exact revisions, and the passages a plugin returns are used only by Prepared Context. Whether search results should add passages and their locations |
+| Naming families and Source types | Whether they must start with the plugin name, so naming alone keeps plugins from taking over each other's families |
+| Search controls in Prepared Context | Letting built-in families accept a section's `search` too is a new optional field that leaves existing calls unaffected; a separate proposal is recommended. Removing or renaming Prepared Context's other feature-specific parameters is a breaking change that needs a new API version, so v1 leaves them as they are |
+| Shrinking the window | Built-in Atomic Memory shrinks the next Source window when the model times out. Plugins cannot affect the window size, so an oversized window keeps timing out. Whether plugins may request a smaller window for the Scope with an agreed exception |
+| Windows that keep failing | A Source window that always fails blocks later invocations in the Scope. Whether to offer an operation that skips it or moves it to a dead letter, and how to record skipped input |
+| Consuming every Source type | `inputs.source_types` must list each type; built-in Atomic Memory consumes every Source usable for generation. Whether to support `"*"` for all types |
+| Built-in-only capabilities | Scope Prompt management, the write gate with decision observations, and model usage statistics serve only built-in families today, and plugins cannot use them. Whether and how to open them to plugins |
+| Atomic Memory's dedicated APIs | With the generic merge APIs, Atomic Memory merge, forget, and restoration have a dedicated and a generic entry point that behave the same. Whether to keep the dedicated APIs or make them aliases of the generic ones |
+
+# Future possibilities
+
+- Hot reload and plugin-defined APIs (including plugin pages): built on host processes, see [RFC #2579](https://github.com/oceanbase/powercontext/pull/2579).
+- Standalone Source plugins and collection: plugins that only provide Source types; central configuration of collection
+ targets that devices pull and sync; running collection on a device and reporting Sources to a central Server; moving
+ large files. A follow-up proposal defines them and replaces the old Source plugin framework.
+- Long-lived processes for background calls too, reusing connections and initialization while keeping the interfaces
+ unchanged.
+- More plugin types, such as candidate content for Recall or text fragments for Prepared Context.
+- Plugin installation and dependencies: installing plugins from PyPI, a Git URL, or a wheel, a separate dependency
+ environment per plugin, and a registry of the plugin versions each instance has loaded.
diff --git a/docs/zh/rfcs/2549-plugin-interfaces.md b/docs/zh/rfcs/2549-plugin-interfaces.md
new file mode 100644
index 0000000000..f9a6170c79
--- /dev/null
+++ b/docs/zh/rfcs/2549-plugin-interfaces.md
@@ -0,0 +1,1020 @@
+---
+title: 插件接口
+description: Artifact 插件实现少量接口,由 PC Server 在需要时调用:后台调用在 Supervisor Worker 中执行,同步调用在 core 管理的插件宿主进程中执行;调用方只使用现有的 Source 与 Artifact 接口,core 只管理两类核心数据及其消费进度,不托管插件生命周期。
+---
+
+- Proposal Name: `plugin_interfaces`
+- Start Date: 2026-11-08
+- RFC PR: [oceanbase/powercontext#2549](https://github.com/oceanbase/powercontext/pull/2549)
+
+# 概要
+
+本提案重新定义 PowerContext 的第三方插件模型:插件只实现几个固定接口,PC Server 在需要时调用;调用方不直接调用插件,
+而是像使用内置能力一样使用现有的 Source 和 Artifact 接口。
+
+- **Artifact 插件**:拥有一个 Artifact family,实现 `generate` 把尚未消费的 Source 加工成 Artifact,实现
+ `render_artifact` 给出默认展示,可选实现检索和用户修改。插件可以自带 Source 类型,用来记录用户提交给它的内容。
+- **插件形态**:一个插件是一个目录,包含 `plugin.py`,其中把一个声明对象 `ArtifactPlugin` 赋给 `plugin`。
+ 声明写在代码里,没有单独的清单文件;部署相关的设置由插件从环境变量读取。
+- **调用方式**:
+ - 写入插件自带类型的 Source,使用现有的 Source 写入接口。
+ - 读取、检索 Artifact,使用现有的 Artifact 接口。
+ - 新建、编辑、合并、遗忘、恢复插件 Artifact,也使用现有的 Artifact 接口。core 负责鉴权、版本校验和溯源,再同步调用
+ 插件对应的方法;插件校验内容、维护自己的存储,并通过写入器写入。插件没有实现某项操作时,该接口返回 422。
+- **执行**:
+ - `generate` 是后台调用,在 Supervisor 管理的 Worker 子进程中执行,沿用现有的隔离、超时、并发预算、领导租约和失败退避。
+ - Source 写入时的 `resolve` 与 Projection、展示、检索和用户对插件 Artifact 的修改是同步调用,在 core 管理的常驻
+ **插件宿主进程**中执行。每个 Server 进程为每个加载的插件启动一个宿主进程,部署者不需要管理它。
+- **写入**:Artifact 插件在 `async with writer.transaction()` 块中执行写操作,块结束时 core 把写入、消费游标和
+ 完成确认一起提交,异常则整体回滚。插件读不到也改不了游标。
+- **core 的职责**:只管理 `pc_sources`、`pc_artifacts`(含 heads、lineage、合并记录、生命周期、handle 和 tags)及其
+ 消费进度。插件可以有自己的索引和存储,由插件自己管理;core 在部署使用的数据库后端上为每个插件提供一块专属存储,
+ 并把 Server 已配置的模型提供给插件使用。
+- **生命周期**:插件在 Server 启动时按目录加载,变更需要重启。每个 Server 进程只在内存中记录本进程已激活的插件,
+ 不持久化插件的启用状态、注册代次或管理阶段。
+
+本提案取代 [Artifact 插件框架 RFC #2175](2175-artifact-plugin-system.md) 的插件契约与管理机制。旧框架随 v1.5 发布,
+但目前没有对外发布的插件,因此本提案不保留兼容:旧的插件清单、管理接口和插件管理相关的表直接移除,旧插件写入的自定义
+Artifact 数据不迁移。为了不悄悄丢失数据,升级前的迁移检查发现旧框架留有数据时拒绝升级,由部署者在旧版本上先导出和
+清理(见参考级说明第 10 节)。
+
+独立的 Source 插件,即只提供 Source 类型,或从外部系统持续采集(采集目标、checkpoint、在设备上采集并汇报到中心),
+不在本提案范围内,由后续提案定义并取代 [Source 插件框架 RFC #2012](2012-source-plugin-system.md)。在那之前,旧 Source
+插件框架保持不变。
+
+本文描述目标设计,不代表主线或发布版本已经可用。接口名称和代码示例用于说明契约,最终语法以实现为准。
+
+# 动机
+
+现有的 Artifact 插件框架让 PowerContext 替第三方插件承担了大量生命周期管理:
+
+| 框架 | Server 为插件承担的工作 |
+| --- | --- |
+| Artifact 插件(#2175) | 在 `pc_artifact_processing_binding_states` 中维护 `desired_state`、`phase`、`binding_generation`、`last_error`;七种管理状态及其转换;`enable-all / reload / enable / disable / unload` 管理接口与 `expected_generation` 并发校验;用注册代次屏蔽旧 Worker 提交;在 `pc_artifact_family_schemas` 中固化内容 schema 和索引定义,变更返回 409;框架负责自定义声明式索引的构建、重建和可用状态 |
+
+这些机制的代价由所有人承担:
+
+- **插件作者**需要理解 binding、generation、schema 固化规则和索引声明等框架概念,才能写出一个插件。
+- **部署者**需要编写清单文件,并通过管理 API 让插件进入可运行状态。
+- **调用方**需要区分内置能力和插件能力,使用不同的接口。
+- **维护者**需要为第三方代码维护状态机、并发屏蔽、schema 和索引治理以及恢复路径,这部分代码的规模和测试面已经超过
+ 插件业务本身。
+
+插件真正需要的是:在合适的时机被调用,拿到需要的输入,把结果交回 core。调度、隔离、消费进度和事务提交是 core
+本来就有的能力,Supervisor 已经为内置 Artifact 提供了这些能力。Dify 等系统的插件模型也说明,宿主定义接口和调用时机、
+插件只实现接口,足以支撑大量第三方扩展。
+
+本提案的目标:
+
+1. 插件作者只需写一个 `plugin.py`:一个声明对象和几个方法,不需要理解插件管理的内部机制。
+2. 调用方不感知插件:Source 和 Artifact 的现有接口对插件产生的数据同样适用。
+3. 插件代码只在 core 管理的子进程中执行(Worker 或插件宿主进程),插件故障不影响 Server 和其他插件。
+4. 插件结果进入 `pc_sources` / `pc_artifacts`,与消费进度原子提交。
+5. core 只管理这两类核心数据及其消费进度;内容结构、索引和其他存储交给插件自己。
+6. 插件对核心数据的写入只经过 core 提供的写入器,覆盖 Artifact 的全部通用写操作,并沿用 core 现有的校验规则。
+7. 用户对插件 Artifact 的修改经过插件,插件自有存储能与修改同步更新。
+8. Artifact 生成可以按调度执行,也可以由新 Source 触发。
+9. 用户只需部署一个 Server;插件随 Server 启动加载。
+
+# 指南级说明
+
+## 1. 总体设计
+
+```mermaid
+flowchart LR
+ Caller["应用 / Agent / 宿主"]
+ subgraph Server["PC Server(core)"]
+ SourceAPI["现有 Source 接口
写入 Source"]
+ ArtifactAPI["现有 Artifact 接口
读取、检索、修改"]
+ Sup["Supervisor
调度 / 触发 / 预算 / 退避"]
+ Core[("core 数据
pc_sources / pc_artifacts / 游标")]
+ end
+ subgraph Host["插件宿主进程(常驻,core 管理)"]
+ Sync["resolve / Projection / render_artifact
search_artifacts / create_artifact / ..."]
+ end
+ subgraph AW["Worker:Artifact 插件"]
+ Generate["generate(request, writer)"]
+ Tx["writer.transaction()"]
+ Generate --> Tx
+ end
+ Caller --> SourceAPI
+ Caller --> ArtifactAPI
+ SourceAPI -->|"同步调用"| Sync
+ ArtifactAPI -->|"读取"| Core
+ ArtifactAPI -->|"展示、检索,同步调用"| Sync
+ ArtifactAPI -->|"修改,同步调用"| Sync
+ Sync -->|"Source 或 Artifact 写入,一次提交"| Core
+ Sync -.-> Own
+ Core -->|"新 Source 触发"| Sup
+ Sup -->|"未消费的 Source 窗口"| Generate
+ Tx -->|"Artifact 写入、游标、确认,一次提交"| Core
+ Generate -.-> Own[("插件自有存储 / 模型")]
+```
+
+图中有两条路径,一次调用只执行一个插件:
+
+- **生成路径**:新 Source 进入 journal 后,关注这些 Source 的 Artifact 插件被标记为待处理;Supervisor 派发 Worker,传入
+ 该 Scope 尚未消费的 Source 窗口和写入器;插件在写事务块中写入 Artifact,core 连同游标和确认一起提交。
+- **同步路径**:调用方写入插件类型的 Source、展示或检索插件 Artifact、修改插件 Artifact 时,Server 完成鉴权和版本校验后,把请求交给该插件的
+ 宿主进程;插件在宿主进程中完成转换或写入,结果在一个事务中提交后同步返回给调用方。
+
+调用方只和 core 的现有接口打交道:
+
+| 调用方要做的事 | 使用的接口 | 插件参与的方式 |
+| --- | --- | --- |
+| 提交一条插件类型的 Source | 现有 Source 写入接口 | 宿主进程执行插件定义的 `resolve` 和 Projection |
+| 浏览、读取 Artifact | 现有 Artifact 读取接口 | 不参与 |
+| 展示、检索插件 Artifact | 展示接口、现有检索接口、Prepared Context | 宿主进程执行 `render_artifact`、`search_artifacts` |
+| 新建、编辑、合并、遗忘、恢复、撤销合并 | 现有 Artifact 新建与编辑接口、通用合并接口 | 宿主进程执行插件对应的方法;未实现返回 422 |
+| 打 tag、按 tag 查询 | 现有 tags 接口 | 不参与;tags 是 core 数据 |
+
+职责划分:
+
+| 职责 | 归属 |
+| --- | --- |
+| 认证、Scope 授权、公开 HTTP / MCP 接口、用户修改的版本校验与溯源记录 | core |
+| 何时调用:调度、触发合并、Worker 预算、超时、退避、领导租约 | core(现有 Supervisor) |
+| 宿主进程的启动、复用、超时终止与重启 | core |
+| 读取输入、提供受约束的写事务、在同一事务中提交核心数据 / 游标 / 完成确认 | core(Worker 或宿主进程中的插件执行器) |
+| 插件专属存储空间的分配、Server 已配置模型的封装 | core |
+| 插件自带 Source 类型的定义与 Projection 计算;Artifact 内容结构、生成算法、展示、检索、用户提交内容的校验、插件自有的索引与存储及其一致性 | 插件 |
+| 插件代码、依赖和环境变量的部署 | 部署者 |
+
+插件代码只做一件事:给定输入,产出结果或抛出异常。抛出异常即失败,core 不提交任何核心数据;后台调用下次重试同一输入,
+同步调用把错误返回给调用方。
+
+## 2. 插件的形态
+
+Server 从 `POWERCONTEXT_SERVER_PLUGINS_DIR` 加载插件。每个直接子目录是一个插件,目录名就是插件名;目录中必须有
+`plugin.py`,可以有其他模块:
+
+```text
+/opt/powercontext/plugins/
+├── knowledge-base/
+│ ├── plugin.py
+│ └── parsers.py
+└── incident-report/
+ └── plugin.py
+```
+
+`plugin.py` 把一个声明对象赋给模块级变量 `plugin`:
+
+```python
+plugin = ArtifactPlugin(family="incident-report", ..., setup=setup)
+```
+
+声明对象回答 core 在运行插件代码之前需要知道的问题:插件提供什么(Artifact family 和自带的 Source 类型)、消费什么、何时调用、
+资源上限多少。它是普通的 Python 对象,由 Pydantic 校验;没有 YAML 或其他清单文件。
+
+- **部署设置**:插件自己的凭据、外部服务地址等随部署变化的值,由插件在 `setup` 中从环境变量读取。Server 不保存、
+ 不传递也不记录这些值。
+- **core 提供的资源**:插件需要数据库和模型时,不必另行配置,通过上下文使用 core 封装好的对象(见参考级说明 1.2):
+ - `ctx.storage`:部署所用数据库后端上的一块插件专属空间。SQLite 是数据目录下的一个单独文件,OceanBase 和 seekdb
+ 是一个单独的库。插件拿到的连接只指向这块空间,不指向 core 的表。
+ - `ctx.models`:Server 已配置的生成模型和 embedding 模型。插件只能调用,拿不到模型的地址和凭据;调用计入现有的模型
+ 用量统计。
+- **启用与停用**:目录存在且检查通过,插件就可用;需要停用时移出目录并重启 Server。
+- **检查**:Server 启动时在有时限的子进程中导入 `plugin.py`,校验声明,不调用 `setup`。检查失败只影响该插件。
+
+## 3. 插件自带的 Source 类型
+
+Artifact 插件可以在声明的 `definitions` 中提供自己的 Source 类型(见 4.2)。插件用它记录"用户要求做什么",例如上传了
+一份文档、改写了一个分块,再在 `generate` 中把这些 Source 加工成 Artifact。这样每次变化都有对应的 Source,来源可以追溯,
+而不需要另写一个 Source 插件。
+
+```python
+plugin = ArtifactPlugin(
+ family="knowledge-base",
+ definitions=(DocumentUploadDefinition(),), # Source 类型 kb-document
+ inputs=Inputs(source_types=("kb-document",), projection="knowledge-base.upload@1"),
+ on_sources=True,
+ setup=setup,
+)
+```
+
+- **定义**:沿用现有的 `SourceDefinition`:`input_class` 校验输入,`resolve` 把输入解析为 Source,Projection 给出消费方
+ 读取的形态。
+- **写入**:调用方通过现有的 Source 写入接口提交(`POST /v1/scopes/{scope_id}/sources`,`source_type` 为插件定义的类型),
+ 插件自己的代码通过 `ctx.sources.submit` 提交。校验、`resolve` 和 Projection 在插件的宿主进程中执行,保存语义与内置
+ Source 一致。
+- **只能追加**:与所有内置 Source 一样,没有修改和删除;要更正内容,提交一条新的 Source。
+- **大块内容**:`resolve` 必须是纯函数,现有 Source 写入接口也只接受 JSON。文件、图片由插件自定义接口接收
+ (见 [RFC #2579](https://github.com/oceanbase/powercontext/pull/2579)),插件存进自己的存储,Source 中只记录位置、摘要
+ 和媒体类型等元数据。
+- **消费**:插件在 `inputs.source_types` 中列出这些类型即可消费;其他 Artifact 插件也可以消费它们。
+
+## 4. Artifact 插件
+
+### 4.1 要实现的接口
+
+| 接口 | 必需 | 说明 |
+| --- | --- | --- |
+| `setup(ctx) -> ArtifactGenerator` | 是 | 插件对象的工厂(不是新建 Artifact);Worker 中每次调用执行一次,宿主进程中只执行一次后复用 |
+| `generate(request, writer) -> None` | 配置了 `schedule` 或 `on_sources` 时是 | 读取 `request.sources`(未消费的 Source 窗口),在写事务块中写入 Artifact |
+| `render_artifact(request) -> Rendering` | 是 | 给出一个精确 revision 的默认展示内容(Markdown 或纯文本),见参考级说明第 7 节 |
+| `search_artifacts(request) -> SearchResult` | 否 | 用插件自己的索引检索,返回命中的 revision 和片段;通用检索接口和 Prepared Context 调用它 |
+| `create_artifact(request, writer) -> None` | 否 | 用户新建;`request.artifact_id` 由 core 分配 |
+| `update_artifact(request, writer) -> None` | 否 | 用户编辑指定 ID 的 Artifact,`request.expected_revision` 为调用方看到的 revision |
+| `merge_artifacts(request, writer) -> None` | 否 | 用户合并;结果的 ID 由 core 分配 |
+| `forget_artifact(request, writer) -> None` | 否 | 用户遗忘 |
+| `restore_artifact(request, writer) -> None` | 否 | 用户恢复、恢复到旧 revision 或撤销合并 |
+
+后五个方法处理用户修改(见 4.4),每个都是可选的:没有实现的操作,对应接口返回 422,表示这个 family 不支持该操作。
+插件在这些方法中校验用户提交的内容、更新自己的存储,并通过写入器完成 core 要求的那一项写操作。
+
+core 不为插件 family 建立任何索引,也不理解 `content` 的结构:插件的内容怎样分块、是否可以全文或向量检索、怎样展示,
+只有插件知道。因此展示和检索都交给插件:`render_artifact` 必须实现,`search_artifacts` 可选,未实现时通用检索接口和
+Prepared Context 对该 family 返回 422。
+
+### 4.2 声明
+
+| 字段 | 默认值 | 说明 |
+| --- | --- | --- |
+| `family` | 必填 | 插件拥有的 Artifact family,不能与内置 family 或其他插件重复 |
+| `inputs` | `None` | `Inputs(source_types=..., projection=..., projections={...}, max_sources=50)`:消费的 Source 类型、每条 Source 交付的 Projection、单个窗口的条数上限;为空表示不消费 Source。`projection` 适用于所有类型,`projections` 按 Source 类型指定不同的 Projection,每个类型必须有其一 |
+| `on_sources` | `False` | 新 Source 到达后触发;需要 `inputs` |
+| `schedule` | `None` | `Interval(seconds=...)` 或 `Cron(expression=..., timezone=...)`。`schedule` 和 `on_sources` 都不配置时插件没有后台生成,不需要实现 `generate`,Artifact 只由用户修改和插件接口写入(例如笔记) |
+| `scopes` | `None` | 可服务的 Scope 名单;配置了 `schedule` 但不消费 Source 的插件必须填写 |
+| `timeout_seconds` | 300 | 整次调用的时限 |
+| `transaction_timeout_seconds` | 30 | 写事务块的时限,不超过 `timeout_seconds` |
+| `max_workers` | 1 | 跨 Scope 的并发上限 |
+| `change_timeout_seconds` | 30 | 一次用户修改调用的时限,包括其中的写事务块 |
+| `definitions` | `()` | 插件自带的 Source 类型,随插件加载登记,见第 3 节和参考级说明第 2 节 |
+| `setup` | 必填 | 见上表 |
+
+### 4.3 示例
+
+下面的插件为每条工单 Source 生成一份报告:先用自己的向量索引查找相似的已有报告,命中则修订那份报告,否则新建;
+同时把报告写进自己的向量索引。它还支持用户编辑报告,没有实现的新建、合并、遗忘、恢复对该 family 返回 422。向量索引
+放在 core 分配的专属存储中,模型使用 Server 已配置的模型。
+
+```python
+from powercontext.plugins import (
+ ArtifactPlugin, ArtifactWriter, GenerateRequest, Inputs, InvalidContent, PluginContext, Rendering, RenderRequest,
+ SearchHit, SearchRequest, SearchResult, UpdateRequest,
+)
+from powercontext.sources import TextEvidence
+
+
+class IncidentReports:
+ def __init__(self, index: VectorIndex, model: Summarizer) -> None:
+ self.index, self.model = index, model
+
+ async def generate(self, request: GenerateRequest, writer: ArtifactWriter) -> None:
+ # 1. 耗时工作放在写事务之外:模型调用、向量计算
+ planned = []
+ for source in request.sources:
+ evidence = TextEvidence.model_validate(source.projection)
+ summary = await self.model.summarize(evidence.content)
+ planned.append((source, summary, await self.model.embed(summary)))
+
+ # 2. 短写事务:块内读取能看到本块已写入的内容
+ async with writer.transaction() as tx:
+ for source, summary, vector in planned:
+ match = await self.index.nearest(request.scope_id, vector, threshold=0.92)
+ current = await tx.get(match.artifact_id) if match else None
+ artifact_id = current.ref.artifact_id if current else writer.stable_id(
+ "report", source.ref.source_type, source.ref.source_id)
+ written = await tx.put(artifact_id, content={"title": "工单报告", "summary": summary},
+ source_refs=[source.ref])
+ # 拿到真实的 artifact_id 与 revision 后写入自有索引,按 ID upsert
+ await self.index.upsert(request.scope_id, written.ref.artifact_id, written.ref.revision, vector)
+ # 块正常结束:core 提交 Artifact 写入、游标推进与完成确认
+
+ async def update_artifact(self, request: UpdateRequest, writer: ArtifactWriter) -> None:
+ # 用户编辑:校验内容,重算向量,再写入 core 和自有索引
+ report = Report.model_validate(request.content) # 不合格时抛出 InvalidContent,接口返回 422
+ vector = await self.model.embed(report.summary)
+ async with writer.transaction() as tx:
+ written = await tx.put(request.artifact_id, content=report.model_dump(),
+ expected_revision=request.expected_revision)
+ await self.index.upsert(request.scope_id, written.ref.artifact_id, written.ref.revision, vector)
+
+ async def render_artifact(self, request: RenderRequest) -> Rendering:
+ report = Report.model_validate(request.artifact.content)
+ return Rendering(text=f"# {report.title}\n\n{report.summary}")
+
+ async def search_artifacts(self, request: SearchRequest) -> SearchResult:
+ # 通用检索请求原样转交;本插件只支持向量检索
+ if request.search.mode not in (None, "vector"):
+ raise InvalidContent("only vector search is supported", field="mode")
+ vector = await self.model.embed(request.search.query)
+ hits = await self.index.nearest_many(request.scope_id, vector, limit=request.search.limit)
+ return SearchResult(hits=[SearchHit(ref=hit.ref, text=hit.summary, score=hit.score) for hit in hits])
+
+
+async def setup(ctx: PluginContext) -> IncidentReports:
+ index = await ctx.enter_async_context(VectorIndex.connect(ctx.storage.engine()))
+ return IncidentReports(index, Summarizer(ctx.models))
+
+
+plugin = ArtifactPlugin(
+ family="incident-report",
+ inputs=Inputs(source_types=("example-ticket",), projection="powercontext.text-evidence@1"),
+ on_sources=True,
+ setup=setup,
+)
+```
+
+块内抛出异常时,core 回滚块内全部 Artifact 写入,游标不动,下次重试同一窗口。插件自有索引不在 core 的事务中:
+插件按稳定 ID upsert,重试时会覆盖上一次留下的条目;读取自有索引时以 core 的当前数据为准(见参考级说明 3.5)。
+
+### 4.4 调用方怎么使用
+
+调用方使用现有的 Artifact 接口访问插件 family。读取接口直接读 `pc_artifacts`,不运行插件代码;展示、检索和修改接口
+由 core 鉴权后,同步调用插件对应的方法:
+
+| 用途 | 接口 | 插件方法 |
+| --- | --- | --- |
+| 浏览 | `GET /v1/scopes/{scope_id}/artifacts/{family}` | 无 |
+| 读取当前内容、指定 revision、revision 历史、tags | `GET .../{artifact_id}`、`GET .../{artifact_id}/revisions/{revision}`、`GET .../{artifact_id}/revisions`、`GET .../{artifact_id}/tags` | 无 |
+| 默认展示 | `GET .../{artifact_id}/rendering`(可带 `revision`) | `render_artifact` |
+| 检索(请求原样转交,检索方式由插件解释) | `POST /v1/scopes/{scope_id}/artifacts/{family}/search` | `search_artifacts` |
+| 为 Agent 准备上下文 | `POST /v1/context/prepare` 的 `assembly.sections` 中列出插件 family | `search_artifacts` |
+| 新建(只提交内容,ID 由 core 分配) | `POST /v1/scopes/{scope_id}/artifacts`,请求体为 `{"family": ..., "content": {...}}` | `create_artifact` |
+| 编辑(提交完整内容,`If-Match` 校验当前 revision) | `PUT /v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}` | `update_artifact` |
+| 读取治理状态(遗忘、合并前取得 `state_version`) | `GET .../{artifact_id}/governance` | 无 |
+| 合并 | `POST /v1/scopes/{scope_id}/artifacts/{family}/merges` | `merge_artifacts` |
+| 遗忘 | `POST .../{artifact_id}/forget` | `forget_artifact` |
+| 恢复、恢复到旧 revision、撤销合并 | `POST .../{artifact_id}/restorations` | `restore_artifact` |
+| 修改 tags | `PUT .../{artifact_id}/tags`;按 tag 查询用 `POST /v1/scopes/{scope_id}/artifact-tags/query` | 无,由 core 处理 |
+
+修改同步返回结果。插件拒绝内容或请求时返回 422,插件没有实现对应方法时也返回 422,宿主进程超时或不可用时返回 503。
+
+### 4.5 方法一览
+
+| 方法 | 适用 | 必须实现 | 调用时机 | 执行位置 |
+| --- | --- | --- | --- | --- |
+| `setup(ctx)` | 所有插件 | 是 | 宿主进程启动时一次;Worker 每次调用一次 | 宿主进程、Worker |
+| Source 定义:`input_class`、`resolve`、Projection 的 `project`、`read` | 声明了 `definitions` 的插件 | 是 | Source 写入(通用写入接口、`ctx.sources.submit`);把 Source 交付给 `generate` 时计算 Projection | 宿主进程、Worker |
+| `generate` | Artifact 插件 | 配置了 `schedule` 或 `on_sources` 时是 | 定时,或新 Source 到达 | Worker |
+| `render_artifact` | Artifact 插件 | 是 | 默认展示接口 | 宿主进程 |
+| `search_artifacts` | Artifact 插件 | 否 | 通用检索接口;Prepared Context 中列出该 family | 宿主进程 |
+| `create_artifact`、`update_artifact`、`merge_artifacts`、`forget_artifact`、`restore_artifact` | Artifact 插件 | 否 | 对应的通用修改接口 | 宿主进程 |
+
+列出、读取当前内容或指定 revision、revision 历史、治理状态和 tags 不调用插件方法,由 core 直接从它保存的数据提供。
+必须实现的方法在宿主进程执行 `setup` 之后核对,缺少时插件加载失败,状态为 `failed`。
+
+## 5. 调度与触发
+
+插件的后台调用(`generate`)的调度和触发都交给现有的 Artifact processing Supervisor;同步调用由请求触发,
+不经过 Supervisor。每个插件在启动时注册为一个 Supervisor binding,
+binding 名为 `plugin:`,调度键是 `(binding, Scope)`。
+
+Artifact 插件:
+
+| 声明 | 何时派发 Worker | `request.reason` |
+| --- | --- | --- |
+| `schedule=Interval(...)` 或 `schedule=Cron(...)` | 到期时,对有待处理工作的 Scope | 有新 Source 时为 `sources`,否则为 `schedule` |
+| `on_sources=True` | Scope 的 Source journal 超过该插件的游标,约 2 秒内派发 | `sources` |
+
+- 配置了 `inputs` 的插件,每次调用都附带该 Scope 尚未消费的 Source 窗口;窗口中没有匹配的 Source 时不调用插件,
+ 直接推进游标并确认。
+- 未配置 `inputs` 的插件(例如每天整理一次已有 Artifact)不消费 Source,到期即对 `scopes` 中的每个 Scope 调用一次;
+ 第一版只支持 `Interval`。
+- `Cron` 只能用于配置了 `inputs` 的插件,且不能与 `on_sources` 同时使用:Supervisor 的每个 binding 只接受一种
+ interval 或 cron 策略。
+
+同一插件、同一 Scope 同时最多运行一个 Worker。运行期间到达的触发由 Supervisor 合并成结束后的一次调用。
+
+## 6. 运维
+
+- **启动**:Server 逐个检查插件目录。某个插件检查失败(缺少 `plugin.py`、导入失败、声明无效、family 或 Source 类型
+ 与内置或其他插件冲突)只影响该插件,Server 和其他插件正常启动。
+- **查看**(都需要 `server.admin`,只读,数据都来自启动检查或数据库,任何 Server 角色都给出相同结果):
+ - `GET /v1/plugins`、`GET /v1/plugins/{name}`:每个插件的检查结果、类型、family 或 Source 类型、消费的 Source 类型、
+ 触发方式和 Scope 范围。
+ - `GET /v1/plugins/{name}/scopes`:每个 Scope 是否有待处理的工作、已消费到的 journal 位置和当前 journal 位置。
+ - 调用失败的原因通过日志和指标排查,不在接口中保存。
+- **变更**:修改代码后重启 Server。Server 不监视文件,也不在运行中重新加载插件。
+- **停用**:从插件目录移走后重启。已保存的 Source、Artifact 和游标都保留,但该插件的 family 和
+ Source 类型不再注册:通用接口读取、检索或修改该 family 时返回 404,与不存在的 family 相同。插件 Artifact 的内容通常只
+ 记录摘要或位置,正文、分块和文件在插件自己的存储中,没有插件就无法解释,因此不提供脱离插件的读取。插件 Source 类型的
+ 新写入返回 422。重新部署同名插件时,读取恢复,生成从原游标继续。插件的专属存储
+ 不随之删除,由部署者保留或清理。
+- **宿主进程**:由 core 按需启动、复用和重启,部署者不需要配置或管理。宿主进程的启动、终止和重启只体现在日志和指标中。
+
+# 参考级说明
+
+## 1. 公共契约
+
+公开接口位于新模块 `powercontext.plugins`,替换现有的 `powercontext.artifact_plugins`。接口以 `typing.Protocol` 和 Pydantic 模型表达。
+
+### 1.1 声明与加载
+
+```python
+class Interval(BaseModel):
+ seconds: float # > 0
+
+class Cron(BaseModel):
+ expression: str
+ timezone: str = "Asia/Shanghai"
+
+class ArtifactPlugin(BaseModel): # 字段见指南级说明 4.2
+ family: str
+ inputs: Inputs | None = None
+ ...
+ setup: Callable[[PluginContext], Awaitable[ArtifactGenerator]]
+```
+
+- 插件名取自目录名,必须匹配 `^[a-z][a-z0-9-]{0,63}$`。
+- 声明在构造时校验,包括调度组合、`inputs` 与 `scopes` 的依赖、时限关系和 family 命名。`setup` 必须是 async 函数。
+- 声明中除 `setup`、`definitions` 等代码对象外的部分可以序列化,Server 在检查子进程中得到它,
+ 用于注册 binding 和回答运维查询。
+- 检查时,core 记录插件目录的**代码指纹**(按文件内容计算)。Worker 和宿主进程导入插件前核对,不一致则调用失败并提示
+ 重启 Server,同一个 Server 进程中的调用不会混用新旧代码。
+
+### 1.2 上下文
+
+```python
+class PluginContext:
+ name: str # 插件名
+ scope_id: str | None # Worker 调用的 Scope;宿主进程中为 None,Scope 随每个请求传入
+ plugin_dir: Path # 插件目录(只读约定)
+ logger: logging.Logger # 已绑定插件名的 logger
+ storage: PluginStorage # 插件专属存储
+ models: PluginModels | None # Server 已配置的模型;未配置时为 None
+
+ async def enter_async_context(self, cm: AbstractAsyncContextManager[T]) -> T: ...
+
+class PluginStorage(Protocol):
+ backend: Literal["sqlite", "oceanbase", "seekdb"]
+ def engine(self) -> AsyncEngine: ... # 只指向本插件的专属空间
+
+class ChatMessage(BaseModel):
+ role: Literal["system", "user", "assistant"]
+ content: str
+
+class PluginModels(Protocol):
+ embedding_available: bool # Server 是否配置了 embedding 模型
+ embedding_dimension: int | None # 向量维度;未配置时为 None
+ generation_available: bool # Server 是否配置了生成模型
+ async def embed(self, texts: Sequence[str], /) -> Sequence[Sequence[float]]: ...
+ async def generate(self, messages: Sequence[ChatMessage], /, *, max_tokens: int | None = None) -> str: ...
+ # 调用未配置的模型时抛出 ModelUnavailable;同步调用中返回 503
+```
+
+- 工厂在每次 Worker 调用中执行一次,实例不跨调用复用;在宿主进程中只执行一次,实例服务所有 Scope 的请求,在宿主进程
+ 存续期间复用,资源占用不随 Scope 数量增长。宿主进程中的每个请求都带有 `scope_id`。需要关闭的资源(包括插件自有存储的
+ 连接)通过 `ctx.enter_async_context` 注册,Worker 调用结束或宿主进程停止时由执行器释放。
+- **专属存储**:core 使用 Server 现有的数据库连接配置,在同一个后端上为插件分配一块空间,不需要额外的数据库账号:
+ - SQLite:数据目录下的 `plugins/<插件名>.sqlite3`。
+ - OceanBase / seekdb:名为 `powercontext_plugin_<插件名>` 的库(`-` 换成 `_`),首次使用时由 core 创建。Server 的数据库
+ 登录没有建库权限时,由部署者事先建好。
+ - 表结构和迁移由插件自己管理,core 不读取、不迁移、不备份这块空间。它不在 core 的事务中,与核心数据的一致性按 3.5 处理。
+ - 这块空间属于整个插件,不按 Scope 隔离。插件按 `scope_id` 划分自己的数据,并在读取时自行确认数据属于本次请求的 Scope,
+ 不能只凭调用方给出的 ID 返回数据。
+ - 插件与 Server 使用同一个数据库登录,这一划分防止插件误写 core 的表,不防止恶意插件;插件目录本身被当作可信代码。
+- **模型**:`models` 封装 Server 的推理配置,插件拿不到模型的地址、请求头和凭据。Server 两种模型都没有配置时 `models`
+ 为 None;只配置了其中一种时,插件用 `embedding_available`、`generation_available` 判断。插件应把 `embedding_dimension`
+ 等 embedding 配置记在自己的索引上,配置变化后重建,避免把不同模型的向量混在一起比较。调用沿用 Server 的超时设置,并计入现有的模型用量统计。插件也可以从环境变量读取自己的
+ 模型配置,自行创建客户端。
+- 插件得不到 core 数据库的连接,也没有游标的写入接口。插件对核心数据的写入只有两条路径:宿主进程中 `resolve` 的
+ 返回值,和 `ArtifactWriter`。
+
+## 2. 插件 Source 类型契约
+
+- **定义注册**:`definitions` 中的 Source 类型与 Projection 在加载时登记到现有的 Source 定义清单
+ (`pc_source_definition_manifests`)。与内置类型、旧 Source 插件框架登记的类型或其他插件冲突时,检查失败。
+- **写入**:core 按 `definition.input_class` 校验输入,调用 `resolve` 得到 Source,计算各 Projection 后保存,语义与内置
+ Source 写入一致。校验、`resolve` 和 Projection 在该插件的宿主进程中同步执行,使用 Server 的同步调用时限。`resolve`
+ 必须是纯函数,不能有复制文件等副作用。
+- **Source 身份**:由 `resolve` 决定。重复提交同一内容得到相同身份时,沿用现有去重规则,不产生重复 Source;身份相同但
+ 内容不同时返回冲突。
+- **权限**:写入需要调用者对该 Scope 有 Source 写权限,并且 Scope 在插件的 `scopes` 范围内。
+- **发现**:`GET /v1/capabilities` 与 `pc_source_capabilities` 列出已加载插件的 Source 类型。
+
+## 3. Artifact 插件契约
+
+### 3.1 请求
+
+```python
+class ArtifactGenerator(Protocol):
+ # 配置了 schedule 或 on_sources 时必须实现
+ async def generate(self, request: GenerateRequest, writer: ArtifactWriter, /) -> None: ...
+
+class GenerateRequest(BaseModel):
+ scope_id: str
+ reason: Literal["schedule", "sources"]
+ sources: Sequence[GenerationSource] # 本次窗口;未配置 inputs 时为空
+
+class GenerationSource(BaseModel):
+ ref: SourceRef # 精确 Source 引用
+ position: int # Source 在 journal 中的位置,用于按写入顺序处理
+ projection: JsonValue # 按该 Source 类型声明的 Projection 内容
+```
+
+### 3.2 写入器与写事务
+
+```python
+class ArtifactWriter(ArtifactReads):
+ family: str # 固定为声明中的 family
+ def stable_id(self, *parts: str) -> str: ...
+ def transaction(self) -> AbstractAsyncContextManager[ArtifactTransaction]: ...
+
+class ArtifactTransaction(ArtifactReads):
+ ... # 读取(见 3.4)加写操作(见下表)
+```
+
+`generate` 的执行分两部分:
+
+- **事务外**:插件通过 `writer` 读取已提交的数据(每次读取使用独立的只读短事务),完成模型调用、向量计算等耗时工作。
+- **写事务块**:`async with writer.transaction() as tx:`。
+ - 块开始时,core 开启写事务并重新校验 Supervisor 租约和调用意图。
+ - 块内的写操作直接在该事务中执行,立即返回写入后的 `ArtifactSnapshot`(含精确 revision);块内读取能看到本块已写入的内容。
+ - 块正常结束时,core 在同一事务中以 CAS 推进游标、确认本次调用并提交。
+ - 块内抛出异常、超过 `transaction_timeout_seconds` 或提交冲突,事务整体回滚,本次调用失败。
+
+规则:
+
+- 每次调用最多开启一个写事务块;第二次开启抛出异常。游标和调用确认只随这一个事务提交,因此一个窗口的核心数据不会
+ 被部分提交。
+- 插件没有开启写事务块就正常返回,表示本窗口无需产出,执行器单独提交游标推进和调用确认。
+- 写事务块提交后,`generate` 中后续代码抛出的异常不再影响已提交的结果,执行器记录为提交后错误,不重试该窗口。
+ 插件应把写事务块作为 `generate` 的最后一步。
+- 写事务块的时限计入整次调用的 `timeout_seconds`。块内只应做读取、写操作和插件自有存储的写入,不应调用模型。
+
+### 3.3 写操作
+
+写事务覆盖 Artifact 的全部通用写操作,每个操作复用 core 现有的实现与校验规则,违反规则时立即抛出异常:
+
+| 操作 | 语义 |
+| --- | --- |
+| `put(artifact_id, *, content, source_refs=(), artifact_refs=(), expected_revision=None)` | 不存在则新建 revision 1;已存在则按当前 head 追加 revision。给出 `expected_revision` 时,当前 revision 不一致即冲突;不给出时,使用本次调用在事务外读到的 revision(见 3.5),没有读过则不校验。内容与当前 revision 相同时不写入,返回当前快照,本次传入的 `source_refs` 和 `artifact_refs` 不会被记录(见下方写入约束)。用户修改中的 `put` 例外,见 4.1 |
+| `revert(artifact_id, *, to_revision)` | 以历史 revision 的内容追加新 revision,记录 `restored_from_revision`;目标已被遗忘或被合并冻结时一并恢复。返回 `ArtifactChange`:目标的新快照,以及一并恢复和退役的其他 Artifact 的精确引用 |
+| `set_lifecycle(artifact_id, state, *, replacement_artifact_id=None)` | `deprecated` 为遗忘,`active` 为恢复,`retired` 不可逆。恢复走共享恢复流程:记录一个内容相同的新 revision,被合并冻结的输入会撤销冻结它的合并,按组恢复。遗忘和退役不追加 revision。返回值同 `revert`,插件据此同步更新自有索引中受级联影响的条目 |
+| `merge(inputs, *, into, content, source_refs=(), artifact_refs=())` | 沿用公共合并方法([RFC #1859](1859_artifact_merge.md)):以 `inputs` 的当前 revision 为输入,新建 `into`,记录合并关系并冻结输入。`into` 必须是新 ID;同一组输入在撤销后再次合并时,插件应把输入 revision 纳入 `stable_id`,避免与已退役的结果撞号 |
+| `preview_undo_merge(artifact_id)` / `undo_merge(artifact_id)` | 撤销产生该 Artifact 的合并,恢复输入并退役结果,返回恢复与退役的精确引用 |
+| `bind_handle(handle_key, ref)` | 把稳定 handle 绑定到指定 revision |
+| `set_tags(artifact_id, tags)` | 整体替换该 Artifact 的 tags |
+
+写入约束:
+
+- 只能写本 Scope、声明中 `family` 的 Artifact,插件不能写其他 family 或 Scope。
+- `source_refs` 在 `generate` 中只能引用本次窗口中的 Source,在用户修改中只能引用目标 Artifact 当前 lineage 中已有的
+ Source(用于保留原始输入)。`artifact_refs` 可以引用本 Scope 中任意 family 实际存在的 revision;`to_revision` 和
+ `bind_handle` 的目标只能是本 family 中实际存在的 revision。
+- 内容不变时不产生 revision,与内置 Atomic Memory 的处理一致:模型判断已有记忆已经表达了新 Source 的内容时,该记忆
+ 不追加 revision,新 Source 也不进入它的 lineage。插件需要把新 Source 记为某个 Artifact 的证据时,应同时修改其内容。
+- core 不为插件 family 提取检索文本或建立索引,检索由插件用自己的索引完成(见第 7 节)。
+- core 在每个 revision 的元数据中记录写入它的插件名和插件版本摘要,便于在多个 Server 加载了不同版本时追溯每个 revision
+ 由哪个版本写入(见第 10 节)。
+- 每个写事务块的操作数、单份内容大小和总字节数有上限,超出即抛出异常并回滚,不截断、不部分提交。
+- core 不校验 `content` 的业务结构,也不保存或固化 schema。内容结构的演进由插件负责,插件可以在 `content` 中自带版本字段。
+
+### 3.4 读取
+
+`ArtifactWriter`(事务外,只读已提交数据)和 `ArtifactTransaction`(事务内,同时看到本块已写入的内容)提供相同的读取方法,
+范围默认为本 Scope、本 family。`reads(family)` 返回同一 Scope 中其他 family 的只读视图,方法相同,可以读取内置 family
+和其他插件的 family(内容按原样 JSON 返回),不能写入。这让一个插件可以基于另一个插件的结果工作,例如知识库插件读取
+文档插件写入的 Artifact:
+
+```python
+class ArtifactSnapshot(BaseModel):
+ ref: ArtifactRef # 精确 revision
+ content: dict[str, JsonValue]
+ lineage: ArtifactLineage # 直接引用的 Source 与 Artifact
+ lifecycle_state: LifecycleState # active / deprecated / retired
+ merged_into_id: str | None # 被合并冻结时指向合并结果
+ replacement_artifact_id: str | None
+ governance_generation: int # 治理状态版本,用于判断读取后是否被修改
+
+class ArtifactChange(BaseModel): # revert / set_lifecycle 的返回值
+ artifact: ArtifactSnapshot # 目标的新快照
+ restored: Sequence[ArtifactRef] # 一并恢复的其他 Artifact
+ retired: Sequence[ArtifactRef] # 一并退役的其他 Artifact
+
+class ArtifactReads(Protocol):
+ async def get(self, artifact_id: str, *, revision: int | None = None) -> ArtifactSnapshot | None: ...
+ async def revisions(self, artifact_id: str, *, cursor: str | None = None, limit: int = 50) -> ArtifactPage: ...
+ async def list(self, *, states: Collection[LifecycleState] = ("active",), tags: Collection[str] = (),
+ cursor: str | None = None, limit: int = 50) -> ArtifactPage: ...
+ async def by_source(self, ref: SourceRef) -> Sequence[ArtifactSnapshot]: ...
+ async def merge_inputs(self, artifact_id: str) -> Sequence[ArtifactRef]: ...
+ async def tags(self, artifact_id: str) -> Sequence[str]: ...
+ async def resolve_handle(self, handle_key: str) -> ArtifactRef | None: ...
+ def reads(self, family: str) -> ArtifactReads: ... # 同一 Scope 中其他 family 的只读视图
+```
+
+- 快照同时携带精确 revision、lineage 和当前治理状态,插件不需要再分别读取 lineage 和生命周期。
+- `get` 可以读当前内容或指定 revision;`revisions` 列出完整历史;`merge_inputs` 读取合并结果的精确输入。
+- `list` 可以按生命周期状态和 tags 过滤,包括 deprecated、retired 和被合并冻结的 Artifact,用于查找之前已经存在的数据。
+- `by_source` 反查 lineage 引用了某条 Source 的 Artifact,用于 Source 更新后找到需要修订的 Artifact。
+- 读取返回的快照都带精确 revision,可以直接作为写操作的 `artifact_refs` 或 `to_revision`。
+
+### 3.5 一致性与幂等
+
+- **核心数据**:每个窗口的 Artifact 写操作、游标推进和调用确认在一个事务中提交。失败时核心数据完全不变,重试从干净状态
+ 开始;成功后游标已越过该窗口,同一窗口不会再次投递。冲突(例如 head 在读取后被用户修改)在操作时或提交时报告,整体
+ 回滚后由 Supervisor 重新调度,下一次调用读到修改后的状态。
+- **重复处理同一业务对象**:不消费 Source 的定时整理等场景会再次处理已经处理过的对象。插件用 `writer.stable_id(...)` 从业务
+ 身份派生 Artifact ID;`put` 在内容相同时为 no-op,`set_lifecycle` 目标状态与当前相同时为 no-op,`merge` 在输入已全部
+ 合并到同一结果时为 no-op,从而不会产生重复 Artifact 或无意义的 revision。
+- **事务外读取后写入**:插件在事务外读到某个 Artifact,又在写事务块中对它 `put` 而没有给出 `expected_revision` 时,core
+ 以事务外读到的 revision 作为期望值。读取后该 Artifact 被用户修改,写入即冲突,本次调用回滚后重新调度。这与内置
+ Atomic Memory 在提交前核对所读记忆是否变化的做法一致,用户修改不会被插件基于旧内容的写入静默覆盖。
+- **插件自有存储**:插件的索引和其他存储(core 分配的专属存储,或插件自己连接的任何数据库、对象存储、向量库)不在 core
+ 的事务中。插件用自己的客户端写入并提交自有存储,core 不参与,也不保证两者一致。core 的事务是核心数据的唯一提交点:
+ 自有存储中的数据只有被 core 中已提交的 revision 引用后才算生效。这不需要两阶段提交,按下面的做法即可保持一致:
+ 1. 在写事务块内、拿到 `ArtifactSnapshot` 之后写自有存储,按 `artifact_id` upsert。这样自有存储的写入失败会让核心写入
+ 一起回滚。
+ 2. 写事务提交失败时,自有存储可能留下本次的条目。重试会处理同一窗口、得到同一个稳定 ID,覆盖这些条目。
+ 3. 把自有索引当作候选来源:命中的 ID 用 `get` 读取 core 中的当前快照,以快照内容和状态为准。这样回滚留下的条目
+ 不会让插件读到过期内容。插件也可以定期按 core 数据重建索引。
+ 4. 大块内容(正文、分块、文件字节)按内容摘要存成不可变的记录:先写入并提交到自有存储,再在写事务块中提交内容里
+ 带有该摘要的 Artifact。不能用 `(artifact_id, revision)` 作为键:写事务回滚后,同一个 revision 号会再次分配给内容
+ 不同的写入。按内容摘要存储时,重试写入同样的内容得到同一个键;提交失败只会留下没有被任何 revision 引用的记录,
+ 由插件按"不被引用"定期清理。
+- **对插件自有存储的要求**:上述做法与存储的种类无关,但存储需要满足三点,插件选型时自行确认,core 不做检查:
+ - 能按插件给定的键幂等写入(upsert 或"不存在才插入")。只能由存储分配 ID 的系统,需要插件先确定 ID 再写入。
+ - 写入返回时数据已经持久化。
+ - 写入后立即可读。有刷新延迟的系统(例如 Elasticsearch 的 refresh 间隔、异步建索引的向量库)可能出现 core 已经引用、
+ 自有存储中暂时读不到的情况,插件读取时需要重试或按暂时缺失处理。
+- `generate` 本身可能因失败重试而执行多次,模型调用和插件发出的外部请求不是恰好一次。
+
+## 4. 用户对插件 Artifact 的修改
+
+用户修改使用 core 的通用 Artifact 接口。Server 完成鉴权和版本预检后,把请求交给该插件的宿主进程;插件方法在宿主进程中
+执行,通过写入器完成写入,结果在一个短事务中提交并同步返回。用户修改不进入 Supervisor,也不排队。
+
+### 4.1 请求与方法
+
+```python
+class CreateRequest(BaseModel):
+ scope_id: str
+ artifact_id: str # core 分配,调用方不提交
+ content: dict[str, JsonValue]
+
+class UpdateRequest(BaseModel):
+ scope_id: str
+ artifact_id: str
+ expected_revision: int
+ content: dict[str, JsonValue]
+
+class MergeRequest(BaseModel):
+ scope_id: str
+ inputs: Sequence[MergeInput] # 每项为 artifact_id、revision、state_version
+ into: str # core 分配的结果 ID
+ content: dict[str, JsonValue]
+
+class ForgetRequest(BaseModel):
+ scope_id: str
+ artifact_id: str
+ expected_revision: int
+ expected_state_version: int
+
+class RestoreRequest(BaseModel):
+ scope_id: str
+ artifact_id: str
+ operation: Literal["restore", "undo_merge"]
+ revision: int | None = None # restore 时可指定恢复到的 revision
+
+class InvalidContent(Exception): # 插件拒绝内容;接口返回 422,消息和字段返回给调用方
+ def __init__(self, message: str, *, field: str | None = None) -> None: ...
+```
+
+| 方法 | 插件必须完成的写操作 |
+| --- | --- |
+| `create_artifact(request, writer)` | 恰好一次 `put(request.artifact_id, ...)`,目标此前不存在 |
+| `update_artifact(request, writer)` | 恰好一次 `put(request.artifact_id, ..., expected_revision=request.expected_revision)` |
+| `merge_artifacts(request, writer)` | 恰好一次 `merge(输入 ID, into=request.into, ...)`,输入与请求一致 |
+| `forget_artifact(request, writer)` | 恰好一次 `set_lifecycle(request.artifact_id, "deprecated")` |
+| `restore_artifact(request, writer)` | `restore` 为恰好一次 `revert` 或 `set_lifecycle(..., "active")`;`undo_merge` 为恰好一次 `undo_merge(request.artifact_id)` |
+
+- 这些调用中的写事务只允许上表对应的那一项写操作,以及对同一 Artifact 的 `set_tags`;写其他 Artifact 时立即抛出异常。
+ 方法正常返回但没有完成要求的写操作,core 回滚并返回 502,表示插件没有按约定完成。
+- 插件可以规范化内容(例如补全默认字段),写入的内容以插件提交的为准。
+- `create_artifact`、`update_artifact` 中的 `put` 总是追加 revision,内容与当前 revision 相同时也一样。这与内置 family 的
+ 编辑一致:每次用户编辑都有一条新的 `lineage_only` Content Source(见 4.2),需要一个新 revision 记录它。
+- 插件应在写事务块中、拿到快照之后更新自己的存储,做法同 3.5。
+
+### 4.2 core 负责的部分
+
+- **鉴权**:与内置 family 相同。新建和新建合并结果需要 Scope 的贡献权限,结果归属于调用者;修改已有 Artifact 需要该
+ Artifact 的写权限(`artifact.write`)。插件在这些调用中只获得完成本次请求所需的写入范围。
+- **版本校验**:调用插件前先检查 `If-Match`、`revision` 和 `state_version`,不一致直接返回 412 或 409,不调用插件;提交时
+ 在写事务中再次校验,期间被其他写入改变则回滚并返回 409。
+- **溯源**:与内置 family 相同,新建、编辑、恢复和撤销合并时,core 在同一事务中把调用方提交的内容另存为一条内部
+ Content Source(`lineage_only`,操作分别为 `artifact_create`、`artifact_replace`、`artifact_restore`、`artifact_undo_merge`),
+ 作为新 revision 的直接来源记入 lineage。这类 Source 由 core 写入,不进入任何插件的输入窗口。
+- **合并注册表**:通用的合并、遗忘、恢复接口建立在共享合并引擎([RFC #1859](1859_artifact_merge.md))的注册表之上,对所有登记过的 family 生效。内置 family 登记了 Experience 和 Atomic Memory,
+ 由各自的适配器处理;插件 family 在加载时登记,处理方式是转交给插件对应的方法。没有登记的 family 返回 422。
+- **不提供预览**:Atomic Memory 的专用接口支持先预览恢复影响、再凭签名 token 执行。通用接口不提供预览,也就不需要签名
+ 密钥;恢复和撤销合并按执行时的当前状态进行,合并和遗忘依靠 `revision` 与 `state_version` 校验。
+- **tags**:`GET` / `PUT .../{artifact_id}/tags` 和 `POST /v1/scopes/{scope_id}/artifact-tags/query` 对插件 family 与内置
+ family 相同,由 core 直接处理,不调用插件,也不产生新的内容 revision。
+
+### 4.3 接口
+
+| 接口 | 规则 |
+| --- | --- |
+| `POST /v1/scopes/{scope_id}/artifacts` | 请求体增加插件分支 `{"family": "<插件 family>", "content": {...}}`,family 不能是内置 family 的名字;ID 由 core 分配;响应与内置 family 的新建相同,包括 `Location` 和 `ETag` |
+| `PUT /v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}` | 请求体为 `{"content": {...}}`;`If-Match` 与当前 revision 不一致返回 412;Artifact 必须已存在 |
+| `GET .../{artifact_id}/governance` | 返回当前 revision、生命周期状态、`state_version` 和合并目标;由 core 处理 |
+| `POST /v1/scopes/{scope_id}/artifacts/{family}/merges` | 输入为至少两个当前 Artifact 的 `artifact_id`、`revision`、`state_version`,以及结果的完整内容;新建结果 Artifact 并冻结所有输入 |
+| `POST .../{artifact_id}/forget` | 携带 `revision` 和 `state_version`;把 Artifact 标为 deprecated(无替代者),不追加 revision,退出检索 |
+| `POST .../{artifact_id}/restorations` | `operation` 为 `restore`(默认,可指定 `revision` 恢复到旧内容)或 `undo_merge`;按组执行级联恢复和退役 |
+
+| 情况 | 响应 |
+| --- | --- |
+| 插件没有实现对应方法 | 422,说明该 family 不支持此操作 |
+| 插件抛出 `InvalidContent` | 422,带插件给出的消息和字段 |
+| 版本不一致 | 412(`If-Match`)或 409(`revision`、`state_version`) |
+| 插件抛出其他异常 | 500,只记录异常类型,不返回异常消息 |
+| 插件没有完成要求的写操作 | 502 |
+| 超过 `change_timeout_seconds`、宿主进程不可用或处于重启退避 | 503 |
+
+- 新建请求的插件分支没有判别映射:OpenAPI 无法为任意插件 family 写映射。内置分支保留原来按 family 的判别,插件分支与它
+ 并列,由 family 名决定走哪个分支,内置 family 的请求不会被插件分支接受。这与 tags 接口把 family 改为字符串的处理相同。
+- 只有删除语义的需求(例如撤下一份文档)使用遗忘;core 不提供物理删除。插件可以在 `forget_artifact` 中清理自己存储中的
+ 正文和索引,core 中的 revision 与 Source 保留。
+- **并发**:用户修改与后台的 `generate` 可能同时发生。`generate` 对事务外读过的 Artifact 写入时,core 按读到的 revision
+ 校验(见 3.5),发现冲突即整体回滚,下一次调用基于修改后的状态重新决策;用户修改不会被插件基于旧内容的写入静默覆盖。
+
+## 5. 消费游标
+
+Artifact 插件的每次后台调用处理该 Scope 中游标之后的一个 Source 窗口:
+
+- 窗口不超过 `inputs.max_sources` 条。窗口中没有匹配的 Source 时不加载插件,直接推进游标并确认。
+- 窗口内类型不匹配的 Source 被过滤,但游标仍推进过整个窗口;匹配类型的 Source 缺少所需 Projection 时调用失败,不跳过。
+- 游标只在执行器的提交事务中以 CAS 推进,插件得不到游标值,也没有写入接口。读取窗口与提交之间即使发生领导权变更或
+ 重复派发,同一窗口也只提交一次。
+- 是否有待处理的工作由持久化数据判断(Source journal 位置与游标之差),Server 重启后不会丢失待处理输入。
+ `on_sources` 的插件在新 Source 写入后约 2 秒内被派发。
+- 未配置 `scopes` 时,自动调度对所有 journal 超过游标的 Scope 生效;配置了 `scopes` 时只接受名单内的 Scope。
+
+## 6. 插件宿主进程
+
+同步调用需要在请求时限内返回,不能每次都启动 Worker:每个 Worker 都是全新的 Python 进程,导入 core 的执行模块需要数秒。
+因此同步调用交给常驻的宿主进程:
+
+| 调用 | 执行位置 |
+| --- | --- |
+| Source 写入的输入校验、`resolve`、Projection | 宿主进程 |
+| `create_artifact`、`update_artifact`、`merge_artifacts`、`forget_artifact`、`restore_artifact` | 宿主进程 |
+| `render_artifact`、`search_artifacts` | 宿主进程 |
+| `generate` | Supervisor Worker |
+
+- **数量与启动**:每个 Server 进程为每个加载的插件维护至多一个宿主进程,插件加载后在后台启动,之后复用。插件都要实现
+ `render_artifact`,所以每个加载的插件都有宿主进程。
+- **执行**:宿主进程启动时导入一次 `plugin.py` 并核对声明,写入器在宿主进程中执行。一个宿主进程可以并发处理多个请求,
+ 并发上限由 Server 配置决定。
+- **超时与故障**:每次调用受各自的时限约束(Source 写入、展示和检索使用 Server 的同步调用时限,用户修改使用
+ `change_timeout_seconds`)。超时、崩溃或失去响应时,core 终止该进程,进行中的请求返回 503,并在退避后重新启动;退避期间
+ 该插件的同步调用返回 503,后台调用不受影响。
+- **代码变更**:宿主进程在存续期间不重新导入插件,启动时核对代码指纹。修改插件后需要重启 Server,与 Worker 的规则一致。
+- **隔离**:与 Worker 相同,宿主进程隔离普通故障,不是不可信代码的沙箱。
+
+**已激活插件的内存记录**:每个 Server 进程在内存中记录本进程加载的插件:插件名、目录、类型、声明摘要、检查结果,以及
+宿主进程的当前状态(未启动、运行中、重启退避)。路由用户请求、启动宿主进程和派发 Worker 都以这份记录为准。它不持久化,
+也不跨进程同步;Server 重启后按插件目录重新建立。运维接口只返回各进程一致的检查结果和声明,宿主进程状态通过日志和指标查看。
+
+## 7. 公开读取、展示与检索
+
+读取由 core 直接提供,不执行插件代码:
+
+- list / get / revisions / governance / tags 复用现有的通用 Artifact 读取路径。插件 family 的 `content` 按插件写入时的
+ JSON 原样返回,不需要 `pc_artifact_family_schemas` 中的 schema。被合并冻结、deprecated 和 retired 的 Artifact 按公共
+ 合并与生命周期规则展示。
+- list 返回的条目只含元数据,`title` 和 `summary` 对插件 family 为空;内容需要通过 get 读取。
+
+插件可能只在 `content` 中保存摘要或引用,把大块内容放在自己的存储中(例如知识库文档只记录切块组的摘要),这时原样返回
+的 `content` 不足以展示。展示和检索因此交给插件:
+
+```python
+class RenderRequest(BaseModel):
+ scope_id: str
+ artifact: ArtifactSnapshot # 要展示的精确 revision
+ caller: Caller | None
+
+class Rendering(BaseModel):
+ format: Literal["markdown", "text"] = "markdown"
+ text: str
+
+class SearchRequest(BaseModel):
+ scope_id: str
+ search: dict[str, JsonValue] # 通用检索接口的完整请求(JSON),原样转交
+ max_bytes: int | None = None # 来自 Prepared Context 时,请求的字节预算
+ caller: Caller | None
+
+class SearchHit(BaseModel):
+ ref: ArtifactRef # 命中的精确 revision
+ text: str # 用作上下文的片段,例如一个切块
+ score: float | None = None
+ locator: dict[str, JsonValue] | None = None # 片段在 Artifact 中的位置,例如页码、切块序号
+
+class SearchResult(BaseModel):
+ hits: Sequence[SearchHit]
+```
+
+- **展示**:`GET .../{artifact_id}/rendering` 调用 `render_artifact`,返回插件给出的默认展示内容;原始 `content` 仍由读取
+ 接口返回,供程序使用。同一 revision 在同一插件版本下的展示结果不变,core 可以按 family、ID、revision 和插件版本缓存。
+ 内置 family 不提供这个接口。
+- **检索**:通用检索接口把 `SearchArtifactsRequest` 的全部字段(`query`、`query_vector`、`indexes`、`mode`、`filters`、
+ `admission`、`fusion`、`rerank`、`min_score` 等,`include_scores` 除外,它只决定 core 是否返回分数)原样交给
+ `search_artifacts`,由插件按自己的索引解释;插件不支持的参数
+ 抛出 `InvalidContent`,接口返回 422。core 只保留命中中调用者有权读取的 active 当前 revision,按插件给出的顺序返回
+ 精确 revision,`include_scores` 时附带插件给出的分数。插件因此不能借检索返回调用者看不到的数据。
+- **Prepared Context**:`assembly.sections` 可以列出插件 family。core 以 Prepared Context 的 `query`、该项的 `limit` 和
+ 请求的字节预算调用 `search_artifacts`,把命中的片段作为上下文条目按 section 顺序装填,与内置 family 一样带精确 revision
+ 引用、计入字节预算和回执,超出预算的条目被截断或省略。每项可以携带可选的 `search`,内容是检索请求中除 `query`、`limit` 以外的字段,原样转交插件;内置 family 携带
+ `search` 时返回 422。
+- Review 和 Recall 不对插件 family 开放,对应接口返回明确的“不支持”错误;`GET /v1/capabilities` 列出插件 family。
+
+## 8. 加载与生命周期
+
+- Server 启动时检查 `POWERCONTEXT_SERVER_PLUGINS_DIR` 的每个直接子目录(未设置则不加载插件):在有时限的子进程中导入
+ `plugin.py` 并校验声明,不调用 `setup`。检查通过后注册插件提供的 Source 类型或 family(读取、检索和通用合并接口),
+ 有调度或触发时注册 Supervisor binding,并写入本进程的已激活插件记录。检查失败只影响该插件。
+- Server 停止时先停止接受同步调用、结束宿主进程,再按现有流程停止 Supervisor 并回收 Worker。
+- 插件只有 `loaded` 和 `failed` 两种结果,只存在于进程内存中。Server 不持久化插件的启用状态、注册代次或配置快照;
+ 每次启动按当时的目录内容重新检查。插件代码只在检查子进程、宿主进程和 Worker 中导入,Server 进程不导入插件代码。
+- 没有热加载,因此不需要注册代次来屏蔽旧 Worker,现有的 Supervisor 租约 fence 已经足以屏蔽领导权变更后的旧提交。
+- Worker 每次调用重新导入插件代码,导入前核对代码指纹(见 1.1)。插件目录在 Server 运行期间被修改后,新的 Worker 调用
+ 和新启动的宿主进程都会失败并提示重启,不会出现同一个 Server 中新旧代码混用。部署者应在修改后重启 Server。
+- `api` 角色的 Server 实例不运行后台任务,但也会检查插件并注册 family 和 Source 类型,用于读取、检索、Source 写入和用户
+ 修改;它按需启动宿主进程,不派发 Worker。`background` 角色的实例不接收请求,不启动宿主进程。
+- 运行中增减插件(热加载)和插件自定义 API 见 [RFC #2579](https://github.com/oceanbase/powercontext/pull/2579),建立在宿主进程之上。
+
+## 9. 执行约束
+
+- **隔离**:`generate` 在 Supervisor 启动的 Worker 子进程中执行,同步调用在宿主进程中执行,崩溃、内存泄漏和
+ 阻塞不影响 Server。子进程隔离普通故障,不是不可信代码的沙箱:插件以子进程的操作系统权限运行,可以访问其文件、网络和
+ 环境变量。
+- **超时与预算**:`timeout_seconds` 是 Worker 总时限,`transaction_timeout_seconds` 是写事务块时限,`max_workers` 是该插件
+ 跨 Scope 的并发上限,分别映射到 Supervisor binding 的现有参数或由执行器强制。
+- **写事务的锁影响**:写事务块持有期间会锁住涉及的 head 和游标行;在 SQLite 上,写事务会阻塞整个数据库的其他写入。
+ 因此写事务块必须短,时限超出即回滚。
+- **遗留的调用意图**:插件移除后,它遗留的调用意图不再被处理,也不阻塞数据库就绪检查。
+- **失败与退避**:调用失败时不提交任何核心数据,该插件该 Scope 进入 Supervisor 现有的退避,不影响其他 Scope 和插件。
+ core 不会因为重复失败自动跳过输入;持续失败需要修复插件或数据后重启。
+- **多实例部署**:OceanBase 多 Server 实例时,派发只发生在持有 Supervisor 领导租约的实例上;Source 写入、读取、
+ 展示、检索和用户修改可以在任意实例执行,各实例使用自己的宿主进程。插件自有存储需要能被所有实例访问;core 分配的专属存储
+ 位于共享的数据库后端上,天然满足这一点。SQLite 部署只有一个 Server 进程。
+
+## 10. 持久化
+
+| 数据 | 处理 |
+| --- | --- |
+| `pc_sources`、Projection、Source journal、`pc_source_definition_manifests` | 保留;插件 Source 类型的定义与写入结果 |
+| `pc_artifacts`、`pc_artifact_heads`、`pc_artifact_lineage_*`、合并记录、handle、tags | 保留;Artifact 插件的结果 |
+| `pc_source_cursors` | 保留;Artifact 插件的消费游标,键为 `(scope_id, plugin:)` |
+| Supervisor 的调用意图、pending、租约表 | 保留;插件 binding 与内置 binding 共用,也是 `GET /v1/plugins/{name}/scopes` 的数据来源。插件 binding 以 `plugin:` 开头,不在数据库中登记 |
+| `pc_artifacts` 的新列 `metadata` | 新增;可为空,按与 `content` 相同的方式保存规范化 JSON 字节,存放不参与检索的附加信息。插件写入的 revision 记录插件名和插件版本摘要,内置 family 为空。不参与内容摘要的计算 |
+| `pc_artifact_fts` 与 `pc_artifact_heads.searchable_text` | 只用于内置 family;插件 family 不写入 |
+| `pc_artifact_processing_binding_states` 中为旧插件框架新增的列(期望状态、管理阶段、注册代次等) | 移除 |
+| `pc_artifact_family_schemas` | 移除 |
+| 自定义 family 声明式索引使用的搜索状态与向量索引数据 | 移除;实现 PR 确认内置 family 不依赖后删除对应表 |
+| 旧 Artifact 插件写入的 Artifact、head、lineage | 不迁移;留有数据时拒绝升级,见下文 |
+| 插件专属存储(`plugins/<插件名>.sqlite3` 或 `powercontext_plugin_<插件名>` 库) | 不属于 core 的 schema;core 只负责分配,表结构、迁移和清理由插件和部署者负责 |
+| 插件自有的其他索引与存储 | 不属于 core,由插件和部署者管理 |
+
+新增与移除通过**一个新的迁移版本**完成,已发布版本的迁移资源保持不变。迁移说明中列出新增、删除的表、列和数据范围。
+迁移检查(preflight)先确认旧 Artifact 插件框架没有留下数据:旧插件 family 的 Artifact、旧插件的登记与调用意图。
+发现任何一项时拒绝升级,列出插件名、family 和行数,数据库保持不变;部署者
+在旧版本上导出所需内容、卸载旧插件并清理数据后再升级。只有这些表为空时,迁移才删除它们。
+
+这个版本的就绪检查(Server 每次启动时对当前数据库执行)与旧版本有两点不同:
+
+- 调用意图中以 `plugin:` 开头的 binding 视为插件 binding,不要求在数据库中登记。插件被移除后遗留的意图不处理、
+ 可以在运维接口中看到,不阻止 Server 启动;同名插件重新放入后继续处理。
+- 全文索引的核对只覆盖内置 family 的数据。
+
+导出与恢复(portable archive)中 Artifact revision 记录的格式版本加一,包含 `metadata`。恢复旧格式的导出时 `metadata`
+为空;旧版本的 Server 不能恢复新格式的导出。
+
+## 11. 接口变更
+
+| 变更 | 内容 |
+| --- | --- |
+| 移除 | `/v1/admin/artifact-plugins` 及其子路径;旧 Artifact 插件的 `plugin.yml` 清单 |
+| 移除 | 自定义 family 的声明式索引(`ArtifactSearchDeclaration`、`TextIndex` 等)、`ArtifactFamily` 的 schema 声明、`ArtifactDraft` 和旧的 Worker 插件执行器 |
+| 新增 | `powercontext.plugins`:`ArtifactPlugin`、`Inputs`、`Interval`、`Cron`、`PluginContext`、`PluginStorage`、`PluginModels`、写入器与读取接口、用户修改的请求模型与 `InvalidContent`、展示与检索的请求和结果模型 |
+| 调整 | `POST /v1/scopes/{scope_id}/artifacts` 的请求体增加插件分支 `{"family", "content"}`,ID 由 core 分配,由插件的 `create_artifact` 处理。`CreateArtifactRequest` 改为内置分支 `CreateBuiltinArtifactRequest`(原有的按 family 区分的联合类型)与插件分支 `CreatePluginArtifactRequest` 的二选一,按 family 名选择:内置 family 名走内置分支,校验与原来相同;其他名字走插件分支,没有插件提供该 family 时返回 422。生成的 Python 客户端中,内置请求多包一层;`ArtifactCreated.family` 从内置 family 枚举改为字符串 |
+| 调整 | `POST /v1/scopes/{scope_id}/sources` 的 `source_type` 从只能是 `content` 的枚举改为字符串(默认仍为 `content`),可以是插件定义的 Source 类型,此时 `content` 是该类型的输入,由插件校验并解析为 Source;未知类型返回 404。`SourceType` 枚举保留 |
+| 新增 | `GET /v1/plugins`、`GET /v1/plugins/{name}`、`GET /v1/plugins/{name}/scopes`(`server.admin`,只读) |
+| 新增 | `GET .../{artifact_id}/governance`、`POST /v1/scopes/{scope_id}/artifacts/{family}/merges`、`POST .../{artifact_id}/forget`、`POST .../{artifact_id}/restorations`:基于共享合并引擎注册表的通用接口,对所有登记过的 family 生效(内置为 Experience 和 Atomic Memory,以及加载的插件 family;插件 family 由插件对应的方法处理)。Atomic Memory 的专用接口保持不变 |
+| 调整 | `PUT /v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}` 接受插件 family,请求体为 `{"content": {...}}`,由插件的 `update_artifact` 处理 |
+| 调整 | tags 接口接受插件 family:`TaggableArtifactFamily` 从枚举改为与 `ArtifactReadFamily` 相同格式的字符串,服务端校验为内置 family 或已加载的插件 family。网络上的数据格式不变,生成的客户端少了这个枚举类型 |
+| 调整 | 通用 Artifact 读取接口支持插件 family;通用检索接口对插件 family 把完整请求交给插件的 `search_artifacts`,未实现时返回 422;`GET /v1/capabilities` 列出插件 family |
+| 新增 | `GET /v1/scopes/{scope_id}/artifacts/{family}/{artifact_id}/rendering`:插件 family 的默认展示内容,由插件的 `render_artifact` 提供 |
+| 调整 | `POST /v1/context/prepare`:`ContextAssemblySection.family` 从枚举改为字符串,可以是已加载的插件 family;新增可选的 `search`(检索请求中除 `query`、`limit` 以外的字段),只对插件 family 生效;回执中条目的 family 从枚举改为字符串,可以是插件 family |
+| 调整 | 导出与恢复的 Artifact revision 记录格式版本加一,增加 `metadata` |
+| 调整 | 环境变量 `POWERCONTEXT_SERVER_PLUGINS_DIR` 取代 `POWERCONTEXT_SERVER_ARTIFACT_PLUGINS_DIR` |
+
+`openapi/powercontext.yaml` 按上表更新后重新生成 `src/powercontext/http/_generated/` 和各集成的操作表。
+
+## 12. 可观测性
+
+插件调用复用 Supervisor 与 Worker 现有的操作词汇、执行身份和结果字段,不为定时调用生成 request ID。新增的字段标识
+插件名、调用的方法、执行位置(Worker 或宿主进程)、触发原因和写入规模;宿主进程的启动、终止和重启各记录一条事件。
+
+- 指标标签可以使用插件名、方法和执行位置,不包含 Scope ID。
+- 日志经 `log_safely` 输出,不包含 Source 内容、Projection、Artifact 内容和环境变量值;插件异常只记录类型和
+ 已知错误分类,不记录异常消息。
+- 新增字段按开发指南登记到允许列表,并在中英文可观测性文档中说明。
+- 调用结果只进入日志和指标,不持久化,运维接口不提供“最近一次调用结果”。
+
+# 缺点
+
+- **宿主进程常驻占用资源**:每个 Server 进程为每个需要同步调用的插件多一个常驻进程,多实例部署时数量随实例数增加。
+ 同步调用多一次进程间往返和序列化。
+- **同步调用依赖插件可用**:宿主进程崩溃或处于重启退避时,该插件的 Source 写入和用户修改返回 503;插件代码中的缺陷会
+ 直接表现为用户请求失败。
+- **写事务持锁**:写事务块期间持有数据库锁,SQLite 上会阻塞其他写入。插件若在块内执行慢操作,会拖慢整个 Server 的写入,
+ 直到块超时回滚。
+- **插件自有存储与核心数据不保证一致**:两者不在同一事务中,提交失败可能在自有存储中留下条目。插件需要按稳定 ID 覆盖、
+ 按内容摘要保存不可变记录、清理未被引用的记录,并以 core 的当前数据为准;自有存储还要满足幂等写入、写入即持久和
+ 写后可读的要求。
+- **后台调用每次都要启动 Worker 并加载插件**:子进程启动和工厂初始化有固定开销,不适合秒级高频的后台调用;插件不能在
+ Worker 调用之间复用连接或缓存。
+- **专属存储只按约定隔离**:插件与 Server 使用同一个数据库登录,技术上能访问 core 的表。
+- **新旧 Source 扩展方式并存**:在后续提案落地前,添加 Source 类型有两种方式:本提案中插件自带的 Source 类型,以及旧
+ Source 插件框架的 write 插件。两者登记到同一份定义清单,名字不能重复。
+- **不是安全沙箱**:Worker 隔离普通故障,不限制插件的文件、网络和凭据访问,也不提供硬性的内存或 CPU 配额。
+- **依赖必须兼容**:所有插件与 Server 共用一个 Python 环境,依赖冲突需要部署者解决。
+- **同一份代码部署多次需要额外处理**:family 写在声明中,同一份代码复制成两个目录会因 family 冲突而检查失败。需要多次
+ 部署时,插件应从环境变量读取 family 等标识。
+- **内容结构不受 core 治理**:core 不校验 `content` 的 schema,用户提交的内容由插件校验;历史 revision 的解释需要插件和
+ 应用自行保持兼容。
+- **展示和检索依赖插件**:插件 family 的展示、检索和 Prepared Context 都要进入宿主进程,插件不可用时这些接口返回 503;
+ 插件没有实现 `search_artifacts` 时,它的数据不能通过通用检索和 Prepared Context 使用。
+- **多个 Server 的插件版本可能不一致**:core 记录每个 revision 由哪个插件版本写入,但不阻止不同实例加载不同版本;新旧
+ 版本能否同时读写同一份数据,由插件负责。汇总各实例当前加载的版本需要维护一张实例登记表,留给插件安装的后续提案。
+- **代码变更需要重启**:没有热加载,插件更新期间 Server 会短暂不可用。
+
+# 理由与备选
+
+| 方案 | 取舍 |
+| --- | --- |
+| 保留现有设计,只简化接口 | 插件作者体验改善有限,生命周期状态机、管理服务和 schema 固化的维护成本仍在 |
+| 清单文件(YAML)描述插件 | 插件需要同时维护清单和代码,两者的字段要保持一致;清单中的变量替换、配置模型和导出约定又是一套额外规则。声明写在代码里,由同一个 Pydantic 模型校验,插件作者只写 Python |
+| 插件在 Server 进程内执行 | 部署和调用最简单,但插件故障会拖垮 Server;Supervisor 已经提供隔离和调度,没有理由另起一套 |
+| 插件完全自管存储和游标 | core 读取接口无法直接使用,各插件需要重复实现窗口读取与幂等,游标与结果也难以原子提交 |
+| 插件返回草稿,或由写入器在内存中暂存操作,最后统一提交 | 可以原子提交,但插件在提交前拿不到真实的 revision,难以同步写入自有索引;合并、撤销合并、生命周期等操作需要在内存中重新实现一遍 core 的语义;检索也看不到暂存内容 |
+| 用一个事务包住整个 `generate` | 模型调用可能持续数分钟,长时间持锁会阻塞其他写入,SQLite 上会阻塞整个数据库;插件自有存储是另一个数据库,一个事务本来也覆盖不了 |
+| `generate` 内的短写事务块(本提案) | 耗时工作在块外;块内直接复用 core 的写操作和校验,拿到真实 revision,读取能看到本块写入;块结束时与游标、确认一起提交,异常即回滚。代价是块内持锁,需要时限约束 |
+| 用户修改排队,插件在下一次后台调用中处理 | 调用方只能异步等待;一个始终失败的修改会阻塞该 Scope 的后续生成;还需要持久化请求队列,而 core 目前没有任务管理机制 |
+| 用户修改由 core 直接执行,不经过插件 | 实现简单,但插件拿不到用户提交的内容:无法校验,自有存储和索引只能等之后重建才能跟上。存储由插件决定,修改也应由插件处理;内置 family 的新建和编辑同样经过各自的 family 代码 |
+| 用一个 `write` 方法同时处理新建和编辑 | 新建不带 ID、由 core 分配,编辑必须指定 ID 和期望 revision;两者的输入和校验不同,合成一个方法需要插件自己分辨 |
+| 同步调用每次派发一个 Worker | 复用现有机制,但 Worker 每次都启动新的 Python 进程,仅导入 core 模块就需要数秒,不能放在用户请求路径上 |
+| Source 的 `resolve` 等代码在 Server 进程中执行 | 省去进程间往返,但插件阻塞或崩溃会影响 Server;插件的同级模块以顶层名字进入 `sys.modules`,多个插件的同名模块会互相冲突,后续也无法可靠地重新加载 |
+| 常驻宿主进程处理同步调用,Worker 处理后台调用(本提案) | 同步调用不需要每次启动进程,Server 进程不导入插件代码;后台调用保留 Worker 的按次隔离和预算。代价是常驻进程的资源占用和进程管理 |
+| 插件存储进入 core 的事务(插件在 core 库中建表) | 可以原子提交,但 core 要管理插件的迁移、升级和权限,三种后端都要支持,插件数据会混入 core 的 schema。插件的存储由插件决定,不放进 core |
+| 插件存储与 core 之间用两阶段提交 | 对象存储、向量库等多数插件存储不支持 XA,core 也要为每种存储实现协调者。按内容摘要先写不可变记录、以 core 的提交为唯一生效点,就能得到需要的一致性 |
+| 一个插件声明多个 family | 可以在一个事务中写多个 family,但声明、权限和合并注册都要按多 family 设计。需要配合的场景用两个插件加跨 family 只读即可 |
+| 把 core 的环境变量或数据库配置交给插件 | core 的配置不全来自环境变量;插件拿到 core 的数据库凭据就能绕过写入器。提供封装好的 `ctx.storage` 和 `ctx.models` |
+| 插件专用的触发与管理接口 | 调用方要区分插件与内置能力;现有的 Source 与 Artifact 接口已经覆盖调用方需要的操作 |
+| 通用合并接口只对插件 family 开放 | 需要在接口里额外维护一份 family 白名单。能操作哪些 family 本来就由注册表决定,没有适配器的 family 无法登记;内置 family 的权限检查在引擎内,两个入口走同一套检查 |
+| core 按声明的字段为插件 family 建全文索引 | 插件作者不用自建索引,但 core 不知道内容怎样分块、是否适合全文或向量检索,建出的索引常常用不上;还要与内置 family 共用索引表,就绪检查也要区分两者。检索交给插件,core 只负责权限过滤 |
+| 读取也经过插件(例如 `read_artifact` 展开内容) | 通用读取就能拿到完整内容,但最常用的读取路径每次都要进入宿主进程,插件不可用时连读取都失败。读取保持由 core 提供,展示另设接口 |
+| 在数据库中登记插件 binding 与 family(旧 Artifact 插件框架的做法) | 能发现其他插件冒用已有 family 和遗留的调用意图,但 core 要保存并维护插件状态,还要提供解除冲突的管理操作。新框架没有稳定的插件身份,登记表能多发现的问题有限;留到插件安装与分发的提案中,与插件身份一起设计 |
+| 插件版本记在单独的表、`created_by` 或 `content` 的保留键中 | 单独的表同样要进入导出与恢复;改动 `created_by` 或 `content` 会改变已有字段的含义和内容摘要。`metadata` 列只需一次加列和导出格式升级 |
+| 本提案中支持热加载 | 需要处理多实例各自重新扫描、旧 Worker 提交的屏蔽和宿主进程切换,单独提案更清楚 |
+
+选择这一方案的原因:
+
+- 插件作者只需写一个 `plugin.py`:一个声明、一个工厂、一两个方法。
+- 调用方只用现有的 Source 与 Artifact 接口,不感知插件。
+- 隔离、调度、触发、退避和领导权都复用已经在生产中使用的 Supervisor。
+- 写操作直接复用 core 现有实现,插件写入和用户修改遵循同一套语义;用户修改经过插件,插件存储能同步更新。
+- Source 与 Artifact 留在 core 的统一存储中,现有读取接口、lineage、合并记录和访问控制直接适用。
+- core 只管理两类核心数据及其进度,其余交给插件。
+
+# 先例
+
+- **Dify 插件**:按插件类型定义接口,插件实现对应方法,Dify 在工作流或应用需要时调用;插件运行在独立的插件守护进程中。
+ 本提案沿用“宿主定义接口与调用时机”的思路:同步调用使用 core 管理的常驻宿主进程,后台调用复用 PowerContext 已有的
+ Supervisor Worker。
+- **pytest 插件与 setuptools entry points**:插件以 Python 对象声明自己提供的能力,宿主导入后读取,不需要单独的清单文件。
+- **事务上下文管理器**:SQLAlchemy 的 `async with engine.begin()` 等接口在块开始时开启事务、正常结束时提交、异常时回滚。
+ `writer.transaction()` 沿用这一使用方式,并在提交时附带游标与调用确认。
+- **PowerContext 现有基础**:[Artifact processing Supervisor](1515_artifact_processing_supervisor.md) 的调度、调用意图、
+ Worker 与租约 fence;[公共合并方法](1859_artifact_merge.md);通用 Artifact 读写接口与内置 family 编辑时记录的
+ `lineage_only` Content Source;`SourceDefinition` / `SourceProjection`、Source journal 与消费游标。
+
+# 未决问题
+
+| 问题 | 说明 |
+| --- | --- |
+| 写事务块的时限与限额默认值 | 默认 30 秒是否合适;SQLite 与 OceanBase 是否采用不同默认值;单块操作数与字节上限 |
+| 宿主进程的默认参数 | 同步调用时限、单个宿主进程的并发上限和重启退避的默认值 |
+| 专属存储的建库权限 | Server 的数据库登录没有建库权限时,除了由部署者事先建库,是否允许退回到 core 库中按插件名前缀建表 |
+| 插件 family 接入 Recall | Prepared Context 已接入,Recall 是否需要接入、候选内容怎样定义 |
+| 通用检索结果带片段 | 通用检索接口目前只返回精确 revision,插件返回的片段只用于 Prepared Context。是否在检索结果中增加片段和位置 |
+| family 与 Source 类型的命名 | 是否要求以插件名为前缀,从命名上避免不同插件冒用同一个 family |
+| Prepared Context 的检索控制 | 内置 family 也接受每项的 `search`,是新增的可选字段,不影响现有调用,建议另行提案。Prepared Context 中其他按功能定制的参数,移除或改名是不兼容变更,需要新的 API 版本,不在 v1 中整理 |
+| 窗口大小的回退 | 内置 Atomic Memory 在模型超时时会缩小下一次的 Source 窗口。插件无法影响窗口大小,过大的窗口会持续超时。是否允许插件以约定的异常请求缩小该 Scope 的窗口 |
+| 持续失败的窗口 | 始终失败的 Source 窗口会阻塞该 Scope 的后续调用。是否提供跳过或转入死信的运维操作,以及如何记录被跳过的输入 |
+| 消费全部 Source 类型 | `inputs.source_types` 需要逐一列出类型;内置 Atomic Memory 消费所有可用于生成的 Source。是否支持 `"*"` 表示全部类型 |
+| 内置专属能力 | Scope Prompt 管理、写入闸门与决策观测、模型用量统计目前只服务内置 family,插件无法接入。是否以及如何向插件开放 |
+| Atomic Memory 专用接口的去留 | 通用合并接口上线后,Atomic Memory 的合并、遗忘、恢复同时有专用和通用两个入口,行为一致。专用接口是否保留,或改为通用接口的别名 |
+
+# 后续可能
+
+- 热加载与插件自定义 API(含插件页面):建立在宿主进程之上,见 [RFC #2579](https://github.com/oceanbase/powercontext/pull/2579)。
+- 独立的 Source 插件与采集:只提供 Source 类型的插件;采集目标的集中配置与设备拉取同步;在设备上运行采集并把 Source
+ 汇报到中心;大文件的传输。由后续提案定义,并取代旧 Source 插件框架。
+- 后台调用也改用常驻进程,在保持接口不变的前提下复用连接和初始化结果。
+- 更多插件类型,例如为 Recall 提供候选内容、为 Prepared Context 提供文本片段。
+- 插件安装与依赖:从 PyPI、Git 地址或 wheel 安装插件,每个插件独立的依赖环境,以及记录各实例加载的插件版本的登记表。