From 655780ff88ae319d92a206e4e25e8fceedc08fdd Mon Sep 17 00:00:00 2001 From: Marco0300 Date: Thu, 3 Sep 2026 11:25:14 +0200 Subject: [PATCH] add configurable qualification scoring --- README.md | 12 ++++ apps/api/README.md | 12 ++++ apps/api/app/main.py | 85 ++++++++++++++++++++++++++ apps/api/app/scoring.py | 67 ++++++++++++++++++++ apps/api/schema.sql | 19 ++++++ apps/api/tests/test_phase10_scoring.py | 67 ++++++++++++++++++++ apps/web/README.md | 10 +++ apps/web/app.js | 21 ++++++- apps/web/index.html | 3 + apps/web/smoke-test.html | 5 +- apps/web/styles.css | 1 + docs/OPERATIONS.md | 12 ++++ docs/SECURITY.md | 11 ++++ 13 files changed, 321 insertions(+), 4 deletions(-) create mode 100644 apps/api/app/scoring.py create mode 100644 apps/api/tests/test_phase10_scoring.py diff --git a/README.md b/README.md index 896f334..92eabb7 100644 --- a/README.md +++ b/README.md @@ -130,6 +130,18 @@ False-positive exclusions must reject or quarantine values from asset URLs, imag Phase 9 does not authorize automated outreach. There is no SMTP probing, SMTP banner/VRFY/EXPN check, email validation message, send endpoint, campaign queue, or follow-up action. An extracted address is an observation requiring human review and explicit policy authorization before any separate future contact workflow. +## Phase 10 configurable scoring boundary + +Phase 10 separates **fit score**, **priority band**, and **contact eligibility**. A score is a deterministic ranking signal; it never authorizes contact. Rules are represented by a named, versioned rule set with explicit weights, thresholds, band definitions, eligibility gates, freshness policy, and suppression behavior. The active rule-set identifier/version is stored with each result so a historical score can be explained without silently applying today's policy. + +A reproducible calculation uses the tenant-scoped business snapshot, normalized values, eligible evidence observations, rule-set/version, algorithm/version, and calculation time/freshness inputs. Explanations must retain the contributing factors, normalized inputs or evidence references, weights/points, exclusions, uncertainty reasons, and the final band. Do not accept a client-submitted score, band, eligibility flag, or rule version as authoritative. + +Priority bands are policy labels (for example, high/medium/low or an explicitly configured equivalent) and must be derived from the versioned thresholds. Eligibility is evaluated separately and fail-closed: suppression/do-not-contact, stale or expired required evidence, unresolved/uncertain required signals, missing policy prerequisites, and authorization/tenant failures can make a prospect ineligible regardless of score. `suppressed` always wins and must remain visible; stale and uncertain observations must not be silently treated as absent or positive. + +Recalculation is an explicit, tenant-scoped operation. It must snapshot the input/rule versions, record before/after score, band, eligibility, explanation, actor/job, timestamp, and reason in the audit trail, and be idempotent or safely repeatable. A policy/rule change must not rewrite history without an auditable recalculation; partial or failed recalculation must report its incomplete state rather than presenting mixed results as current. + +Phase 10 remains a pilot boundary unless the runtime exposes all of the above controls end to end. Production work includes administrative rule-set lifecycle/approval, immutable calculation inputs, deterministic rounding/tie-breaking, scheduled recalculation with leases, retention and export semantics for explanations/audit, and regression tests proving suppression, stale, uncertain, and cross-tenant isolation behavior. See the API, security, and operations contracts for the authoritative safeguards. + ## Verification ```bash diff --git a/apps/api/README.md b/apps/api/README.md index e2469e2..21b0d26 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -138,6 +138,18 @@ Exclude false positives before persistence and response: values in scripts/style Extraction results are suggestions only and do not create a send/contact capability. The API exposes no SMTP-probe, validation-message, outreach, or campaign endpoint. If the feature is disabled, unapproved, over limit, blocked, or uncertain, fail closed with an explicit status/reason rather than an empty successful result. The current MVP remains pilot-only until extraction limits, suppression enforcement, retention/deletion jobs, provenance/audit coverage, and tenant-isolation tests are production hardened. +## Phase 10 scoring contract + +Scoring is a tenant-scoped, deterministic derivation with three separate outputs: `score` (fit/ranking), `priority_band` (versioned threshold policy), and `eligibility` (whether a later, separately authorized workflow may act). A high score or priority band never authorizes contact. The API must calculate these values server-side from a named rule set and immutable rule-set version; weights, thresholds, required evidence, freshness windows, suppression precedence, and uncertainty handling are configuration, not undocumented code defaults. + +Each result should expose the rule-set ID/version and algorithm/version, calculation timestamp, input/evidence snapshot or stable references, contributing factors, points/weights, exclusions, band decision, eligibility decision/reasons, and stale/uncertain state. Explanations are reviewable lineage, not proof of identity, consent, deliverability, or permission. Normalize and round deterministically, define tie-breaking, and reject client-supplied score/band/eligibility/version fields. + +Eligibility is evaluated independently of score. Tenant-scoped suppression/do-not-contact is an unconditional ineligible result. Required evidence that is stale, expired, missing, blocked, partial, or uncertain must produce an explicit reason and fail closed according to the active rule set; it must not be converted into a zero, a positive signal, or an empty successful result. A score may remain visible for triage while eligibility is `ineligible` or `unknown`, and suppression must remain visible after recalculation. + +Recalculation must be an explicit authenticated operation, preferably represented by the existing tenant-scoped job contract for larger sets. It must capture the requested rule-set/version, input snapshot, actor/job/idempotency lineage, started/completed time, counts and failures, and before/after score, band, eligibility, and explanation changes. Every rule change and recalculation is auditable; retries cannot duplicate or erase history, and a partial run must be marked incomplete. Audit and explanation reads use the same organization predicate as business reads, and cross-tenant IDs/jobs/rule sets behave as not found. + +The current MVP's scoring surface is limited compared with the Phase 10 contract: production still needs an authorized rule-set management API, approval/activation and rollback semantics, immutable evidence snapshots, scheduled/durable recalculation, concurrency protection, deterministic migration of old scores, and comprehensive tests for suppression, stale/uncertain evidence, audit completeness, and tenant isolation. + ## Remaining limitations and production migration work SQLite is a pilot store with an MVP job/event schema but no production migration runner, durable queue, scheduler, worker lease/recovery, durable backup command, or tested restore workflow. Authentication currently uses a development password fallback and does not by itself provide production Argon2id, MFA, CSRF protection, rate limiting, or a complete retention-grade audit system. Website scanning remains subject to pilot limits: no production egress proxy/isolation, distributed crawl coordinator, hardened resolver, or compliance-grade scan-history retention. Production work must add SSRF/DNS-rebinding/redirect-chain tests, egress policy, authenticated history/cache isolation, budget/abuse enforcement, durable result retention/deletion, observability, reviewed content/robots/terms policy, and the Phase 7 PSL/DNS/availability controls. Redis/Celery/Postgres remain future options, not implemented dependencies. diff --git a/apps/api/app/main.py b/apps/api/app/main.py index 3e4bd60..6d31cb2 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -12,12 +12,14 @@ if __package__ in (None, ""): from app.domain_intelligence import normalize_registrable_domain, resolve_domain, generate_candidate_domains from app.website_scanner import scan_website, validate_url from app.contact_extractor import extract_contacts, MAX_HTML_BYTES, MAX_RESULTS + from app.scoring import DEFAULT_RULES, signals_for_business, evaluate_score, SCORE_VERSION else: from .domain import deduplication_key, deduplicate_businesses, is_suppressed, normalize_business, score_business, normalize_domain, normalize_phone, match_businesses from .sources import adapter_for, contains_secret from .domain_intelligence import normalize_registrable_domain, resolve_domain, generate_candidate_domains from .website_scanner import scan_website, validate_url from .contact_extractor import extract_contacts, MAX_HTML_BYTES, MAX_RESULTS + from .scoring import DEFAULT_RULES, signals_for_business, evaluate_score, SCORE_VERSION ORGANIZATION_ID = "demo-tenant" SCHEMA = Path(__file__).resolve().parents[1] / "schema.sql" SESSION_DAYS = 7 @@ -57,6 +59,9 @@ def connect(db_path: str) -> sqlite3.Connection: if col not in cols: db.execute(f"ALTER TABLE businesses ADD COLUMN {col} {definition}") db.execute("UPDATE businesses SET updated_at=COALESCE(updated_at,created_at) WHERE updated_at IS NULL") db.execute("INSERT OR IGNORE INTO organizations (id,name) VALUES (?,?)", (ORGANIZATION_ID, "Demo organization")) + for organization in db.execute("SELECT id FROM organizations").fetchall(): + for rule in DEFAULT_RULES: + db.execute("INSERT OR IGNORE INTO score_rules(organization_id,code,name,description,condition_json,points,max_applications,enabled,version) VALUES(?,?,?,?,?,?,?,?,?)", (organization["id"], rule["code"], rule["name"], rule["description"], json.dumps(rule["condition_json"], sort_keys=True), rule["points"], rule["max_applications"], rule["enabled"], rule["version"])) email, password = os.environ.get("BOOTSTRAP_ADMIN_EMAIL"), os.environ.get("BOOTSTRAP_ADMIN_PASSWORD") if email and password and not db.execute("SELECT id FROM users WHERE email=?", (email.strip().lower(),)).fetchone(): ph, salt = hash_password(password); db.execute("INSERT INTO users (organization_id,email,password_hash,password_salt,role) VALUES (?,?,?,?,?)", (ORGANIZATION_ID,email.strip().lower(),ph,salt,"owner")) @@ -120,6 +125,8 @@ class ApiHandler(BaseHTTPRequestHandler): return self.send_json(200,{"items":[dict(r) for r in db.execute("SELECT id,email,role,organization_id,created_at FROM users WHERE organization_id=? ORDER BY id",(org,))]}) if path=="/api/v1/dashboard/summary": row=db.execute("SELECT COUNT(*) businesses,COALESCE(AVG(score),0) average_score FROM businesses WHERE organization_id=?",(org,)).fetchone(); return self.send_json(200,{"organization_id":org,"businesses":row["businesses"],"average_score":round(row["average_score"],2),"suppressed":db.execute("SELECT COUNT(*) FROM suppressions WHERE organization_id=?",(org,)).fetchone()[0]}) + if path=="/api/v1/score-rules": return self.list_score_rules(db,org) + if path=="/api/v1/scoring/summary": return self.scoring_summary(db,org) if path=="/api/v1/businesses": return self.list_businesses(db,org,parse_qs(parsed.query)) if path=="/api/v1/merge-history": return self.list_merge_history(db,org) if path=="/api/v1/sources": return self.list_sources(db,org) @@ -356,6 +363,81 @@ class ApiHandler(BaseHTTPRequestHandler): db.execute("UPDATE jobs SET status='queued',error_code=NULL,completed_at=NULL,updated_at=CURRENT_TIMESTAMP WHERE id=?",(jid,));self.add_job_event(db,jid,user["organization_id"],"retry","Job retry queued",job["progress"]);self.audit(db,user,"job.retried",str(jid));db.commit();getattr(self.server,"job_wakeup",threading.Event()).set();return self.send_json(200,job_json(db.execute("SELECT * FROM jobs WHERE id=?",(jid,)).fetchone())) return self.send_json(404,{"error":"not_found"}) + def ensure_score_rules(self, db, org): + for rule in DEFAULT_RULES: + db.execute("INSERT OR IGNORE INTO score_rules(organization_id,code,name,description,condition_json,points,max_applications,enabled,version) VALUES(?,?,?,?,?,?,?,?,?)", (org, rule["code"], rule["name"], rule["description"], json.dumps(rule["condition_json"], sort_keys=True), rule["points"], rule["max_applications"], rule["enabled"], rule["version"])) + + def list_score_rules(self, db, org): + self.ensure_score_rules(db, org); db.commit() + rows = db.execute("SELECT * FROM score_rules WHERE organization_id=? ORDER BY code,id", (org,)).fetchall() + items = [] + for row in rows: + item = row_json(row) + try: item["condition_json"] = json.loads(item["condition_json"]) + except (TypeError, ValueError): item["condition_json"] = {} + item["enabled"] = bool(item["enabled"]); items.append(item) + return self.send_json(200, {"organization_id": org, "items": items}) + + def create_score_rule(self, payload, db, user): + code = str(payload.get("code", "")).strip(); name = str(payload.get("name", "")).strip(); condition = payload.get("condition_json", payload.get("condition", {})) + try: points = int(payload.get("points", 0)); maximum = int(payload.get("max_applications", 1)); version = int(payload.get("version", 1)) + except (TypeError, ValueError): return self.send_json(400, {"error": "invalid_rule"}) + if not code or not name or not isinstance(condition, dict) or maximum < 1 or version < 1 or points < -100 or points > 100: return self.send_json(400, {"error": "invalid_rule"}) + try: + cur = db.execute("INSERT INTO score_rules(organization_id,code,name,description,condition_json,points,max_applications,enabled,version) VALUES(?,?,?,?,?,?,?,?,?)", (user["organization_id"], code, name, str(payload.get("description", "")), json.dumps(condition, sort_keys=True), points, maximum, int(bool(payload.get("enabled", True))), version)) + except sqlite3.IntegrityError: return self.send_json(409, {"error": "duplicate_rule"}) + self.audit(db, user, "score_rule.created", code); db.commit() + row = db.execute("SELECT * FROM score_rules WHERE id=?", (cur.lastrowid,)).fetchone(); item = row_json(row); item["condition_json"] = condition; item["enabled"] = bool(item["enabled"]) + return self.send_json(201, item) + + def update_score_rule(self, rid, payload, db, user): + row = db.execute("SELECT * FROM score_rules WHERE id=? AND organization_id=?", (rid, user["organization_id"])).fetchone() + if not row: return self.send_json(404, {"error": "not_found"}) + allowed = {"name", "description", "condition_json", "points", "max_applications", "enabled", "version"}; values = {k: payload[k] for k in allowed if k in payload} + if not values: return self.send_json(400, {"error": "no_changes"}) + if "condition_json" in values and not isinstance(values["condition_json"], dict): return self.send_json(400, {"error": "invalid_rule"}) + if "points" in values: + try: values["points"] = int(values["points"]) + except (TypeError, ValueError): return self.send_json(400, {"error": "invalid_rule"}) + columns=[]; params=[] + for key, value in values.items(): columns.append(key + "=?"); params.append(json.dumps(value, sort_keys=True) if key == "condition_json" else (int(bool(value)) if key == "enabled" else value)) + params += [rid, user["organization_id"]]; db.execute("UPDATE score_rules SET " + ",".join(columns) + ",updated_at=CURRENT_TIMESTAMP WHERE id=? AND organization_id=?", params); self.audit(db, user, "score_rule.updated", str(rid)); db.commit() + item = row_json(db.execute("SELECT * FROM score_rules WHERE id=?", (rid,)).fetchone()) + try: item["condition_json"] = json.loads(item["condition_json"]) + except (TypeError, ValueError): item["condition_json"] = {} + item["enabled"] = bool(item["enabled"]); return self.send_json(200, item) + + def recalculate_score(self, bid, payload, db, user): + org = user["organization_id"]; business = self.business(db, bid, org) + if not business: return self.send_json(404, {"error": "not_found"}) + scans = db.execute("SELECT result_json,classification,scanned_at FROM website_scans WHERE business_id=? AND organization_id=? ORDER BY id DESC LIMIT 1", (bid, org)).fetchone(); website = {"classification": business["website_class"]} + if scans: + try: website.update(json.loads(scans["result_json"] or "{}")) + except (TypeError, ValueError): pass + website["classification"] = scans["classification"] + contacts = [dict(r) for r in db.execute("SELECT public_business,suppressed,do_not_contact FROM contact_extractions WHERE business_id=? AND organization_id=?", (bid, org))] + drow = db.execute("SELECT status,result_json,checked_at FROM domain_checks WHERE business_id=? AND organization_id=? ORDER BY id DESC LIMIT 1", (bid, org)).fetchone(); domain = dict(drow) if drow else {} + if drow: + try: domain.update(json.loads(drow["result_json"] or "{}")) + except (TypeError, ValueError): pass + suppressed = is_suppressed(dict(business), [dict(r) for r in db.execute("SELECT kind,value FROM suppressions WHERE organization_id=?", (org,))]) + signals = signals_for_business(dict(business), website, contacts, domain, suppressed); self.ensure_score_rules(db, org); rules = [dict(r) for r in db.execute("SELECT * FROM score_rules WHERE organization_id=?", (org,))]; result = evaluate_score(signals, rules) + override_score = payload.get("override_score"); override_eligible = payload.get("override_eligible") + if override_score is not None or override_eligible is not None: + reason = str(payload.get("override_reason", "")).strip() + if not reason: return self.send_json(400, {"error": "override_reason_required"}) + if override_score is not None: result["score"] = max(0, min(100, int(override_score))) + if override_eligible is not None and not suppressed: result["eligible"] = bool(override_eligible) + result["priority_band"] = "ineligible" if not result["eligible"] else ("high" if result["score"] >= 70 else "medium" if result["score"] >= 40 else "low") + db.execute("UPDATE businesses SET score=?,score_version=?,score_factors=?,updated_at=CURRENT_TIMESTAMP WHERE id=? AND organization_id=?", (result["score"], SCORE_VERSION, json.dumps(result["explanations"], sort_keys=True), bid, org)) + cur=db.execute("INSERT INTO score_history(organization_id,business_id,score,eligible,priority_band,score_version,explanations_json,signals_json,override_score,override_eligible,override_reason,actor_user_id) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)", (org,bid,result["score"],int(result["eligible"]),result["priority_band"],SCORE_VERSION,json.dumps(result["explanations"],sort_keys=True),json.dumps(signals,sort_keys=True),override_score,override_eligible,payload.get("override_reason"),user["id"])) + self.audit(db,user,"business.score_recalculated",f"{bid}:{result['score']}"); db.commit(); result.update({"business_id": bid, "history_id": cur.lastrowid}); return self.send_json(200, result) + + def scoring_summary(self, db, org): + row=db.execute("SELECT COUNT(*) businesses,COALESCE(AVG(score),0) average_score,SUM(CASE WHEN score>=70 THEN 1 ELSE 0 END) high_priority FROM businesses WHERE organization_id=? AND merge_status='active'",(org,)).fetchone() + bands={r["priority_band"]:r["count"] for r in db.execute("SELECT priority_band,COUNT(*) count FROM score_history WHERE organization_id=? GROUP BY priority_band",(org,))} + return self.send_json(200,{"organization_id":org,"businesses":row["businesses"],"average_score":round(row["average_score"],2),"high_priority":row["high_priority"] or 0,"bands":bands,"history_count":db.execute("SELECT COUNT(*) FROM score_history WHERE organization_id=?",(org,)).fetchone()[0]}) + def list_businesses(self,db,org,query): def number(name, default=None): raw=query.get(name,[None])[0] @@ -390,6 +472,8 @@ class ApiHandler(BaseHTTPRequestHandler): return self.create_job(self.read_json(),db,user) if user["role"] not in MUTATING_ROLES:return self.send_json(403,{"error":"forbidden"}) payload=self.read_json(); org=user["organization_id"] + if path=="/api/v1/score-rules": return self.create_score_rule(payload,db,user) + if len(path.split("/"))==7 and path.split("/")[3:]==["businesses",path.split("/")[4],"score","recalculate"]: return self.recalculate_score(int(path.split("/")[4]) if path.split("/")[4].isdigit() else -1,payload,db,user) if path.startswith("/api/v1/jobs/"): return self.job_action(db,user,path) if path=="/api/v1/businesses":return self.create_business(payload,db,user) @@ -422,6 +506,7 @@ class ApiHandler(BaseHTTPRequestHandler): if not user:return if user["role"] not in MUTATING_ROLES:return self.send_json(403,{"error":"forbidden"}) bits=path.split("/") + if len(bits)==5 and bits[:4]==["","api","v1","score-rules"] and bits[4].isdigit(): return self.update_score_rule(int(bits[4]),self.read_json(),db,user) if len(bits)==5 and bits[:4]==["","api","v1","sources"] and bits[4].isdigit(): return self.update_source(int(bits[4]),self.read_json(),db,user) if len(bits)==6 and bits[:4]==["","api","v1","businesses"] and bits[5]=="pipeline":return self.update_pipeline(int(bits[4]) if bits[4].isdigit() else -1,self.read_json(),db,user) return self.send_json(404,{"error":"not_found"}) diff --git a/apps/api/app/scoring.py b/apps/api/app/scoring.py new file mode 100644 index 0000000..0b71702 --- /dev/null +++ b/apps/api/app/scoring.py @@ -0,0 +1,67 @@ +"""Deterministic, explainable qualification scoring.""" +from __future__ import annotations +import json +from datetime import datetime, timezone + +SCORE_VERSION = "phase10-1" +DEFAULT_RULES = [ + {"code": "business_name", "name": "Named business", "description": "Business has a usable name", "condition_json": {"signal": "business.name", "operator": "present"}, "points": 15, "max_applications": 1, "enabled": 1, "version": 1}, + {"code": "business_site", "name": "Business website", "description": "Business has a non-social website", "condition_json": {"signal": "business.website_class", "operator": "equals", "value": "business_site"}, "points": 20, "max_applications": 1, "enabled": 1, "version": 1}, + {"code": "website_healthy", "name": "Healthy website", "description": "Latest website scan is healthy", "condition_json": {"signal": "website.classification", "operator": "equals", "value": "healthy"}, "points": 15, "max_applications": 1, "enabled": 1, "version": 1}, + {"code": "contact_email", "name": "Email contact", "description": "A direct business email is available", "condition_json": {"signal": "business.email", "operator": "present"}, "points": 15, "max_applications": 1, "enabled": 1, "version": 1}, + {"code": "contact_phone", "name": "Phone contact", "description": "A business phone is available", "condition_json": {"signal": "business.phone", "operator": "present"}, "points": 10, "max_applications": 1, "enabled": 1, "version": 1}, + {"code": "extracted_contact", "name": "Extracted contact", "description": "A public, non-suppressed contact was extracted", "condition_json": {"signal": "contacts.public_count", "operator": "gte", "value": 1}, "points": 10, "max_applications": 1, "enabled": 1, "version": 1}, + {"code": "domain_verified", "name": "Domain check", "description": "Domain check resolved successfully", "condition_json": {"signal": "domain.status", "operator": "in", "value": ["resolved", "ok", "healthy"]}, "points": 5, "max_applications": 1, "enabled": 1, "version": 1}, + {"code": "verified_business", "name": "Verified business", "description": "Business has been verified", "condition_json": {"signal": "state.verified", "operator": "truthy"}, "points": 10, "max_applications": 1, "enabled": 1, "version": 1}, +] + +def _get(data, path): + value = data + for part in str(path).split("."): + if not isinstance(value, dict): return None + value = value.get(part) + return value + +def _match(condition, signals): + if not isinstance(condition, dict): return False + if "all" in condition: return all(_match(c, signals) for c in condition["all"]) + if "any" in condition: return any(_match(c, signals) for c in condition["any"]) + if "not" in condition: return not _match(condition["not"], signals) + value = _get(signals, condition.get("signal", "")); op = condition.get("operator", "truthy"); expected = condition.get("value") + section = signals.get(str(condition.get("signal", "")).split(".")[0], {}) + if isinstance(section, dict) and (section.get("stale") or section.get("uncertain")): return False + if op in ("truthy", "present"): return bool(value) if op == "truthy" else value not in (None, "", [], {}) + if op == "equals": return value == expected + if op == "in": return value in (expected if isinstance(expected, list) else [expected]) + if op in ("gte", "lte", "gt", "lt"): + try: return {"gte": value >= expected, "lte": value <= expected, "gt": value > expected, "lt": value < expected}[op] + except (TypeError, ValueError): return False + return False + +def evaluate_score(signals, rules): + total = 0; explanations = [] + ordered = sorted((dict(r) for r in rules), key=lambda r: (str(r.get("code", "")), int(r.get("id", 0) or 0))) + for rule in ordered: + enabled = bool(rule.get("enabled", 1)); applied = enabled and _match(_condition(rule), signals) + points = int(rule.get("points", 0) or 0) if applied else 0 + total += points + explanations.append({"code": rule.get("code", ""), "name": rule.get("name", rule.get("code", "")), "version": int(rule.get("version", 1) or 1), "enabled": enabled, "applied": applied, "points": points, "reason": (rule.get("description") or rule.get("name") or rule.get("code") or "Rule") + (" (matched)" if applied else " (not matched)")}) + total = max(0, min(100, total)); state = signals.get("state", {}) if isinstance(signals, dict) else {} + eligible = not bool(state.get("suppressed")) and str(state.get("merge_status", "active")) == "active" + if not eligible: band = "ineligible" + elif total >= 70: band = "high" + elif total >= 40: band = "medium" + else: band = "low" + return {"score": total, "score_version": SCORE_VERSION, "eligible": eligible, "priority_band": band, "explanations": explanations} + +def _condition(rule): + raw = rule.get("condition_json", {}) + if isinstance(raw, str): + try: return json.loads(raw) + except (TypeError, ValueError): return {} + return raw + +def signals_for_business(business, website=None, contacts=None, domain=None, suppressed=False): + b = dict(business); website = website or {}; contacts = contacts or []; domain = domain or {} + public = [c for c in contacts if c.get("public_business") and not c.get("suppressed") and not c.get("do_not_contact")] + return {"business": {"name": b.get("name", ""), "email": b.get("email", ""), "phone": b.get("phone", ""), "description": b.get("description", ""), "website_domain": b.get("website_domain", ""), "website_class": b.get("website_class", "")}, "website": website, "contacts": {"count": len(contacts), "public_count": len(public)}, "domain": domain, "state": {"verified": bool(b.get("verified")), "suppressed": bool(suppressed), "merge_status": b.get("merge_status", "active"), "merged": b.get("merge_status") == "merged"}} diff --git a/apps/api/schema.sql b/apps/api/schema.sql index fd42d32..1755863 100644 --- a/apps/api/schema.sql +++ b/apps/api/schema.sql @@ -212,3 +212,22 @@ CREATE TABLE IF NOT EXISTS contact_extractions ( ); CREATE INDEX IF NOT EXISTS idx_contact_extractions_org ON contact_extractions(organization_id,created_at DESC,id DESC); CREATE INDEX IF NOT EXISTS idx_contact_extractions_business ON contact_extractions(organization_id,business_id,id DESC); + +-- Phase 10 deterministic qualification rules and immutable score audit. +CREATE TABLE IF NOT EXISTS score_rules ( + id INTEGER PRIMARY KEY AUTOINCREMENT, organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + code TEXT NOT NULL, name TEXT NOT NULL, description TEXT NOT NULL DEFAULT '', condition_json TEXT NOT NULL DEFAULT '{}', + points INTEGER NOT NULL DEFAULT 0, max_applications INTEGER NOT NULL DEFAULT 1, enabled INTEGER NOT NULL DEFAULT 1, + version INTEGER NOT NULL DEFAULT 1, created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(organization_id,code) +); +CREATE INDEX IF NOT EXISTS idx_score_rules_org ON score_rules(organization_id,enabled,code); +CREATE TABLE IF NOT EXISTS score_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, organization_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE, + business_id INTEGER NOT NULL REFERENCES businesses(id) ON DELETE CASCADE, score INTEGER NOT NULL, eligible INTEGER NOT NULL, + priority_band TEXT NOT NULL, score_version TEXT NOT NULL, explanations_json TEXT NOT NULL DEFAULT '[]', signals_json TEXT NOT NULL DEFAULT '{}', + override_score INTEGER, override_eligible INTEGER, override_reason TEXT, actor_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_score_history_business ON score_history(organization_id,business_id,created_at DESC,id DESC); +CREATE INDEX IF NOT EXISTS idx_score_history_org ON score_history(organization_id,created_at DESC,id DESC); diff --git a/apps/api/tests/test_phase10_scoring.py b/apps/api/tests/test_phase10_scoring.py new file mode 100644 index 0000000..bf3a750 --- /dev/null +++ b/apps/api/tests/test_phase10_scoring.py @@ -0,0 +1,67 @@ +import json +import os +import sqlite3 +import threading +import unittest +from http.client import HTTPConnection +from tempfile import TemporaryDirectory + +from app.main import create_server +from app.scoring import DEFAULT_RULES, evaluate_score + + +class ScoringEngineTests(unittest.TestCase): + def test_defaults_are_deterministic_and_emit_explanations_and_band(self): + signals = {"business": {"name": "Acme", "email": "a@acme.test", "website_domain": "acme.test"}, "website": {"classification": "healthy"}, "state": {"suppressed": False}} + first = evaluate_score(signals, DEFAULT_RULES) + self.assertEqual(first, evaluate_score(signals, DEFAULT_RULES)) + self.assertEqual(0 <= first["score"] <= 100, True) + self.assertEqual(first["priority_band"], "medium") + self.assertTrue(all("code" in item and "reason" in item for item in first["explanations"])) + + def test_disabled_and_versioned_rules_change_score_without_nondeterminism(self): + signals = {"business": {"name": "Acme"}, "state": {"suppressed": False}} + enabled = evaluate_score(signals, [{"code": "x", "name": "X", "condition_json": {"signal": "business.name", "operator": "present"}, "points": 30, "enabled": 1, "version": 1}]) + disabled = evaluate_score(signals, [{"code": "x", "name": "X", "condition_json": {"signal": "business.name", "operator": "present"}, "points": 30, "enabled": 0, "version": 2}]) + self.assertEqual(enabled["score"], 30) + self.assertEqual(disabled["score"], 0) + + def test_suppression_is_ineligible_and_stale_uncertain_signals_do_not_penalize(self): + signals = {"business": {"name": "Acme"}, "website": {"classification": "unknown", "stale": True}, "domain": {"status": "error"}, "state": {"suppressed": True}} + result = evaluate_score(signals, DEFAULT_RULES) + self.assertFalse(result["eligible"]) + self.assertEqual(result["priority_band"], "ineligible") + self.assertNotIn("negative", json.dumps(result["explanations"]).lower()) + + +class ScoringApiTests(unittest.TestCase): + def setUp(self): + self.tmp = TemporaryDirectory(); self.db_path = self.tmp.name + "/db.sqlite" + os.environ["BOOTSTRAP_ADMIN_EMAIL"] = "score-owner@example.test"; os.environ["BOOTSTRAP_ADMIN_PASSWORD"] = "password" + self.server = create_server("127.0.0.1", 0, self.db_path); threading.Thread(target=self.server.serve_forever, daemon=True).start() + self.conn = HTTPConnection("127.0.0.1", self.server.server_port, timeout=4); self.cookie = None + self.request("POST", "/api/v1/auth/login", {"email": "score-owner@example.test", "password": "password"}) + def tearDown(self): + self.server.shutdown(); self.server.server_close(); self.tmp.cleanup() + def request(self, method, path, payload=None): + body = json.dumps(payload).encode() if payload is not None else None; headers = {"Content-Type": "application/json"} if body else {} + if self.cookie: headers["Cookie"] = self.cookie + self.conn.request(method, path, body, headers); response = self.conn.getresponse(); cookie = response.getheader("Set-Cookie") + if cookie: self.cookie = cookie.split(";", 1)[0] + return response.status, json.loads(response.read() or b"{}") + def test_rule_crud_tenant_scope_and_recalculation_audit(self): + status, rules = self.request("GET", "/api/v1/score-rules"); self.assertEqual(status, 200); self.assertEqual(len(rules["items"]), len(DEFAULT_RULES)) + status, rule = self.request("POST", "/api/v1/score-rules", {"code": "custom", "name": "Custom", "condition_json": {"signal": "business.name", "operator": "present"}, "points": 7}); self.assertEqual(status, 201) + self.assertEqual(self.request("PATCH", f"/api/v1/score-rules/{rule['id']}", {"enabled": False})[0], 200) + _, business = self.request("POST", "/api/v1/businesses", {"name": "Acme", "website": "https://acme.test", "email": "a@acme.test"}) + status, result = self.request("POST", f"/api/v1/businesses/{business['id']}/score/recalculate", {"reason": "qa"}); self.assertEqual(status, 200) + self.assertIn("explanations", result); self.assertIn("eligible", result); self.assertEqual(result["business_id"], business["id"]) + status, summary = self.request("GET", "/api/v1/scoring/summary"); self.assertEqual(status, 200); self.assertEqual(summary["businesses"], 1); self.assertGreaterEqual(summary["history_count"], 1) + db = sqlite3.connect(self.db_path); self.assertEqual(db.execute("SELECT COUNT(*) FROM score_history").fetchone()[0], 1); db.close() + def test_suppressed_business_recalculation_is_force_ineligible(self): + _, business = self.request("POST", "/api/v1/businesses", {"name": "Acme", "website": "https://acme.test"}) + self.request("POST", "/api/v1/suppressions", {"kind": "domain", "value": "acme.test"}) + status, result = self.request("POST", f"/api/v1/businesses/{business['id']}/score/recalculate", {}); self.assertEqual(status, 200); self.assertFalse(result["eligible"]) + + +if __name__ == "__main__": unittest.main() diff --git a/apps/web/README.md b/apps/web/README.md index 6ae7ea4..9fb30c6 100644 --- a/apps/web/README.md +++ b/apps/web/README.md @@ -74,6 +74,16 @@ Extraction is not a browser crawler. The browser must not fetch target pages dir The API remains authoritative for official-site scope, tenant isolation, suppression enforcement, limits, retention, provenance, and permissions. A cached extraction must show its observed time and freshness, never “live.” Candidate confidence, role/free-mail labels, syntax, and MX/DNS uncertainty are review metadata only and cannot enable a contact action. +## Phase 10 scoring UI contract + +The UI may display server-provided `score`, `priority_band`, and `eligibility` as separate fields. It should show the active rule-set ID/version, calculated time/freshness, explanation factors with their points/weights, exclusions, and stale/uncertain reasons. A band is a triage label, not permission to contact; never derive or override these values solely in browser code. + +Render eligibility independently and prominently: **Eligible**, **Ineligible**, or **Unknown/review required** must not be collapsed into a score band. Show suppression/do-not-contact as a hard, persistent state that wins over score, verification, pipeline, cached data, or refresh. Keep stale, expired, blocked, partial, and uncertain evidence visibly distinct from missing evidence and never present them as a positive or negative fact. There is no outreach control in this phase. + +If the API exposes recalculation, the UI must show the requested rule-set/version, job/progress/partial state, actor/time, and before/after explanation or band changes; acceptance of a request is not completion. Recalculation history and audit details remain tenant-scoped server capabilities. Rule-set administration, activation, rollback, and eligibility policy are not client-side authorization controls. + +Phase 10 remains a pilot display contract until the API supplies stable versioned rule metadata, reproducible input lineage, complete explanation payloads, explicit eligibility reasons, and audited recalculation results. Browser smoke coverage should include score/band disagreement with eligibility, suppression precedence, stale/uncertain rendering, version changes, partial recalculation, and cross-tenant non-disclosure. + ## Remaining limitations The static client has no client-side crawler, scanner, contact extractor, enrichment scheduler, outreach integration, availability provider, or SSE delivery. It can display server-provided Phase 9 observations, but production still requires server-side official-site scoping, SSRF/DNS-rebinding/redirect controls, hard extraction/page/byte/time/candidate budgets, durable history/cache isolation and retention/deletion, abuse/rate controls, suppression regression tests, and authenticated provenance/audit coverage. For domain intelligence, display registrable-domain/PSL version and unresolved reasons, DNS status and freshness/TTL (not “available”), independent MX/NS/TXT uncertainty, and association confidence with explainable evidence. Never auto-attach candidate domains or treat `nxdomain`/`no_data` as availability. CSV preview is capped for display and is not an import workflow. diff --git a/apps/web/app.js b/apps/web/app.js index 6ecd65e..66897b8 100644 --- a/apps/web/app.js +++ b/apps/web/app.js @@ -87,7 +87,22 @@ function renderContactExtractionPanel(p){const panel=$('contactExtractionPanel');if(!panel)return;panel.innerHTML=`

PUBLIC CONTACT EXTRACTION

Extracted contacts ${extractedContactItems(p.extracted_contacts||p.contact_extraction).length}

Extraction uses approved public business pages only. It does not probe SMTP, verify mailbox access, or send outreach.

${p.extracted_contacts||p.contact_extraction?renderExtractedContactsMarkup(p.extracted_contacts||p.contact_extraction):'

No public contacts extracted yet. Start an extraction to review evidence.

'}
`;} function renderExtractedContactsMarkup(payload){const contacts=extractedContactItems(payload);if(!contacts.length)return '

No public contacts extracted yet. Start an extraction to review evidence.

';return '

Previously extracted contacts are available. Refresh to check approved public business pages again.

';} async function loadContactExtraction(id,{extract=false}={}){const state=$('contactExtractionState'),extractButton=$('extractContactsBtn'),refreshButton=$('refreshContactExtractionBtn');if(!state||!id)return;[extractButton,refreshButton].forEach(button=>{if(button)button.disabled=true;});state.innerHTML='
Loading public contacts…
';try{const scan=selectedDetail?.website_scan||selectedDetail?.websiteScan||{};const payload={approved_public_pages_only:true,smtp_probing:false,outreach:false,website_scan_id:scan.id,source_url:scan.final_url||scan.input_url,html:scan.html};const result=await jsonRequest(extract?`/api/v1/businesses/${encodeURIComponent(id)}/contacts/extract`:`/api/v1/contact-extractions?business_id=${encodeURIComponent(id)}`,extract?{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)}:{});if(selectedDetail&&Number(selectedDetail.id)===Number(id))selectedDetail={...selectedDetail,extracted_contacts:result.contacts||result.extracted_contacts||result.items||result};renderExtractedContacts(result);}catch(error){if(error.message!=='unauthorized')state.innerHTML=``;}finally{[extractButton,refreshButton].forEach(button=>{if(button)button.disabled=false;});}} - function renderDetail(p){const s=scoreFor(p),st=statusOf(p),f=freshness(p),factors=p.score_factors||p.factors||[],blocked=st==='review'||st==='suppressed',stage=p.pipeline_stage||p.pipeline?.stage||(Array.isArray(p.pipeline)?p.pipeline.at(-1)?.stage:'')||'new',contacts=p.contacts||[],domains=p.domains||[],websites=p.websites||[],evidence=p.evidence||p.evidence_timeline||[],notes=p.notes||[],review=p.review_status||p.review|| (st==='review'?'Needs review':st[0].toUpperCase()+st.slice(1));$('detailPanel').innerHTML=`

PROSPECT DETAIL

${esc(p.name)}

${esc(p.website_domain||'no detected website')}

${esc(review)}
Fit score${s}/ 100
${esc(p.confidence||(s>=80?'High':s>=60?'Medium':'Low'))} confidence

Pipeline stage

Contacts ${contacts.length}

${listItems(contacts,'No contacts added.','email')}

Loading public contacts…
Loading website scan…
Loading domain intelligence…

Domains & websites

${listItems(domains,'No domains recorded.','domain')}${listItems(websites,'No websites recorded.','url')}

Evidence timeline

${listItems(evidence,'No evidence events recorded.','description')}${factors.length?factors.map(x=>`

✓ ${esc(labelFactor(x))}${esc(p.confidence||'Medium')}

`).join(''):''}

Notes ${notes.length}

${listItems(notes,'No notes added.','body')}

Review status

${esc(review)}

${st!=='suppressed'?'':''}

${blocked?`

${st==='suppressed'?'Suppressed records cannot be contacted.':'Review this prospect before outreach is available.'}

`:''}`;renderContactExtractionPanel(p);loadContactExtraction(p.id);renderWebsiteScanPanel(p);renderDomainPanel(p);renderDedupPanel();} + const scoreItems = payload => payloadItems(payload, ['rules','score_rules','items','factors']); + const scoreValue = (item, keys, fallback='Unknown') => { for (const key of keys) if (item?.[key] !== undefined && item[key] !== null && item[key] !== '') return item[key]; return fallback; }; + const scoreRole = () => String(currentUser?.role || currentUser?.roles?.[0] || '').toLowerCase(); + const canEditScores = () => ['owner','admin'].includes(scoreRole()) || Boolean(currentUser?.permissions?.includes?.('score_rules:write')); + const scoreExplanation = factor => typeof factor === 'string' ? {name:labelFactor(factor), points:scoreValue(({named_business:20,business_site:30,email:25,phone:15,description:10}),[factor],0)} : factor || {}; + function renderScoreConfig(payload) { const panel=$('scoreRulesPanel'); if(!panel)return; const rules=scoreItems(payload), version=scoreValue(payload,['version','score_version','scoring_version'],'Unknown'), editable=canEditScores(); panel.innerHTML=`

CONFIGURATION

Score rules

${editable?'Admin controls available':'Read-only for this role'}

Rules are workspace configuration. ${editable?'Changes require an authorized admin action.':'Your role can view rules but cannot edit them.'}

${rules.length?rules.map(rule=>`
${esc(scoreValue(rule,['label','name','rule','key'],'Scoring rule'))}${scoreValue(rule,['enabled','active'],false)?'Enabled':'Disabled'}${esc(scoreValue(rule,['points','weight','value'],0))} ptsv${esc(scoreValue(rule,['version','rule_version'],version))}${editable?``:''}
`).join(''):'

No score rules returned by the workspace.

'}

Configuration version: ${esc(version)}

`; } + function renderScoreDistribution(payload) { const panel=$('scoreDistributionPanel'); if(!panel)return; const buckets=payloadItems(payload,['distribution','buckets','items']); const bandMap=payload?.bands&&typeof payload.bands==='object'&&!Array.isArray(payload.bands)?payload.bands:{}; const summary=payload?.summary||payload||{}; const fallback=[['High priority',summary.high_priority??bandMap.high??0],['Medium priority',bandMap.medium??0],['Low priority',bandMap.low??0],['Ineligible',bandMap.ineligible??0]]; const rows=buckets.length?buckets.map(item=>[scoreValue(item,['label','band','name'],'Score band'),scoreValue(item,['count','total','value'],0)]):fallback; const total=rows.reduce((n,row)=>n+Number(row[1]||0),0); panel.innerHTML=`

SCORING SUMMARY

Score distribution

${total} prospects
${rows.map(([label,count])=>`
${esc(label)}${esc(count)}
`).join('')}

Distribution is tenant-scoped and reflects API results only.

`; } + async function loadScorePanels() { const distribution=$('scoreDistributionPanel'), rules=$('scoreRulesPanel'); if(distribution)distribution.innerHTML='
Loading score distribution…
'; if(rules)rules.innerHTML='
Loading score rules…
'; try { const [distributionPayload,rulesPayload]=await Promise.all([jsonRequest('/api/v1/scoring/summary'),jsonRequest('/api/v1/score-rules')]); renderScoreDistribution(distributionPayload); renderScoreConfig(rulesPayload); } catch(error) { if(error.message!=='unauthorized'){ if(distribution)distribution.innerHTML=``; if(rules)rules.innerHTML=``; } } } + async function updateScoreRule(id, currentPoints) { if(!canEditScores()||!id)return; const value=window.prompt('Points for this rule (0–100):',currentPoints); if(value===null)return; const points=Number(value); if(!Number.isInteger(points)||points<0||points>100){window.alert('Enter a whole number from 0 to 100.');return;} try { await jsonRequest(`/api/v1/score-rules/${encodeURIComponent(id)}`,{method:'PATCH',headers:{'Content-Type':'application/json'},body:JSON.stringify({points})}); await loadScorePanels(); } catch(error) { if(error.message!=='unauthorized')window.alert(error.message||'Unable to update score rule.'); } } + function renderScoreBreakdown(p) { + const panel=$('scoreBreakdownPanel'); if(!panel)return; + const score=scoreFor(p), factors=p.score_factors||p.factors||[], eligible=scoreValue(p,['eligible','score_eligible','eligibility'],'Unknown'), priority=scoreValue(p,['priority','priority_band','score_priority'],score>=80?'High':score>=60?'Medium':'Low'), version=scoreValue(p,['score_version','scoring_version'],'Unknown'); + panel.innerHTML=`

SCORING

Score breakdown

Total score${esc(score)}/ 100
Priority${esc(priority)}
Eligibility${esc(eligible)}
Score version
${esc(version)}
Signals
${factors.length}
Rule explanations
${factors.length?factors.map(raw=>{const item=scoreExplanation(raw);return `
${esc(scoreValue(item,['label','name','rule','factor'],'Scoring signal'))}${esc(scoreValue(item,['points','value','weight'],0))} pts${esc(scoreValue(item,['explanation','reason','description'],'Observed evidence contributes to this score.'))}
`}).join(''):'

No scoring signals returned.

'}

Scores are review signals only. They do not override suppression or authorize outreach.

`; + } + async function recalculateScore() { const button=$('recalculateScoreBtn'), messageEl=$('scoreMessage'); if(!button||!selectedId)return; button.disabled=true; if(messageEl)messageEl.textContent='Recalculation requested…'; try { const result=await jsonRequest(`/api/v1/businesses/${encodeURIComponent(selectedId)}/score/recalculate`,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({})}); if(messageEl)messageEl.textContent=result?.status==='queued'?'Recalculation queued. Refresh when the job completes.':'Score recalculation accepted. Refresh to see the API result.'; } catch(error) { if(error.message!=='unauthorized'&&messageEl){messageEl.textContent=error.message||'Unable to recalculate score.';messageEl.className='form-message error';} } finally {button.disabled=false;} } + function renderDetail(p){const s=scoreFor(p),st=statusOf(p),f=freshness(p),factors=p.score_factors||p.factors||[],blocked=st==='review'||st==='suppressed',stage=p.pipeline_stage||p.pipeline?.stage||(Array.isArray(p.pipeline)?p.pipeline.at(-1)?.stage:'')||'new',contacts=p.contacts||[],domains=p.domains||[],websites=p.websites||[],evidence=p.evidence||p.evidence_timeline||[],notes=p.notes||[],review=p.review_status||p.review|| (st==='review'?'Needs review':st[0].toUpperCase()+st.slice(1));$('detailPanel').innerHTML=`

PROSPECT DETAIL

${esc(p.name)}

${esc(p.website_domain||'no detected website')}

${esc(review)}
Fit score${s}/ 100
${esc(p.confidence||(s>=80?'High':s>=60?'Medium':'Low'))} confidence
Loading score breakdown…

Pipeline stage

Contacts ${contacts.length}

${listItems(contacts,'No contacts added.','email')}

Loading public contacts…
Loading website scan…
Loading domain intelligence…

Domains & websites

${listItems(domains,'No domains recorded.','domain')}${listItems(websites,'No websites recorded.','url')}

Evidence timeline

${listItems(evidence,'No evidence events recorded.','description')}${factors.length?factors.map(x=>`

✓ ${esc(labelFactor(x))}${esc(p.confidence||'Medium')}

`).join(''):''}

Notes ${notes.length}

${listItems(notes,'No notes added.','body')}

Review status

${esc(review)}

${st!=='suppressed'?'':''}

${blocked?`

${st==='suppressed'?'Suppressed records cannot be contacted.':'Review this prospect before outreach is available.'}

`:''}`;renderScoreBreakdown(p);renderContactExtractionPanel(p);loadContactExtraction(p.id);renderWebsiteScanPanel(p);renderDomainPanel(p);renderDedupPanel();} let mergeSource = null, mergeTarget = null, mergeBusy = false; const payloadItems = (payload, keys) => { for (const key of keys) if (Array.isArray(payload?.[key])) return payload[key]; return Array.isArray(payload) ? payload : []; }; const suggestionId = item => item.target_id ?? item.business_id ?? item.prospect_id ?? item.id; @@ -172,9 +187,9 @@ function renderCsv(rows){if(!rows.length){$('csvPreview').innerHTML='

No data rows found

';return;}const h=Object.keys(rows[0]);$('csvPreview').className='csv-table';$('csvPreview').innerHTML=`${h.map(x=>``).join('')}${rows.map(r=>`${h.map(x=>``).join('')}`).join('')}
${esc(x)}
${esc(r[x])}
Showing up to 10 rows · Preview only; nothing added yet.`;} async function login(event){event.preventDefault();const form=event.currentTarget,messageEl=$('loginMessage'),data=Object.fromEntries(new FormData(form).entries());messageEl.textContent='Signing in…';messageEl.className='form-message';try{const res=await fetch(endpoint('/api/v1/auth/login'),{method:'POST',headers:{'Content-Type':'application/json'},credentials:'include',body:JSON.stringify(data)});const body=await res.json().catch(()=>({}));if(!res.ok)throw new Error(body.error||'Invalid email or password.');await bootstrap();}catch(e){if(e.message!=='unauthorized'){messageEl.textContent=e.message;messageEl.className='form-message error';}}} async function logout(){try{await fetch(endpoint('/api/v1/auth/logout'),{method:'POST',credentials:'include'});}finally{showLogin('You have been signed out.');$('loginForm').reset();}} - async function bootstrap(){try{const res=await fetch(endpoint('/api/v1/auth/me'),{credentials:'include'});if(res.status===401){showLogin();return;}if(!res.ok)throw new Error('Could not verify session.');const user=await res.json();showDashboard(user.user||user);await loadData();await loadJobs();await loadSources();}catch(e){if(e.message!=='unauthorized')showLogin('Unable to connect to the workspace. Try again.');}} + async function bootstrap(){try{const res=await fetch(endpoint('/api/v1/auth/me'),{credentials:'include'});if(res.status===401){showLogin();return;}if(!res.ok)throw new Error('Could not verify session.');const user=await res.json();showDashboard(user.user||user);await loadData();await loadJobs();await loadSources();await loadScorePanels();}catch(e){if(e.message!=='unauthorized')showLogin('Unable to connect to the workspace. Try again.');}} document.addEventListener('submit',e=>{if(e.target.id==='contactForm')saveContact(e.target);if(e.target.id==='noteForm')saveNote(e.target);if(e.target.id==='pipelineForm')saveStage(e.target);}); - document.addEventListener('click',e=>{if(e.target.id==='verifyBtn')verify();if(e.target.id==='extractContactsBtn'&&selectedId)loadContactExtraction(selectedId,{extract:true});if(e.target.id==='refreshContactExtractionBtn'&&selectedId)loadContactExtraction(selectedId,{extract:true});if(e.target.id==='retryContactExtractionBtn'&&selectedId)loadContactExtraction(selectedId);if(e.target.id==='scanWebsiteBtn'&&selectedId)loadWebsiteScan(selectedId,{scan:true});if(e.target.id==='refreshWebsiteScanBtn'&&selectedId)loadWebsiteScan(selectedId);if(e.target.id==='retryWebsiteScanBtn'&&selectedId)loadWebsiteScan(selectedId);if(e.target.id==='runDomainCheckBtn')runDomainCheck();if(e.target.id==='retryDomainBtn'&&selectedId)loadDomainIntelligence(selectedId);const availability=e.target.closest?.('[data-domain-availability]');if(availability)checkDomainAvailability(availability.dataset.domain,availability);if(e.target.id==='retryDetailBtn'&&selectedId)loadDetail(selectedId);if(e.target.id==='retryDedupBtn'&&selectedId)loadMatchSuggestions(selectedId);if(e.target.id==='retryHistoryBtn'&&selectedId)loadMergeHistory(selectedId);if(e.target.id==='cancelMergeBtn'||e.target.id==='cancelMergeBtnSecondary')closeMergeDialog();if(e.target.id==='confirmMergeBtn')confirmMerge();const mergeButton=e.target.closest?.('[data-merge-target]');if(mergeButton)openMergeDialog(mergeButton.dataset.mergeTarget,mergeButton.dataset.mergeTargetName);const reverseButton=e.target.closest?.('[data-reverse-merge]');if(reverseButton)reverseMerge(reverseButton.dataset.reverseMerge);if(e.target.id==='retryJobDetailBtn'&&selectedJobId)loadJobDetail(selectedJobId);if(e.target.id==='cancelJobBtn')jobAction('cancel');if(e.target.id==='retryJobBtn')jobAction('retry');const row=e.target.closest?.('[data-job-id]');if(row)loadJobDetail(row.dataset.jobId);}); + document.addEventListener('click',e=>{if(e.target.id==='verifyBtn')verify();if(e.target.id==='recalculateScoreBtn')recalculateScore();const scoreEdit=e.target.closest?.('[data-score-edit]');if(scoreEdit)updateScoreRule(scoreEdit.dataset.scoreEdit,scoreEdit.dataset.scorePoints);if(e.target.id==='extractContactsBtn'&&selectedId)loadContactExtraction(selectedId,{extract:true});if(e.target.id==='refreshContactExtractionBtn'&&selectedId)loadContactExtraction(selectedId,{extract:true});if(e.target.id==='retryContactExtractionBtn'&&selectedId)loadContactExtraction(selectedId);if(e.target.id==='scanWebsiteBtn'&&selectedId)loadWebsiteScan(selectedId,{scan:true});if(e.target.id==='refreshWebsiteScanBtn'&&selectedId)loadWebsiteScan(selectedId);if(e.target.id==='retryWebsiteScanBtn'&&selectedId)loadWebsiteScan(selectedId);if(e.target.id==='runDomainCheckBtn')runDomainCheck();if(e.target.id==='retryDomainBtn'&&selectedId)loadDomainIntelligence(selectedId);const availability=e.target.closest?.('[data-domain-availability]');if(availability)checkDomainAvailability(availability.dataset.domain,availability);if(e.target.id==='retryDetailBtn'&&selectedId)loadDetail(selectedId);if(e.target.id==='retryDedupBtn'&&selectedId)loadMatchSuggestions(selectedId);if(e.target.id==='retryHistoryBtn'&&selectedId)loadMergeHistory(selectedId);if(e.target.id==='cancelMergeBtn'||e.target.id==='cancelMergeBtnSecondary')closeMergeDialog();if(e.target.id==='confirmMergeBtn')confirmMerge();const mergeButton=e.target.closest?.('[data-merge-target]');if(mergeButton)openMergeDialog(mergeButton.dataset.mergeTarget,mergeButton.dataset.mergeTargetName);const reverseButton=e.target.closest?.('[data-reverse-merge]');if(reverseButton)reverseMerge(reverseButton.dataset.reverseMerge);if(e.target.id==='retryJobDetailBtn'&&selectedJobId)loadJobDetail(selectedJobId);if(e.target.id==='cancelJobBtn')jobAction('cancel');if(e.target.id==='retryJobBtn')jobAction('retry');const row=e.target.closest?.('[data-job-id]');if(row)loadJobDetail(row.dataset.jobId);}); $('loginForm').addEventListener('submit',login);$('logoutBtn').addEventListener('click',logout);$('searchInput').addEventListener('input',()=>{page=1;renderRows();});['scoreFilter','statusFilter','websiteClassFilter','pipelineFilter'].forEach(id=>$(id).addEventListener('change',()=>{page=1;loadData();}));$('pageSize').addEventListener('change',e=>{pageSize=Number(e.target.value);page=1;loadData();});$('nextPageBtn').addEventListener('click',()=>{if(hasNextPage){page+=1;loadData();}});$('refreshBtn').addEventListener('click',loadData);$('jobsRefreshBtn').addEventListener('click',()=>loadJobs());$('startDemoJobBtn').addEventListener('click',startDemoJob);$('sourcesRefreshBtn').addEventListener('click',loadSources);$('sourceForm').addEventListener('submit',saveSource);$('sourceType').addEventListener('change',e=>{$('sourceCsvField').hidden=e.target.value!=='csv';});$('discoveryForm').addEventListener('submit',e=>{e.preventDefault();runDiscovery(true);});$('discoveryRunBtn').addEventListener('click',()=>runDiscovery(false));$('sourcesList').addEventListener('click',e=>{const button=e.target.closest?.('[data-source-action]');if(button)sourceAction(button.dataset.sourceId,button.dataset.sourceAction);});$('addForm').addEventListener('submit',addProspect);$('csvInput').addEventListener('change',e=>{const file=e.target.files[0];if(file){const reader=new FileReader();reader.onload=()=>renderCsv(parseCsv(reader.result));reader.readAsText(file);}});$('menuBtn').addEventListener('click',()=>document.querySelector('.sidebar').classList.toggle('open'));document.querySelectorAll('[data-scroll]').forEach(b=>b.addEventListener('click',()=>document.querySelector(b.dataset.scroll)?.scrollIntoView())); bootstrap(); })(); diff --git a/apps/web/index.html b/apps/web/index.html index e132237..ed4ca42 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -32,6 +32,7 @@ Add prospects Jobs Sources + Score rules @@ -45,6 +46,8 @@

High-fit prospects

0

● Score 80+

Freshness under 7d

0%

● Evidence coverage
+
Sign in to load score distribution…
+
Sign in to load score rules…

PIPELINE

Prospect explorer

diff --git a/apps/web/smoke-test.html b/apps/web/smoke-test.html index 647a77c..4588caa 100644 --- a/apps/web/smoke-test.html +++ b/apps/web/smoke-test.html @@ -45,6 +45,9 @@ frame.onload=async()=>{const d=frame.contentDocument;const js=await fetch('app.j ,['Public contact extraction smoke marker and controls',()=>!!d.querySelector('[data-smoke="contact-extraction"]')&&!!d.querySelector('#extractContactsBtn')&&!!d.querySelector('#refreshContactExtractionBtn')&&js.includes('/contacts/extract')&&js.includes('jsonRequest')] ,['Extracted contact evidence fields',()=>['Extracted contacts','Type','Email class','Validation','confidence','Source URL','Suppression'].every(x=>js.includes(x))] ,['Contact extraction safety and states',()=>js.includes('approved public business pages only')&&js.includes('SMTP')&&js.includes('outreach')&&js.includes('Loading public contacts')&&js.includes('No public contacts found')&&js.includes('Contact extraction failed')] - ,['Contact extraction responsive styles',()=>js.includes('contact-extraction-panel')&&js.includes('extracted-contact-facts')&&js.includes('@media')] + ,['Score breakdown and recalculation contract',()=>!!d.querySelector('[data-smoke="score-breakdown"]')&&js.includes('Score breakdown')&&js.includes('Total score')&&js.includes('Priority')&&js.includes('Eligibility')&&js.includes('Score version')&&js.includes('/score/recalculate')&&js.includes('recalculateScoreBtn')] + ,['Score rules configuration and permission affordance',()=>!!d.querySelector('[data-smoke="score-rules"]')&&!!d.querySelector('#scoreRules')&&js.includes('/api/v1/score-rules')&&js.includes('enabled')&&js.includes('points')&&js.includes('Read-only for this role')&&js.includes('canEditScores')] + ,['Score distribution summary and safe states',()=>!!d.querySelector('[data-smoke="score-distribution"]')&&js.includes('/scoring/summary')&&js.includes('Loading score distribution')&&js.includes('Unable to load score distribution')&&js.includes('tenant-scoped')&&js.includes('do not override suppression')] + ,['Score responsive styles',()=>js.includes('score-breakdown-summary')&&js.includes('score-config-row')&&js.includes('distribution-track')&&js.includes('@media')] ];let passed=0;document.querySelector('#checks').innerHTML=checks.map(([name,test])=>{const ok=test();if(ok)passed++;return `
  • ${ok?'PASS':'FAIL'} — ${name}
  • `}).join('');document.querySelector('#summary').textContent=`${passed}/${checks.length} checks passed`;}; diff --git a/apps/web/styles.css b/apps/web/styles.css index a7664ec..52b5969 100644 --- a/apps/web/styles.css +++ b/apps/web/styles.css @@ -5,3 +5,4 @@ .jobs-section{margin-top:28px;scroll-margin-top:24px}.jobs-header{display:flex;align-items:flex-start;justify-content:space-between;gap:24px}.jobs-header h2{margin:.15rem 0 .25rem}.jobs-header p{margin:.25rem 0 0}.jobs-actions{display:flex;gap:8px;flex-wrap:wrap}.jobs-message{min-height:22px;padding:8px 2px;color:var(--green)}.jobs-message.error{color:var(--red)}.job-counts{display:grid;grid-template-columns:repeat(5,1fr);gap:12px;margin:0 0 14px}.job-count{background:var(--surface);border:1px solid var(--line);border-left:4px solid var(--violet);border-radius:10px;padding:14px 16px;box-shadow:var(--shadow)}.job-count span{display:block;color:var(--muted);font-size:12px}.job-count strong{display:block;font-size:25px;margin-top:4px}.job-count.queued{border-left-color:var(--amber)}.job-count.running{border-left-color:#4d8bd8}.job-count.succeeded{border-left-color:var(--green)}.job-count.failed{border-left-color:var(--red)}.job-count.cancelled{border-left-color:#8d879c}.jobs-grid{display:grid;grid-template-columns:minmax(0,1.05fr) minmax(320px,.95fr);gap:18px}.jobs-list,.job-detail{min-height:320px}.jobs-list-body{border-top:1px solid var(--line)}.job-empty{padding:38px 18px;color:var(--muted);text-align:center}.job-row{display:grid;grid-template-columns:minmax(0,1fr) auto 42px;gap:12px;align-items:center;width:100%;padding:14px 16px;border:0;border-bottom:1px solid var(--line);background:transparent;color:inherit;text-align:left;cursor:pointer;font:inherit}.job-row:hover,.job-row.selected{background:var(--violet-soft)}.job-row-main{display:flex;flex-direction:column;gap:3px;min-width:0}.job-row-main strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.job-row-main small,.job-row-progress{color:var(--muted);font-size:12px}.job-row-state{border-radius:999px;padding:4px 8px;font-size:11px;font-weight:700;white-space:nowrap;background:var(--violet-soft);color:var(--violet)}.job-row-state.queued{background:var(--amber-soft);color:var(--amber)}.job-row-state.running{background:#e8f1ff;color:#3269ad}.job-row-state.succeeded{background:var(--green-soft);color:var(--green)}.job-row-state.failed{background:var(--red-soft);color:var(--red)}.job-row-state.cancelled{background:#f0eef4;color:#716a7c}.job-detail{padding:22px}.job-detail-head{display:flex;justify-content:space-between;gap:12px;border-bottom:1px solid var(--line);padding-bottom:16px}.job-detail-head h3{margin:.15rem 0}.job-progress{padding:18px 0}.job-progress-meta{display:flex;justify-content:space-between;gap:12px;font-size:12px;color:var(--muted)}.job-progress-meta strong{color:var(--ink)}.progress-track{height:8px;background:#eef0f5;border-radius:999px;overflow:hidden;margin-top:10px}.progress-track span{display:block;height:100%;background:var(--violet);border-radius:inherit;transition:width .25s}.structured-error{background:var(--red-soft);border:1px solid #f2cdd0;border-radius:8px;padding:12px;margin:4px 0 16px;color:var(--red)}.structured-error p{margin:5px 0}.structured-error pre{white-space:pre-wrap;font-size:11px;margin:8px 0 0}.job-detail-actions{display:flex;gap:8px;min-height:34px}.event-timeline{border-top:1px solid var(--line);margin-top:16px;padding-top:16px}.event-timeline h4{margin:0}.event-timeline ol{list-style:none;padding:0;margin:12px 0 0}.event-timeline li{display:flex;gap:10px;position:relative;padding:0 0 15px}.event-timeline li:not(:last-child):before{content:"";position:absolute;left:4px;top:10px;bottom:0;border-left:1px solid var(--line)}.timeline-dot{z-index:1;width:9px;height:9px;margin-top:4px;border-radius:50%;background:var(--violet);flex:none}.event-timeline li div{display:flex;flex-direction:column;gap:2px}.event-timeline small,.timeline-progress{font-size:11px;color:var(--muted)}@media(max-width:900px){.jobs-grid{grid-template-columns:1fr}.job-counts{grid-template-columns:repeat(3,1fr)}}@media(max-width:700px){.jobs-header{flex-direction:column}.job-counts{grid-template-columns:repeat(2,1fr)}.job-row{grid-template-columns:minmax(0,1fr) auto}.job-row-progress{display:none}} .website-scan-actions .button{min-height:32px}.contact-extraction-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}.contact-extraction-heading h4{margin:.1rem 0}.contact-extraction-actions{display:flex;gap:7px;flex-wrap:wrap}.contact-extraction-safety{border:1px solid #dcd8ff;border-radius:8px;background:var(--violet-soft);color:#5145a7;padding:9px 11px;font-size:12px;margin:10px 0}.extracted-contact-list{display:grid;gap:9px}.extracted-contact{border:1px solid var(--line);border-radius:9px;padding:11px;background:#fff}.extracted-contact-head{display:flex;justify-content:space-between;gap:10px;align-items:start}.extracted-contact-head strong{overflow-wrap:anywhere}.contact-confidence{color:var(--violet);font-size:11px;font-weight:700;white-space:nowrap}.extracted-contact-facts{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:7px;margin:10px 0 0}.extracted-contact-facts div{border:1px solid var(--line);border-radius:7px;padding:7px}.extracted-contact-facts dt{font-size:10px;text-transform:uppercase;letter-spacing:.05em;color:var(--muted)}.extracted-contact-facts dd{margin:3px 0 0;font-size:12px;overflow-wrap:anywhere}.extracted-contact-facts a{color:var(--violet)}.contact-source{grid-column:1 / -1}.suppression-status{color:var(--green)}.contact-extraction-empty{padding:18px 4px}.contact-extraction-empty strong{color:var(--muted)}@media(max-width:700px){.website-scan-heading{flex-direction:column}.website-scan-actions{width:100%}.website-scan-actions .button{flex:1}.website-scan-grid{grid-template-columns:1fr}.signal-list{grid-template-columns:repeat(2,minmax(0,1fr))}.contact-extraction-heading{flex-direction:column}.contact-extraction-actions{width:100%}.contact-extraction-actions .button{flex:1}.extracted-contact-facts{grid-template-columns:repeat(2,minmax(0,1fr))}.contact-source{grid-column:1 / -1}} +.score-overview{margin-top:18px}.score-distribution-panel{min-width:0}.score-rules-section{margin-top:18px;scroll-margin-top:24px}.score-breakdown-panel{background:#fbfbff;border-radius:10px;padding:15px}.score-panel-heading{display:flex;align-items:flex-start;justify-content:space-between;gap:12px}.score-panel-heading h4{margin:.1rem 0}.score-breakdown-summary{display:grid;grid-template-columns:1.4fr 1fr 1fr;gap:8px;margin:12px 0}.score-breakdown-summary>div,.score-meta div{border:1px solid var(--line);border-radius:8px;background:#fff;padding:10px}.score-breakdown-summary small,.score-breakdown-summary b{display:block}.score-breakdown-summary small,.score-meta dt{color:var(--muted);font-size:10px;text-transform:uppercase;letter-spacing:.05em}.score-breakdown-summary b{margin-top:4px}.score-total{display:block;font-size:28px;line-height:1.1;margin-top:3px}.score-total small{display:inline;font-size:12px;margin-left:3px}.score-meta{display:grid;grid-template-columns:1fr 1fr;gap:8px;margin:0}.score-meta dd{margin:3px 0 0}.score-rules-explanation{margin-top:13px}.score-rules-explanation h5{margin:0 0 7px}.score-rule-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:2px 8px;border-top:1px solid var(--line);padding:9px 0}.score-rule-row span{font-weight:650}.score-rule-row strong{color:var(--violet);font-size:12px}.score-rule-row small{grid-column:1 / -1;color:var(--muted)}.score-safety,.score-config-note{border:1px solid #dcd8ff;border-radius:8px;background:var(--violet-soft);color:#5145a7;padding:9px 11px;font-size:12px;margin:12px 0 0}.score-rule-table{border-top:1px solid var(--line);margin-top:12px}.score-config-row{display:grid;grid-template-columns:minmax(140px,1.4fr) .8fr .7fr .7fr auto;gap:10px;align-items:center;padding:11px 0;border-bottom:1px solid var(--line)}.score-config-row span{font-size:12px}.score-config-row span:nth-child(2){color:var(--green);font-weight:700}.score-config-row small{color:var(--muted)}.score-config-row button:disabled{opacity:.7;cursor:not-allowed}.score-distribution-list{display:grid;gap:10px;margin-top:12px}.distribution-row{display:grid;grid-template-columns:minmax(100px,1fr) 42px minmax(100px,2fr);gap:10px;align-items:center}.distribution-row strong{text-align:right}.distribution-track{height:8px;border-radius:99px;background:var(--line);overflow:hidden}.distribution-track i{display:block;height:100%;background:var(--violet);border-radius:inherit}@media(max-width:700px){.score-breakdown-summary{grid-template-columns:1fr 1fr}.score-breakdown-summary>div:first-child{grid-column:1 / -1}.score-config-row{grid-template-columns:1fr 1fr}.score-config-row strong{grid-column:1 / -1}.score-config-row button{justify-self:start}.distribution-row{grid-template-columns:minmax(95px,1fr) 34px minmax(80px,1fr)}} diff --git a/docs/OPERATIONS.md b/docs/OPERATIONS.md index 7f9cabe..ff9473c 100644 --- a/docs/OPERATIONS.md +++ b/docs/OPERATIONS.md @@ -79,6 +79,18 @@ Verify suppression matching before persistence, response, cache, export, or revi There is no SMTP probing, SMTP `VRFY`/`EXPN`, validation email, outreach worker, campaign queue, or follow-up action. Never contact a discovered address. If extraction is disabled, unapproved, out of budget, or uncertain, report deferred/blocked/unknown with the reason. Retain only the minimum value and lineage for the approved retention period; redact addresses and page content from routine logs. +## Phase 10 scoring operations + +Operate scoring as a versioned policy, not as a mutable numeric field. Before activating a rule set, verify its ID/version, owner/approval, weights, thresholds and priority bands, required evidence, freshness windows, suppression precedence, uncertainty behavior, rounding/tie-breaking, tenant scope, and rollback plan. Record the activation/configuration revision; never edit a rule set already used in production history. + +Review score, priority band, and eligibility separately. A high-priority prospect can still be ineligible or unknown. Suppressed/do-not-contact records are hard blocked. Stale, expired, blocked, partial, missing, or uncertain required evidence must retain its state and reason and must not be silently treated as absent, negative, or current. Monitor counts by band and eligibility state, suppression matches, stale/uncertain outcomes, explanation failures, and unexpected score distribution changes. + +Run recalculation only through an authenticated, tenant-scoped operation with an idempotency key or equivalent safe retry control. For each run record rule-set/algorithm versions, input snapshot or cutoff, actor/job, reason, start/end, processed/succeeded/failed counts, and partial status. Verify before/after score, band, eligibility, and explanation changes for representative records; read back the audit events. Do not report a request as complete merely because a job was accepted, and stop on tenant-scope, suppression, snapshot, or audit failures rather than retrying blindly. + +For a rule or evidence-policy change, use a canary or bounded tenant batch, compare old/new explanations and eligibility, preserve the old version for reproducibility, and document rollback/recalculation scope. Ensure cached/list/detail projections do not mix rule versions. Retain and delete calculation inputs, explanations, and audit records under the approved data policy; do not put full contact values or sensitive evidence in routine logs. + +The current Compose/MVP runtime remains pilot-only until durable rule-set storage/approval, scheduled recalculation with worker leases, complete audit/readback, and tenant-isolation and stale/uncertain regression checks are operationally verified. + ## Phase 4 jobs and live logging The Phase 4 MVP provides SQLite-backed job status/detail/event routes and a browser monitor. A job moves `queued` → `running` → `succeeded`/`failed`/`cancelled`, retains its attempt and tenant identity, and appends per-job events with a monotonic sequence cursor. Operators inspect status and replay events by polling; SSE may provide lower-latency delivery but is not implemented and must replay from the persisted cursor and fall back to polling after disconnects. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 752fef9..9dd80f7 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -76,6 +76,17 @@ Source adapters are a security boundary, not a generic fetch facility. Registry If a future approved adapter fetches URLs, apply the SSRF requirements below in addition to source approval. Network discovery is not implemented by this documentation or by the current Compose stack. +## Phase 10 scoring security controls + +- Treat score, priority, and eligibility as separate security-relevant outputs. A score or priority band is ranking metadata only and must never authorize contact, export, enrichment, or another side effect. +- Rule sets must be named, versioned, tenant-scoped, explicitly approved/activated, and immutable once used for a calculation. Store weights, thresholds, required signals, freshness windows, suppression precedence, algorithm version, and deterministic rounding/tie-breaking; do not permit clients to submit or override them. +- Make every result reproducible from a tenant-scoped input/evidence snapshot, normalized values, rule-set/version, algorithm/version, and calculation timestamp/freshness context. Explanations must identify contributing factors, points/weights, exclusions, evidence references, and uncertainty/staleness reasons without leaking another tenant's data or unnecessary personal data. +- Evaluate eligibility independently and fail closed. Suppression/do-not-contact always yields ineligible and remains visible; stale, expired, missing, blocked, partial, or uncertain required evidence must be explicit and cannot be silently treated as zero, false, or positive. Never let recalculation revive a suppressed value. +- Recalculation must be authenticated, authorized, tenant-scoped, idempotent or safely retryable, and auditable. Record actor/job, rule-set and input versions, request reason, start/end, before/after outputs, explanation changes, counts, failures, and partial/incomplete status. Preserve prior results and audit history; do not rewrite history in place. +- Protect rule-set, explanation, recalculation, and audit reads with the same organization predicate as business data. Cross-tenant rule IDs, job IDs, evidence references, and business IDs must not disclose existence. Ensure background workers carry tenant context and cannot process an unscoped batch. + +Phase 10 is not production-ready until rule-set lifecycle permissions/approval, immutable snapshots, audit tamper resistance, retention/deletion policy, concurrency/rollback behavior, and regression tests for suppression precedence, stale/uncertain handling, replay/reproducibility, and tenant isolation are complete. + ## Known limitations before production 1. **Password storage:** production passwords must be hashed with Argon2id using a reviewed cost/memory/parallelism policy. Never store plaintext or reversible passwords, and never log bootstrap credentials. Rehash on login when the policy changes.