feat: Human Gates inbox: one hash-bound review queue to zero #1416

Merged
rockachopa merged 7 commits from timmy/1415-human-gates-inbox-one-hash-bound-review-queue-to into main 2026-08-26 02:09:25 +00:00
14 changed files with 1234 additions and 1 deletions

View File

@ -851,3 +851,20 @@ Checkpoint replacement uses writer-unique, flushed temporary files and an
atomic rename, preventing concurrent writers from colliding or exposing partial atomic rename, preventing concurrent writers from colliding or exposing partial
JSON. Full behavior and safety gates are documented in JSON. Full behavior and safety gates are documented in
[`docs/release-engine-spec.md`](docs/release-engine-spec.md). [`docs/release-engine-spec.md`](docs/release-engine-spec.md).
## Human Gates inbox
Authenticated release producers use `POST /api/v1/human-gates/intake` with an
`Idempotency-Key` and an immutable `"candidate_hash"`; durable account-bound
SQLite storage is configured by `STACKCHAIN_HUMAN_GATE_DB`. Account isolation
binds each queue to the upstream principal ID and login, including its offline
browser cache. Review decisions carry `expected_revision`, a stable idempotency
key across network retries, and return durable receipts. If producer evidence
changes after a release or hold, the update reopens the exact hash for a new
revision-checked decision instead of silently retaining the old outcome.
New hashes mark older pending candidates `superseded` without removing their audit
history. See [`docs/human-gates.md`](docs/human-gates.md) for the complete
producer body, decision API, and **Telegram coalescing contract**. Lock-screen
and Telegram notifications expose count and route only, using
`#/my-work/human-gates`; they never expose project, title, candidate hash,
artifacts, checks, provenance, reasons, or receipts.

39
docs/human-gates.md Normal file
View File

@ -0,0 +1,39 @@
# Human Gates producer and notification contract
Human Gates is an account-bound release-candidate inbox. The canonical mobile route is `#/my-work/human-gates`. Reads may use the last account-scoped browser cache, but Release/Hold decisions require a live authenticated identity and an online server round trip.
## Producer intake
Authenticated producers submit `POST /api/v1/human-gates/intake` with a unique `Idempotency-Key` header and JSON such as:
```json
{
"source": "release-bot",
"project": "stackchain/dashboard",
"candidate_hash": "abc123",
"title": "Dashboard candidate",
"priority": 7,
"artifacts": [{"name": "manifest", "url": "https://forge.example/artifacts/manifest.json"}],
"links": [{"label": "change", "url": "https://forge.example/pulls/1415"}],
"checks": [{"name": "browser", "state": "success", "required": true}],
"score": {"value": 92, "provenance": "release-evaluator/v2"},
"provenance": {"producer": "release-bot", "run_id": "run-9"}
}
```
The immutable identity is authenticated account + `source` + `project` + `candidate_hash`. Retrying the same key and body returns the same gate. Reusing a key for different facts, or redefining an existing candidate hash, returns 409. A newer hash from the same source/project atomically marks older pending candidates `superseded`; detail history remains available for audit. Configure durable storage with `STACKCHAIN_HUMAN_GATE_DB` (default: `$STACKCHAIN_STATE_DIR/human-gates.sqlite3`).
Consumers list `GET /api/v1/human-gates`, inspect `GET /api/v1/human-gates/{id}`, and submit `POST /api/v1/human-gates/{id}/decision` with a new `Idempotency-Key`, `expected_revision`, and either `release` or `hold`. Hold requires a reason. Release requires all three checklist confirmations; if any required check is not successful it also requires an explicit override reason. Durable receipts are available at `GET /api/v1/human-gate-receipts/{receipt_id}`. All endpoints are authenticated, account-bound, and `Cache-Control: no-store`.
## Telegram coalescing contract
Telegram or lock-screen adapters MUST coalesce pending changes per authenticated account and expose **count and route only**:
```json
{
"pending_count": 3,
"route": "#/my-work/human-gates"
}
```
The notification text may say “3 Human Gates pending” and provide the route. It MUST NOT include project names, candidate hash values, titles, artifact/link URLs, checks, scores, provenance, decision history, superseded candidate facts, reasons, or receipts. Multiple intake/supersession events before delivery replace the pending notification with the latest count rather than emitting one message per candidate. A transition to zero clears the outstanding notification; it does not send candidate detail. Producers and adapters must fetch current state after authenticated route open rather than treating notification delivery as an action authorization.

View File

@ -1612,3 +1612,7 @@ textarea { resize: vertical; min-height: 120px; }
.create-pull-mode label{display:flex;align-items:center;gap:7px} .create-pull-mode label{display:flex;align-items:center;gap:7px}
.create-pull-panel button,.create-pull-panel select,.create-pull-panel input{min-height:44px} .create-pull-panel button,.create-pull-panel select,.create-pull-panel input{min-height:44px}
@media(max-width:600px){.create-pull-sheet{padding:0}.create-pull-panel{width:100%;max-height:100dvh;border-radius:18px 18px 0 0}.create-pull-branches{grid-template-columns:1fr}} @media(max-width:600px){.create-pull-sheet{padding:0}.create-pull-panel{width:100%;max-height:100dvh;border-radius:18px 18px 0 0}.create-pull-branches{grid-template-columns:1fr}}
.human-gates{position:fixed;inset:0;z-index:72;background:var(--bg);overflow:auto;padding:18px max(16px,env(safe-area-inset-right)) max(24px,env(safe-area-inset-bottom)) max(16px,env(safe-area-inset-left))}
.human-gates[hidden]{display:none}.human-gates-header{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;max-width:760px;margin:0 auto 14px}.human-gates-header h3{margin:0}.human-gates-list,.human-gate-detail-host{display:grid;gap:10px;max-width:760px;margin:0 auto 14px}.human-gate-card{display:grid;grid-template-columns:1fr auto;text-align:left;gap:6px 12px;min-height:58px;padding:12px;border:1px solid var(--border);border-radius:14px;background:var(--panel)}.human-gate-card span{grid-column:1/-1;color:var(--muted)}.human-gate-detail{display:grid;gap:12px;padding:16px;border:1px solid var(--border);border-radius:16px;background:var(--panel)}.human-gate-detail h3,.human-gate-detail h4,.human-gate-detail p{margin:0}.human-gate-detail label{display:grid;gap:6px}.human-gate-detail label:has(input[type=checkbox]){grid-template-columns:auto 1fr;align-items:center}.human-gate-detail textarea{min-height:78px}.human-gate-detail>div{display:grid;grid-template-columns:1fr 1fr;gap:10px}.human-gates-zero{display:grid;gap:6px;text-align:center;padding:32px 16px;border:1px dashed var(--border);border-radius:16px}.human-gates-launcher span{display:inline-grid;place-items:center;min-width:22px;border-radius:999px;background:var(--accent);color:#06101f}
@media(min-width:761px){.human-gates{inset:8% max(8%,80px);border:1px solid var(--border);border-radius:20px;box-shadow:0 24px 80px rgba(0,0,0,.4)}}

View File

@ -392,6 +392,7 @@
let editingOutboxId = null; let editingOutboxId = null;
let confirmedOwnerLogin = ''; let confirmedOwnerLogin = '';
let planningOwnerLogin = ''; let planningOwnerLogin = '';
let planningOwnerAccountKey = '';
let activeFlushLogin = ''; let activeFlushLogin = '';
let rR = null; let rR = null;
function rRC() { function rRC() {
@ -634,6 +635,46 @@
return payload; return payload;
} }
const humanGates = createHumanGates({
storage:localStorage,
getLogin:()=>planningOwnerLogin,
getAccountKey:()=>planningOwnerAccountKey,
isOnline:()=>navigator.onLine,
location:window.location,
fetchJson:fetchReviewJson,
nodes:{
count:qs('#human-gates-count'), list:qs('#human-gates-list'),
status:qs('#human-gates-status'), panel:qs('#human-gates'),
detail:qs('#human-gate-detail'),
},
});
const openHumanGates = () => humanGates.open().catch(error => {
qs('#human-gates-status').textContent = error.message || 'Human Gates are unavailable.';
});
qs('#open-human-gates').addEventListener('click', openHumanGates);
qs('#close-human-gates').addEventListener('click', () => {
qs('#human-gates').hidden = true;
if (window.location.hash === '#/my-work/human-gates') window.history.replaceState({}, '', '#/my-work');
});
qs('#human-gates-list').addEventListener('click', event => {
const card = event.target.closest('[data-human-gate-id]');
if (!card) return;
humanGates.select(card.dataset.humanGateId);
});
qs('#human-gate-detail').addEventListener('click', event => {
const decision = event.target.closest('[data-gate-decision]')?.dataset.gateDecision;
if (!decision) return;
const detail = qs('#human-gate-detail');
const checklist = Object.fromEntries(Array.from(detail.querySelectorAll('[data-gate-checklist]')).map(input => [input.dataset.gateChecklist, input.checked]));
humanGates.decideAndNext(decision, {
checklist,
reason:detail.querySelector('[data-gate-reason]')?.value || '',
override_reason:detail.querySelector('[data-gate-override]')?.value || '',
}).catch(error => { qs('#human-gates-status').textContent = error.message; });
});
humanGates.load().catch(() => {});
if (window.location.hash === '#/my-work/human-gates') openHumanGates();
function syncCompletedFiledReviews() { function syncCompletedFiledReviews() {
if (!planningOwnerLogin) return Promise.resolve(false); if (!planningOwnerLogin) return Promise.resolve(false);
if (completedFiledSyncFlight) return completedFiledSyncFlight; if (completedFiledSyncFlight) return completedFiledSyncFlight;
@ -5526,6 +5567,9 @@
const retainedPlanningLogin = !snapshot.context.error ? const retainedPlanningLogin = !snapshot.context.error ?
String(snapshot.context.user?.login || '').trim() : ''; String(snapshot.context.user?.login || '').trim() : '';
planningOwnerLogin = retainedPlanningLogin; planningOwnerLogin = retainedPlanningLogin;
planningOwnerAccountKey = retainedPlanningLogin && snapshot.context.user?.id ?
String(snapshot.context.user.id) + ':' + retainedPlanningLogin : '';
timerView.render();
updatePlanningAvailability(); updatePlanningAvailability();
if (planningOwnerLogin) { if (planningOwnerLogin) {
syncPendingTomorrow(); syncPendingTomorrow();
@ -7843,6 +7887,8 @@
confirmedOwnerLogin = String(saved.user?.login || '').trim(); confirmedOwnerLogin = String(saved.user?.login || '').trim();
restoreReleaseReceipt(); restoreReleaseReceipt();
planningOwnerLogin = confirmedOwnerLogin; planningOwnerLogin = confirmedOwnerLogin;
planningOwnerAccountKey = confirmedOwnerLogin && saved.user?.id ?
String(saved.user.id) + ':' + confirmedOwnerLogin : '';
interruptionPrompt.restore(); interruptionPrompt.restore();
updatePlanningAvailability(); updatePlanningAvailability();
syncPendingTomorrow(); syncPendingTomorrow();

207
frontend/human-gates.js Normal file
View File

@ -0,0 +1,207 @@
function createHumanGates(options = {}) {
const storage = options.storage || window.localStorage;
const getLogin = options.getLogin || (() => '');
const getAccountKey = options.getAccountKey || getLogin;
const isOnline = options.isOnline || (() => navigator.onLine);
const location = options.location || window.location;
const fetchJson = options.fetchJson;
const nodes = options.nodes || {};
let queue = { pending_count: 0, items: [] };
let reviewSnapshot = [];
let reviewIndex = -1;
let loadedAccountKey = '';
let loadEpoch = 0;
const decisionKeys = new Map();
let decisionFlight = null;
const escape = value => String(value ?? '').replace(/[&<>"']/g, character => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;',
})[character]);
const cacheKey = () => 'stackchain.human-gates.v1:' + String(getAccountKey() || '').trim().toLowerCase();
const setText = (node, value) => { if (node) node.textContent = value; };
const setHtml = (node, value) => { if (node) node.innerHTML = value; };
function validSnapshot(value) {
return value && Number.isInteger(value.pending_count) && Array.isArray(value.items) ? value : null;
}
function restore() {
if (!getLogin()) return null;
try { return validSnapshot(JSON.parse(storage.getItem(cacheKey()) || 'null')); } catch (_) { return null; }
}
function save(value) {
if (!getLogin()) return;
try { storage.setItem(cacheKey(), JSON.stringify(value)); } catch (_) {}
}
function render() {
setText(nodes.count, String(queue.pending_count));
if (!queue.pending_count) {
setHtml(nodes.list, '<div class="human-gates-zero"><strong>Inbox zero</strong><span>No release candidates need your decision.</span></div>');
setText(nodes.status, 'Human Gates inbox zero.');
return;
}
setText(nodes.status, queue.pending_count + (queue.pending_count === 1 ? ' gate pending.' : ' gates pending.'));
setHtml(nodes.list, queue.items.map(item =>
'<button class="human-gate-card" type="button" data-human-gate-id="' + escape(item.id) + '">' +
'<strong>' + escape(item.title) + '</strong><code>' + escape(item.candidate_hash) + '</code>' +
'<span>Priority ' + escape(item.priority ?? 0) + '</span></button>'
).join(''));
}
function renderDetail(item) {
if (!item) {
setHtml(nodes.detail, '<div class="human-gates-zero"><strong>Inbox zero</strong><span>Fixed review snapshot complete.</span></div>');
return;
}
const checks = (item.checks || []).map(check =>
'<li class="gate-check gate-check-' + escape(check.state) + '"><strong>' + escape(check.name) + '</strong> · ' + escape(check.state) + (check.required ? ' · required' : '') + '</li>'
).join('');
const artifacts = (item.artifacts || []).map(artifact => '<li><a href="' + escape(artifact.url) + '" rel="noreferrer">' + escape(artifact.name) + '</a></li>').join('');
const links = (item.links || []).map(link => '<li><a href="' + escape(link.url) + '" rel="noreferrer">' + escape(link.label) + '</a></li>').join('');
const provenance = Object.entries(item.provenance || {}).map(([key, value]) => '<li><strong>' + escape(key) + '</strong> · ' + escape(value) + '</li>').join('');
const history = (item.history || []).map(event => '<li><strong>' + escape(event.action) + '</strong> · ' + escape(event.at) + '</li>').join('');
setHtml(nodes.detail,
'<article class="human-gate-detail"><h3>' + escape(item.title) + '</h3>' +
'<p>Project <strong>' + escape(item.project) + '</strong></p>' +
'<p>Exact candidate <code>' + escape(item.candidate_hash) + '</code></p>' +
'<p>Score ' + escape(item.score?.value ?? 'not supplied') + ' · ' + escape(item.score?.provenance || '') + '</p>' +
'<h4>Artifacts</h4><ul>' + artifacts + '</ul><h4>Links</h4><ul>' + links + '</ul>' +
'<h4>Checks</h4><ul>' + checks + '</ul><h4>Provenance</h4><ul>' + provenance + '</ul>' +
'<h4>History</h4><ul>' + history + '</ul>' +
'<label><input type="checkbox" data-gate-checklist="exact_hash"> Exact hash reviewed</label>' +
'<label><input type="checkbox" data-gate-checklist="artifacts_reviewed"> Artifacts reviewed</label>' +
'<label><input type="checkbox" data-gate-checklist="provenance_reviewed"> Provenance reviewed</label>' +
'<label>Hold reason<textarea data-gate-reason></textarea></label>' +
'<label>Override reason<textarea data-gate-override></textarea></label>' +
'<div><button type="button" data-gate-decision="release">Release &amp; next</button>' +
'<button type="button" data-gate-decision="hold">Hold &amp; next</button></div></article>'
);
}
async function load() {
const epoch = ++loadEpoch;
const accountKey = String(getAccountKey() || '').trim().toLowerCase();
if (accountKey !== loadedAccountKey) {
loadedAccountKey = accountKey;
queue = { pending_count: 0, items: [] };
reviewSnapshot = [];
reviewIndex = -1;
render();
}
const cached = restore();
if (cached) { queue = cached; render(); }
try {
const live = validSnapshot(await fetchJson('api/v1/human-gates'));
if (epoch !== loadEpoch || accountKey !== loadedAccountKey) return queue;
if (!live) throw new Error('Human Gates response is invalid.');
queue = { pending_count: live.pending_count, items: live.items.slice() };
save(queue);
render();
return queue;
} catch (error) {
if (epoch !== loadEpoch || accountKey !== loadedAccountKey) return queue;
if (!cached) throw error;
setText(nodes.status, 'Offline cached gate list · reconnect before deciding.');
return queue;
}
}
function reviewNext() {
if (reviewIndex < 0) {
reviewSnapshot = queue.items.slice();
reviewIndex = 0;
}
const item = reviewSnapshot[reviewIndex] || null;
renderDetail(item);
if (item) {
const index = reviewIndex;
fetchJson('api/v1/human-gates/' + encodeURIComponent(item.id)).then(detail => {
if (!detail || detail.id !== item.id) throw new Error('Gate detail is invalid.');
if (reviewIndex !== index || reviewSnapshot[index]?.id !== item.id) return;
reviewSnapshot[index] = detail;
renderDetail(detail);
}).catch(() => { setText(nodes.status, 'Gate detail is unavailable. Retry while online.'); });
}
return item;
}
function select(gateId) {
if (reviewIndex < 0) reviewSnapshot = queue.items.slice();
const index = reviewSnapshot.findIndex(item => item.id === gateId);
if (index < 0) throw new Error('Gate is not in the current review snapshot.');
reviewIndex = index;
return reviewNext();
}
function current() { return reviewIndex < 0 ? null : (reviewSnapshot[reviewIndex] || null); }
function idempotencyKey(item, decision, payload) {
const operation = item.id + ':' + item.revision + ':' + decision + ':' + JSON.stringify(payload);
if (decisionKeys.has(operation)) return { operation, key: decisionKeys.get(operation) };
const nonce = globalThis.crypto?.randomUUID?.() || (Date.now().toString(36) + '-' + Math.random().toString(36).slice(2));
const key = 'human-gate:' + item.id + ':' + item.revision + ':' + decision + ':' + nonce;
decisionKeys.set(operation, key);
return { operation, key };
}
async function decideAndNext(decision, values = {}) {
const item = current();
if (!item) throw new Error('No gate is selected.');
if (!isOnline()) throw new Error('Human Gate decisions require an online connection.');
if (!String(getLogin() || '').trim()) throw new Error('Authenticated account identity is required.');
const checklist = values.checklist || {};
const complete = ['exact_hash', 'artifacts_reviewed', 'provenance_reviewed'].every(key => checklist[key] === true);
if (decision === 'release' && !complete) throw new Error('Complete the release checklist before deciding.');
const unmet = (item.checks || []).filter(check => check.required && check.state !== 'success');
if (decision === 'release' && unmet.length && !String(values.override_reason || '').trim()) {
throw new Error('An explicit override reason is required for unmet required checks.');
}
if (decision === 'hold' && !String(values.reason || '').trim()) throw new Error('A hold reason is required.');
const payload = {
expected_revision: item.revision, decision,
reason: String(values.reason || '').trim(),
override_reason: String(values.override_reason || '').trim(), checklist,
};
if (decisionFlight) return decisionFlight;
const operation = (async () => {
const decisionKey = idempotencyKey(item, decision, payload);
const receipt = await fetchJson('api/v1/human-gates/' + encodeURIComponent(item.id) + '/decision', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Idempotency-Key': decisionKey.key },
body: JSON.stringify(payload),
});
decisionKeys.delete(decisionKey.operation);
queue.items = queue.items.filter(candidate => candidate.id !== item.id);
queue.pending_count = Math.max(0, queue.pending_count - 1);
save(queue); render();
reviewIndex += 1;
const next = current();
if (next) reviewNext(); else renderDetail(null);
setText(nodes.status, next ? (decision === 'release' ? 'Released. Reviewing next gate.' : 'Held. Reviewing next gate.') : 'Decision saved. Human Gates review snapshot complete.');
return { receipt, next };
})();
decisionFlight = operation;
try {
return await operation;
} finally {
if (decisionFlight === operation) decisionFlight = null;
}
}
function open() {
location.hash = '#/my-work/human-gates';
if (nodes.panel) nodes.panel.hidden = false;
return load().then(() => reviewNext());
}
return {
load, open, reviewNext, select, decideAndNext, current,
restoreCached: restore,
snapshot: () => JSON.parse(JSON.stringify(queue)),
route: () => location.hash,
};
}
if (typeof module !== 'undefined') module.exports = createHumanGates;
if (typeof window !== 'undefined') window.createHumanGates = createHumanGates;

View File

@ -187,7 +187,16 @@
<button class="end-today-session" id="end-today-session" type="button" hidden>End session</button> <button class="end-today-session" id="end-today-session" type="button" hidden>End session</button>
<button class="find-work-action" id="find-work" type="button">Find work</button> <button class="find-work-action" id="find-work" type="button">Find work</button>
<button class="new-issue" id="new-issue" type="button">New issue</button> <button class="new-issue" id="new-issue" type="button">New issue</button>
<button class="human-gates-launcher" id="open-human-gates" type="button">Review next <span id="human-gates-count">0</span></button>
</div> </div>
<section id="human-gates" class="human-gates" aria-labelledby="human-gates-heading" hidden>
<div class="human-gates-header">
<div><h3 id="human-gates-heading">Human Gates</h3><p id="human-gates-status" class="small" role="status" aria-live="polite"></p></div>
<button id="close-human-gates" type="button">Close</button>
</div>
<div id="human-gates-list" class="human-gates-list"></div>
<div id="human-gate-detail" class="human-gate-detail-host"></div>
</section>
<details class="work-settings"> <details class="work-settings">
<summary id="work-settings-toggle">Queue &amp; settings · <span id="active-work-queue">All</span></summary> <summary id="work-settings-toggle">Queue &amp; settings · <span id="active-work-queue">All</span></summary>
<div class="work-settings-panel"> <div class="work-settings-panel">
@ -2401,6 +2410,7 @@
<script src="static/mobile-plan-today-nav.js"></script> <script src="static/mobile-plan-today-nav.js"></script>
<script src="static/mobile-find-work-nav.js"></script> <script src="static/mobile-find-work-nav.js"></script>
<script src="static/workspace-bootstrap.js"></script> <script src="static/workspace-bootstrap.js"></script>
<script src="static/human-gates.js"></script>
<script src="static/dashboard.js"></script> <script src="static/dashboard.js"></script>
</body> </body>
</html> </html>

View File

@ -151,6 +151,7 @@ const SHELL = [
BASE + 'manifest.webmanifest', BASE + 'manifest.webmanifest',
BASE + 'static/dashboard.css', BASE + 'static/dashboard.css',
BASE + 'static/dashboard.js', BASE + 'static/dashboard.js',
BASE + 'static/human-gates.js',
BASE + 'static/icons/stackchain-192.png', BASE + 'static/icons/stackchain-192.png',
BASE + 'static/icons/stackchain-512.png', BASE + 'static/icons/stackchain-512.png',
BASE + 'static/session.js', BASE + 'static/session.js',

330
src/human_gate_store.py Normal file
View File

@ -0,0 +1,330 @@
"""Durable, account-bound human release gate inbox."""
from __future__ import annotations
import hashlib
import json
import re
import sqlite3
import uuid
from pathlib import Path
from typing import Callable
from src.private_state import connect_private_sqlite
_HASH = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._:-]{1,127}$")
_PROJECT = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
_STATES = {"pending", "released", "held", "superseded"}
_CHECKLIST = {"exact_hash", "artifacts_reviewed", "provenance_reviewed"}
class GateConflict(RuntimeError):
"""The gate or idempotency revision no longer matches."""
class GateValidationError(ValueError):
"""The producer or reviewer payload is not safe to persist."""
class GateNotFound(LookupError):
"""No gate exists for this account."""
def _canonical(value: object) -> str:
return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False)
def _fingerprint(value: object) -> str:
return hashlib.sha256(_canonical(value).encode()).hexdigest()
class HumanGateStore:
def __init__(self, path: str | Path, *, clock: Callable[[], float]):
self.path = Path(path)
self.clock = clock
self._initialize()
def _connect(self) -> sqlite3.Connection:
connection = connect_private_sqlite(self.path, timeout=2.0)
connection.row_factory = sqlite3.Row
return connection
def _initialize(self) -> None:
with self._connect() as connection:
connection.execute("PRAGMA journal_mode=WAL")
connection.executescript(
"""
CREATE TABLE IF NOT EXISTS human_gates (
id TEXT PRIMARY KEY, login TEXT NOT NULL, source TEXT NOT NULL,
project TEXT NOT NULL, candidate_hash TEXT NOT NULL,
title TEXT NOT NULL, priority INTEGER NOT NULL,
payload_json TEXT NOT NULL, state TEXT NOT NULL,
revision INTEGER NOT NULL, created_at REAL NOT NULL,
updated_at REAL NOT NULL, superseded_by TEXT,
decision_reason TEXT NOT NULL DEFAULT '',
override_reason TEXT NOT NULL DEFAULT '',
checklist_json TEXT NOT NULL DEFAULT '{}',
UNIQUE(login, source, project, candidate_hash)
);
CREATE INDEX IF NOT EXISTS human_gates_queue
ON human_gates(login, state, priority DESC, created_at, id);
CREATE TABLE IF NOT EXISTS human_gate_intake_keys (
login TEXT NOT NULL, idempotency_key TEXT NOT NULL,
fingerprint TEXT NOT NULL, gate_id TEXT NOT NULL,
PRIMARY KEY(login, idempotency_key)
);
CREATE TABLE IF NOT EXISTS human_gate_history (
sequence INTEGER PRIMARY KEY AUTOINCREMENT, gate_id TEXT NOT NULL,
action TEXT NOT NULL, at REAL NOT NULL, details_json TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS human_gate_receipts (
receipt_id TEXT PRIMARY KEY, login TEXT NOT NULL,
idempotency_key TEXT NOT NULL, fingerprint TEXT NOT NULL,
gate_id TEXT NOT NULL, receipt_json TEXT NOT NULL,
UNIQUE(login, idempotency_key)
);
"""
)
@staticmethod
def _login(value: str) -> str:
value = str(value).strip().lower()
if not value or len(value) > 255:
raise GateValidationError("login is required")
return value
@staticmethod
def _key(value: str) -> str:
value = str(value).strip()
if not value or len(value) > 128:
raise GateValidationError("Idempotency key is required")
return value
@staticmethod
def _candidate(raw: dict) -> dict:
if not isinstance(raw, dict):
raise GateValidationError("Candidate must be an object")
source = str(raw.get("source", "")).strip()
project = str(raw.get("project", "")).strip()
candidate_hash = str(raw.get("candidate_hash", "")).strip()
title = " ".join(str(raw.get("title", "")).split())
priority = raw.get("priority", 0)
if not source or len(source) > 128:
raise GateValidationError("source is invalid")
if not _PROJECT.fullmatch(project):
raise GateValidationError("project is invalid")
if not _HASH.fullmatch(candidate_hash):
raise GateValidationError("candidate hash is invalid")
if not title or len(title) > 300:
raise GateValidationError("title is invalid")
if isinstance(priority, bool) or not isinstance(priority, int) or not 0 <= priority <= 100:
raise GateValidationError("priority is invalid")
normalized = {
"source": source, "project": project, "candidate_hash": candidate_hash,
"title": title, "priority": priority,
"artifacts": HumanGateStore._references(raw.get("artifacts", []), "name"),
"links": HumanGateStore._references(raw.get("links", []), "label"),
"checks": HumanGateStore._checks(raw.get("checks", [])),
"score": raw.get("score") if isinstance(raw.get("score"), dict) else {},
"provenance": raw.get("provenance") if isinstance(raw.get("provenance"), dict) else {},
}
if len(_canonical(normalized)) > 100_000:
raise GateValidationError("Candidate payload is too large")
return normalized
@staticmethod
def _references(raw: object, label: str) -> list[dict]:
if not isinstance(raw, list) or len(raw) > 50:
raise GateValidationError("references are invalid")
result = []
for item in raw:
if not isinstance(item, dict):
raise GateValidationError("reference is invalid")
name, url = str(item.get(label, "")).strip(), str(item.get("url", "")).strip()
if not name or len(name) > 200 or not url.startswith("https://") or len(url) > 2048:
raise GateValidationError("reference is invalid")
result.append({label: name, "url": url})
return result
@staticmethod
def _checks(raw: object) -> list[dict]:
if not isinstance(raw, list) or len(raw) > 100:
raise GateValidationError("checks are invalid")
result = []
for item in raw:
if not isinstance(item, dict):
raise GateValidationError("check is invalid")
name, state = str(item.get("name", "")).strip(), item.get("state")
if not name or len(name) > 200 or state not in {"success", "failure", "pending", "skipped"}:
raise GateValidationError("check is invalid")
result.append({"name": name, "state": state, "required": bool(item.get("required", True))})
return result
def _history(self, connection: sqlite3.Connection, gate_id: str) -> list[dict]:
return [
{"sequence": row[0], "action": row[1], "at": row[2], **json.loads(row[3])}
for row in connection.execute(
"SELECT sequence, action, at, details_json FROM human_gate_history WHERE gate_id=? ORDER BY sequence",
(gate_id,),
)
]
def _present(self, connection: sqlite3.Connection, row: sqlite3.Row, *, history: bool = False) -> dict:
payload = json.loads(row["payload_json"])
item = {
"id": row["id"], **payload, "state": row["state"], "revision": row["revision"],
"created_at": row["created_at"], "updated_at": row["updated_at"],
"superseded_by": row["superseded_by"],
}
if row["state"] in {"released", "held"}:
item.update({
"reason": row["decision_reason"], "override_reason": row["override_reason"],
"checklist": json.loads(row["checklist_json"]),
})
if history:
item["history"] = self._history(connection, row["id"])
return item
def has_intake_key(self, login: str, idempotency_key: str) -> bool:
with self._connect() as connection:
return connection.execute(
"SELECT 1 FROM human_gate_intake_keys WHERE login=? AND idempotency_key=?",
(self._login(login), self._key(idempotency_key)),
).fetchone() is not None
def intake(self, login: str, raw: dict, *, idempotency_key: str) -> dict:
login, key, payload = self._login(login), self._key(idempotency_key), self._candidate(raw)
fingerprint = _fingerprint(payload)
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
prior = connection.execute(
"SELECT fingerprint, gate_id FROM human_gate_intake_keys WHERE login=? AND idempotency_key=?",
(login, key),
).fetchone()
if prior:
if prior["fingerprint"] != fingerprint:
raise GateConflict("Idempotency key was already used for another candidate")
row = connection.execute("SELECT * FROM human_gates WHERE id=? AND login=?", (prior["gate_id"], login)).fetchone()
return self._present(connection, row, history=True)
existing = connection.execute(
"SELECT * FROM human_gates WHERE login=? AND source=? AND project=? AND candidate_hash=?",
(login, payload["source"], payload["project"], payload["candidate_hash"]),
).fetchone()
if existing:
old_payload = json.loads(existing["payload_json"])
immutable_old = {key: value for key, value in old_payload.items() if key != "checks"}
immutable_new = {key: value for key, value in payload.items() if key != "checks"}
if immutable_old != immutable_new:
raise GateConflict("Candidate hash is already bound to different facts")
if old_payload != payload:
if existing["state"] == "superseded":
raise GateConflict("Candidate hash was superseded by a newer candidate")
now = float(self.clock())
revision = existing["revision"] + 1
reopened = existing["state"] in {"released", "held"}
state = "pending" if reopened else existing["state"]
action = "reopened" if reopened else "updated"
connection.execute(
"UPDATE human_gates SET payload_json=?, state=?, revision=?, updated_at=?, decision_reason='', override_reason='', checklist_json='{}' WHERE id=?",
(_canonical(payload), state, revision, now, existing["id"]),
)
connection.execute(
"INSERT INTO human_gate_history(gate_id,action,at,details_json) VALUES (?,?,?,?)",
(existing["id"], action, now, _canonical({"candidate_hash": payload["candidate_hash"]})),
)
existing = connection.execute(
"SELECT * FROM human_gates WHERE id=? AND login=?",
(existing["id"], login),
).fetchone()
connection.execute("INSERT INTO human_gate_intake_keys VALUES (?,?,?,?)", (login, key, fingerprint, existing["id"]))
return self._present(connection, existing, history=True)
now, gate_id = float(self.clock()), str(uuid.uuid4())
old_rows = connection.execute(
"SELECT id, revision FROM human_gates WHERE login=? AND source=? AND project=? AND state='pending'",
(login, payload["source"], payload["project"]),
).fetchall()
connection.execute(
"INSERT INTO human_gates(id,login,source,project,candidate_hash,title,priority,payload_json,state,revision,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?, 'pending',1,?,?)",
(gate_id, login, payload["source"], payload["project"], payload["candidate_hash"], payload["title"], payload["priority"], _canonical(payload), now, now),
)
connection.execute("INSERT INTO human_gate_history(gate_id,action,at,details_json) VALUES (?,?,?,?)", (gate_id, "intake", now, _canonical({"candidate_hash": payload["candidate_hash"]})))
for old in old_rows:
connection.execute("UPDATE human_gates SET state='superseded', revision=?, updated_at=?, superseded_by=? WHERE id=? AND state='pending'", (old["revision"] + 1, now, gate_id, old["id"]))
connection.execute("INSERT INTO human_gate_history(gate_id,action,at,details_json) VALUES (?,?,?,?)", (old["id"], "superseded", now, _canonical({"superseded_by": gate_id, "candidate_hash": payload["candidate_hash"]})))
connection.execute("INSERT INTO human_gate_intake_keys VALUES (?,?,?,?)", (login, key, fingerprint, gate_id))
row = connection.execute("SELECT * FROM human_gates WHERE id=?", (gate_id,)).fetchone()
return self._present(connection, row, history=True)
def list(self, login: str, *, state: str = "pending", limit: int = 100) -> dict:
login = self._login(login)
if state not in _STATES and state != "all":
raise GateValidationError("state is invalid")
limit = min(max(int(limit), 1), 100)
with self._connect() as connection:
pending_count = connection.execute("SELECT COUNT(*) FROM human_gates WHERE login=? AND state='pending'", (login,)).fetchone()[0]
where, args = ("login=?", [login]) if state == "all" else ("login=? AND state=?", [login, state])
rows = connection.execute(f"SELECT * FROM human_gates WHERE {where} ORDER BY CASE WHEN state='pending' THEN 0 ELSE 1 END, priority DESC, created_at, id LIMIT ?", (*args, limit)).fetchall()
return {"pending_count": pending_count, "items": [self._present(connection, row) for row in rows]}
def detail(self, login: str, gate_id: str) -> dict:
with self._connect() as connection:
row = connection.execute("SELECT * FROM human_gates WHERE login=? AND id=?", (self._login(login), gate_id)).fetchone()
if row is None:
raise GateNotFound("Gate not found")
return self._present(connection, row, history=True)
def decide(self, login: str, gate_id: str, *, expected_revision: int, decision: str, reason: str, override_reason: str, checklist: dict, idempotency_key: str) -> dict:
login, key = self._login(login), self._key(idempotency_key)
reason, override_reason = str(reason).strip(), str(override_reason).strip()
if decision not in {"release", "hold"}:
raise GateValidationError("decision is invalid")
if decision == "hold" and not reason:
raise GateValidationError("Hold reason is required")
if decision == "release" and (
not isinstance(checklist, dict)
or set(checklist) != _CHECKLIST
or not all(value is True for value in checklist.values())
):
raise GateValidationError("A complete release checklist is required")
if decision == "hold":
checklist = {}
request = {"gate_id": gate_id, "expected_revision": expected_revision, "decision": decision, "reason": reason, "override_reason": override_reason, "checklist": checklist}
fingerprint = _fingerprint(request)
with self._connect() as connection:
connection.execute("BEGIN IMMEDIATE")
prior = connection.execute("SELECT fingerprint, receipt_json FROM human_gate_receipts WHERE login=? AND idempotency_key=?", (login, key)).fetchone()
if prior:
if prior["fingerprint"] != fingerprint:
raise GateConflict("Idempotency key was already used for another decision")
return json.loads(prior["receipt_json"])
row = connection.execute("SELECT * FROM human_gates WHERE login=? AND id=?", (login, gate_id)).fetchone()
if row is None:
raise GateNotFound("Gate not found")
if row["state"] != "pending" or row["revision"] != expected_revision:
raise GateConflict("Gate revision is stale")
payload = json.loads(row["payload_json"])
unmet = [check["name"] for check in payload["checks"] if check["required"] and check["state"] != "success"]
if decision == "release" and unmet and not override_reason:
raise GateValidationError("An explicit override reason is required for unmet checks")
now, receipt_id, revision = float(self.clock()), str(uuid.uuid4()), row["revision"] + 1
state = "released" if decision == "release" else "held"
connection.execute("UPDATE human_gates SET state=?,revision=?,updated_at=?,decision_reason=?,override_reason=?,checklist_json=? WHERE id=?", (state, revision, now, reason, override_reason, _canonical(checklist), gate_id))
receipt = {"receipt_id": receipt_id, "gate_id": gate_id, "candidate_hash": row["candidate_hash"], "state": state, "revision": revision, "decided_at": now, "reason": reason, "override_reason": override_reason, "checklist": checklist, "unmet_required_checks": unmet}
connection.execute("INSERT INTO human_gate_history(gate_id,action,at,details_json) VALUES (?,?,?,?)", (gate_id, state, now, _canonical({"receipt_id": receipt_id, "reason": reason, "override_reason": override_reason, "unmet_required_checks": unmet})))
connection.execute("INSERT INTO human_gate_receipts VALUES (?,?,?,?,?,?)", (receipt_id, login, key, fingerprint, gate_id, _canonical(receipt)))
return receipt
def has_receipt_key(self, login: str, idempotency_key: str) -> bool:
with self._connect() as connection:
return connection.execute(
"SELECT 1 FROM human_gate_receipts WHERE login=? AND idempotency_key=?",
(self._login(login), self._key(idempotency_key)),
).fetchone() is not None
def receipt(self, login: str, receipt_id: str) -> dict:
with self._connect() as connection:
row = connection.execute("SELECT receipt_json FROM human_gate_receipts WHERE login=? AND receipt_id=?", (self._login(login), receipt_id)).fetchone()
if row is None:
raise GateNotFound("Receipt not found")
return json.loads(row[0])

View File

@ -42,6 +42,12 @@ from src.gitea_proxy import (
repos, repos,
) )
from src.idempotency import IdempotencyLedger, IdempotencyLedgerBusy from src.idempotency import IdempotencyLedger, IdempotencyLedgerBusy
from src.human_gate_store import (
GateConflict,
GateNotFound,
GateValidationError,
HumanGateStore,
)
from src.image_sanitizer import sanitize_image from src.image_sanitizer import sanitize_image
from src.login_attempt_store import LoginAttemptStore, LoginAttemptStoreError, client_source from src.login_attempt_store import LoginAttemptStore, LoginAttemptStoreError, client_source
from src.live_snapshot_store import ( from src.live_snapshot_store import (
@ -441,6 +447,14 @@ class ReadinessPayloadError(ValueError):
"""Raised when Gitea returns a structurally invalid readiness payload.""" """Raised when Gitea returns a structurally invalid readiness payload."""
class HumanGateDecision(BaseModel):
expected_revision: PositiveInt
decision: Literal["release", "hold"]
reason: str = Field(default="", max_length=2_000)
override_reason: str = Field(default="", max_length=2_000)
checklist: dict[str, bool]
async def _upstream_identity() -> tuple[int, str]: async def _upstream_identity() -> tuple[int, str]:
upstream = await current_user() upstream = await current_user()
principal_id = upstream.get("id") if isinstance(upstream, dict) else None principal_id = upstream.get("id") if isinstance(upstream, dict) else None
@ -589,6 +603,17 @@ class PasskeyAuthorization(PasskeyCeremony, PasskeyAuthorizationTarget):
pass pass
def _human_gate_store() -> HumanGateStore:
state_dir = os.getenv("STACKCHAIN_STATE_DIR", ".stackchain-state")
return HumanGateStore(
os.getenv(
"STACKCHAIN_HUMAN_GATE_DB",
os.path.join(state_dir, "human-gates.sqlite3"),
),
clock=time.time,
)
def _passkey_store() -> PasskeyStore: def _passkey_store() -> PasskeyStore:
state_dir = os.getenv("STACKCHAIN_STATE_DIR", ".stackchain-state") state_dir = os.getenv("STACKCHAIN_STATE_DIR", ".stackchain-state")
database = os.getenv( database = os.getenv(
@ -1719,7 +1744,7 @@ async def require_operator_session(request: Request, call_next):
async def prevent_live_api_caching(request, call_next): async def prevent_live_api_caching(request, call_next):
response = await call_next(request) response = await call_next(request)
path = dashboard_auth.application_path(request) path = dashboard_auth.application_path(request)
if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/tomorrow", "/api/v1/tomorrow/promote", "/api/v1/week", "/api/v1/week/promote", "/api/v1/week/start-early", "/api/v1/week/reconcile", "/api/v1/week/reschedule", "/api/v1/week/pull-item", "/api/v1/today/session", "/api/v1/later", "/api/v1/saved-searches", "/api/v1/completed-filed-reviews", "/api/v1/security-events", "/api/v1/push-subscription"} or path.startswith("/api/v1/work/") or ( if path in {"/api/v1/context", "/api/v1/background-identity", "/api/v1/events", "/api/v1/live", "/api/v1/available-issues", "/api/v1/search", "/api/v1/work-route", "/api/v1/today", "/api/v1/tomorrow", "/api/v1/tomorrow/promote", "/api/v1/week", "/api/v1/week/promote", "/api/v1/week/start-early", "/api/v1/week/reconcile", "/api/v1/week/reschedule", "/api/v1/week/pull-item", "/api/v1/today/session", "/api/v1/later", "/api/v1/saved-searches", "/api/v1/completed-filed-reviews", "/api/v1/security-events", "/api/v1/push-subscription"} or path.startswith("/api/v1/human-gate") or path.startswith("/api/v1/work/") or (
path.startswith("/api/v1/repos/") path.startswith("/api/v1/repos/")
and path.endswith("/review") and path.endswith("/review")
) or path.startswith("/api/v1/notifications") or ( ) or path.startswith("/api/v1/notifications") or (
@ -1791,6 +1816,107 @@ def health() -> dict[str, str]:
return {"status": "ok", "service": "stackchain-dashboard"} return {"status": "ok", "service": "stackchain-dashboard"}
async def _human_gate_login(request: Request) -> str:
session = getattr(request.state, "dashboard_session", None)
if session is not None and session.principal_login and session.principal_id:
return f"{session.principal_id}:{session.principal_login}"
principal_id, login = await _upstream_identity()
return f"{principal_id}:{login}"
def _gate_error(error: Exception) -> HTTPException:
if isinstance(error, sqlite3.Error):
return HTTPException(status_code=503, detail="Human Gates are temporarily unavailable")
if isinstance(error, GateNotFound):
return HTTPException(status_code=404, detail=str(error))
if isinstance(error, GateConflict):
return HTTPException(status_code=409, detail=str(error))
return HTTPException(status_code=422, detail=str(error))
@app.post("/api/v1/human-gates/intake")
async def intake_human_gate(
payload: dict,
request: Request,
idempotency_key: str = Header(alias="Idempotency-Key", min_length=1, max_length=128),
):
login = await _human_gate_login(request)
try:
store = _human_gate_store()
existed = await asyncio.to_thread(store.has_intake_key, login, idempotency_key)
gate = await asyncio.to_thread(
store.intake, login, payload, idempotency_key=idempotency_key
)
except (GateValidationError, GateConflict, GateNotFound, sqlite3.Error) as error:
raise _gate_error(error) from error
return JSONResponse(
gate, status_code=200 if existed else 201, headers={"Cache-Control": "no-store"}
)
@app.get("/api/v1/human-gates")
async def list_human_gates(
request: Request,
state: str = Query(default="pending"),
limit: int = Query(default=100, ge=1, le=100),
):
login = await _human_gate_login(request)
try:
result = await asyncio.to_thread(_human_gate_store().list, login, state=state, limit=limit)
except (GateValidationError, sqlite3.Error) as error:
raise _gate_error(error) from error
return JSONResponse(result, headers={"Cache-Control": "no-store"})
@app.get("/api/v1/human-gates/{gate_id}")
async def human_gate_detail(gate_id: str, request: Request):
login = await _human_gate_login(request)
try:
result = await asyncio.to_thread(_human_gate_store().detail, login, gate_id)
except (GateValidationError, GateNotFound, sqlite3.Error) as error:
raise _gate_error(error) from error
return JSONResponse(result, headers={"Cache-Control": "no-store"})
@app.post("/api/v1/human-gates/{gate_id}/decision")
async def decide_human_gate(
gate_id: str,
payload: HumanGateDecision,
request: Request,
idempotency_key: str = Header(alias="Idempotency-Key", min_length=1, max_length=128),
):
login = await _human_gate_login(request)
try:
store = _human_gate_store()
existed = await asyncio.to_thread(store.has_receipt_key, login, idempotency_key)
receipt = await asyncio.to_thread(
store.decide,
login,
gate_id,
expected_revision=payload.expected_revision,
decision=payload.decision,
reason=payload.reason,
override_reason=payload.override_reason,
checklist=payload.checklist,
idempotency_key=idempotency_key,
)
except (GateValidationError, GateConflict, GateNotFound, sqlite3.Error) as error:
raise _gate_error(error) from error
return JSONResponse(
receipt, status_code=200 if existed else 201, headers={"Cache-Control": "no-store"}
)
@app.get("/api/v1/human-gate-receipts/{receipt_id}")
async def human_gate_receipt(receipt_id: str, request: Request):
login = await _human_gate_login(request)
try:
result = await asyncio.to_thread(_human_gate_store().receipt, login, receipt_id)
except (GateValidationError, GateNotFound, sqlite3.Error) as error:
raise _gate_error(error) from error
return JSONResponse(result, headers={"Cache-Control": "no-store"})
@app.post("/api/v1/session") @app.post("/api/v1/session")
async def sign_in(payload: DashboardSignIn, request: Request, response: Response): async def sign_in(payload: DashboardSignIn, request: Request, response: Response):
peer_host = request.client.host if request.client is not None else "unknown" peer_host = request.client.host if request.client is not None else "unknown"

View File

@ -0,0 +1,129 @@
import sqlite3
import httpx
import pytest
from src import main
from src.human_gate_store import HumanGateStore
CANDIDATE = {
"source": "release-bot", "project": "stackchain/dashboard", "candidate_hash": "abc123",
"title": "Release candidate", "priority": 7,
"artifacts": [{"name": "manifest", "url": "https://forge.example/manifest"}],
"links": [{"label": "pull", "url": "https://forge.example/pulls/1"}],
"checks": [{"name": "unit", "state": "success", "required": True}],
"score": {"value": 98, "provenance": "eval/v1"},
"provenance": {"run": "9"},
}
CHECKLIST = {"exact_hash": True, "artifacts_reviewed": True, "provenance_reviewed": True}
@pytest.fixture
def gate_api(monkeypatch, tmp_path):
ticks = iter(range(100, 120))
store = HumanGateStore(tmp_path / "gates.sqlite3", clock=lambda: next(ticks))
monkeypatch.setattr(main, "_human_gate_store", lambda: store, raising=False)
async def identity():
return {"id": 1, "login": "timmy"}
monkeypatch.setattr(main, "current_user", identity)
return store
@pytest.mark.anyio
async def test_intake_list_and_detail_are_account_bound_and_no_store(gate_api):
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
created = await client.post("/api/v1/human-gates/intake", json=CANDIDATE, headers={"Idempotency-Key": "run-9"})
repeated = await client.post("/api/v1/human-gates/intake", json=CANDIDATE, headers={"Idempotency-Key": "run-9"})
listing = await client.get("/api/v1/human-gates")
detail = await client.get(f"/api/v1/human-gates/{created.json()['id']}")
assert created.status_code == 201
assert repeated.status_code == 200
assert repeated.json()["id"] == created.json()["id"]
assert listing.json()["pending_count"] == 1
assert detail.json()["candidate_hash"] == "abc123"
assert detail.json()["history"][0]["action"] == "intake"
assert all(response.headers["cache-control"] == "no-store" for response in (created, repeated, listing, detail))
@pytest.mark.anyio
async def test_decision_requires_revision_and_returns_durable_receipt(gate_api):
gate = gate_api.intake("1:timmy", CANDIDATE, idempotency_key="run-9")
transport = httpx.ASGITransport(app=main.app)
payload = {"expected_revision": gate["revision"], "decision": "release", "reason": "", "override_reason": "", "checklist": CHECKLIST}
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
decided = await client.post(f"/api/v1/human-gates/{gate['id']}/decision", json=payload, headers={"Idempotency-Key": "decision-9"})
repeated = await client.post(f"/api/v1/human-gates/{gate['id']}/decision", json=payload, headers={"Idempotency-Key": "decision-9"})
receipt = await client.get(f"/api/v1/human-gate-receipts/{decided.json()['receipt_id']}")
stale = await client.post(f"/api/v1/human-gates/{gate['id']}/decision", json=payload, headers={"Idempotency-Key": "decision-10"})
assert decided.status_code == 201
assert repeated.status_code == 200
assert receipt.json() == decided.json()
assert stale.status_code == 409
assert all(response.headers["cache-control"] == "no-store" for response in (decided, repeated, receipt, stale))
@pytest.mark.anyio
async def test_recycled_login_cannot_read_another_principal_gates(monkeypatch, tmp_path):
store = HumanGateStore(tmp_path / "gates.sqlite3", clock=lambda: 100)
monkeypatch.setattr(main, "_human_gate_store", lambda: store, raising=False)
principal = {"id": 1, "login": "timmy"}
async def identity():
return principal.copy()
monkeypatch.setattr(main, "current_user", identity)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
created = await client.post(
"/api/v1/human-gates/intake", json=CANDIDATE,
headers={"Idempotency-Key": "principal-1"},
)
principal["id"] = 2
listing = await client.get("/api/v1/human-gates")
assert created.status_code == 201
assert listing.json()["pending_count"] == 0
@pytest.mark.anyio
async def test_gate_store_failure_is_sanitized_no_store(monkeypatch):
def unavailable():
raise sqlite3.OperationalError("sensitive database path")
monkeypatch.setattr(main, "_human_gate_store", unavailable)
async def identity():
return {"id": 1, "login": "timmy"}
monkeypatch.setattr(main, "current_user", identity)
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
response = await client.get("/api/v1/human-gates")
assert response.status_code == 503
assert response.headers["cache-control"] == "no-store"
assert response.json() == {"detail": "Human Gates are temporarily unavailable"}
@pytest.mark.anyio
async def test_gate_mutations_require_idempotency_key_and_validate_override(gate_api):
failing = {**CANDIDATE, "candidate_hash": "fail123", "checks": [{"name": "browser", "state": "failure", "required": True}]}
gate = gate_api.intake("1:timmy", failing, idempotency_key="run-fail")
transport = httpx.ASGITransport(app=main.app)
async with httpx.AsyncClient(transport=transport, base_url="http://test") as client:
missing_key = await client.post("/api/v1/human-gates/intake", json=CANDIDATE)
no_override = await client.post(
f"/api/v1/human-gates/{gate['id']}/decision",
json={"expected_revision": 1, "decision": "release", "reason": "", "override_reason": "", "checklist": CHECKLIST},
headers={"Idempotency-Key": "decision-fail"},
)
assert missing_key.status_code == 422
assert no_override.status_code == 422
assert "override reason" in no_override.json()["detail"]

View File

@ -0,0 +1,142 @@
import sqlite3
import pytest
from src.human_gate_store import GateConflict, GateValidationError, HumanGateStore
def candidate(hash_value="abc123", *, priority=2, check_state="success"):
return {
"source": "release-bot",
"project": "stackchain/dashboard",
"candidate_hash": hash_value,
"title": "Dashboard candidate",
"priority": priority,
"artifacts": [{"name": "manifest", "url": "https://forge.example/artifacts/manifest.json"}],
"links": [{"label": "change", "url": "https://forge.example/pulls/1415"}],
"checks": [{"name": "browser", "state": check_state, "required": True}],
"score": {"value": 92, "provenance": "release-evaluator/v2"},
"provenance": {"producer": "release-bot", "run_id": "run-9"},
}
def checklist():
return {"exact_hash": True, "artifacts_reviewed": True, "provenance_reviewed": True}
def test_intake_is_account_bound_idempotent_and_survives_restart(tmp_path):
path = tmp_path / "gates.sqlite3"
store = HumanGateStore(path, clock=lambda: 100)
first = store.intake("timmy", candidate(), idempotency_key="producer-9")
repeated = store.intake("timmy", candidate(), idempotency_key="producer-9")
restarted = HumanGateStore(path, clock=lambda: 101)
assert repeated == first
assert restarted.detail("timmy", first["id"])["candidate_hash"] == "abc123"
assert restarted.list("alex")["pending_count"] == 0
with sqlite3.connect(path) as connection:
assert connection.execute("PRAGMA journal_mode").fetchone()[0] == "wal"
def test_new_hash_supersedes_only_older_pending_candidate_without_losing_audit(tmp_path):
store = HumanGateStore(tmp_path / "gates.sqlite3", clock=iter([100, 101]).__next__)
old = store.intake("timmy", candidate("abc123"), idempotency_key="run-1")
new = store.intake("timmy", candidate("def456"), idempotency_key="run-2")
old_detail = store.detail("timmy", old["id"])
queue = store.list("timmy")
assert old_detail["state"] == "superseded"
assert old_detail["superseded_by"] == new["id"]
assert old_detail["history"][-1]["action"] == "superseded"
assert queue["pending_count"] == 1
assert queue["items"][0]["candidate_hash"] == "def456"
def test_pending_queue_orders_highest_priority_then_oldest(tmp_path):
clock = iter([100, 101, 102]).__next__
store = HumanGateStore(tmp_path / "gates.sqlite3", clock=clock)
low = store.intake("timmy", {**candidate("a1", priority=1), "project": "p/one"}, idempotency_key="1")
oldest_high = store.intake("timmy", {**candidate("b2", priority=5), "project": "p/two"}, idempotency_key="2")
newest_high = store.intake("timmy", {**candidate("c3", priority=5), "project": "p/three"}, idempotency_key="3")
assert [item["id"] for item in store.list("timmy")["items"]] == [oldest_high["id"], newest_high["id"], low["id"]]
def test_decision_checks_revision_rules_and_returns_durable_idempotent_receipt(tmp_path):
path = tmp_path / "gates.sqlite3"
store = HumanGateStore(path, clock=iter([100, 101]).__next__)
gate = store.intake("timmy", candidate(), idempotency_key="run-1")
receipt = store.decide(
"timmy", gate["id"], expected_revision=gate["revision"], decision="release",
reason="", override_reason="", checklist=checklist(), idempotency_key="decision-1",
)
repeated = store.decide(
"timmy", gate["id"], expected_revision=gate["revision"], decision="release",
reason="", override_reason="", checklist=checklist(), idempotency_key="decision-1",
)
assert repeated == receipt
assert receipt["state"] == "released"
assert receipt["candidate_hash"] == "abc123"
assert HumanGateStore(path, clock=lambda: 999).receipt("timmy", receipt["receipt_id"]) == receipt
with pytest.raises(GateConflict):
store.decide("timmy", gate["id"], expected_revision=gate["revision"], decision="hold", reason="later", override_reason="", checklist=checklist(), idempotency_key="decision-2")
def test_hold_requires_reason_and_release_requires_checklist_and_override_for_unmet_checks(tmp_path):
store = HumanGateStore(tmp_path / "gates.sqlite3", clock=lambda: 100)
gate = store.intake("timmy", candidate(check_state="failure"), idempotency_key="run-1")
with pytest.raises(GateValidationError, match="Hold reason"):
store.decide("timmy", gate["id"], expected_revision=1, decision="hold", reason="", override_reason="", checklist={}, idempotency_key="d1")
with pytest.raises(GateValidationError, match="checklist"):
store.decide("timmy", gate["id"], expected_revision=1, decision="release", reason="", override_reason="needed", checklist={"exact_hash": True}, idempotency_key="d2")
with pytest.raises(GateValidationError, match="override reason"):
store.decide("timmy", gate["id"], expected_revision=1, decision="release", reason="", override_reason="", checklist=checklist(), idempotency_key="d3")
receipt = store.decide("timmy", gate["id"], expected_revision=1, decision="hold", reason="Awaiting owner", override_reason="", checklist={}, idempotency_key="d4")
assert receipt["state"] == "held"
assert receipt["checklist"] == {}
def test_same_hash_update_coalesces_one_card_and_preserves_history(tmp_path):
store = HumanGateStore(tmp_path / "gates.sqlite3", clock=iter([100, 101]).__next__)
original = store.intake("timmy", candidate(check_state="pending"), idempotency_key="run-1")
updated = store.intake("timmy", candidate(check_state="success"), idempotency_key="run-2")
assert updated["id"] == original["id"]
assert updated["revision"] == 2
assert updated["checks"][0]["state"] == "success"
assert updated["history"][-1]["action"] == "updated"
assert store.list("timmy")["pending_count"] == 1
def test_updated_checks_reopen_a_released_hash_for_review(tmp_path):
store = HumanGateStore(tmp_path / "gates.sqlite3", clock=iter([100, 101, 102]).__next__)
gate = store.intake("timmy", candidate(), idempotency_key="run-1")
store.decide(
"timmy", gate["id"], expected_revision=1, decision="release",
reason="", override_reason="", checklist=checklist(), idempotency_key="decision-1",
)
reopened = store.intake(
"timmy", candidate(check_state="failure"), idempotency_key="run-2",
)
assert reopened["state"] == "pending"
assert reopened["revision"] == 3
assert reopened["checks"][0]["state"] == "failure"
assert reopened["history"][-1]["action"] == "reopened"
assert store.list("timmy")["pending_count"] == 1
def test_same_hash_identity_facts_cannot_be_redefined(tmp_path):
store = HumanGateStore(tmp_path / "gates.sqlite3", clock=lambda: 100)
store.intake("timmy", candidate(), idempotency_key="run-1")
with pytest.raises(GateConflict, match="hash"):
store.intake("timmy", {**candidate(), "title": "Changed facts"}, idempotency_key="run-2")

View File

@ -0,0 +1,157 @@
import json
import subprocess
from pathlib import Path
MODULE = Path(__file__).parents[1] / "frontend" / "human-gates.js"
INDEX = Path(__file__).parents[1] / "frontend" / "index.html"
DASHBOARD = Path(__file__).parents[1] / "frontend" / "dashboard.js"
def run_node(body):
script = f"const createHumanGates=require({json.dumps(str(MODULE))});\n" + body
result = subprocess.run(["node", "-e", script], check=True, text=True, capture_output=True)
return json.loads(result.stdout)
def test_queue_loads_pending_count_uses_account_cache_and_renders_inbox_zero():
output = run_node(r"""
const values=new Map(); const storage={getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)};
let response={pending_count:1,items:[{id:'g1',title:'Candidate',candidate_hash:'abc123',priority:4,state:'pending',revision:1,checks:[]}]};
const nodes={count:{textContent:''},list:{innerHTML:''},status:{textContent:''},panel:{hidden:true}};
const gates=createHumanGates({storage,getLogin:()=> 'timmy',isOnline:()=>true,nodes,location:{hash:''},fetchJson:async()=>response});
(async()=>{ await gates.load(); const first={snapshot:gates.snapshot(),count:nodes.count.textContent,html:nodes.list.innerHTML,keys:[...values.keys()]}; response={pending_count:0,items:[]}; await gates.load(); process.stdout.write(JSON.stringify({first,zero:{snapshot:gates.snapshot(),status:nodes.status.textContent,html:nodes.list.innerHTML}})); })();
""")
assert output["first"]["count"] == "1"
assert "abc123" in output["first"]["html"]
assert output["first"]["keys"] == ["stackchain.human-gates.v1:timmy"]
assert output["zero"]["snapshot"]["pending_count"] == 0
assert "Inbox zero" in output["zero"]["html"]
def test_review_next_is_a_fixed_snapshot_and_decision_and_next_advances_without_new_arrivals():
output = run_node(r"""
const calls=[]; const nodes={count:{textContent:''},list:{innerHTML:''},status:{textContent:''},panel:{hidden:true},detail:{innerHTML:''}};
const initial={pending_count:2,items:[{id:'old',title:'Old',candidate_hash:'a1',revision:1,checks:[]},{id:'next',title:'Next',candidate_hash:'b2',revision:1,checks:[]}]};
const gates=createHumanGates({storage:{getItem:()=>null,setItem(){}},getLogin:()=> 'timmy',isOnline:()=>true,nodes,location:{hash:'#/my-work/human-gates'},fetchJson:async(path,options={})=>{calls.push({path,options}); if(options.method==='POST') return {receipt_id:'r1',state:'released'}; return initial;}});
(async()=>{await gates.load(); const reviewed=gates.reviewNext(); initial.items.unshift({id:'new',title:'New arrival',candidate_hash:'c3',revision:1,checks:[]}); const result=await gates.decideAndNext('release',{checklist:{exact_hash:true,artifacts_reviewed:true,provenance_reviewed:true}}); process.stdout.write(JSON.stringify({reviewed,result,current:gates.current(),calls,hash:gates.route()}));})();
""")
assert output["reviewed"]["id"] == "old"
assert output["result"]["receipt"]["receipt_id"] == "r1"
assert output["current"]["id"] == "next"
assert output["hash"] == "#/my-work/human-gates"
post = next(call for call in output["calls"] if call["options"].get("method") == "POST")
assert post["path"] == "api/v1/human-gates/old/decision"
assert "Idempotency-Key" in post["options"]["headers"]
def test_review_loads_exact_detail_with_links_provenance_and_history():
output = run_node(r"""
const detail={id:'g1',title:'Candidate',project:'stackchain/dashboard',candidate_hash:'abc123',revision:1,checks:[{name:'unit',state:'success',required:true}],artifacts:[{name:'manifest',url:'https://forge.example/manifest'}],links:[{label:'pull',url:'https://forge.example/pull/1'}],score:{value:98,provenance:'eval/v1'},provenance:{producer:'bot',run_id:'9'},history:[{action:'intake',at:100}]};
const nodes={count:{},list:{},status:{},panel:{},detail:{innerHTML:''}};
const gates=createHumanGates({storage:{getItem:()=>null,setItem(){}},getLogin:()=> 'timmy',isOnline:()=>true,nodes,location:{hash:''},fetchJson:async path=>path.endsWith('/g1')?detail:{pending_count:1,items:[detail]}});
(async()=>{await gates.load();gates.reviewNext();await new Promise(resolve=>setTimeout(resolve,0));process.stdout.write(JSON.stringify({html:nodes.detail.innerHTML}));})();
""")
assert "stackchain/dashboard" in output["html"]
assert "https://forge.example/pull/1" in output["html"]
assert "bot" in output["html"]
assert "intake" in output["html"]
def test_release_requires_override_for_unmet_required_checks_and_decisions_require_online_identity():
output = run_node(r"""
let online=true, login='timmy', posts=0;
const item={id:'g1',title:'Failing',candidate_hash:'a1',revision:1,checks:[{name:'browser',state:'failure',required:true}]};
const gates=createHumanGates({storage:{getItem:()=>null,setItem(){}},getLogin:()=>login,isOnline:()=>online,nodes:{count:{},list:{},status:{},panel:{},detail:{}},location:{hash:''},fetchJson:async(path,options={})=>{if(options.method==='POST'){posts++;return {receipt_id:'r1'}};return {pending_count:1,items:[item]};}});
(async()=>{await gates.load();gates.reviewNext();let override,offline,identity;try{await gates.decideAndNext('release',{checklist:{exact_hash:true,artifacts_reviewed:true,provenance_reviewed:true}})}catch(e){override=e.message} online=false;try{await gates.decideAndNext('hold',{reason:'wait',checklist:{exact_hash:true,artifacts_reviewed:true,provenance_reviewed:true}})}catch(e){offline=e.message} online=true;login='';try{await gates.decideAndNext('hold',{reason:'wait',checklist:{exact_hash:true,artifacts_reviewed:true,provenance_reviewed:true}})}catch(e){identity=e.message}process.stdout.write(JSON.stringify({override,offline,identity,posts}));})();
""")
assert "override reason" in output["override"]
assert "online" in output["offline"]
assert "identity" in output["identity"]
assert output["posts"] == 0
def test_offline_cache_is_scoped_to_immutable_account_identity():
output = run_node(r"""
const values=new Map(); const storage={getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)};
let account='1:timmy'; const nodes={count:{},list:{},status:{},panel:{}};
const gates=createHumanGates({storage,getLogin:()=> 'timmy',getAccountKey:()=>account,isOnline:()=>true,nodes,location:{hash:''},fetchJson:async()=>({pending_count:1,items:[{id:'g1',title:'Private',candidate_hash:'a1'}]})});
(async()=>{await gates.load();account='2:timmy';process.stdout.write(JSON.stringify({keys:[...values.keys()],restored:gates.restoreCached()}));})();
""")
assert output["keys"] == ["stackchain.human-gates.v1:1:timmy"]
assert output["restored"] is None
def test_account_switch_clears_in_memory_gate_data_before_failed_load():
output = run_node(r"""
const values=new Map(); const storage={getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)};
let account='1:timmy', fail=false; const nodes={count:{},list:{innerHTML:''},status:{},panel:{}};
const gates=createHumanGates({storage,getLogin:()=> 'timmy',getAccountKey:()=>account,isOnline:()=>true,nodes,location:{hash:''},fetchJson:async()=>{if(fail)throw new Error('offline');return {pending_count:1,items:[{id:'private-1',title:'Principal 1 private',candidate_hash:'secret'}]}}});
(async()=>{await gates.load();account='2:timmy';fail=true;try{await gates.load()}catch(_){}process.stdout.write(JSON.stringify({snapshot:gates.snapshot(),html:nodes.list.innerHTML}));})();
""")
assert output["snapshot"] == {"pending_count": 0, "items": []}
assert "Principal 1 private" not in output["html"]
assert "secret" not in output["html"]
def test_stale_account_load_cannot_overwrite_new_account_queue_or_cache():
output = run_node(r"""
const values=new Map(); const storage={getItem:k=>values.get(k)||null,setItem:(k,v)=>values.set(k,v)};
let account='1:timmy', resolveA, resolveB;
const responseA=new Promise(resolve=>resolveA=resolve), responseB=new Promise(resolve=>resolveB=resolve);
const gates=createHumanGates({storage,getLogin:()=> 'timmy',getAccountKey:()=>account,isOnline:()=>true,nodes:{count:{},list:{innerHTML:''},status:{},panel:{}},location:{hash:''},fetchJson:()=>account.startsWith('1:')?responseA:responseB});
(async()=>{const loadA=gates.load();account='2:timmy';const loadB=gates.load();resolveB({pending_count:1,items:[{id:'b',title:'B gate',candidate_hash:'bhash'}]});await loadB;resolveA({pending_count:1,items:[{id:'a-secret',title:'A secret',candidate_hash:'asecret'}]});await loadA;process.stdout.write(JSON.stringify({snapshot:gates.snapshot(),cached:JSON.parse(values.get('stackchain.human-gates.v1:2:timmy'))}));})();
""")
assert [item["id"] for item in output["snapshot"]["items"]] == ["b"]
assert [item["id"] for item in output["cached"]["items"]] == ["b"]
def test_decision_retry_reuses_the_same_idempotency_key():
output = run_node(r"""
let attempts=0; const keys=[];
const item={id:'g1',title:'Candidate',candidate_hash:'a1',revision:1,checks:[]};
const gates=createHumanGates({storage:{getItem:()=>null,setItem(){}},getLogin:()=> 'timmy',isOnline:()=>true,nodes:{count:{},list:{},status:{},panel:{},detail:{}},location:{hash:''},fetchJson:async(path,options={})=>{if(options.method==='POST'){keys.push(options.headers['Idempotency-Key']);attempts++;if(attempts===1)throw new Error('network');return {receipt_id:'r1'}};return {pending_count:1,items:[item]};}});
(async()=>{await gates.load();gates.reviewNext();const values={reason:'wait',checklist:{}};try{await gates.decideAndNext('hold',values)}catch(_){}await gates.decideAndNext('hold',values);process.stdout.write(JSON.stringify({keys}));})();
""")
assert len(output["keys"]) == 2
assert output["keys"][0] == output["keys"][1]
def test_concurrent_decision_taps_submit_once_and_advance_once():
output = run_node(r"""
let posts=0, releasePost; const posted=new Promise(resolve=>releasePost=resolve);
const items=[{id:'g1',title:'One',candidate_hash:'a1',revision:1,checks:[]},{id:'g2',title:'Two',candidate_hash:'b2',revision:1,checks:[]}];
const gates=createHumanGates({storage:{getItem:()=>null,setItem(){}},getLogin:()=> 'timmy',isOnline:()=>true,nodes:{count:{},list:{},status:{},panel:{},detail:{}},location:{hash:''},fetchJson:async(path,options={})=>{if(options.method==='POST'){posts++;await posted;return {receipt_id:'r1'}};return {pending_count:2,items};}});
(async()=>{await gates.load();gates.reviewNext();const values={reason:'wait',checklist:{}};const first=gates.decideAndNext('hold',values);const second=gates.decideAndNext('hold',values);releasePost();await Promise.all([first,second]);process.stdout.write(JSON.stringify({posts,current:gates.current()?.id,snapshot:gates.snapshot()}));})();
""")
assert output["posts"] == 1
assert output["current"] == "g2"
assert output["snapshot"]["pending_count"] == 1
assert [item["id"] for item in output["snapshot"]["items"]] == ["g2"]
def test_selecting_a_queue_card_opens_that_exact_gate():
output = run_node(r"""
const details={
g1:{id:'g1',title:'First',project:'p/one',candidate_hash:'a1',revision:1,checks:[]},
g2:{id:'g2',title:'Second',project:'p/two',candidate_hash:'b2',revision:1,checks:[]},
};
const nodes={count:{},list:{},status:{},panel:{},detail:{innerHTML:''}};
const gates=createHumanGates({storage:{getItem:()=>null,setItem(){}},getLogin:()=> 'timmy',isOnline:()=>true,nodes,location:{hash:''},fetchJson:async path=>path.includes('/g')?details[path.split('/').pop()]:{pending_count:2,items:Object.values(details)}});
(async()=>{await gates.load();gates.reviewNext();gates.select('g2');await new Promise(resolve=>setTimeout(resolve,0));process.stdout.write(JSON.stringify({current:gates.current().id,html:nodes.detail.innerHTML}));})();
""")
assert output["current"] == "g2"
assert "Second" in output["html"]
assert "p/two" in output["html"]
def test_human_gate_mobile_shell_and_deep_route_are_wired():
index = INDEX.read_text()
dashboard = DASHBOARD.read_text()
assert 'id="human-gates"' in index
assert 'id="human-gates-count"' in index
assert 'Review next <span id="human-gates-count"' in index
assert 'static/human-gates.js' in index
assert "#/my-work/human-gates" in dashboard
assert "createHumanGates" in dashboard
assert "planningOwnerAccountKey = confirmedOwnerLogin && saved.user?.id" in dashboard

View File

@ -0,0 +1,24 @@
from pathlib import Path
README = Path(__file__).parents[1] / "README.md"
def test_readme_documents_hash_bound_producer_intake_and_revision_decisions():
text = README.read_text()
assert "POST /api/v1/human-gates/intake" in text
assert "Idempotency-Key" in text
assert '"candidate_hash"' in text
assert "expected_revision" in text
assert "STACKCHAIN_HUMAN_GATE_DB" in text
assert "principal ID and login" in text
assert "reopens the exact hash" in text
def test_readme_defines_privacy_safe_telegram_coalescing_contract():
text = README.read_text()
assert "Telegram coalescing contract" in text
assert "#/my-work/human-gates" in text
assert "count and route only" in text
assert "candidate hash" in text
assert "superseded" in text

View File

@ -1373,6 +1373,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
"/dashboard/manifest.webmanifest", "/dashboard/manifest.webmanifest",
"/dashboard/static/dashboard.css", "/dashboard/static/dashboard.css",
"/dashboard/static/dashboard.js", "/dashboard/static/dashboard.js",
"/dashboard/static/human-gates.js",
"/dashboard/static/icons/stackchain-192.png", "/dashboard/static/icons/stackchain-192.png",
"/dashboard/static/icons/stackchain-512.png", "/dashboard/static/icons/stackchain-512.png",
"/dashboard/static/session.js", "/dashboard/static/session.js",