diff --git a/README.md b/README.md
index ae3f807..f64be36 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/docs/human-gates.md b/docs/human-gates.md
new file mode 100644
index 0000000..65abc70
--- /dev/null
+++ b/docs/human-gates.md
@@ -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.
diff --git a/frontend/dashboard.css b/frontend/dashboard.css
index d80e4b2..fdd0985 100644
--- a/frontend/dashboard.css
+++ b/frontend/dashboard.css
@@ -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)}}
\ No newline at end of file
diff --git a/frontend/dashboard.js b/frontend/dashboard.js
index 5e10907..e990b62 100644
--- a/frontend/dashboard.js
+++ b/frontend/dashboard.js
@@ -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();
diff --git a/frontend/human-gates.js b/frontend/human-gates.js
new file mode 100644
index 0000000..3e7bd89
--- /dev/null
+++ b/frontend/human-gates.js
@@ -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 => ({
+ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''',
+ })[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, '
Inbox zeroNo release candidates need your decision.
');
+ 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 =>
+ ''
+ ).join(''));
+ }
+
+ function renderDetail(item) {
+ if (!item) {
+ setHtml(nodes.detail, 'Inbox zeroFixed review snapshot complete.
');
+ return;
+ }
+ const checks = (item.checks || []).map(check =>
+ '' + escape(check.name) + ' · ' + escape(check.state) + (check.required ? ' · required' : '') + ''
+ ).join('');
+ const artifacts = (item.artifacts || []).map(artifact => '' + escape(artifact.name) + '').join('');
+ const links = (item.links || []).map(link => '' + escape(link.label) + '').join('');
+ const provenance = Object.entries(item.provenance || {}).map(([key, value]) => '' + escape(key) + ' · ' + escape(value) + '').join('');
+ const history = (item.history || []).map(event => '' + escape(event.action) + ' · ' + escape(event.at) + '').join('');
+ setHtml(nodes.detail,
+ '' + escape(item.title) + '
' +
+ 'Project ' + escape(item.project) + '
' +
+ 'Exact candidate ' + escape(item.candidate_hash) + '
' +
+ 'Score ' + escape(item.score?.value ?? 'not supplied') + ' · ' + escape(item.score?.provenance || '') + '
' +
+ 'Artifacts
Links
' +
+ 'Checks
Provenance
' +
+ 'History
' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '' +
+ '
'
+ );
+ }
+
+ 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;
diff --git a/frontend/index.html b/frontend/index.html
index 1ad952b..ee574a3 100644
--- a/frontend/index.html
+++ b/frontend/index.html
@@ -187,7 +187,16 @@
+
+
Queue & settings · All
@@ -2401,6 +2410,7 @@
+