- sources.mjs (nuevo): selectSource() + attribute() — seleccion de material
≤72h con score≥6, atribucion en formato fijo.
- core.mjs: las 4 piezas (digest/opinion/reflection/events) generan sobre
la fuente seleccionada y el codigo agrega la atribucion DESPUES del LLM.
- guard.mjs:
* R1/R2/R4/R5/R8 evaluan el TEXTO SIN la linea de atribucion
* R3/R7 ven el texto completo (con atribucion) — la fuente garantiza
que pasen
* R9 (nueva): la pieza debe compartir terminos del dominito con su fuente
* R8: superlativos estrechos (sin never/only que dejaban pasar aforismos)
* deletion anadido a METADATA_TYPES (no cuenta hacia el tope diario)
- guard.test.mjs: 24/24 verdes (incluye tests R14a/c/d)
- outbox.mjs solo en /srv/cayce-core; daemon/social/mail/ops lo importan por ruta absoluta. deploy.sh ya no instala copias.
- relay-watcher.mjs: cada 5 min cruza eventos de la pubkey en 5 relays con outbound_drafts; sin aprobacion => PUBLISH_OFF + DM + audit. Probado: detecto 2 kind0 bypass reales (post-hoc registrados).
- guard R1-R8 (18 tests) + blocklist + check-single-outbox al check diario.
- stale duplicados archivados en forensics/scripts-archive-20261102 (nostr-daemon.mjs, dm-debug, publish-10050, dm-test2, find-followers).
Nueva regla del operador (2026-10-31): los ficheros de secretos solo se
inspeccionan con herramientas que no muestran valores (jq keys, grep -c,
stat). Prohibido cat/grep -r sobre .env y volcar errores con DSN.
- tests/test_no_credential_patterns.py: 3 tests — DSN con credencial
literal fuera de placeholders, nsec bech32 versionado, tokens hex de
64 chars fuera de tests/.
- scripts/check-credential-leaks.sh: hook que falla si journald
reciente contiene DSN con contraseña o nsec (redactando el valor en
la salida). Sin timer propio (no autorizado); ejecutar manual o desde
cron del operador.
Contexto: la rotación del rol hardcap y el DSN filtrado a journald por
el test del gate (corregido; la credencial filtrada ya está rotada).
- adminAuth: exige Authorization: Bearer <HARDCAP_ADMIN_TOKEN> (o
X-Admin-Token). Comparación timingSafeEqual sobre sha256 de ambos
valores (sin leak de longitud ni timing). Fail-closed: sin token
configurado, TODAS las rutas admin responden 503.
- 401s ahora se loguean con método, ruta e IP (primera trazabilidad de
acceso que tiene el admin API desde que existe).
- token generado (32 bytes hex) y almacenado SOLO en
/srv/hardcap-backend/.env — nunca impreso ni versionado.
- test-admin-auth.mjs: 6 tests (sin token, incorrecto, vacío, correcto,
header alternativo, misma-longitud-distinto) — todos pasan en la VPS.
- desplegado en src + dist; servicio reiniciado y activo.
Nota de contexto: el admin API carecía de auth desde su creación
(index.ts: \x27TODO: add auth middleware before production\x27) y estuvo
alcanzable sin credenciales en :3001 hasta la contención de hoy.
- hardcap PATCH /admin/articles/:id → PUBLISHED now stages into
outbound_drafts (channel hardcap) instead of publishing, when
HARDCAP_PUBLISH_MODE=draft (default). Fail-closed. Verified E2E:
PATCH on a QUEUED article returned {staged:true, draft_id:7} and the
article stayed QUEUED.
- bitcoinhotels.org: audited — frontend only (Next.js), no publishing
path; nothing to contain.
- outbox.mjs: self-sufficient pool (createRequire from
/srv/cayce-core/node_modules/pg — hosts may lack pg; found by the
promo simulation which now works end-to-end).
- runtime/hardcap/admin.ts: versioned copy of the patched route
(hardcap-backend itself is not a git repo).
- deploy.sh: hardcap env wiring (HARDCAP_PUBLISH_MODE, IDENTITY_PATH,
CAYCE_PGURL appended server-side).
🔴 UNRELATED CRITICAL FINDING (needs operator decision, not patched
without approval): hardcap admin API has NO authentication and binds
*:3001 — reachable from the public internet (HTTP 200 from outside,
including DELETE /admin/articles/all). Recommend: auth middleware +
bind 127.0.0.1 + firewall, plus rotation of credentials that appeared
in journald (hardcap DB role DSN leaked via a psql error line during
testing — fixed in the same patch, but journald retains it).
- publish-10050.mjs: publishes Cayce kind 10050 (NIP-65 message-relays/
inbox) listing the 6 relays where the listener actually reads DMs.
EXECUTED once with operator authorization: event id
188ef397824e8f29b5e1bea661ae65b1f8532ee97617747b9fbde57c6e8dfce9,
accepted 6/6 relays.
- initiative.mjs: the cycle DM to the supervisor now includes a summary
of outbound_drafts from the last 24h (id, type, first 15 words) so the
operator reviews what the agent WANTED to publish.
- initiative.mjs: supervisor directive (CAYCE_DIRECTIVE_BTCPAY=1,
reversible) injected into the cycle prompt: grants/opportunity search
SUSPENDED, single priority = consolidate the BTCPay Server proposal
(opportunity #106 / draft #33) ready for HUMAN sending. MANDATE text
untouched (only the supervisor can change it).
- initiative.mjs: add_opportunity now rejects duplicates (the agent had
re-registered OpenSats ×10, HRF ×6).
- opportunity #106 status -> priority-1.
- check-relay-silence.mjs: queries the relays for Cayce-signed events
since a timestamp; allows only kind 10050 and supervisor DMs; exits 1
on any violation. Verified: 0 events, 0 violations since deployment.
- deploy.sh: sudo for the daemon restart, optional files tolerated.
Gate: make check green (96 tests).
Problem: Cayce publishes straight to relays with no approval gate —
cayce-initiative publish_note, core.mjs digest/opinion/reflection/events,
mention auto-replies, social promo (Facebook/Instagram via sfeed), and
mail — while the operator believed approvals existed (they never did;
ADR-0005 Telegram queue was never implemented).
Mechanism (outbox.mjs, deployed to all runtimes):
- CAYCE_PUBLISH_MODE=draft (default): gateOutbound() stages EVERY outbound
artifact into outbound_drafts (entity DB) with identity_path +
identity_hash (sha256 of prompts/cayce-identity.md) before anything
else happens. Fail-closed: if staging fails, the event is discarded,
never published.
- CAYCE_PUBLISH_MODE=live: original behavior, unchanged (reversible with
one line per service .env).
- Chokepoint: the nostr daemon /publish endpoint gates ALL callers
(initiative publish_note, core.mjs ×4, banner kind 0); the daemon own
mention-reply path gates too, storing original_event for context;
startup profile publish gates as well. DM-to-supervisor (/dm) is
internal and stays live.
Identity (point 8b):
- prompts/cayce-identity.md: the 5 hardcoded voice blocks extracted
VERBATIM (verified byte-identical by runtime test) with block markers.
- core.mjs / editorials.mjs now load their voice blocks from this file
(loadIdentityBlock); identity hash recorded on every draft.
- runtime/ holds the versioned copies of the previously-unversioned
runtime scripts + deploy.sh installer.
No timers enabled or disabled. No production data modified.
The previous redaction (${PG_URL%%@*@}) never matched when the URL has a
single @, so the full DSN (user:password@host) went to the log/journal.
Now: scheme + ***:***@ + hostport/db only.
Found during the point-3 E2E test; the leaked value in that transcript
was the already-public test DB credential (itest probe DB).
/usr/local/bin/cayce-check-backup-freshness (from
deploy/backup/check-freshness.sh): exits 1 if the newest
/var/backups/cayce/YYYY-MM-DD/ dir has mtime older than 26h, or if no
backups exist while the age recipient IS configured (backups expected).
Info-exit-0 when backups have never been configured. Not attached to a
timer (only cayce-backup.timer was authorized); documented for manual
or cron use.
Verified: fresh dir => OK 0h exit 0; dir with 2-day mtime => ALERT 48h
exit 1; no-dir + recipient => ALERT; no-dir + no-recipient => INFO.
RESTORE.md documents the exact from-zero recovery on a fresh VPS:
download+verify (sha256 over encrypted artifacts, no private key needed
on the pull side), decrypt on the operator Mac, Postgres pg_restore,
Gitea unzip+migrate, Caddy/systemd/.env config restore, repo re-clone,
backup pipeline reinstall with the SAME age recipient, and a
post-restore checklist.
E2E test executed on the VPS (no production touched — cayce-ops has no
production DB; a probe DB was created and dropped):
- manifest.sha256: 3/3 artifacts OK (verified WITHOUT any private key)
- age decrypt roundtrip with a throwaway keypair (destroyed afterwards)
- pg_restore TOC valid; full restore into a scratch DB: 5000/5000 rows
- gitea zip valid, config tar valid (34 entries)
- all test material removed: probe DB dropped, throwaway key deleted,
recipient file removed, test backup deleted (was encrypted to the
destroyed throwaway key)
System user backup-pull created on the VPS (no sudo, locked password).
authorized_keys restricted with restrict + forced command
rrsync -ro /var/backups/cayce. Verified with a since-deleted throwaway
key: no shell, no arbitrary commands, no sftp/scp, no reads outside
/var/backups/cayce, no writes; the positive rsync pull works.
authorized_keys is left EMPTY — the operator adds their real key with
the exact line documented in deploy/backup/backup-pull-setup.md.
deploy/backup/make-backup.sh (installed as /usr/local/bin/cayce-make-backup):
- pg_dump custom-format of the cayce-ops DB (URL from env or .env,
SQLAlchemy driver qualifier stripped, credentials never logged)
- gitea dump (repos + sqlite + attachments), live; consistency notes in RESTORE.md
- config tar: Caddyfile(+d), gitea app.ini + internal_token, cayce-* units, .env
- EACH artifact encrypted with age BEFORE leaving the tmpdir; recipient
read from /etc/cayce-backup/age-recipient.pub (public key only). The
private age key never touches this server.
- plaintext exists ONLY in a mktemp dir removed by an EXIT trap; a partial
day directory is removed on failure; any step failure => exit != 0 =>
journald entry
- manifest.sha256 over the encrypted artifacts (pull side verifies
integrity WITHOUT the age private key)
- destination /var/backups/cayce/YYYY-MM-DD/ (750 root:backup-pull, 640)
- retention: 7 daily dirs, pruned by mtime
systemd: cayce-backup.service (oneshot, hardened: ProtectSystem=strict,
NoNewPrivileges, ProtectHome=read-only) + cayce-backup.timer (daily
03:15 UTC, Persistent). Timer ENABLED on the VPS per operator instruction;
runs fail loudly (exit 1, clear journald message) until the age recipient
is provided — that is the designed not-configured state.
The previous text asserted participation in an external jurisdiction
pilot without a verifiable source. Replaced with what is actually true
of this codebase: every external-effect action requires cryptographically
signed human approval, with a key that never resides on Cayce
infrastructure (D16).
Merchants write in Spanish; negations like nunca, lamentablemente or
no podemos must veto approval exactly like their English counterparts.
Previously an all-Spanish rejection mail could fall through to OTHER,
and a mixed mail with the approval word in English approved despite a
Spanish negation.
- negation list adds: nunca, jamas, tampoco, ningun/ninguna, nadie,
nada, no podemos, no es posible, no aceptamos, rechaz*, descartad*,
lamentablemente, no ha sido aprobad*, no procede
- new Spanish REJECTION rule (rechaz*, solicitud denegada, no cumple
los requisitos) and Spanish APPROVAL rule (aprob*, aceptad*,
bienvenid*, encantados de trabajar)
- veto rule unchanged: any negation anywhere -> needs_review
- tests: 15 new (12 veto cases incl. 2 mixed-language, pure-Spanish
approval still approves, Spanish rejection classifies rejection)
Gate: ruff 0 · 96 unit + 1 PG integration · gitleaks clean.
- verify_audit_chain(): rows with entry_hash IS NULL are only tolerated
BEFORE the first hashed row (documented legacy prefix). A NULL hash
AFTER the chain started is tampering and fails verification at that
row id — previously any NULL row anywhere was silently skipped, so an
attacker with UPDATE could erase intermediate evidence undetected.
- migration 0004: audit_log becomes append-only. SQLite: BEFORE UPDATE/
DELETE triggers RAISE(ABORT). Postgres: BEFORE triggers RAISE EXCEPTION.
Closes the hole for the table owner and direct SQL connections.
- monitor.verify_chain_against_anchors(): compares the chain tail against
anchors published to Nostr. Explicit statuses: no_anchors (never
anchored / notes missing on relays), local_only (no fetcher), ok
(relay note attests the tail or the last local anchor), mismatch.
- tests: NULL-before-genesis ok, NULL-in-middle fails at that id, NULL
late fails, UPDATE rejected, DELETE rejected, INSERT still allowed,
no-anchors explicit, local-only explicit, mismatch detected, match ok.
Gate: ruff 0 · 81 unit + 1 PG integration · alembic 0001→0004 on PG ·
gitleaks clean · make check exit 0.