diff --git a/packages/schema/dist/index.d.ts b/packages/schema/dist/index.d.ts index af5ec8e863..9141236b20 100644 --- a/packages/schema/dist/index.d.ts +++ b/packages/schema/dist/index.d.ts @@ -8,6 +8,7 @@ export * from "./openclawContract.js"; export * from "./openClawExtensionSlugs.js"; export * from "./packages.js"; export * from "./pluginCategories.js"; +export * from "./registryScanBridge.js"; export { ApiRoutes, LegacyApiRoutes } from "./routes.js"; export * from "./schemas.js"; export * from "./textFiles.js"; diff --git a/packages/schema/dist/index.js b/packages/schema/dist/index.js index a4e03f0046..0403b8c7ec 100644 --- a/packages/schema/dist/index.js +++ b/packages/schema/dist/index.js @@ -7,7 +7,8 @@ export * from "./openclawContract.js"; export * from "./openClawExtensionSlugs.js"; export * from "./packages.js"; export * from "./pluginCategories.js"; +export * from "./registryScanBridge.js"; export { ApiRoutes, LegacyApiRoutes } from "./routes.js"; export * from "./schemas.js"; export * from "./textFiles.js"; -//# sourceMappingURL=index.js.map \ No newline at end of file +//# sourceMappingURL=index.js.map diff --git a/packages/schema/dist/index.js.map b/packages/schema/dist/index.js.map index a27090003f..da4c663a00 100644 --- a/packages/schema/dist/index.js.map +++ b/packages/schema/dist/index.js.map @@ -1 +1 @@ -{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAC;AACrD,cAAc,kBAAkB,CAAC;AACjC,cAAc,sBAAsB,CAAC;AACrC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,cAAc,CAAC;AAC7B,cAAc,uBAAuB,CAAC;AACtC,cAAc,6BAA6B,CAAC;AAC5C,cAAc,eAAe,CAAC;AAC9B,cAAc,uBAAuB,CAAC;AACtC,OAAO,EAAE,SAAS,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AACzD,cAAc,cAAc,CAAC;AAC7B,cAAc,gBAAgB,CAAC"} \ No newline at end of file +{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,eAAe,EAAE,QAAQ,EAAE,MAAM,UAAU,CAAC;AACrD,cAAc,kBAAkB,CAAC;AACjC,cAAc,sBAAsB,CAAC;AACrC,cAAc,gBAAgB,CAAC;AAC/B,cAAc,cAAc,CAAC;AAC7B,cAAc,uBAAuB,CAAC;AACtC,cAAc,6BAA6B,CAAC;AAC5C,cAAc,eAAe,CAAC;AAC9B,cAAc,uBAAuB,CAAC;AACtC,cAAc,yBAAyB,CAAC;AACxC,OAAO,EAAE,SAAS,EAAE,eAAe,EAAE,MAAM,aAAa,CAAC;AACzD,cAAc,cAAc,CAAC;AAC7B,cAAc,gBAAgB,CAAC"} diff --git a/packages/schema/dist/registryScanBridge.d.ts b/packages/schema/dist/registryScanBridge.d.ts new file mode 100644 index 0000000000..6c1266965d --- /dev/null +++ b/packages/schema/dist/registryScanBridge.d.ts @@ -0,0 +1,52 @@ +import type { CatalogFeedEntry, CatalogFeedInstallCandidate } from "./catalogFeed.js"; +export declare const OPENCLAW_REGISTRY_EXPORT_SCHEMA_VERSION = 1; +export type OpenClawRegistryExportReflectedState = "pending" | "reviewed" | "rejected" | "scan_pending" | "scan_passed" | "scan_failed" | "registry_included" | "registry_removed"; +export type OpenClawRegistryExportInput = { + feedId: string; + feedSequence: number; + feedPayloadDigest?: string | null; + entry: CatalogFeedEntry; + candidate?: CatalogFeedInstallCandidate; + exportedAt: string; + exportActorId?: string | null; +}; +export type OpenClawRegistryExport = { + schemaVersion: typeof OPENCLAW_REGISTRY_EXPORT_SCHEMA_VERSION; + exportId: string; + idempotencyKey: string; + exportedAt: string; + exportActorId: string | null; + clawhub: { + feed: { + id: string; + sequence: number; + payloadDigest: string | null; + entryId: string; + entryState: CatalogFeedEntry["state"]; + }; + publisher: { + id: string; + official: boolean; + }; + candidate: { + kind: CatalogFeedEntry["type"]; + id: string; + title: string; + package: string; + version: string; + sourceRef: string; + artifactDigest: string; + sourceType: "clawhub" | "github" | "other"; + github: CatalogFeedInstallCandidate["github"] | null; + }; + scanState: null; + reviewState: null; + }; + openclaw: { + reviewState: OpenClawRegistryExportReflectedState | null; + scanState: OpenClawRegistryExportReflectedState | null; + registryState: OpenClawRegistryExportReflectedState | null; + reviewId: string | null; + }; +}; +export declare function buildOpenClawRegistryExport(input: OpenClawRegistryExportInput): OpenClawRegistryExport; diff --git a/packages/schema/dist/registryScanBridge.js b/packages/schema/dist/registryScanBridge.js new file mode 100644 index 0000000000..cc679a0c2a --- /dev/null +++ b/packages/schema/dist/registryScanBridge.js @@ -0,0 +1,101 @@ +export const OPENCLAW_REGISTRY_EXPORT_SCHEMA_VERSION = 1; +function stableExportPart(value) { + return encodeURIComponent(value == null ? "" : String(value)); +} +function sourceTypeForCandidate(candidate) { + if (candidate.github) + return "github"; + if (candidate.sourceRef === "public-clawhub") + return "clawhub"; + return "other"; +} +function canonicalGitHubPart(candidate) { + const github = candidate.github; + if (!github) + return ""; + return [github.repo, github.path, github.commit, github.contentHash] + .map(stableExportPart) + .join(":"); +} +function candidateKey(candidate) { + return [ + candidate.sourceRef, + candidate.package, + candidate.version, + candidate.integrity, + canonicalGitHubPart(candidate), + ] + .map(stableExportPart) + .join(":"); +} +export function buildOpenClawRegistryExport(input) { + if (!Number.isSafeInteger(input.feedSequence) || input.feedSequence < 0) { + throw new Error("feedSequence must be a non-negative safe integer"); + } + if (!Number.isFinite(Date.parse(input.exportedAt))) { + throw new Error("exportedAt must be a valid ISO date"); + } + const candidate = input.candidate ?? input.entry.install.candidates[0]; + if (!candidate) + throw new Error("OpenClaw registry export requires an install candidate"); + if (input.candidate && + !input.entry.install.candidates.some((entryCandidate) => candidateKey(entryCandidate) === candidateKey(candidate))) { + throw new Error("OpenClaw registry export candidate must belong to the feed entry"); + } + const idempotencyKey = [ + "openclaw-registry-export-v1", + input.feedId, + input.feedSequence, + input.feedPayloadDigest ?? "", + input.entry.type, + input.entry.id, + input.entry.version, + candidate.sourceRef, + candidate.package, + candidate.version, + candidate.integrity, + canonicalGitHubPart(candidate), + ] + .map(stableExportPart) + .join(":"); + return { + schemaVersion: OPENCLAW_REGISTRY_EXPORT_SCHEMA_VERSION, + exportId: idempotencyKey, + idempotencyKey, + exportedAt: input.exportedAt, + exportActorId: input.exportActorId ?? null, + clawhub: { + feed: { + id: input.feedId, + sequence: input.feedSequence, + payloadDigest: input.feedPayloadDigest ?? null, + entryId: input.entry.id, + entryState: input.entry.state, + }, + publisher: { + id: input.entry.publisher.id, + official: input.entry.publisher.trust === "official", + }, + candidate: { + kind: input.entry.type, + id: input.entry.id, + title: input.entry.title, + package: candidate.package, + version: candidate.version, + sourceRef: candidate.sourceRef, + artifactDigest: candidate.integrity, + sourceType: sourceTypeForCandidate(candidate), + github: candidate.github ?? null, + }, + scanState: null, + reviewState: null, + }, + openclaw: { + reviewState: null, + scanState: null, + registryState: null, + reviewId: null, + }, + }; +} +//# sourceMappingURL=registryScanBridge.js.map diff --git a/packages/schema/dist/registryScanBridge.js.map b/packages/schema/dist/registryScanBridge.js.map new file mode 100644 index 0000000000..95f3798929 --- /dev/null +++ b/packages/schema/dist/registryScanBridge.js.map @@ -0,0 +1 @@ +{"version":3,"file":"registryScanBridge.js","sourceRoot":"","sources":["../src/registryScanBridge.ts"],"names":[],"mappings":"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"} diff --git a/packages/schema/src/index.ts b/packages/schema/src/index.ts index af5ec8e863..9141236b20 100644 --- a/packages/schema/src/index.ts +++ b/packages/schema/src/index.ts @@ -8,6 +8,7 @@ export * from "./openclawContract.js"; export * from "./openClawExtensionSlugs.js"; export * from "./packages.js"; export * from "./pluginCategories.js"; +export * from "./registryScanBridge.js"; export { ApiRoutes, LegacyApiRoutes } from "./routes.js"; export * from "./schemas.js"; export * from "./textFiles.js"; diff --git a/packages/schema/src/registryScanBridge.test.ts b/packages/schema/src/registryScanBridge.test.ts new file mode 100644 index 0000000000..b937b885f1 --- /dev/null +++ b/packages/schema/src/registryScanBridge.test.ts @@ -0,0 +1,260 @@ +import { describe, expect, it } from "vitest"; +import { CATALOG_FEED_ID, CATALOG_FEED_SOURCE_REF, type CatalogFeedEntry } from "./catalogFeed.js"; +import { buildOpenClawRegistryExport } from "./registryScanBridge.js"; + +function makeEntry(overrides: Partial = {}): CatalogFeedEntry { + return { + type: "plugin", + id: "@openclaw/demo", + title: "Demo", + version: "1.0.0", + state: "available", + publisher: { id: "openclaw", trust: "official" }, + install: { + candidates: [ + { + sourceRef: CATALOG_FEED_SOURCE_REF, + package: "@openclaw/demo", + version: "1.0.0", + integrity: "sha256:artifact", + }, + ], + }, + ...overrides, + }; +} + +describe("OpenClaw registry scan bridge", () => { + it("projects explicit ClawHub feed facts without adding OpenClaw approval", () => { + const exported = buildOpenClawRegistryExport({ + feedId: CATALOG_FEED_ID, + feedSequence: 42, + feedPayloadDigest: "sha256:payload", + entry: makeEntry(), + exportedAt: "2026-07-02T00:00:00.000Z", + exportActorId: "system:catalog-feed", + }); + + expect(exported).toMatchObject({ + schemaVersion: 1, + exportedAt: "2026-07-02T00:00:00.000Z", + exportActorId: "system:catalog-feed", + clawhub: { + feed: { + id: "clawhub-official", + sequence: 42, + payloadDigest: "sha256:payload", + entryId: "@openclaw/demo", + entryState: "available", + }, + publisher: { + id: "openclaw", + official: true, + }, + candidate: { + kind: "plugin", + id: "@openclaw/demo", + package: "@openclaw/demo", + sourceRef: "public-clawhub", + sourceType: "clawhub", + artifactDigest: "sha256:artifact", + github: null, + }, + scanState: null, + reviewState: null, + }, + openclaw: { + reviewState: null, + scanState: null, + registryState: null, + reviewId: null, + }, + }); + expect(exported.exportId).toBe(exported.idempotencyKey); + expect(exported.idempotencyKey).toContain("openclaw-registry-export-v1"); + }); + + it("keeps GitHub source facts as candidate provenance", () => { + const exported = buildOpenClawRegistryExport({ + feedId: "clawhub-official-skills", + feedSequence: 3, + entry: makeEntry({ + type: "skill", + id: "@nvidia/aiq-deploy", + publisher: { id: "nvidia", trust: "official" }, + install: { + candidates: [ + { + sourceRef: "public-github", + package: "@nvidia/aiq-deploy", + version: "1111111111111111111111111111111111111111", + integrity: "sha256:content", + github: { + repo: "NVIDIA/skills", + path: "skills/aiq-deploy", + commit: "1111111111111111111111111111111111111111", + contentHash: "content", + }, + }, + ], + }, + }), + exportedAt: "2026-07-02T00:00:00.000Z", + }); + + expect(exported.clawhub.candidate).toMatchObject({ + kind: "skill", + sourceRef: "public-github", + sourceType: "github", + github: { + repo: "NVIDIA/skills", + path: "skills/aiq-deploy", + commit: "1111111111111111111111111111111111111111", + contentHash: "content", + }, + }); + }); + + it("accepts copied GitHub candidates with equivalent provenance fields", () => { + const entry = makeEntry({ + type: "skill", + id: "@nvidia/aiq-deploy", + install: { + candidates: [ + { + sourceRef: "public-github", + package: "@nvidia/aiq-deploy", + version: "1111111111111111111111111111111111111111", + integrity: "sha256:content", + github: { + repo: "NVIDIA/skills", + path: "skills/aiq-deploy", + commit: "1111111111111111111111111111111111111111", + contentHash: "content", + }, + }, + ], + }, + }); + + const exported = buildOpenClawRegistryExport({ + feedId: "clawhub-official-skills", + feedSequence: 3, + entry, + candidate: { + sourceRef: "public-github", + package: "@nvidia/aiq-deploy", + version: "1111111111111111111111111111111111111111", + integrity: "sha256:content", + github: { + contentHash: "content", + commit: "1111111111111111111111111111111111111111", + path: "skills/aiq-deploy", + repo: "NVIDIA/skills", + }, + }, + exportedAt: "2026-07-02T00:00:00.000Z", + }); + + expect(exported.clawhub.candidate.github?.repo).toBe("NVIDIA/skills"); + }); + + it("uses feed revision and candidate facts for duplicate suppression", () => { + const entry = makeEntry(); + const first = buildOpenClawRegistryExport({ + feedId: CATALOG_FEED_ID, + feedSequence: 1, + entry, + exportedAt: "2026-07-02T00:00:00.000Z", + }); + const retry = buildOpenClawRegistryExport({ + feedId: CATALOG_FEED_ID, + feedSequence: 1, + entry, + exportedAt: "2026-07-02T01:00:00.000Z", + }); + const resubmission = buildOpenClawRegistryExport({ + feedId: CATALOG_FEED_ID, + feedSequence: 2, + entry, + exportedAt: "2026-07-02T01:00:00.000Z", + }); + + expect(retry.idempotencyKey).toBe(first.idempotencyKey); + expect(resubmission.idempotencyKey).not.toBe(first.idempotencyKey); + }); + + it("includes GitHub provenance in the duplicate suppression key", () => { + const first = buildOpenClawRegistryExport({ + feedId: "clawhub-official-skills", + feedSequence: 1, + entry: makeEntry({ + type: "skill", + id: "@nvidia/demo", + install: { + candidates: [ + { + sourceRef: "public-github", + package: "@nvidia/demo", + version: "1", + integrity: "sha256:same", + github: { repo: "NVIDIA/skills", path: "one", commit: "1", contentHash: "same" }, + }, + ], + }, + }), + exportedAt: "2026-07-02T00:00:00.000Z", + }); + const second = buildOpenClawRegistryExport({ + feedId: "clawhub-official-skills", + feedSequence: 1, + entry: makeEntry({ + type: "skill", + id: "@nvidia/demo", + install: { + candidates: [ + { + sourceRef: "public-github", + package: "@nvidia/demo", + version: "1", + integrity: "sha256:same", + github: { repo: "NVIDIA/skills", path: "two", commit: "1", contentHash: "same" }, + }, + ], + }, + }), + exportedAt: "2026-07-02T00:00:00.000Z", + }); + + expect(second.idempotencyKey).not.toBe(first.idempotencyKey); + }); + + it("rejects candidates that are not present on the exported feed entry", () => { + expect(() => + buildOpenClawRegistryExport({ + feedId: CATALOG_FEED_ID, + feedSequence: 1, + entry: makeEntry(), + candidate: { + sourceRef: "public-clawhub", + package: "@other/demo", + version: "1.0.0", + integrity: "sha256:other", + }, + exportedAt: "2026-07-02T00:00:00.000Z", + }), + ).toThrow("candidate must belong to the feed entry"); + }); + + it("carries non-available entry state as a separate ClawHub fact", () => { + const exported = buildOpenClawRegistryExport({ + feedId: CATALOG_FEED_ID, + feedSequence: 1, + entry: makeEntry({ state: "blocked" }), + exportedAt: "2026-07-02T00:00:00.000Z", + }); + + expect(exported.clawhub.feed.entryState).toBe("blocked"); + expect(exported.openclaw.registryState).toBeNull(); + }); +}); diff --git a/packages/schema/src/registryScanBridge.ts b/packages/schema/src/registryScanBridge.ts new file mode 100644 index 0000000000..39039f9d48 --- /dev/null +++ b/packages/schema/src/registryScanBridge.ts @@ -0,0 +1,171 @@ +import type { CatalogFeedEntry, CatalogFeedInstallCandidate } from "./catalogFeed.js"; + +export const OPENCLAW_REGISTRY_EXPORT_SCHEMA_VERSION = 1; + +export type OpenClawRegistryExportReflectedState = + | "pending" + | "reviewed" + | "rejected" + | "scan_pending" + | "scan_passed" + | "scan_failed" + | "registry_included" + | "registry_removed"; + +export type OpenClawRegistryExportInput = { + feedId: string; + feedSequence: number; + feedPayloadDigest?: string | null; + entry: CatalogFeedEntry; + candidate?: CatalogFeedInstallCandidate; + exportedAt: string; + exportActorId?: string | null; +}; + +export type OpenClawRegistryExport = { + schemaVersion: typeof OPENCLAW_REGISTRY_EXPORT_SCHEMA_VERSION; + exportId: string; + idempotencyKey: string; + exportedAt: string; + exportActorId: string | null; + clawhub: { + feed: { + id: string; + sequence: number; + payloadDigest: string | null; + entryId: string; + entryState: CatalogFeedEntry["state"]; + }; + publisher: { + id: string; + official: boolean; + }; + candidate: { + kind: CatalogFeedEntry["type"]; + id: string; + title: string; + package: string; + version: string; + sourceRef: string; + artifactDigest: string; + sourceType: "clawhub" | "github" | "other"; + github: CatalogFeedInstallCandidate["github"] | null; + }; + scanState: null; + reviewState: null; + }; + openclaw: { + reviewState: OpenClawRegistryExportReflectedState | null; + scanState: OpenClawRegistryExportReflectedState | null; + registryState: OpenClawRegistryExportReflectedState | null; + reviewId: string | null; + }; +}; + +function stableExportPart(value: string | number | null | undefined) { + return encodeURIComponent(value == null ? "" : String(value)); +} + +function sourceTypeForCandidate(candidate: CatalogFeedInstallCandidate) { + if (candidate.github) return "github" as const; + if (candidate.sourceRef === "public-clawhub") return "clawhub" as const; + return "other" as const; +} + +function canonicalGitHubPart(candidate: CatalogFeedInstallCandidate) { + const github = candidate.github; + if (!github) return ""; + return [github.repo, github.path, github.commit, github.contentHash] + .map(stableExportPart) + .join(":"); +} + +function candidateKey(candidate: CatalogFeedInstallCandidate) { + return [ + candidate.sourceRef, + candidate.package, + candidate.version, + candidate.integrity, + canonicalGitHubPart(candidate), + ] + .map(stableExportPart) + .join(":"); +} + +export function buildOpenClawRegistryExport( + input: OpenClawRegistryExportInput, +): OpenClawRegistryExport { + if (!Number.isSafeInteger(input.feedSequence) || input.feedSequence < 0) { + throw new Error("feedSequence must be a non-negative safe integer"); + } + if (!Number.isFinite(Date.parse(input.exportedAt))) { + throw new Error("exportedAt must be a valid ISO date"); + } + + const candidate = input.candidate ?? input.entry.install.candidates[0]; + if (!candidate) throw new Error("OpenClaw registry export requires an install candidate"); + if ( + input.candidate && + !input.entry.install.candidates.some( + (entryCandidate) => candidateKey(entryCandidate) === candidateKey(candidate), + ) + ) { + throw new Error("OpenClaw registry export candidate must belong to the feed entry"); + } + const idempotencyKey = [ + "openclaw-registry-export-v1", + input.feedId, + input.feedSequence, + input.feedPayloadDigest ?? "", + input.entry.type, + input.entry.id, + input.entry.version, + candidate.sourceRef, + candidate.package, + candidate.version, + candidate.integrity, + canonicalGitHubPart(candidate), + ] + .map(stableExportPart) + .join(":"); + + return { + schemaVersion: OPENCLAW_REGISTRY_EXPORT_SCHEMA_VERSION, + exportId: idempotencyKey, + idempotencyKey, + exportedAt: input.exportedAt, + exportActorId: input.exportActorId ?? null, + clawhub: { + feed: { + id: input.feedId, + sequence: input.feedSequence, + payloadDigest: input.feedPayloadDigest ?? null, + entryId: input.entry.id, + entryState: input.entry.state, + }, + publisher: { + id: input.entry.publisher.id, + official: input.entry.publisher.trust === "official", + }, + candidate: { + kind: input.entry.type, + id: input.entry.id, + title: input.entry.title, + package: candidate.package, + version: candidate.version, + sourceRef: candidate.sourceRef, + artifactDigest: candidate.integrity, + sourceType: sourceTypeForCandidate(candidate), + github: candidate.github ?? null, + }, + scanState: null, + reviewState: null, + }, + openclaw: { + reviewState: null, + scanState: null, + registryState: null, + reviewId: null, + }, + }; +} diff --git a/specs/README.md b/specs/README.md index 9b868c17f7..2556bffcee 100644 --- a/specs/README.md +++ b/specs/README.md @@ -19,6 +19,7 @@ into `docs/` and leave only the design record here. - `spec.md`: product + implementation spec for the original registry model. - `orgs.md`: org, publisher membership, and scoped identity plan. +- `registry-scan-bridge.md`: OpenClaw registry review and scan bridge for ClawHub account and publisher feeds. - `github-import.md`: GitHub import feature spec. - `github-backed-skills.md`: source-backed GitHub skills catalog and install invariants. - `diffing.md`: skill version diffing UI/API design. diff --git a/specs/registry-scan-bridge.md b/specs/registry-scan-bridge.md new file mode 100644 index 0000000000..9a103c9d82 --- /dev/null +++ b/specs/registry-scan-bridge.md @@ -0,0 +1,169 @@ +--- +summary: "OpenClaw registry review and scan bridge for ClawHub account and publisher feeds." +read_when: + - Exporting ClawHub account or publisher feed entries to OpenClaw review + - Reflecting OpenClaw registry, review, or scan state back into ClawHub + - Changing feed, scan, provenance, or install-eligibility boundaries +--- + +# Registry And Scan Bridge + +ClawHub account and publisher feeds can provide useful candidate data for +OpenClaw registry review, but they must not bypass OpenClaw review, scans, +package integrity checks, source-profile trust, or local policy. + +This spec defines the bridge boundary between ClawHub feed facts and OpenClaw +registry decisions. + +## Product Behavior + +ClawHub may export account or publisher feed entries into an OpenClaw review +lane. The export is a candidate package, skill, or feed-entry record with +ClawHub provenance. It is not a registry inclusion decision. + +OpenClaw review systems may preserve ClawHub provenance, feed revision metadata, +scan inputs, artifact details, and publisher state while making their own +review and inclusion decision. + +Registry decisions may be reflected back to ClawHub as separate status fields. +They must not replace or mutate ClawHub official publisher state. + +## Exported Facts + +An export should carry explicit facts only: + +- ClawHub export id +- ClawHub feed id +- ClawHub feed sequence or revision +- ClawHub feed payload digest when available +- account id +- publisher id +- package id, skill id, or feed entry id +- package name, slug, version, and source type when available +- artifact URL and artifact digest when available +- source URL and source revision when available +- ClawHub official publisher state when available +- ClawHub claim or verification state when available +- ClawHub scan state when available +- ClawHub review state when available +- export actor or system id +- export timestamp + +The export should not carry inferred trust. For example, ClawHub official state +is a publisher identity signal, not an OpenClaw registry approval. + +## Boundary Rules + +Keep these signals separate: + +- ClawHub publisher identity +- ClawHub claim state +- ClawHub verified state +- ClawHub official publisher state +- ClawHub scan state +- ClawHub review state +- OpenClaw registry review state +- OpenClaw scan state +- OpenClaw source-profile trust +- package artifact integrity +- local approval +- OpenClaw install eligibility + +ClawHub official status is not OpenClaw approval. OpenClaw registry inclusion is +a downstream review result. Local approval is separate from OpenClaw registry +inclusion. + +OpenClaw install eligibility remains gated by verified feed state, +source-profile trust, package integrity, OpenClaw review or scan requirements +where configured, and local policy. + +## Review Flow + +The bridge should support this flow: + +1. ClawHub produces or refreshes an account or publisher feed. +2. ClawHub selects eligible public feed entries for export. +3. ClawHub creates an export record with explicit provenance and artifact facts. +4. OpenClaw review receives or fetches the export record. +5. OpenClaw review performs its own validation, scans, and policy checks. +6. OpenClaw review records a decision. +7. ClawHub may display that decision as OpenClaw review state, separate from + ClawHub official state. + +The bridge should be idempotent. Retrying the same export should not create +duplicate review candidates when the ClawHub feed revision and exported entry +are unchanged. + +## Retry, Withdrawal, And Resubmission + +The contract should define: + +- export idempotency key +- duplicate submission behavior +- retry behavior after transient failures +- withdrawal behavior when a ClawHub entry is removed, hidden, suspended, or + revoked +- resubmission behavior when the feed revision, artifact digest, source + revision, or scan state changes +- stale decision behavior when the underlying ClawHub facts change + +Withdrawal should not erase the previous decision history. It should produce a +new state that points back to the original export and decision where possible. + +## Reflected Status + +ClawHub may show reflected OpenClaw status such as: + +- OpenClaw review pending +- OpenClaw reviewed +- OpenClaw rejected +- OpenClaw scan pending +- OpenClaw scan passed +- OpenClaw scan failed +- OpenClaw registry included +- OpenClaw registry removed + +These are OpenClaw status fields. They should not be stored as ClawHub official +state and should not make the publisher official. + +ClawHub should show reflected status only when it can tie the status back to the +exact ClawHub export id or feed revision that OpenClaw reviewed. + +## Audit Requirements + +Record audit events for: + +- candidate exported +- export retried +- duplicate export suppressed +- export withdrawn +- export resubmitted +- OpenClaw decision received +- reflected status updated +- reflected status cleared because ClawHub facts changed + +Each audit event should include: + +- actor or system id +- timestamp +- export id +- feed id and feed revision +- account id or publisher id +- package, skill, or entry id +- prior state +- new state +- reason +- related OpenClaw review id when available + +Private review evidence should not be copied into public feeds, profile +payloads, or search documents. + +## Open Questions + +- Which OpenClaw review API should receive ClawHub candidates first? +- Should OpenClaw pull export records from ClawHub, or should ClawHub push + candidates into OpenClaw review? +- What minimum evidence package does OpenClaw need for the first candidate? +- Which ClawHub scan states are stable enough to export as facts? +- Should OpenClaw decisions be reflected on profile pages, package pages, feed + pages, or all three?