Merge pull request 'feat: Human Gates inbox: one hash-bound review queue to zero' (#1416) from timmy/1415-human-gates-inbox-one-hash-bound-review-queue-to into main
All checks were successful
CI / lint (push) Successful in 4m13s
CI / build-release (push) Successful in 7s
CI / browser-journey (push) Successful in 7m54s
CI / release-candidate (push) Successful in 8s

This commit is contained in:
rockachopa 2026-08-26 02:09:24 +00:00
commit e9d257e203
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
JSON. Full behavior and safety gates are documented in
[`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-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}}
.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 confirmedOwnerLogin = '';
let planningOwnerLogin = '';
let planningOwnerAccountKey = '';
let activeFlushLogin = '';
let rR = null;
function rRC() {
@ -634,6 +635,46 @@
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() {
if (!planningOwnerLogin) return Promise.resolve(false);
if (completedFiledSyncFlight) return completedFiledSyncFlight;
@ -5526,6 +5567,9 @@
const retainedPlanningLogin = !snapshot.context.error ?
String(snapshot.context.user?.login || '').trim() : '';
planningOwnerLogin = retainedPlanningLogin;
planningOwnerAccountKey = retainedPlanningLogin && snapshot.context.user?.id ?
String(snapshot.context.user.id) + ':' + retainedPlanningLogin : '';
timerView.render();
updatePlanningAvailability();
if (planningOwnerLogin) {
syncPendingTomorrow();
@ -7843,6 +7887,8 @@
confirmedOwnerLogin = String(saved.user?.login || '').trim();
restoreReleaseReceipt();
planningOwnerLogin = confirmedOwnerLogin;
planningOwnerAccountKey = confirmedOwnerLogin && saved.user?.id ?
String(saved.user.id) + ':' + confirmedOwnerLogin : '';
interruptionPrompt.restore();
updatePlanningAvailability();
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="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="human-gates-launcher" id="open-human-gates" type="button">Review next <span id="human-gates-count">0</span></button>
</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">
<summary id="work-settings-toggle">Queue &amp; settings · <span id="active-work-queue">All</span></summary>
<div class="work-settings-panel">
@ -2401,6 +2410,7 @@
<script src="static/mobile-plan-today-nav.js"></script>
<script src="static/mobile-find-work-nav.js"></script>
<script src="static/workspace-bootstrap.js"></script>
<script src="static/human-gates.js"></script>
<script src="static/dashboard.js"></script>
</body>
</html>

View File

@ -151,6 +151,7 @@ const SHELL = [
BASE + 'manifest.webmanifest',
BASE + 'static/dashboard.css',
BASE + 'static/dashboard.js',
BASE + 'static/human-gates.js',
BASE + 'static/icons/stackchain-192.png',
BASE + 'static/icons/stackchain-512.png',
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,
)
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.login_attempt_store import LoginAttemptStore, LoginAttemptStoreError, client_source
from src.live_snapshot_store import (
@ -441,6 +447,14 @@ class ReadinessPayloadError(ValueError):
"""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]:
upstream = await current_user()
principal_id = upstream.get("id") if isinstance(upstream, dict) else None
@ -589,6 +603,17 @@ class PasskeyAuthorization(PasskeyCeremony, PasskeyAuthorizationTarget):
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:
state_dir = os.getenv("STACKCHAIN_STATE_DIR", ".stackchain-state")
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):
response = await call_next(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/")
and path.endswith("/review")
) or path.startswith("/api/v1/notifications") or (
@ -1791,6 +1816,107 @@ def health() -> dict[str, str]:
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")
async def sign_in(payload: DashboardSignIn, request: Request, response: Response):
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/static/dashboard.css",
"/dashboard/static/dashboard.js",
"/dashboard/static/human-gates.js",
"/dashboard/static/icons/stackchain-192.png",
"/dashboard/static/icons/stackchain-512.png",
"/dashboard/static/session.js",