@seasonkoh/webaz 0.1.16 → 0.1.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +60 -5
  2. package/dist/layer0-foundation/L0-2-state-machine/engine.js +3 -0
  3. package/dist/layer1-agent/L1-1-mcp-server/server.js +899 -720
  4. package/dist/layer2-business/L2-8-feedback/build-feedback-engine.js +287 -0
  5. package/dist/layer2-business/L2-9-contribution/build-reputation-engine.js +102 -0
  6. package/dist/layer2-business/L2-9-contribution/build-tasks-engine.js +180 -0
  7. package/dist/layer3-trust/L3-1-dispute-engine/dispute-engine.js +16 -0
  8. package/dist/layer4-economics/L4-3-reputation/reputation-engine.js +1 -0
  9. package/dist/mcp.js +7 -3
  10. package/dist/pwa/data/onboarding-cases.js +345 -0
  11. package/dist/pwa/data/onboarding-quiz.js +247 -0
  12. package/dist/pwa/public/app.js +1459 -96
  13. package/dist/pwa/public/i18n.js +303 -2
  14. package/dist/pwa/public/icon-192.png +0 -0
  15. package/dist/pwa/public/icon-512.png +0 -0
  16. package/dist/pwa/public/manifest.json +5 -2
  17. package/dist/pwa/public/openapi.json +1 -1
  18. package/dist/pwa/public/sw.js +1 -1
  19. package/dist/pwa/routes/admin-protocol-params.js +80 -2
  20. package/dist/pwa/routes/admin-reports.js +14 -9
  21. package/dist/pwa/routes/auth-read.js +3 -1
  22. package/dist/pwa/routes/build-feedback.js +82 -0
  23. package/dist/pwa/routes/build-reputation.js +10 -0
  24. package/dist/pwa/routes/build-tasks.js +73 -0
  25. package/dist/pwa/routes/disputes-write.js +149 -1
  26. package/dist/pwa/routes/governance-auto-deactivate.js +108 -0
  27. package/dist/pwa/routes/governance-onboarding.js +785 -0
  28. package/dist/pwa/routes/leaderboard.js +10 -2
  29. package/dist/pwa/routes/orders-action.js +5 -1
  30. package/dist/pwa/routes/products-meta.js +30 -0
  31. package/dist/pwa/routes/profile-identity.js +1 -1
  32. package/dist/pwa/routes/public-utils.js +44 -0
  33. package/dist/pwa/routes/rewards-apply.js +210 -0
  34. package/dist/pwa/routes/rewards-auto-downgrade.js +65 -0
  35. package/dist/pwa/routes/rewards-escrow-expire.js +48 -0
  36. package/dist/pwa/routes/wallet-write.js +17 -31
  37. package/dist/pwa/routes/webauthn.js +1 -1
  38. package/dist/pwa/server.js +641 -64
  39. package/package.json +6 -3
@@ -0,0 +1,82 @@
1
+ import { submitBuildFeedback, listMyBuildFeedback, getBuildFeedback, adminListBuildFeedback, adminUpdateBuildFeedback, triagePendingBuildFeedback, } from '../../layer2-business/L2-8-feedback/build-feedback-engine.js';
2
+ export function registerBuildFeedbackRoutes(app, deps) {
3
+ const { db, auth, requireSupportAdmin } = deps;
4
+ const hasPasskey = (userId) => ((db.prepare('SELECT COUNT(*) AS n FROM webauthn_credentials WHERE user_id = ?').get(userId)?.n) || 0) > 0;
5
+ // ── 提交 ──────────────────────────────────────────────
6
+ app.post('/api/build-feedback', (req, res) => {
7
+ const user = auth(req, res);
8
+ if (!user)
9
+ return;
10
+ const { type, area, severity, subject, text, body, scene } = req.body ?? {};
11
+ // 分级门(RFC-004 精确化 2026-06-05):
12
+ // 「报告问题」(ux_issue/bug = 用)→ 登录即可,不要 Passkey(没 Passkey 也要能报问题)
13
+ // 「建设平台」(proposal = 建)→ 必须 Passkey(真人锚点,后期贡献/奖励才有归属)
14
+ if (String(type) === 'proposal' && !hasPasskey(user.id)) {
15
+ return void res.status(403).json({
16
+ error: '提交「改进提案 / proposal」需先绑定 Passkey 成为可问责真人 —— 提案是建设行为,被采纳会记入共建信誉,需真人锚点。报告 bug / 体验问题无需 Passkey。请在 webaz.xyz「我的」绑定 Passkey。',
17
+ error_code: 'PROPOSAL_REQUIRES_PASSKEY',
18
+ });
19
+ }
20
+ const result = submitBuildFeedback(db, {
21
+ userId: user.id,
22
+ type, area, severity, subject,
23
+ body: text ?? body, // 接受 text 或 body
24
+ sceneJson: scene,
25
+ source: 'agent',
26
+ });
27
+ if ('error' in result)
28
+ return void res.status(result.error_code === 'RATE_LIMITED' ? 429 : 400).json(result);
29
+ res.json(result);
30
+ });
31
+ // ── 闭环:我的反馈进度 ──(必须在 /:id 之前声明)──────────
32
+ app.get('/api/build-feedback/mine', (req, res) => {
33
+ const user = auth(req, res);
34
+ if (!user)
35
+ return;
36
+ res.json({ feedback: listMyBuildFeedback(db, user.id) });
37
+ });
38
+ app.get('/api/build-feedback/:id', (req, res) => {
39
+ const user = auth(req, res);
40
+ if (!user)
41
+ return;
42
+ const isAdmin = !!(user.is_admin || user.admin_permissions); // 宽松判定;admin 端点另有严格门
43
+ const row = getBuildFeedback(db, String(req.params.id), user.id, !!isAdmin);
44
+ if (!row)
45
+ return void res.status(404).json({ error: '反馈不存在或无权查看' });
46
+ res.json(row);
47
+ });
48
+ // ── maintainer triage ────────────────────────────────
49
+ app.get('/api/admin/build-feedback', (req, res) => {
50
+ if (!requireSupportAdmin(req, res))
51
+ return;
52
+ const status = typeof req.query.status === 'string' ? req.query.status : undefined;
53
+ res.json({ feedback: adminListBuildFeedback(db, status) });
54
+ });
55
+ // RFC-005 Phase 2:AI 自动 triage(advisory)— 批量处理 received 反馈:去重 + 标风险/摘要 + 置 triaged。
56
+ // 不 resolve、不记功(人类的)。无 AI key 时只做确定性去重 + 置 triaged。
57
+ // ⚠️ 必须在 /:id 之前声明,否则 'triage' 会被 :id 捕获。
58
+ app.post('/api/admin/build-feedback/triage', async (req, res) => {
59
+ if (!requireSupportAdmin(req, res))
60
+ return;
61
+ const limit = Math.min(50, Math.max(1, Number((req.body ?? {}).limit) || 20));
62
+ try {
63
+ const r = await triagePendingBuildFeedback(db, limit);
64
+ res.json(r);
65
+ }
66
+ catch (e) {
67
+ res.status(500).json({ error: 'triage failed', detail: String(e.message) });
68
+ }
69
+ });
70
+ app.post('/api/admin/build-feedback/:id', (req, res) => {
71
+ const admin = requireSupportAdmin(req, res);
72
+ if (!admin)
73
+ return;
74
+ const { status, resolution, rfc_draft, credit } = req.body ?? {};
75
+ const result = adminUpdateBuildFeedback(db, {
76
+ id: String(req.params.id), status, resolution, rfcDraft: rfc_draft, credit: !!credit, adminId: admin.id,
77
+ });
78
+ if ('error' in result)
79
+ return void res.status(404).json(result);
80
+ res.json(result);
81
+ });
82
+ }
@@ -0,0 +1,10 @@
1
+ import { getBuildProfile } from '../../layer2-business/L2-9-contribution/build-reputation-engine.js';
2
+ export function registerBuildReputationRoutes(app, deps) {
3
+ const { db, auth } = deps;
4
+ app.get('/api/build-reputation/me', (req, res) => {
5
+ const user = auth(req, res);
6
+ if (!user)
7
+ return;
8
+ res.json(getBuildProfile(db, user.id));
9
+ });
10
+ }
@@ -0,0 +1,73 @@
1
+ import { createBuildTask, listBuildTasks, getBuildTask, claimBuildTask, submitBuildTask, releaseBuildTask, resolveBuildTask, } from '../../layer2-business/L2-9-contribution/build-tasks-engine.js';
2
+ export function registerBuildTasksRoutes(app, deps) {
3
+ const { db, auth, requireSupportAdmin } = deps;
4
+ app.post('/api/build-tasks', (req, res) => {
5
+ const user = auth(req, res);
6
+ if (!user)
7
+ return;
8
+ const { title, area, description, rfc_ref } = req.body ?? {};
9
+ const result = createBuildTask(db, { creatorId: user.id, title, area, description, rfcRef: rfc_ref });
10
+ if ('error' in result)
11
+ return void res.status(result.error_code === 'RATE_LIMITED' ? 429 : 400).json(result);
12
+ res.json(result);
13
+ });
14
+ app.get('/api/build-tasks', (req, res) => {
15
+ const user = auth(req, res);
16
+ if (!user)
17
+ return;
18
+ const status = typeof req.query.status === 'string' ? req.query.status : undefined;
19
+ const area = typeof req.query.area === 'string' ? req.query.area : undefined;
20
+ const claimerId = req.query.mine === '1' ? user.id : undefined;
21
+ res.json({ tasks: listBuildTasks(db, { status, area, claimerId }) });
22
+ });
23
+ app.get('/api/build-tasks/:id', (req, res) => {
24
+ const user = auth(req, res);
25
+ if (!user)
26
+ return;
27
+ const row = getBuildTask(db, String(req.params.id));
28
+ if (!row)
29
+ return void res.status(404).json({ error: '任务不存在' });
30
+ res.json(row);
31
+ });
32
+ app.post('/api/build-tasks/:id/claim', (req, res) => {
33
+ const user = auth(req, res);
34
+ if (!user)
35
+ return;
36
+ const result = claimBuildTask(db, String(req.params.id), user.id, (req.body ?? {}).provenance);
37
+ if ('error' in result) {
38
+ const code = result.error_code === 'NOT_FOUND' ? 404 : result.error_code === 'TOO_MANY_CLAIMS' ? 429 : 409;
39
+ return void res.status(code).json(result);
40
+ }
41
+ res.json(result);
42
+ });
43
+ app.post('/api/build-tasks/:id/submit', (req, res) => {
44
+ const user = auth(req, res);
45
+ if (!user)
46
+ return;
47
+ const { pr_ref, note } = req.body ?? {};
48
+ const result = submitBuildTask(db, String(req.params.id), user.id, pr_ref, note);
49
+ if ('error' in result)
50
+ return void res.status(result.error_code === 'NOT_FOUND' ? 404 : 400).json(result);
51
+ res.json(result);
52
+ });
53
+ app.post('/api/build-tasks/:id/release', (req, res) => {
54
+ const user = auth(req, res);
55
+ if (!user)
56
+ return;
57
+ const result = releaseBuildTask(db, String(req.params.id), user.id);
58
+ if ('error' in result)
59
+ return void res.status(result.error_code === 'NOT_FOUND' ? 404 : 400).json(result);
60
+ res.json(result);
61
+ });
62
+ // 验收终态 —— 仅 admin/maintainer(验收=真人,RFC-006 不变量 2;不发奖励/不记信誉)
63
+ app.post('/api/admin/build-tasks/:id/resolve', (req, res) => {
64
+ const admin = requireSupportAdmin(req, res);
65
+ if (!admin)
66
+ return;
67
+ const { status, note } = req.body ?? {};
68
+ const result = resolveBuildTask(db, String(req.params.id), String(status), admin.id, note);
69
+ if ('error' in result)
70
+ return void res.status(result.error_code === 'NOT_FOUND' ? 404 : 400).json(result);
71
+ res.json(result);
72
+ });
73
+ }
@@ -1,6 +1,6 @@
1
1
  import express from 'express';
2
2
  export function registerDisputesWriteRoutes(app, deps) {
3
- const { db, auth, generateId, detectFraud, errorRes, isEligibleArbitrator, requireHumanPresence, getDisputeDetails, respondToDispute, arbitrateDispute, addPartyEvidence, requestEvidence, markEvidenceExpiry, uploadEvidence, EVIDENCE_MAX_BYTES, EVIDENCE_ALLOWED_MIME, appendOrderEvent, FUND_BASE_RATE, settleCommission, depositToFund, calculatePv, recordDisputeReputation, issueAgentStrike, publishDisputeCase, logAdminAction, snfSend } = deps;
3
+ const { db, auth, generateId, detectFraud, errorRes, isEligibleArbitrator, requireHumanPresence, getDisputeDetails, respondToDispute, arbitrateDispute, addPartyEvidence, requestEvidence, markEvidenceExpiry, uploadEvidence, EVIDENCE_MAX_BYTES, EVIDENCE_ALLOWED_MIME, appendOrderEvent, FUND_BASE_RATE, settleCommission, depositToFund, calculatePv, recordDisputeReputation, issueAgentStrike, publishDisputeCase, logAdminAction, snfSend, getProtocolParam } = deps;
4
4
  // 被诉方反驳
5
5
  app.post('/api/disputes/:id/respond', (req, res) => {
6
6
  const user = auth(req, res);
@@ -472,4 +472,152 @@ export function registerDisputesWriteRoutes(app, deps) {
472
472
  return void res.json({ error: result.error });
473
473
  res.json({ success: true, request_id: result.requestId });
474
474
  });
475
+ // ─── task #1093 stage 6: arbitrator_pause / resume auto_judge ─────
476
+ // Spec: docs/ARBITRATION-PLAYBOOK.md §2.1 (clock conflict resolution)
477
+ // Freezes the 48h respondent-silence + arbitrate_deadline clocks while
478
+ // arbitrator legitimately needs more time(e.g., evidence collection).
479
+ //
480
+ // Both endpoints require caller is one of dispute.assigned_arbitrators.
481
+ // Repause(extend) allowed — each pause writes an audit_log entry.
482
+ // No Iron-Rule Passkey: routine arbitrator action, fully audit-traceable.
483
+ function isAssignedArbitrator(disputeId, userId) {
484
+ const row = db.prepare(`SELECT assigned_arbitrators FROM disputes WHERE id = ?`).get(disputeId);
485
+ if (!row)
486
+ return false;
487
+ let arr = [];
488
+ try {
489
+ arr = JSON.parse(row.assigned_arbitrators || '[]');
490
+ }
491
+ catch {
492
+ arr = [];
493
+ }
494
+ return arr.includes(userId);
495
+ }
496
+ function appendAuditLog(disputeId, entry) {
497
+ // Append-only JSON array. Reads existing audit_log, appends, writes back.
498
+ const row = db.prepare(`SELECT audit_log FROM disputes WHERE id = ?`).get(disputeId);
499
+ let arr = [];
500
+ try {
501
+ arr = JSON.parse(row?.audit_log || '[]');
502
+ }
503
+ catch {
504
+ arr = [];
505
+ }
506
+ arr.push({ ...entry, at: Math.floor(Date.now() / 1000) });
507
+ db.prepare(`UPDATE disputes SET audit_log = ? WHERE id = ?`).run(JSON.stringify(arr), disputeId);
508
+ }
509
+ // task #1093 stage 6 P0 fix:pause 必须扩展 deadline,否则 cron 解冻后立即 auto-judge
510
+ // 且 /respond 端点硬查 deadline,暂停期间 respondent 提交反驳会被拒
511
+ function extendIsoDeadlineBySeconds(text, secondsToAdd) {
512
+ if (!text || secondsToAdd <= 0)
513
+ return text;
514
+ const ms = new Date(text).getTime();
515
+ if (isNaN(ms))
516
+ return text; // unparseable — leave as is
517
+ return new Date(ms + secondsToAdd * 1000).toISOString();
518
+ }
519
+ app.post('/api/disputes/:id/arbitrator-pause-auto-judge', (req, res) => {
520
+ const user = auth(req, res);
521
+ if (!user)
522
+ return;
523
+ const userId = user.id;
524
+ const disputeId = req.params.id;
525
+ const body = req.body || {};
526
+ const reason = String(body.reason || '').trim();
527
+ const untilTs = Number(body.until_ts || 0);
528
+ if (!reason || reason.length < 10) {
529
+ return void errorRes(res, 400, 'REASON_TOO_SHORT', '暂停理由至少 10 字符(写入 audit_log 公示)');
530
+ }
531
+ if (!untilTs || untilTs <= Math.floor(Date.now() / 1000)) {
532
+ return void errorRes(res, 400, 'INVALID_UNTIL_TS', 'until_ts 必须是未来 epoch 秒');
533
+ }
534
+ const maxHours = Number(getProtocolParam('arbitration_max_pause_hours', 168));
535
+ const maxAllowed = Math.floor(Date.now() / 1000) + maxHours * 3600;
536
+ if (untilTs > maxAllowed) {
537
+ return void errorRes(res, 400, 'EXCEEDS_MAX_HOURS', `until_ts 超过最大暂停窗口 ${maxHours}h(playbook §2.1)`);
538
+ }
539
+ const dispute = db.prepare(`SELECT id, status, ruling_type, assigned_arbitrators, auto_judge_paused_until, respond_deadline, arbitrate_deadline FROM disputes WHERE id = ?`).get(disputeId);
540
+ if (!dispute)
541
+ return void errorRes(res, 404, 'NOT_FOUND', 'dispute 不存在');
542
+ if (dispute.ruling_type) {
543
+ return void errorRes(res, 409, 'ALREADY_RULED', '已裁决的 dispute 不能暂停自动判定时钟');
544
+ }
545
+ if (dispute.status !== 'open' && dispute.status !== 'in_review') {
546
+ return void errorRes(res, 409, 'WRONG_STATUS', `status='${dispute.status}',只能 pause open / in_review`);
547
+ }
548
+ if (!isAssignedArbitrator(disputeId, userId)) {
549
+ return void errorRes(res, 403, 'NOT_ASSIGNED_ARBITRATOR', '仅 assigned_arbitrators 可暂停自动判定时钟');
550
+ }
551
+ // P0 fix:计算 deadline 扩展秒数
552
+ // - 首次 pause:increment = untilTs - now
553
+ // - repause(已经 paused):increment = untilTs - existing_paused_until(可能 < 0,clamp 0)
554
+ // 这样多次 pause 累加正确;repause 缩短无效果(只 audit_log 记)
555
+ const nowSec = Math.floor(Date.now() / 1000);
556
+ const baseline = dispute.auto_judge_paused_until && dispute.auto_judge_paused_until > nowSec
557
+ ? dispute.auto_judge_paused_until
558
+ : nowSec;
559
+ const incrementSec = Math.max(0, untilTs - baseline);
560
+ db.transaction(() => {
561
+ // 扩展 deadline(若 increment > 0)
562
+ let newRespondDeadline = dispute.respond_deadline;
563
+ let newArbitrateDeadline = dispute.arbitrate_deadline;
564
+ if (incrementSec > 0) {
565
+ newRespondDeadline = extendIsoDeadlineBySeconds(dispute.respond_deadline, incrementSec);
566
+ newArbitrateDeadline = extendIsoDeadlineBySeconds(dispute.arbitrate_deadline, incrementSec);
567
+ db.prepare(`UPDATE disputes SET respond_deadline = ?, arbitrate_deadline = ? WHERE id = ?`)
568
+ .run(newRespondDeadline, newArbitrateDeadline, disputeId);
569
+ }
570
+ db.prepare(`UPDATE disputes SET auto_judge_paused_until = ?, auto_judge_pause_reason = ? WHERE id = ?`)
571
+ .run(untilTs, reason, disputeId);
572
+ appendAuditLog(disputeId, {
573
+ event: 'arbitrator_pause_auto_judge',
574
+ actor: userId,
575
+ reason,
576
+ until_ts: untilTs,
577
+ deadline_extended_seconds: incrementSec,
578
+ is_repause: dispute.auto_judge_paused_until !== null && dispute.auto_judge_paused_until > nowSec,
579
+ spec_ref: 'playbook §2.1',
580
+ });
581
+ })();
582
+ res.json({
583
+ success: true,
584
+ dispute_id: disputeId,
585
+ paused_until: untilTs,
586
+ paused_until_iso: new Date(untilTs * 1000).toISOString(),
587
+ max_hours: maxHours,
588
+ deadline_extended_seconds: incrementSec,
589
+ note: incrementSec > 0
590
+ ? `自动判定时钟已冻结,respond/arbitrate deadline 已延后 ${Math.round(incrementSec / 3600)}h。补证据期满或证据齐全后请显式 resume。`
591
+ : 'pause 已记录(repause 缩短无 deadline 变化)。',
592
+ });
593
+ });
594
+ app.post('/api/disputes/:id/arbitrator-resume-auto-judge', (req, res) => {
595
+ const user = auth(req, res);
596
+ if (!user)
597
+ return;
598
+ const userId = user.id;
599
+ const disputeId = req.params.id;
600
+ const dispute = db.prepare(`SELECT id, ruling_type, auto_judge_paused_until FROM disputes WHERE id = ?`).get(disputeId);
601
+ if (!dispute)
602
+ return void errorRes(res, 404, 'NOT_FOUND', 'dispute 不存在');
603
+ if (dispute.ruling_type) {
604
+ return void errorRes(res, 409, 'ALREADY_RULED', '已裁决的 dispute 不需 resume');
605
+ }
606
+ if (!dispute.auto_judge_paused_until) {
607
+ return void errorRes(res, 409, 'NOT_PAUSED', '当前未暂停,无需 resume');
608
+ }
609
+ if (!isAssignedArbitrator(disputeId, userId)) {
610
+ return void errorRes(res, 403, 'NOT_ASSIGNED_ARBITRATOR', '仅 assigned_arbitrators 可 resume');
611
+ }
612
+ db.transaction(() => {
613
+ db.prepare(`UPDATE disputes SET auto_judge_paused_until = NULL, auto_judge_pause_reason = NULL WHERE id = ?`)
614
+ .run(disputeId);
615
+ appendAuditLog(disputeId, {
616
+ event: 'arbitrator_resume_auto_judge',
617
+ actor: userId,
618
+ spec_ref: 'playbook §2.1',
619
+ });
620
+ })();
621
+ res.json({ success: true, dispute_id: disputeId, note: '自动判定时钟已解冻' });
622
+ });
475
623
  }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Run one auto-deactivate sweep. Returns deactivation report for caller
3
+ * (cron caller logs to console; admin endpoint can call directly for query).
4
+ */
5
+ export function runAutoDeactivateSweep(deps) {
6
+ const { db, generateId, getProtocolParam } = deps;
7
+ const thresholdCount = Number(getProtocolParam('governance_auto_deactivate_threshold_count', 5));
8
+ const thresholdPct = Number(getProtocolParam('governance_auto_deactivate_threshold_pct', 0.3));
9
+ const minSample = Number(getProtocolParam('governance_auto_deactivate_min_sample', 10));
10
+ const cooldownDays = Number(getProtocolParam('governance_resign_cooldown_days', 30));
11
+ // Candidates: users with role 'verifier' in users.roles JSON
12
+ // + verifier_stats with tasks_done ≥ min_sample
13
+ // + tasks_wrong ≥ threshold_count
14
+ // + tasks_wrong/tasks_done ≥ threshold_pct
15
+ // (verifier_stats is the source-of-truth for confirmed_wrong; server.ts:5387 increments it
16
+ // on overturn, admin-verifier-flow.ts:130 decrements it on appeal success — exactly the
17
+ // "confirmed_wrong" signal playbook §6.2 requires.)
18
+ const candidates = db.prepare(`
19
+ SELECT u.id AS user_id, u.roles,
20
+ vs.tasks_done, vs.tasks_wrong
21
+ FROM users u
22
+ JOIN verifier_stats vs ON vs.user_id = u.id
23
+ WHERE u.roles IS NOT NULL
24
+ AND u.roles LIKE '%"verifier"%'
25
+ AND vs.tasks_done >= ?
26
+ AND vs.tasks_wrong >= ?
27
+ AND (CAST(vs.tasks_wrong AS REAL) / CAST(vs.tasks_done AS REAL)) >= ?
28
+ `).all(minSample, thresholdCount, thresholdPct);
29
+ const result = { scanned: candidates.length, deactivated: [] };
30
+ const cooldownUntil = Math.floor(Date.now() / 1000) + cooldownDays * 86400;
31
+ for (const c of candidates) {
32
+ // Recheck role inside transaction (idempotency + race safety)
33
+ try {
34
+ let didDeactivate = false;
35
+ db.transaction(() => {
36
+ const u = db.prepare("SELECT roles FROM users WHERE id = ?").get(c.user_id);
37
+ let roles = [];
38
+ try {
39
+ roles = JSON.parse(u?.roles || '[]');
40
+ }
41
+ catch {
42
+ roles = [];
43
+ }
44
+ if (!roles.includes('verifier'))
45
+ return; // already deactivated
46
+ const wrongPct = c.tasks_wrong / c.tasks_done;
47
+ const reason = `confirmed_wrong_count=${c.tasks_wrong}/${c.tasks_done} (${(wrongPct * 100).toFixed(1)}%) ≥ threshold (count=${thresholdCount}, pct=${(thresholdPct * 100).toFixed(0)}%, min_sample=${minSample})`;
48
+ // 1. UPDATE all active rows → inactive
49
+ db.prepare("UPDATE governance_applications SET status = 'inactive' WHERE user_id = ? AND role = 'verifier' AND status = 'active'").run(c.user_id);
50
+ // 2. INSERT auto_deactivate row(audit + appeal source)
51
+ const id = generateId('gapp');
52
+ db.prepare(`
53
+ INSERT INTO governance_applications
54
+ (id, user_id, role, action, status, cooldown_until, appeal_reason)
55
+ VALUES (?, ?, 'verifier', 'auto_deactivate', 'inactive', ?, ?)
56
+ `).run(id, c.user_id, cooldownUntil, reason);
57
+ // Note: appeal_reason field stores the trigger reason (overloaded but keeps schema flat).
58
+ // The user's appeal text(if they file one)goes on a separate appeal row(action='appeal').
59
+ // 3. Remove from users.roles JSON
60
+ const newRoles = roles.filter(r => r !== 'verifier');
61
+ db.prepare("UPDATE users SET roles = ? WHERE id = ?").run(JSON.stringify(newRoles), c.user_id);
62
+ // 4. Notify user with appeal link
63
+ try {
64
+ db.prepare(`INSERT INTO notifications (id, user_id, type, title, body, order_id) VALUES (?,?,?,?,?,?)`)
65
+ .run(generateId('ntf'), c.user_id, 'governance', '⚠️ 你的 verifier 资格已被自动卸任 / Verifier role auto-deactivated', `${reason}\n\nspec docs/GOVERNANCE-ONBOARDING.md §6.2 §7.2:14 天内可在 #governance-me 提交申诉。\n14-day appeal window opens. Submit appeal at #governance-me.`, null);
66
+ }
67
+ catch (_e) { /* notification failure must not block deactivate */ }
68
+ didDeactivate = true;
69
+ result.deactivated.push({
70
+ user_id: c.user_id,
71
+ role: 'verifier',
72
+ tasks_done: c.tasks_done,
73
+ tasks_wrong: c.tasks_wrong,
74
+ wrong_pct: wrongPct,
75
+ reason,
76
+ });
77
+ })();
78
+ void didDeactivate;
79
+ }
80
+ catch (e) {
81
+ console.error('[governance-auto-deactivate] error for user', c.user_id, e);
82
+ }
83
+ }
84
+ return result;
85
+ }
86
+ /**
87
+ * Boot the cron. Registers an interval timer based on the
88
+ * governance_auto_deactivate_cron_hours protocol param value.
89
+ *
90
+ * Does NOT run an immediate sweep on boot — phase A solo maintainer
91
+ * wants to observe explicitly via admin endpoint first.
92
+ */
93
+ export function startAutoDeactivateCron(deps) {
94
+ const hours = Number(deps.getProtocolParam('governance_auto_deactivate_cron_hours', 24));
95
+ const ms = Math.max(1, hours) * 60 * 60 * 1000;
96
+ setInterval(() => {
97
+ try {
98
+ const r = runAutoDeactivateSweep(deps);
99
+ if (r.deactivated.length > 0) {
100
+ console.log(`[gov-auto-deactivate] swept ${r.scanned} candidates, deactivated ${r.deactivated.length}:`, r.deactivated.map(d => `${d.user_id}(${d.tasks_wrong}/${d.tasks_done})`).join(', '));
101
+ }
102
+ }
103
+ catch (e) {
104
+ console.error('[gov-auto-deactivate-cron]', e);
105
+ }
106
+ }, ms);
107
+ console.log(`⚖️ governance auto-deactivate cron 已启动 (每 ${hours}h, anchor=confirmed_wrong per playbook §6.2)`);
108
+ }