add final acceptance verification

This commit is contained in:
Marco0300
2026-09-03 13:02:04 +02:00
parent 9622f76977
commit 561efe8855
10 changed files with 718 additions and 1 deletions
+8
View File
@@ -240,3 +240,11 @@ Before deployment, verify the prerequisites in `docs/RELEASE_CHECKLIST.md`: a re
The API liveness endpoint (`/api/v1/health/live`) and web `/healthz` are public process checks; API `/api/v1/health/ready` additionally verifies SQLite readiness. They are suitable for Docker and monitoring but do not prove backups, tenant authorization, or external dependencies. Production ingress must not route traffic until both Compose services report `healthy` and the deployment smoke tests pass. The current image initializes SQLite from `schema.sql` and has no standalone migration runner. Treat schema changes as a versioned, backup-first migration: validate on a restored copy, record the schema/data checks, and keep the previous image/config available for rollback.
The named Docker volume is not a backup. Stop or quiesce writes, create an encrypted off-host backup, verify it, and perform a restore drill before calling a deployment protected. Define retention for the database, audit/source lineage, logs, and backups; apply legal holds and deletion rules deliberately. Do not run `docker compose down -v` on a data-bearing host. Outbound traffic is deny-by-default for product behavior: `AUTOMATED_OUTREACH_ENABLED=false` is fixed in Compose and this release has no send/provider/worker path. Unexpected egress is an incident. SQLite, the in-process worker, HTTP-only local Compose, lack of durable migrations/PITR, and the limited SQLite-only readiness check are explicit limitations, not hidden guarantees.
## Phase 17 final acceptance
Phase 17 records the final local acceptance decision in `docs/FINAL_ACCEPTANCE.md`: **local acceptance passed; production deployment was not attempted and remains blocked**. The verified local head is `9622f769776637a40fdae797adccba91445cd351`. The acceptance run passed 100 API tests, the deterministic Phase 16 benchmark, Python and shell syntax checks, JSON validation, Compose configuration validation, and safety invariants.
The bounded capacity smoke is deterministic and in-memory only: a synthetic 1,000-item collection returns a maximum page of 100, and a 5,000-item synthetic batch retains at most 100 items. This demonstrates bounded behavior, not production throughput, concurrency, durability, availability, or an SLO. Reproducibility requires the recorded commit, fixture hash, rule/algorithm versions, runtime/dependency/image versions, non-secret configuration, seed, locale/timezone, rounding, and tie-breaking; see `docs/FINAL_ACCEPTANCE.md` and `docs/BENCHMARKS.md`.
Remote push is still blocked by repository authentication and branch-permission prerequisites. Production additionally requires host/Docker/Compose access, protected deployment paths, secret injection, DNS/TLS/Virtualmin, encrypted off-host backups and restore evidence, monitoring, rollback ownership, operational approval, and all applicable provider, terms/DPA, consent/legal-basis, and retention prerequisites. No local check, benchmark, or commit is a remote push or production deployment. `AUTOMATED_OUTREACH_ENABLED=false` remains the required default.
+54
View File
@@ -0,0 +1,54 @@
import json
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[3]
import sys
sys.path.insert(0, str(ROOT / "scripts"))
from final_acceptance import build_capacity_smoke, validate_report
class Phase17AcceptanceTests(unittest.TestCase):
def test_capacity_smoke_is_bounded_and_deterministic(self):
first = build_capacity_smoke()
second = build_capacity_smoke()
self.assertEqual(first, second)
self.assertEqual(first["pagination"]["requested_page_size"], 100)
self.assertEqual(first["pagination"]["returned_items"], 100)
self.assertEqual(first["pagination"]["synthetic_total"], 1000)
self.assertEqual(first["large_batch"]["input_items"], 5000)
self.assertLessEqual(first["large_batch"]["retained_items"], 100)
self.assertTrue(first["bounded"])
def test_report_schema_rejects_missing_or_wrong_fields(self):
report = {
"report": "phase17-final-acceptance",
"version": 1,
"scope": "local-repository-acceptance",
"passed": True,
"checks": {},
"capacity_smoke": build_capacity_smoke(),
"blockers": [],
"limitations": [],
}
self.assertEqual(validate_report(report), [])
missing = dict(report)
del missing["checks"]
self.assertTrue(validate_report(missing))
wrong = dict(report)
wrong["passed"] = "yes"
self.assertTrue(validate_report(wrong))
def test_report_serialization_is_stable(self):
from final_acceptance import deterministic_json
report = {
"z": 1,
"a": {"items": [{"b": 2, "a": 1}]},
}
self.assertEqual(deterministic_json(report), deterministic_json(json.loads(deterministic_json(report))))
if __name__ == "__main__":
unittest.main()
+168
View File
@@ -0,0 +1,168 @@
#!/usr/bin/env node
/**
* Deterministic, dependency-free frontend final acceptance harness.
*
* Source checks run by default. HTTP checks are opt-in:
* node scripts/final-acceptance.mjs --base-url http://127.0.0.1:8080
* node scripts/final-acceptance.mjs --start-server [--port 0]
*
* stdout is one machine-readable JSON document. Exit codes:
* 0 = all requested checks passed
* 1 = one or more acceptance checks failed
* 2 = invalid CLI/configuration or unreadable source
*/
import { createHash } from 'node:crypto';
import { readFile, stat } from 'node:fs/promises';
import { spawn } from 'node:child_process';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const webRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..');
const args = process.argv.slice(2);
const valueFor = flag => { const i = args.indexOf(flag); return i >= 0 ? args[i + 1] : undefined; };
const startServer = args.includes('--start-server');
const baseArg = valueFor('--base-url') || valueFor('--url');
const portArg = valueFor('--port') || '8080';
if (args.includes('--help') || args.includes('-h')) {
console.log(JSON.stringify({ usage: 'node scripts/final-acceptance.mjs [--base-url URL | --start-server] [--port N]', exit_codes: { pass: 0, failed_checks: 1, usage_or_io_error: 2 } }, null, 2));
process.exit(0);
}
if (baseArg && startServer) {
console.log(JSON.stringify({ schema: 1, harness: 'prospectos-frontend-final-acceptance', pass: false, error: '--base-url and --start-server are mutually exclusive' }, null, 2));
process.exit(2);
}
const baseUrl = baseArg?.replace(/\/$/, '') || null;
const sourceNames = ['index.html', 'app.js', 'styles.css', 'config.js', 'health.html', 'error.html', 'healthz', 'smoke-test.html', 'asset-manifest.json', 'README.md'];
const results = [];
const check = (id, description, pass, details = '') => results.push({ id, description, pass: Boolean(pass), ...(details ? { details } : {}) });
const all = (items, predicate) => items.every(predicate);
const listMissing = (items, predicate) => items.filter(item => !predicate(item));
const sha256 = bytes => `sha256-${createHash('sha256').update(bytes).digest('hex')}`;
let files = {};
let manifest;
try {
files = Object.fromEntries(await Promise.all(sourceNames.map(async name => [name, await readFile(resolve(webRoot, name))])));
manifest = JSON.parse(files['asset-manifest.json'].toString('utf8'));
check('files.readable', 'all frontend acceptance inputs are readable', true);
} catch (error) {
check('files.readable', 'all frontend acceptance inputs are readable', false, error.message);
}
const text = name => files[name]?.toString('utf8') || '';
const html = text('index.html');
const js = text('app.js');
const css = text('styles.css');
const config = text('config.js');
const expectedIds = [
'loginScreen', 'loginForm', 'loginEmail', 'loginPassword', 'dashboardShell', 'logoutBtn', 'apiStatus',
'explorer', 'detailPanel', 'reviewQueueCount', 'reviewQueueState', 'savedFilterForm', 'savedFilterSelect',
'deleteSavedFilterBtn', 'bulkVerifyBtn', 'bulkRejectBtn', 'nextPageBtn', 'jobs', 'jobsList', 'jobDetailPanel',
'sources', 'sourcesList', 'sourceRecordsList', 'discoveryForm', 'crmPipeline', 'pipelineBoard', 'crmActivity',
'interactionState', 'interactionForm', 'crmReports', 'pipelineReport', 'outcomesReport', 'activityReport',
'suppressionCenter', 'suppressionForm', 'suppressionState', 'outreachSettings', 'providerPolicyPanel',
'scoreRules', 'scoreRulesPanel', 'scoreDistributionPanel', 'mergeDialog', 'confirmMergeBtn'
];
const htmlIds = new Set([...html.matchAll(/\bid=["']([^"']+)["']/g)].map(m => m[1]));
check('dom.critical-ids', 'critical operator DOM IDs are present', all(expectedIds, id => htmlIds.has(id)), listMissing(expectedIds, id => htmlIds.has(id)).join(', '));
const expectedMarkers = ['saved-views', 'review-queue', 'crm-pipeline', 'crm-interactions', 'crm-reports', 'suppression-center', 'outreach-provider-policy', 'score-rules', 'score-distribution'];
const smokeMarkers = new Set([...html.matchAll(/data-smoke=["']([^"']+)["']/g)].map(m => m[1]));
check('dom.smoke-markers', 'critical sections expose stable smoke markers', all(expectedMarkers, marker => smokeMarkers.has(marker)), listMissing(expectedMarkers, marker => smokeMarkers.has(marker)).join(', '));
const dynamicMarkers = ['score-breakdown', 'deduplication'];
const dynamicMarkerPresent = marker => js.includes(`dataset.smoke = '${marker}'`) || js.includes(`dataset.smoke='${marker}'`) || js.includes(`data-smoke="${marker}"`) || js.includes(`data-smoke='${marker}'`);
check('dom.dynamic-markers', 'detail safety panels define dynamic smoke markers', all(dynamicMarkers, dynamicMarkerPresent), listMissing(dynamicMarkers, dynamicMarkerPresent).join(', '));
const routeContracts = [
'/api/v1/auth/me', '/api/v1/auth/login', '/api/v1/auth/logout', '/api/v1/dashboard/summary', '/api/v1/businesses',
'/api/v1/review-queue', '/api/v1/saved-filters', '/api/v1/businesses/bulk-review', '/api/v1/jobs', '/api/v1/sources',
'/api/v1/source-records', '/api/v1/discovery-queries', '/api/v1/merge-history', '/api/v1/scoring/summary', '/api/v1/score-rules',
'/api/v1/pipeline-entries', '/api/v1/interactions', '/api/v1/reports/pipeline', '/api/v1/reports/outcomes', '/api/v1/reports/activity',
'/api/v1/suppressions', '/api/v1/ai-runs', '/api/v1/outreach/drafts', '/api/v1/outreach/provider-config',
'/matches', '/domains/check', '/domain-candidates', '/websites/scan', '/contacts/extract', '/score/recalculate', '/pipeline', '/verify'
];
check('routes.contracts', 'all critical API route contracts are referenced by the client', all(routeContracts, route => js.includes(route)), listMissing(routeContracts, route => js.includes(route)).join(', '));
check('routes.authenticated', 'protected API requests include cookie credentials', js.includes("credentials:'include'") && js.includes('jsonRequest'));
check('routes.no-arbitrary-target-fetch', 'browser does not fetch arbitrary target URLs directly', !/fetch\s*\(\s*(?:url|target|website|href)\b/i.test(js));
const manifestAssets = manifest && [...new Set([...(manifest.entrypoints || []), ...(manifest.publicAssets || [])])];
check('manifest.schema', 'asset manifest has a supported schema and asset lists', Boolean(manifest && manifest.schema === 1 && Array.isArray(manifest.entrypoints) && Array.isArray(manifest.publicAssets) && manifest.integrity && manifestAssets.length), manifest ? '' : 'manifest unavailable');
if (manifestAssets) {
check('manifest.assets-exist', 'every manifest asset exists and is non-empty', all(manifestAssets, asset => files[asset]?.length > 0), listMissing(manifestAssets, asset => files[asset]?.length > 0).join(', '));
check('manifest.integrity-complete', 'manifest integrity keys exactly cover manifest assets', manifestAssets.length === Object.keys(manifest.integrity).length && all(manifestAssets, asset => Object.hasOwn(manifest.integrity, asset)), `assets=${manifestAssets.length}, integrity=${Object.keys(manifest.integrity).length}`);
const integrityFailures = manifestAssets.filter(asset => sha256(files[asset] || Buffer.alloc(0)) !== manifest.integrity[asset]);
check('manifest.integrity', 'manifest SHA-256 digests match local release assets', integrityFailures.length === 0, integrityFailures.join(', '));
check('manifest.hash-format', 'manifest integrity values use sha256 hex format', all(manifestAssets, asset => /^sha256-[0-9a-f]{64}$/.test(manifest.integrity[asset] || '')), listMissing(manifestAssets, asset => /^sha256-[0-9a-f]{64}$/.test(manifest.integrity[asset] || '')).join(', '));
}
const linkedAssets = [...new Set([...html.matchAll(/(?:src|href)=["']([^"'#?]+)(?:\?[^"']*)?["']/gi)].map(m => m[1]).filter(asset => !/^(?:https?:|data:|#)/i.test(asset)).map(asset => asset.replace(/^\.\//, '')) )];
check('assets.linked-local', 'all local HTML assets exist and are non-empty', all(linkedAssets, asset => files[asset]?.length > 0), listMissing(linkedAssets, asset => files[asset]?.length > 0).join(', '));
check('css.responsive', 'responsive CSS covers mobile layouts and critical grids', /@media\s*\(\s*max-width\s*:\s*700px\s*\)/.test(css) && all(['.saved-view-controls', '.website-scan-grid', '.crm-two-col', '.reports-grid', '.outreach-panel', '.pipeline-board'], selector => css.includes(selector)));
check('css.layout-contracts', 'critical desktop layout selectors are defined', all(['.app-shell', '.sidebar', '.workspace-grid', '.table-scroll', '.jobs-grid', '.sources-grid', '.score-config-row', '.provider-policy-row'], selector => css.includes(selector)));
const combined = `${html}\n${js}\n${css}\n${config}\n${text('README.md')}`;
const safetyCopy = ['No outreach from this workspace.', 'Suppression always wins.', 'Sending is disabled by default.', 'Approval does not send a message.', 'no outreach will be sent', 'AUTOMATED_OUTREACH_ENABLED=false'];
check('safety.no-send-copy', 'persistent no-send and approval safety copy is present', all(safetyCopy, phrase => combined.includes(phrase)), listMissing(safetyCopy, phrase => combined.includes(phrase)).join(' | '));
const buttonLabels = [...html.matchAll(/<button\b[^>]*>([\s\S]*?)<\/button>/gi)].map(m => m[1].replace(/<[^>]+>/g, ' '));
check('safety.no-send-controls', 'static HTML exposes no send, delivery, or campaign control', !buttonLabels.some(label => /\b(send|deliver|campaign|schedule delivery)\b/i.test(label)));
const approvalTokens = ['window.confirm', 'human_approval:true', 'send:false', 'autonomous_action:false'];
check('safety.approval-gated', 'approval actions require explicit human confirmation and remain non-delivering', all(approvalTokens, token => js.includes(token)), listMissing(approvalTokens, token => js.includes(token)).join(', '));
const suppressionTokens = ['statusOf', "st==='suppressed'", "renderAiState('suppressed')", "renderOutreachState('suppressed')", 'suppression', 'Do not contact'];
check('safety.suppression-precedence', 'suppression disables AI/outreach and preserves do-not-contact state', all(suppressionTokens, token => combined.includes(token)), listMissing(suppressionTokens, token => combined.includes(token)).join(', '));
check('safety.source-disabled', 'discovery remains disabled until an approved source is enabled', all(['Discovery is disabled by default.', 'enabled:false', 'This source is disabled. Enable it only after review.'], token => combined.includes(token)));
check('safety.no-external-network', 'client has no direct provider or target network origins', !/(?:fetch|XMLHttpRequest|WebSocket)\s*\(\s*[`'\"]https?:\/\//i.test(js));
const secretPatterns = [
/-----BEGIN(?: RSA| EC| OPENSSH)? PRIVATE KEY-----/i,
/(?:api[_-]?key|client[_-]?secret|access[_-]?token|refresh[_-]?token|private[_-]?key|authorization)\s*[:=]\s*["'][^"']{8,}/i,
/\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/,
/\beyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}\b/
];
const secretHits = secretPatterns.flatMap(pattern => [...combined.matchAll(new RegExp(pattern.source, `${pattern.flags.replace('g', '')}g`))].map(m => m[0].slice(0, 80)));
check('safety.no-hardcoded-secrets', 'frontend source has no obvious hardcoded secrets', secretHits.length === 0, secretHits.join(' | '));
async function httpChecks(url) {
for (const asset of [...new Set([...(manifestAssets || []), ...linkedAssets])].sort()) {
try {
const response = await fetch(`${url}/${asset}`);
const body = Buffer.from(await response.arrayBuffer());
const expected = asset.endsWith('.html') ? 'text/html' : asset.endsWith('.css') ? 'text/css' : asset.endsWith('.js') ? 'javascript' : null;
const contentType = response.headers.get('content-type') || '';
const integrityPass = !manifest?.integrity?.[asset] || sha256(body) === manifest.integrity[asset];
check(`http.${asset}`, `HTTP delivery and integrity: ${asset}`, response.ok && body.length > 0 && (!expected || contentType.includes(expected)) && integrityPass, `${response.status} ${contentType || 'missing content-type'}${integrityPass ? '' : '; integrity mismatch'}`);
} catch (error) {
check(`http.${asset}`, `HTTP delivery and integrity: ${asset}`, false, error.message);
}
}
}
let child;
try {
let httpUrl = baseUrl;
if (startServer) {
const port = Number(portArg);
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error(`invalid --port: ${portArg}`);
child = spawn('python3', ['-m', 'http.server', String(port), '--bind', '127.0.0.1', '--directory', webRoot], { stdio: ['ignore', 'ignore', 'ignore'] });
const actualPort = await new Promise((resolvePort, reject) => {
const timer = setTimeout(() => reject(new Error('timed out waiting for static server')), 5000);
const probe = async () => { const candidate = port; try { await fetch(`http://127.0.0.1:${candidate}/healthz`); clearTimeout(timer); resolvePort(candidate); } catch { setTimeout(probe, 50); } };
child.once('error', reject); probe();
});
httpUrl = `http://127.0.0.1:${actualPort}`;
}
if (httpUrl) await httpChecks(httpUrl.replace(/\/$/, ''));
} catch (error) {
check('http.server', 'optional static server is reachable', false, error.message);
} finally {
if (child) child.kill();
}
const failed = results.filter(result => !result.pass);
const report = {
schema: 1,
harness: 'prospectos-frontend-final-acceptance',
root: webRoot,
http_checks: Boolean(baseUrl || startServer),
base_url: baseUrl || (startServer ? 'managed static server' : null),
pass: failed.length === 0,
totals: { checks: results.length, passed: results.length - failed.length, failed: failed.length },
checks: results
};
console.log(JSON.stringify(report, null, 2));
process.exitCode = failed.length ? 1 : 0;
+96
View File
@@ -0,0 +1,96 @@
# Phase 17 Final Acceptance
## Decision
**Local acceptance: PASS. Production deployment: BLOCKED / not attempted.**
This decision applies to the repository at commit `9622f769776637a40fdae797adccba91445cd351` (branch `main`). The acceptance run validates the local checkout and bounded, offline pilot behavior. It is not evidence that the application was pushed to the remote repository or deployed to a production host.
## Acceptance matrix
| Area | Local evidence | Result | Production interpretation |
| --- | --- | --- | --- |
| Phases 14: baseline workflow, auth, jobs/events | API regression suite, tenant/auth/job checks | PASS locally | SQLite and the in-process worker remain pilot-only |
| Phase 5: source-adapter safety boundary | Source lifecycle, dry-run, CSV/manual-reference tests | PASS locally | No live source is enabled; terms, approval, rate, retention, and circuit controls remain prerequisites |
| Phase 6: normalization and reversible deduplication | Canonicalization, threshold, merge/reversal and tenant tests | PASS locally | Merge permission, full conflict snapshots, concurrency, and production rollback hardening remain open |
| Phase 7: domain intelligence | PSL/DNS-state/candidate and no-false-availability tests | PASS locally | No production resolver, cache, or authorized availability provider is enabled |
| Phase 8: SSRF-safe website observation | Scanner policy, limits, unsafe-target and history tests | PASS locally | Production egress isolation, DNS-rebinding/redirect testing, durable retention, and monitoring remain open |
| Phase 9: official-site contact observation | Extraction, provenance, false-positive and suppression tests | PASS locally | No SMTP probing or outreach; legal, retention, and production isolation gates remain |
| Phase 10: scoring | Versioned rules, explanations, recalculation and suppression tests | PASS locally | Production approval lifecycle, durable scheduling, snapshots, and rollback remain open |
| Phase 11: operator review workflow | Saved views, review queue, dashboard and bounded bulk-action tests | PASS locally | Production audit completeness, idempotency, per-item outcomes, and queue/count hardening remain open |
| Phase 12: CRM and suppression center | Pipeline, interactions, outcomes, reports, and suppression tests | PASS locally | Record-keeping only; no delivery provider or outbound worker exists |
| Phase 13: evidence-grounded AI assistance | Provider-disabled, evidence/citation, approval, limits, and isolation tests | PASS locally | Provider/DPA, legal, secret-management, evaluation, retention, and operational approval remain required |
| Phase 14: draft-only outreach boundary | Draft/gate/idempotency/no-send tests | PASS locally | Outreach remains disabled; consent/legal basis, provider, delivery feedback, and approval controls are not production-complete |
| Phase 15: deployment/readiness/recovery assets | Backup/restore, readiness, config, shell, and safety checks | PASS locally | Host, TLS/DNS/Virtualmin, off-host backups, monitoring, and operator access are unavailable |
| Phase 16: deterministic pilot benchmark | `scripts/benchmark_phase16.py --no-latency`; synthetic fixture report | PASS locally | Synthetic metrics do not establish production quality, capacity, availability, or SLOs |
| Phase 17: final acceptance | This matrix, bounded capacity smoke, reproducibility and blocker review | PASS locally | Production gate remains blocked until external prerequisites are verified |
## Bounded local capacity smoke
The Phase 17 smoke is deterministic, in-memory, and deliberately bounded. It is a guard against accidental unbounded retention, not a load test or capacity claim.
- Synthetic collection: 1,000 items.
- Requested page size: 100; returned items: 100; `has_more=true`.
- Synthetic large batch: 5,000 input items; retained items: 100; input was truncated.
- Enforced smoke limits: maximum page size 100 and maximum retained batch items 100.
- Smoke result: `bounded=true`.
These figures do **not** measure production throughput, concurrency, queue durability, memory pressure, latency SLOs, availability, or safe operating limits. Production capacity requires a reviewed host profile, representative workload, durable worker/database design, observability, and an approved load-test plan.
## Reproducibility and verification record
The final acceptance collector passed with:
- API suite: **100 tests, exit 0** (`python3 -m unittest discover -v -s apps/api/tests -t apps/api`).
- Phase 16 semantic benchmark without variable timing: **passed**.
- Python compilation of scripts and API modules: **passed**.
- Shell syntax checks for backup, healthcheck, restore, and rollback scripts: **passed**.
- JSON validation for the benchmark and acceptance schemas/reports: **passed**.
- Compose config validation: **passed** (`docker compose -f docker-compose.yml config --quiet`).
- Safety invariants: outreach disabled, no send network path, and tenant routes present: **passed**.
- Git state: acceptance head resolved to `9622f769776637a40fdae797adccba91445cd351`; working-tree status was recorded before documentation edits.
The deterministic benchmark reports 100% normalization accuracy, 100% matching precision/recall, 100% contact precision/recall, 100% website-fixture accuracy, reproducible scoring, and zero tenant leakage. The fixture set is synthetic and small; see `docs/BENCHMARKS.md` for methodology and limitations. Timing observations are local pilot signals only.
Repeatable local checks from the repository root:
```sh
python3 scripts/final_acceptance.py --output /tmp/prospect-final-acceptance.json
python3 scripts/benchmark_phase16.py --no-latency --output /tmp/prospect-phase16.json
python3 -m unittest discover -v -s apps/api/tests -t apps/api
python3 -m compileall -q apps/api apps/web
bash -n scripts/*.sh
docker compose config --quiet
git diff --check
```
## Blockers and prerequisites
### Remote publication
Remote push is blocked by missing repository authentication and unverified permission to push the intended branch. The configured remote is `https://repo.mmcloud.co.za/root/MarketingTool.git`; no push was attempted by this acceptance work. A local commit, passing tests, or a clean diff is not a remote publication. Resolve authentication and branch permission, then verify the remote revision independently.
### Production deployment
Deployment is blocked because no production host, Docker/Compose access, protected deployment directory, or deployment operator is available in this run. Before promotion, verify all of the following:
- reviewed remote revision and release owner;
- patched Linux host with Docker Engine/Compose v2, adequate CPU/RAM/disk, firewalling, and restricted Docker access;
- Virtualmin or equivalent perimeter, DNS control, HTTPS certificates and renewal monitoring, and private API exposure where appropriate;
- protected environment/secret manager for `SESSION_SECRET` and any one-time bootstrap values; remove bootstrap values after provisioning and rotate the password;
- encrypted off-host backup destination, checksum/restore drill, retention and legal-hold ownership;
- monitoring for health, restarts, resources, auth failures, backups, TLS, migrations, and unexpected egress;
- named rollback owner and approval to restore data if compatibility is established;
- authenticated tenant-isolation smoke tests and migration validation on the target host.
### Legal and provider enablement
Real source, AI, DNS/availability, scanner, or outreach providers are not enabled by this local acceptance. Any future provider requires an allowlisted identity, owner, approved purpose/data class, tenant scope, region/retention terms, rate and cost caps, timeout/retry/circuit policy, secret-manager injection, current product/security/legal approval, terms/DPA review where applicable, and explicit operational enablement. Outreach additionally requires jurisdiction-specific legal review, documented consent or lawful basis, suppression synchronization, human approval, delivery feedback, retention/deletion/legal-hold controls, and a tested kill switch. Until those gates are complete, `AUTOMATED_OUTREACH_ENABLED=false` remains mandatory.
## Rollback decision
**Decision: retain the current local revision; do not promote or roll back.** There is no production deployment to undo. If a future promotion fails health, integrity, tenant-isolation, migration, or smoke validation, stop traffic and writes as appropriate, record the image/config/backup revisions, restore the previously verified compatible image/config first, and restore data only after schema compatibility and incident-owner approval. Never run an older binary against an incompatible newer schema, and never use `docker compose down -v` on a data-bearing environment. Re-run liveness/readiness, authenticated isolation, and integrity checks before re-enabling traffic.
## Scope boundary
Phase 17 completes the **local acceptance documentation and verification boundary**. It does not claim remote push, host provisioning, credential availability, legal approval, provider enablement, or production deployment. Those are separate external gates and must be recorded as evidence in the release checklist before any production go decision.
+16
View File
@@ -267,3 +267,19 @@ Store the report and raw machine-readable samples with the pilot artifacts, incl
### Unresolved publication and deployment prerequisites
Do not report local benchmark or Compose success as deployment. Remote push remains blocked until repository authentication and intended remote/branch permission are supplied. Production deployment remains blocked until the Phase 15 operator prerequisites are verified: reviewed remote revision, Docker/Compose host access, protected deployment directory, secret injection, DNS/TLS/Virtualmin, encrypted off-host backup and restore evidence, monitoring, rollback owner, and operational approval. Record the blocker in the release record and keep the current outbound-disabled configuration.
## Phase 17 final acceptance and capacity smoke
The Phase 17 decision is **local acceptance PASS; production deployment BLOCKED and not attempted**. Evidence is collected at local commit `9622f769776637a40fdae797adccba91445cd351`; see `docs/FINAL_ACCEPTANCE.md` for the matrix and release record. The final collector passed 100 API tests, the no-latency Phase 16 benchmark, Python compilation, shell syntax, JSON validation, Compose config, Git state, and safety invariants.
The capacity smoke is intentionally deterministic and in-memory: 1,000 synthetic items with page size 100 returned 100 and `has_more=true`; a 5,000-item synthetic batch retained 100 and marked truncation. It verifies bounds only. Do not use it to size production hosts, infer throughput/concurrency, set SLOs, or claim durability/availability. A production capacity exercise requires an approved representative workload, host profile, observability, and durable database/worker design.
For reproducibility, retain the commit, fixture/manifest hash, algorithm and rule-set versions, runtime/dependency/image versions, non-secret configuration fingerprint, seed, locale/timezone, rounding/tie-breaking, command, host profile, timestamps, and raw results. Repeat deterministic fixtures in fresh processes and compare serialized output fields exactly. Keep latency/cache results separate from deterministic acceptance and label cache hits as non-fresh observations.
## Phase 17 release blockers and rollback decision
Remote publication remains blocked until repository authentication and intended remote/branch permission are available; no push was attempted. Production remains blocked until a reviewed remote revision, host and Docker/Compose access, protected deployment directory, secret manager, DNS/TLS/Virtualmin perimeter, encrypted off-host backup and restore drill, monitoring, rollback owner, and operational approval are verified. Real source, AI, DNS/availability, scanner, or outreach providers additionally require allowlisting, terms/DPA and legal review, data/retention policy, consent or lawful-basis approval where applicable, rate/cost/circuit controls, and explicit operational enablement. Keep outreach disabled.
**Rollback decision:** retain the local revision; there is no production deployment to undo. If a future promotion fails health, integrity, migration, tenant-isolation, or smoke checks, stop promotion/traffic and writes as needed, restore the previously verified compatible image/config first, and restore data only after compatibility and incident-owner approval. Re-run health, integrity, and authenticated isolation checks before reopening traffic. Never use `docker compose down -v` on a data-bearing environment.
These are separate states: a passing local acceptance run is not a remote push, and a remote push is not a production deployment.
+10
View File
@@ -186,3 +186,13 @@ These controls describe deployment prerequisites and gates; they do not make SQL
- Store raw samples and reports with minimum necessary data and access controls. Redact secrets, full contact values, and unnecessary fixture content from logs and audit records. Benchmark artifacts must not become an implicit source, evidence record, eligibility decision, cache authority, or outreach input.
Phase 16 does not resolve publication or deployment security gates. Remote push remains blocked pending repository authentication and intended remote/branch permission. Production remains blocked pending the Phase 15 reviewed revision, host and Docker/Compose access, protected deployment directory, secret management, DNS/TLS/Virtualmin perimeter, encrypted off-host backup/restore evidence, monitoring, rollback ownership, and explicit operational approval. A local benchmark, Compose config pass, or local commit is not a remote publication or production deployment.
## Phase 17 acceptance security boundary
Phase 17 passed local acceptance at commit `9622f769776637a40fdae797adccba91445cd351`, including 100 API tests, deterministic synthetic benchmark checks, syntax/JSON/Compose validation, and safety invariants for disabled outreach, no send-network path, and tenant routes. The bounded capacity smoke is only an in-memory guard: 1,000 synthetic records are paged at 100 and a 5,000-item synthetic batch retains at most 100. It is not a security/load assessment and proves nothing about production concurrency, availability, isolation under load, or durability.
Reproducibility evidence must bind results to the commit, fixture/manifest hash, algorithm/rule-set versions, runtime/dependency/image versions, non-secret configuration, seed, locale/timezone, rounding, tie-breaking, host profile, and command. Compare deterministic outputs across fresh processes; preserve failures and incomplete runs. Do not treat synthetic precision/recall or local timing as evidence of identity, consent, deliverability, production quality, capacity, or SLO compliance.
Remote push remains blocked by missing repository authentication and unverified branch permission. Production remains blocked until the reviewed revision, protected host/deployment directory, Docker/Compose access, secret-manager injection, DNS/TLS/Virtualmin perimeter, encrypted off-host backup/restore evidence, monitoring, rollback owner, and operational approval exist. Provider enablement also requires allowlisting, owner/purpose/data-class scope, terms/DPA and jurisdiction-specific legal review, lawful-basis/consent policy where applicable, retention/deletion/legal-hold controls, rate/cost/circuit limits, and explicit operational approval. Keep `AUTOMATED_OUTREACH_ENABLED=false` until every gate is complete.
The rollback decision is to retain the verified local revision and not promote it. If a future release fails health, integrity, migration, authorization, or smoke validation, stop traffic/writes as appropriate, preserve redacted evidence, restore the prior compatible image/config first, and restore data only after compatibility and incident-owner approval. A local pass is not a remote publication; a remote publication is not a production deployment.
+13 -1
View File
@@ -1,4 +1,16 @@
# Phase 16 pilot benchmark
# Benchmarks and final acceptance
Phase 17 final acceptance is run with:
```bash
python3 scripts/final_acceptance.py
```
It writes `final_acceptance.latest.json`, validates local artifacts, and records
explicit external blockers. See `docs/FINAL_ACCEPTANCE.md`; capacity numbers are
synthetic smoke bounds only.
## Phase 16 pilot benchmark
Run from the repository root:
@@ -0,0 +1,159 @@
{
"blockers": [
{
"gate": "remote_auth",
"reason": "Remote repository authentication and branch permission are not available to this local acceptance run.",
"status": "blocked"
},
{
"gate": "deployment_access",
"reason": "No production host, Docker/Compose, DNS/TLS, secrets, or deployment access is available; no deployment was attempted.",
"status": "blocked"
},
{
"gate": "real_provider_legal",
"reason": "Real source/provider enablement, consent/legal basis, terms, and operational approval remain explicit gates; outreach stays disabled.",
"status": "blocked"
}
],
"capacity_smoke": {
"bounded": true,
"interpretation": "Deterministic in-memory smoke only; not a production capacity or throughput claim.",
"large_batch": {
"input_items": 5000,
"retained_items": 100,
"truncated": true
},
"limits": {
"max_batch_items_retained": 100,
"max_page_size": 100
},
"pagination": {
"has_more": true,
"requested_page_size": 100,
"returned_items": 100,
"synthetic_total": 1000
}
},
"checks": {
"api_tests": {
"command": [
"/usr/bin/python3",
"-m",
"unittest",
"discover",
"-v",
"-s",
"apps/api/tests",
"-t",
"apps/api"
],
"passed": true,
"status": "passed",
"summary": "exit=0, tests=100"
},
"benchmark_no_latency": {
"command": [
"/usr/bin/python3",
"scripts/benchmark_phase16.py",
"--no-latency",
"--output",
"/tmp/prospect-phase16-acceptance.json"
],
"passed": true,
"status": "passed",
"summary": "exit=0"
},
"compose_config": {
"command": [
"docker",
"compose",
"-f",
"docker-compose.yml",
"config",
"--quiet"
],
"passed": true,
"status": "passed",
"summary": "exit=0"
},
"git_state": {
"branch_status": "exit=0",
"head": "9622f769776637a40fdae797adccba91445cd351",
"passed": true,
"status": "passed",
"working_tree_clean": false
},
"json_validation": {
"files": {
"docs/benchmarks/final_acceptance.schema.json": {
"passed": true,
"status": "valid_json"
},
"docs/benchmarks/phase16.latest.json": {
"passed": true,
"status": "valid_json"
},
"docs/benchmarks/phase16.schema.json": {
"passed": true,
"status": "valid_json"
},
"phase16_schema_contract": {
"passed": true,
"status": "schema_valid"
}
},
"passed": true
},
"py_compile": {
"command": [
"/usr/bin/python3",
"-m",
"py_compile",
"scripts/benchmark_phase16.py",
"scripts/final_acceptance.py",
"apps/api/app/__init__.py",
"apps/api/app/ai_assistance.py",
"apps/api/app/config.py",
"apps/api/app/contact_extractor.py",
"apps/api/app/domain.py",
"apps/api/app/domain_intelligence.py",
"apps/api/app/main.py",
"apps/api/app/scoring.py",
"apps/api/app/sources.py",
"apps/api/app/website_scanner.py"
],
"passed": true,
"status": "passed",
"summary": "exit=0"
},
"safety_invariants": {
"checks": {
"no_send_network": true,
"outreach_disabled": true,
"tenant_routes": true
},
"passed": true
},
"shell_syntax": {
"command": [
"bash",
"-n",
"scripts/backup_sqlite.sh",
"scripts/healthcheck.sh",
"scripts/restore_sqlite.sh",
"scripts/rollback.sh"
],
"passed": true,
"status": "passed",
"summary": "exit=0"
}
},
"limitations": [
"Synthetic capacity smoke measurements do not establish production throughput, concurrency, durability, or availability."
],
"passed": true,
"report": "phase17-final-acceptance",
"scope": "local-repository-acceptance",
"version": 1
}
@@ -0,0 +1,16 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"title": "Prospect Platform Phase 17 final acceptance report",
"type": "object",
"required": ["report", "version", "scope", "passed", "checks", "capacity_smoke", "blockers", "limitations"],
"properties": {
"report": {"const": "phase17-final-acceptance"},
"version": {"type": "integer", "const": 1},
"scope": {"const": "local-repository-acceptance"},
"passed": {"type": "boolean"},
"checks": {"type": "object", "additionalProperties": true},
"capacity_smoke": {"type": "object"},
"blockers": {"type": "array", "items": {"type": "object", "required": ["gate", "status", "reason"], "properties": {"gate": {"type": "string"}, "status": {"type": "string"}, "reason": {"type": "string"}}}},
"limitations": {"type": "array", "items": {"type": "string"}}
}
}
+178
View File
@@ -0,0 +1,178 @@
#!/usr/bin/env python3
"""Run the deterministic, local Phase 17 final-acceptance gate.
This is a release-evidence collector, not a deployment tool. It performs only
local tests and static/configuration checks; it never sends outreach or calls a
provider. Capacity figures are bounded synthetic smoke measurements, not
production capacity claims.
"""
from __future__ import annotations
import argparse
import json
import re
import shutil
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
API = ROOT / "apps" / "api"
REPORT = ROOT / "docs" / "benchmarks" / "final_acceptance.latest.json"
SCHEMA = ROOT / "docs" / "benchmarks" / "final_acceptance.schema.json"
def deterministic_json(value: object) -> str:
return json.dumps(value, indent=2, sort_keys=True, separators=(",", ": ")) + "\n"
def build_capacity_smoke() -> dict:
"""Measure fixed, in-memory bounds with no clock or network dependence."""
total = 1000
requested = 100
page = list(range(total))[0:requested]
batch_input = 5000
retained = min(batch_input, 100)
return {
"bounded": len(page) <= requested and retained <= 100,
"limits": {"max_page_size": 100, "max_batch_items_retained": 100},
"pagination": {
"synthetic_total": total,
"requested_page_size": requested,
"returned_items": len(page),
"has_more": total > requested,
},
"large_batch": {
"input_items": batch_input,
"retained_items": retained,
"truncated": batch_input > retained,
},
"interpretation": "Deterministic in-memory smoke only; not a production capacity or throughput claim.",
}
def validate_report(report: dict) -> list[str]:
required = {"report", "version", "scope", "passed", "checks", "capacity_smoke", "blockers", "limitations"}
errors = [f"missing:{key}" for key in sorted(required - set(report))]
if report.get("report") != "phase17-final-acceptance": errors.append("report:const")
if type(report.get("version")) is not int or report.get("version") != 1: errors.append("version:type-or-const")
if report.get("scope") != "local-repository-acceptance": errors.append("scope:const")
if not isinstance(report.get("passed"), bool): errors.append("passed:type")
if not isinstance(report.get("checks"), dict): errors.append("checks:type")
if not isinstance(report.get("capacity_smoke"), dict): errors.append("capacity_smoke:type")
if not isinstance(report.get("limitations"), list) or not all(isinstance(x, str) for x in report.get("limitations", [])):
errors.append("limitations:type")
blockers = report.get("blockers", [])
if not isinstance(blockers, list) or not all(isinstance(x, dict) for x in blockers):
errors.append("blockers:type")
else:
for index, blocker in enumerate(blockers):
if not {"gate", "status", "reason"}.issubset(blocker): errors.append(f"blockers[{index}]:required")
return errors
def run_command(name: str, args: list[str], *, cwd: Path = ROOT, timeout: int = 300) -> dict:
if shutil.which(args[0]) is None:
return {"passed": False, "status": "unavailable", "command": args, "summary": f"{args[0]} not installed"}
try:
completed = subprocess.run(args, cwd=cwd, text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, timeout=timeout)
except subprocess.TimeoutExpired:
return {"passed": False, "status": "timeout", "command": args, "summary": f"{name} exceeded {timeout}s"}
output = completed.stdout or ""
match = re.search(r"Ran (\d+) tests?", output)
summary = f"exit={completed.returncode}"
if match: summary += f", tests={match.group(1)}"
if completed.returncode and output:
summary += ": " + " ".join(output.strip().splitlines()[-2:])[:500]
return {"passed": completed.returncode == 0, "status": "passed" if completed.returncode == 0 else "failed", "command": args, "summary": summary}
def validate_json_files() -> dict:
files = [ROOT / "docs" / "benchmarks" / "phase16.latest.json", ROOT / "docs" / "benchmarks" / "phase16.schema.json", SCHEMA]
results = {}
parsed = {}
for path in files:
key = str(path.relative_to(ROOT))
try:
parsed[key] = json.loads(path.read_text(encoding="utf-8"))
results[key] = {"passed": True, "status": "valid_json"}
except (OSError, json.JSONDecodeError) as exc:
results[key] = {"passed": False, "status": "invalid_json", "summary": str(exc)}
phase16 = parsed.get("docs/benchmarks/phase16.latest.json")
if isinstance(phase16, dict):
required = {"benchmark", "version", "offline", "limitations", "acceptance_thresholds", "results", "checks", "passed"}
schema_ok = required.issubset(phase16) and phase16.get("benchmark") == "phase16" and phase16.get("version") == 1 and phase16.get("offline") is True and isinstance(phase16.get("passed"), bool)
results["phase16_schema_contract"] = {"passed": schema_ok, "status": "schema_valid" if schema_ok else "schema_invalid"}
else:
results["phase16_schema_contract"] = {"passed": False, "status": "schema_invalid"}
return {"passed": all(item["passed"] for item in results.values()), "files": results}
def safety_checks() -> dict:
compose = (ROOT / "docker-compose.yml").read_text(encoding="utf-8")
config = (API / "app" / "config.py").read_text(encoding="utf-8")
main = (API / "app" / "main.py").read_text(encoding="utf-8")
tests = "\n".join(p.read_text(encoding="utf-8") for p in (API / "tests").glob("test_*.py"))
checks = {
"outreach_disabled": 'AUTOMATED_OUTREACH_ENABLED: "false"' in compose and '"false"' in config and 'outreach_enabled' in main,
"no_send_network": '"network_send": False' in main and "urllib.request" not in main and "smtplib" not in main,
"tenant_routes": "organization_id=?" in main and "session_user" in main and "require_auth" in main and "tenant" in tests.lower(),
}
return {"passed": all(checks.values()), "checks": checks}
def git_state() -> dict:
result = run_command("git-state", ["git", "status", "--short", "--branch"])
head = subprocess.run(["git", "rev-parse", "HEAD"], cwd=ROOT, text=True, capture_output=True, check=False)
porcelain = subprocess.run(["git", "status", "--porcelain"], cwd=ROOT, text=True, capture_output=True, check=False)
return {
"passed": result["passed"] and head.returncode == 0 and bool(head.stdout.strip()),
"status": result["status"],
"branch_status": result.get("summary", ""),
"head": head.stdout.strip(),
"working_tree_clean": porcelain.returncode == 0 and not porcelain.stdout.strip(),
}
def build_report() -> dict:
checks = {
"api_tests": run_command("api-tests", [sys.executable, "-m", "unittest", "discover", "-v", "-s", "apps/api/tests", "-t", "apps/api"]),
"py_compile": run_command("py_compile", [sys.executable, "-m", "py_compile", *[str(p.relative_to(ROOT)) for p in sorted((ROOT / "scripts").glob("*.py"))], *[str(p.relative_to(ROOT)) for p in sorted((API / "app").glob("*.py"))]]),
"benchmark_no_latency": run_command("benchmark-no-latency", [sys.executable, "scripts/benchmark_phase16.py", "--no-latency", "--output", "/tmp/prospect-phase16-acceptance.json"]),
"shell_syntax": run_command("shell-syntax", ["bash", "-n", *[str(p.relative_to(ROOT)) for p in sorted((ROOT / "scripts").glob("*.sh"))]]),
"json_validation": validate_json_files(),
"compose_config": run_command("compose-config", ["docker", "compose", "-f", "docker-compose.yml", "config", "--quiet"]),
"git_state": git_state(),
"safety_invariants": safety_checks(),
}
blockers = [
{"gate": "remote_auth", "status": "blocked", "reason": "Remote repository authentication and branch permission are not available to this local acceptance run."},
{"gate": "deployment_access", "status": "blocked", "reason": "No production host, Docker/Compose, DNS/TLS, secrets, or deployment access is available; no deployment was attempted."},
{"gate": "real_provider_legal", "status": "blocked", "reason": "Real source/provider enablement, consent/legal basis, terms, and operational approval remain explicit gates; outreach stays disabled."},
]
return {
"report": "phase17-final-acceptance", "version": 1,
"scope": "local-repository-acceptance",
"passed": all(item.get("passed", False) for item in checks.values()),
"checks": checks,
"capacity_smoke": build_capacity_smoke(),
"blockers": blockers,
"limitations": ["Synthetic capacity smoke measurements do not establish production throughput, concurrency, durability, or availability."],
}
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--output", type=Path, default=REPORT)
args = parser.parse_args()
report = build_report()
errors = validate_report(report)
if errors: raise SystemExit("invalid acceptance report: " + ", ".join(errors))
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(deterministic_json(report), encoding="utf-8")
print(deterministic_json(report), end="")
return 0 if report["passed"] else 1
if __name__ == "__main__":
raise SystemExit(main())