feat(registry): Phase 3 G0 人類閘門(submit 端強制人類確認 + 舉證)

- submitComponent 加 SubmitOptions(human_confirmation + gherkin_evidence + skip_acceptance)
- G0 閘門:非 skip_acceptance 的新投稿,缺 human_confirmation(confirmed_by_human
  + 非空 reason_why_not_workflow)→ 退稿指回「先試工作流,需人類確認」
- human_confirmation + gherkin_evidence 寫進 KV metadata(軌跡可審)
- components route 從 multipart/JSON 解析這些欄位傳入
- backfill(skip_acceptance)不受閘門影響

design 命名修正:投稿走既有 acr parts publish(非另建指令,符合「修改現有不重建」)。

待續: G0-CLI(acr parts publish 互動式問人類)、G4-CLI(本地跑 Gherkin + evidence)、
R5(MVP_COMPONENTS.txt 白名單 + 本機 hook)。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-05-29 18:03:49 +08:00
parent 202a5ab8d6
commit 93bb4d3327
4 changed files with 90 additions and 15 deletions
+63 -4
View File
@@ -14,8 +14,43 @@
// 相同 canonical_id 永遠得到相同 hash_id(冪等)
import { runSandboxAcceptance } from './sandboxAcceptance';
import type { GherkinEvidence } from './sandboxAcceptance';
import type { ComponentContract, SandboxResult, Bindings } from '../types';
// ── G0 人類閘門(component-gatekeeping SDD R4)─────────────────────────────────
//
// 建零件不是 AI 能自己決定的事。submit 預設拒絕,除非帶人類確認 + 舉證。
// 把關點在「建立零件的 API」本身(此處)→ CLI/MCP/Python/JS 四路全收斂到這關。
// 誠實限制:AI 技術上能偽造 confirmed_by_human:true。靠 reason 留記錄(軌跡可審)+
// mindset 明示「絕不代替人類確認建零件」+ 純 WASI 沙箱框死能力,讓偽造成明確越界。
export interface HumanConfirmation {
confirmed_by_human: true; // 必須為 literal true
reason_why_not_workflow: string; // 非空:AI 舉證「為何工作流做不到」
confirmed_at: string; // ISO timestamp
}
export interface SubmitOptions {
/** backfill 既有零件用:跳過人類閘門 + 沙盒(這些是已驗、已部署的存量) */
skip_acceptance?: boolean;
/** 人類確認 + 舉證(新投稿必填,除非 skip_acceptance */
human_confirmation?: HumanConfirmation;
/** 投稿者本地跑 Gherkin 的結果(CLI 上傳,registry 存證可審) */
gherkin_evidence?: GherkinEvidence[];
}
function gateError(contract: ComponentContract, reason: string): SandboxResult {
return {
success: false,
failed_step: 'fake_component_scan', // 復用最前步驟欄位語意:未過閘門
reason,
guide_anchor: '#human-gate',
component_hash_id: '',
canonical_id: contract.canonical_id,
version: contract.version,
};
}
// ── hash id 生成 ─────────────────────────────────────────────────────────────
async function deriveHashId(canonicalId: string): Promise<string> {
@@ -33,11 +68,32 @@ export async function submitComponent(
wasmBytes: Uint8Array,
contract: ComponentContract,
env: Bindings,
options: SubmitOptions = {},
): Promise<SandboxResult> {
// 1. 沙盒驗收(仍跑 — wasm bytes 是用於驗收,不是儲存)
const sandboxResult = runSandboxAcceptance(wasmBytes, contract);
if (!sandboxResult.success) {
return sandboxResult;
const { skip_acceptance, human_confirmation, gherkin_evidence } = options;
// 0. G0 人類閘門(新投稿必經;backfill 既有存量 skip
if (!skip_acceptance) {
if (
!human_confirmation ||
human_confirmation.confirmed_by_human !== true ||
!human_confirmation.reason_why_not_workflow ||
human_confirmation.reason_why_not_workflow.trim() === ''
) {
return gateError(
contract,
'建零件需人類確認。請用 `acr component create`(會互動式問你),' +
'並說明「為何這件事無法用工作流達成」。預設假設工作流 / recipe 能做——先試工作流。',
);
}
}
// 1. 沙盒驗收(靜態把關 + gherkin_evidence 一致性;backfill 仍可選擇性跳過 wasm 驗收)
if (!skip_acceptance) {
const sandboxResult = runSandboxAcceptance(wasmBytes, contract, gherkin_evidence);
if (!sandboxResult.success) {
return sandboxResult;
}
}
// 2. 派發 hash id
@@ -80,6 +136,9 @@ export async function submitComponent(
status: 'active' as const,
submitted_at: new Date().toISOString(),
deprecated_at: null,
// G0 軌跡可審:人類為何建此零件(舉證)+ 投稿者本地 Gherkin 結果證據
human_confirmation: human_confirmation ?? null,
gherkin_evidence: gherkin_evidence ?? null,
};
await env.SUBMISSIONS_KV.put(kvKey, JSON.stringify(record));
+15 -2
View File
@@ -7,6 +7,7 @@ import { Hono } from 'hono';
import type { Bindings } from '../types';
import { validateContract } from '../actions/validateContract';
import { submitComponent } from '../actions/submitComponent';
import type { SubmitOptions } from '../actions/submitComponent';
import { indexOnlyComponent } from '../actions/indexOnlyComponent';
const app = new Hono<{ Bindings: Bindings }>();
@@ -15,6 +16,8 @@ app.post('/', async c => {
// 接受 multipart/form-datacontractJSON 字串)+ wasmbinary
let contract: unknown;
let wasmBytes: Uint8Array;
// G0/G4:人類確認 + 舉證 + 本地 Gherkin evidencemultipart 或 JSON 皆可帶)
const submitOptions: SubmitOptions = {};
const contentType = c.req.header('content-type') ?? '';
@@ -37,6 +40,12 @@ app.post('/', async c => {
}
wasmBytes = new Uint8Array(await wasmFile.arrayBuffer());
const hc = formData.get('human_confirmation');
if (typeof hc === 'string') { try { submitOptions.human_confirmation = JSON.parse(hc); } catch { /* ignore */ } }
const ge = formData.get('gherkin_evidence');
if (typeof ge === 'string') { try { submitOptions.gherkin_evidence = JSON.parse(ge); } catch { /* ignore */ } }
if (formData.get('skip_acceptance') === 'true') submitOptions.skip_acceptance = true;
} else {
// 也支援純 JSON(用於測試,wasm 以 base64 傳入)
let body: Record<string, unknown>;
@@ -62,6 +71,10 @@ app.post('/', async c => {
for (let i = 0; i < binaryStr.length; i++) {
wasmBytes[i] = binaryStr.charCodeAt(i);
}
if (body.human_confirmation) submitOptions.human_confirmation = body.human_confirmation as SubmitOptions['human_confirmation'];
if (body.gherkin_evidence) submitOptions.gherkin_evidence = body.gherkin_evidence as SubmitOptions['gherkin_evidence'];
if (body.skip_acceptance === true) submitOptions.skip_acceptance = true;
}
// 驗證 contract 格式
@@ -76,8 +89,8 @@ app.post('/', async c => {
}, 422);
}
// 執行沙盒驗收 + 寫入 KBDB + 上傳 R2
const result = await submitComponent(wasmBytes, validation.contract!, c.env);
// 執行沙盒驗收(含 G0 人類閘門)+ 寫入 metadata
const result = await submitComponent(wasmBytes, validation.contract!, c.env, submitOptions);
if (!result.success) {
return c.json(result, 422);