diff --git a/Directory.Build.props b/Directory.Build.props index d9176f3..4ad83ea 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -1,7 +1,7 @@ net10.0 - 0.1.0 + 0.2.0 enable enable true diff --git a/MILESTONES.md b/MILESTONES.md index 07ff8de..960b844 100644 --- a/MILESTONES.md +++ b/MILESTONES.md @@ -1,6 +1,6 @@ # GuestOps Milestone Report -Version: **0.1.0** +Version: **0.2.0 release candidate** Last updated: **29 September 2026** This is the working delivery tracker for GuestOps Web. Update a milestone when its state changes and link the pull request, release artifact, test run, or acceptance record that proves the change. @@ -33,16 +33,16 @@ This is the working delivery tracker for GuestOps Web. Update a milestone when i | 6 | Team onboarding and account recovery | B | Implemented / acceptance required | Invitation, password reset, and recovery flows are promoted to local `main`; verify deployed links, mail delivery, token expiry, and administrator recovery procedures. | | 7 | Google connection recovery | B | Implemented / acceptance required | Connection epochs, checkpoint recovery, and revocation handling are promoted to local `main`; complete real Google acceptance and worker-restart exercises. | | 8 | Operational readiness tooling | A | Implemented / acceptance required | Backup, restore, release, and diagnostic tooling is promoted to local `main`; execute it on the actual Debian host and retain evidence. | -| 9 | Gitea and reproducible releases | A | In progress | The reviewed candidate is promoted in the local `main` history. CI now records the full commit, matched application version, archive checksum and immutable image IDs, and the rollback procedure is documented. Push the merge, retain the successful release evidence off-host, and create a new immutable approval tag; the existing `0.1.0` tag remains attached to the original foundation release. | +| 9 | Gitea and reproducible releases | A | In progress | The `0.2.0` candidate is versioned on `main`. CI records the full commit, matched application version, archive checksum and immutable image IDs, and the rollback procedure is documented. Retain the successful default-branch evidence off-host and create the immutable approval tag only after Gate B approval; the existing `0.1.0` tag remains attached to the foundation release. | | 10 | Debian deployment and persistence | A | In progress | Compose uses separate named database and shared key volumes, private host configuration, loopback-only API access and bounded logs. The confirmation-gated persistence drill verifies restart and container-recreation behaviour. Run it on the provisioned Debian host, complete HTTPS and controlled-reboot acceptance, and retain the evidence. | | 11 | Backups, monitoring, and recovery | A | In progress | Encrypted backup and isolated restore tooling now includes opt-in systemd scheduling without command-line secrets. Install and test it on Debian, configure monitored off-host transfer and durable logs, name alert/retention owners, and retain evidence from a timed restore and recovery drill. | | 12 | Google mailbox and reviewed-reply acceptance | B | In progress | The synthetic-data provider runbook, exact scenario set and restricted-record validator are implemented. Complete every scenario against the accepted Debian release and dedicated Google sandbox accounts, independently review the evidence, and retain the validated record. | | 13 | Rezlynx/Guestline adapter | C | Planned | Obtain the provider contract and sandbox, implement the adapter and mapping, and accept idempotency, stale-data, ambiguous-write, and reconciliation paths. | | 14 | Payment links and status | C | Planned | Select/confirm the payment-provider path, complete sandbox and webhook acceptance, and prove expiry, replay protection, reconciliation, and support recovery. | -| 15 | Knowledge, AI, and FAQ activation | B | In progress | Owners can run a bounded no-send batch evaluation against current FAQ rules and approved knowledge, with false-positive/negative results and documented zero-error activation thresholds and stop conditions. Curate hotel-specific cases, evaluate AI suggestions separately, train staff, name monitoring/rollback owners, and retain staged-activation evidence. | -| 16 | Identity, preferences, and privacy | B/C | In progress | Login throttling now uses the client address only after one-hop processing from the explicitly trusted reverse proxy. Finish privacy/retention decisions, preference coverage, identity acceptance and audit review. | +| 15 | Knowledge, AI, and FAQ activation | B | Implemented / acceptance required | Owners can run a bounded no-send batch evaluation, and a release-bound acceptance record enforces positive/negative coverage, zero FAQ errors, separate AI review, staff training, stop-control evidence and named monitoring/rollback owners. Complete the supervised evaluation and retain independent approval. | +| 16 | Identity, preferences, and privacy | B/C | Implemented / acceptance required | Login throttling trusts the client address only after one-hop processing by the configured proxy. A release-bound review now covers owner-controlled preferences, account/session controls, data inventory, retention/deletion/legal-hold ownership, provider decisions, audit evidence and known identity limitations. Complete the legal/operational decisions and independently approve the record. | | 17 | Inbox usability and desktop parity | B | Implemented / acceptance required | The inbox uses tenant-scoped stable cursor pagination in pages of 50 and protects unsaved drafts during route/history navigation, reload, conversation selection, filtering and search. Operational timestamps use the saved hotel timezone, and a release-bound desktop-parity acceptance record is implemented. The implementation and preview HTTP suite pass; run the supervised exercise against the approved release and retain independent approval. | -| 18 | Pilot, capacity, and release approval | B/C | In progress | A bounded read-only sandbox capacity probe, machine-validated pilot decision and Gate B/C incident-exercise record are implemented. Agree targets, run the probe with host monitoring, complete the supervised pilot and incident exercises, resolve or explicitly contain findings, and retain separate hotel-owner and technical go/no-go decisions. | +| 18 | Pilot, capacity, and release approval | B/C | In progress | The `0.2.0` Gate B candidate has bounded capacity, five-business-day pilot, incident and final-decision record validators with agreed targets. Push and retain CI evidence, complete Gate A and Gate B prerequisites, run the probe and supervised exercises, resolve or contain findings, and retain separate hotel-owner and technical approval. Gate C remains dependent on milestones 13 and 14. | ## Delivery sequence @@ -55,7 +55,7 @@ Milestones 13 (Guestline/Rezlynx) and 14 (payments) can progress as parallel pro ## Next actions - [ ] Push the local release-candidate promotion to the intended default branch and retain its successful CI evidence. -- [ ] Choose the next semantic version, update both project version files, then create and archive a new immutable approval tag (the existing `0.1.0` tag identifies the foundation release). +- [ ] Retain successful `0.2.0` default-branch CI evidence, then create and archive the immutable approval tag only after Gate B approval. - [ ] Deploy to the target Debian environment with persistent MongoDB and data-protection keys. - [ ] Run and record backup, restore, restart, monitoring, and rollback exercises. - [ ] Complete real Google mailbox acceptance without using production guest data. diff --git a/README.md b/README.md index 6470101..850406d 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,7 @@ A Linux-hosted hotel email workspace, developed separately from the Windows GuestOps application. **This migration now includes AI draft generation, staff-approved Gmail sending, reviewed OHIP reservation updates, NMI hosted invoices, controlled FAQ auto-replies and team onboarding. It is not yet a production-complete replacement.** -Current development version: **0.1.0** +Current release-candidate version: **0.2.0** Project progress is tracked in the [milestone report](MILESTONES.md). User-visible changes and release limitations are recorded in the [release notes](RELEASE_NOTES.md). diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index e0a5355..e45796d 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,8 +1,8 @@ # GuestOps Release Notes -## Next release — Unreleased +## 0.2.0 — Gate B release candidate -The reviewed candidate is now promoted into the local `main` history. It is not yet approved for live hotel operations and does not become a release until the merge is pushed, CI evidence is retained and a new immutable semantic-version tag is approved. +This candidate freezes the implemented Gate B scope for controlled acceptance. It is not yet approved for live hotel operations and does not become a release until the exact commit is pushed, CI and operational evidence are retained, the supervised pilot is approved and the immutable `0.2.0` tag is created. ### Promoted scope @@ -10,8 +10,9 @@ The reviewed candidate is now promoted into the local `main` history. It is not - Approval-controlled OHIP PMS and NMI payment workflows. - FAQ automation controls, team invitations, password recovery, and stronger Google connection recovery. - No-send FAQ batch evaluation with false-positive and false-negative reporting before activation. +- Release-bound automation and identity/privacy acceptance records covering training, provider decisions, retention ownership and known limitations. - Stable tenant-scoped inbox pagination beyond the former 500-message view, unsaved-draft guards including browser history navigation, consistent hotel-timezone timestamps across operational screens, and a release-bound desktop-parity acceptance record. -- A bounded read-only sandbox capacity probe plus machine-validated, release-bound supervised-pilot and incident-exercise records. +- A bounded read-only sandbox capacity probe plus machine-validated, release-bound supervised-pilot run, go/no-go and incident-exercise records. - Backup, restore, opt-in systemd scheduling, deployment, persistence-drill, diagnostic, release-evidence, Google acceptance-record validation and rollback tooling. These capabilities still require their separately documented provider, host and operational acceptance. Google, PMS and payment-provider acceptance is not established by local automated tests. @@ -40,4 +41,4 @@ The `0.1.0` tag identifies the initial GuestOps Web foundation. It is not approv ### Versioning -The .NET projects and frontend package currently share version `0.1.0`. Before the next approval tag, choose the next semantic version and update both files together. Move the **Next release** section to that version only after the exact commit, checksummed artifacts and acceptance evidence have been approved. +The foundation remains tagged `0.1.0`. The .NET projects and frontend package now share candidate version `0.2.0`; create that immutable tag only after the exact commit, checksummed artifacts and Gate B acceptance evidence have been approved. diff --git a/deploy/automation-acceptance.example.json b/deploy/automation-acceptance.example.json new file mode 100644 index 0000000..6c0c432 --- /dev/null +++ b/deploy/automation-acceptance.example.json @@ -0,0 +1,28 @@ +{ + "schemaVersion": 1, + "system": "guestops-automation-acceptance", + "targetGate": "B", + "dataClassification": "synthetic-only", + "releaseCommit": "0000000000000000000000000000000000000000", + "releaseRecordSha256": "0000000000000000000000000000000000000000000000000000000000000000", + "environment": "https://sandbox-guestops.example.invalid", + "operator": "REPLACE OPERATOR", + "reviewedBy": "REPLACE REVIEWER", + "startedAt": "2026-10-01T09:00:00Z", + "endedAt": "2026-10-01T10:00:00Z", + "reviewedAt": "2026-10-01T11:00:00Z", + "faqEvaluation": {"positiveCases": 0, "negativeCases": 0, "falsePositives": 0, "falseNegatives": 0, "reportSha256": "0000000000000000000000000000000000000000000000000000000000000000"}, + "aiEvaluation": {"casesReviewed": 0, "unsafeDraftsApproved": 0, "reportSha256": "0000000000000000000000000000000000000000000000000000000000000000"}, + "staffTrained": 0, + "monitoringOwner": "REPLACE", + "rollbackOwner": "REPLACE", + "scenarios": [ + {"id": "ai-suggestion-review", "status": "not-run", "evidence": []}, + {"id": "faq-positive-negative", "status": "not-run", "evidence": []}, + {"id": "faq-stop-control", "status": "not-run", "evidence": []}, + {"id": "knowledge-curation", "status": "not-run", "evidence": []}, + {"id": "monitoring-rollback", "status": "not-run", "evidence": []}, + {"id": "staff-training", "status": "not-run", "evidence": []} + ], + "postAcceptanceState": {"faqMode": "off", "pmsWrites": "disabled", "paymentCreation": "disabled"} +} diff --git a/deploy/automation_acceptance.py b/deploy/automation_acceptance.py new file mode 100644 index 0000000..5e793f9 --- /dev/null +++ b/deploy/automation_acceptance.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 +"""Validate restricted Gate B knowledge, AI and FAQ acceptance evidence.""" + +from __future__ import annotations + +import argparse +import datetime as dt +import json +from pathlib import Path +import re +from urllib.parse import urlparse + + +SCENARIOS = { + "knowledge-curation", "faq-positive-negative", "faq-stop-control", + "ai-suggestion-review", "staff-training", "monitoring-rollback", +} + + +def require(condition: bool, message: str) -> None: + if not condition: + raise ValueError(message) + + +def timestamp(value: object, field: str) -> dt.datetime: + require(isinstance(value, str) and value.endswith("Z"), f"{field} must be a UTC timestamp ending in Z.") + try: + return dt.datetime.fromisoformat(value.removesuffix("Z") + "+00:00") + except ValueError as error: + raise ValueError(f"{field} is not a valid timestamp.") from error + + +def name(value: object, field: str) -> str: + result = str(value or "").strip() + require(2 <= len(result) <= 120 and "@" not in result, f"{field} requires a name without an email address.") + return result + + +def refs(value: object, field: str) -> None: + require(isinstance(value, list) and 1 <= len(value) <= 10 and all( + isinstance(item, str) and 3 <= len(item) <= 200 and "@" not in item for item in value + ), f"{field} requires safe opaque evidence references.") + + +def validate(record: object) -> None: + require(isinstance(record, dict), "Acceptance record must be a JSON object.") + require(record.get("schemaVersion") == 1, "Unsupported automation acceptance schema.") + require(record.get("system") == "guestops-automation-acceptance", "system must be guestops-automation-acceptance.") + require(record.get("targetGate") == "B", "Automation acceptance must target Gate B.") + require(record.get("dataClassification") == "synthetic-only", "Automation acceptance must use synthetic data only.") + require(re.fullmatch(r"[0-9a-f]{40}", str(record.get("releaseCommit", ""))) is not None, "releaseCommit must be a full lowercase Git SHA.") + require(re.fullmatch(r"[0-9a-f]{64}", str(record.get("releaseRecordSha256", ""))) is not None, "releaseRecordSha256 must be a SHA-256 digest.") + origin = urlparse(str(record.get("environment", ""))) + require(origin.scheme == "https" and origin.hostname and origin.path in ("", "/") and not origin.query and not origin.fragment and origin.username is None and origin.password is None, + "environment must be an HTTPS origin without credentials, path, query or fragment.") + operator = name(record.get("operator"), "operator") + reviewer = name(record.get("reviewedBy"), "reviewedBy") + require(operator.casefold() != reviewer.casefold(), "operator and reviewedBy must be different people.") + started = timestamp(record.get("startedAt"), "startedAt") + ended = timestamp(record.get("endedAt"), "endedAt") + reviewed = timestamp(record.get("reviewedAt"), "reviewedAt") + require(started <= ended <= reviewed, "Acceptance timestamps are out of order.") + + faq = record.get("faqEvaluation") + require(isinstance(faq, dict), "faqEvaluation is required.") + for field in ("positiveCases", "negativeCases"): + require(isinstance(faq.get(field), int) and not isinstance(faq.get(field), bool) and faq[field] > 0, + f"faqEvaluation.{field} must be a positive integer.") + require(faq.get("falsePositives") == 0 and faq.get("falseNegatives") == 0, + "FAQ activation requires zero false positives and zero false negatives.") + require(re.fullmatch(r"[0-9a-f]{64}", str(faq.get("reportSha256", ""))) is not None, + "faqEvaluation.reportSha256 must identify the retained report.") + ai = record.get("aiEvaluation") + require(isinstance(ai, dict) and isinstance(ai.get("casesReviewed"), int) and not isinstance(ai.get("casesReviewed"), bool) and ai["casesReviewed"] > 0, + "aiEvaluation requires at least one reviewed case.") + require(isinstance(ai.get("unsafeDraftsApproved"), int) and not isinstance(ai.get("unsafeDraftsApproved"), bool) + and ai["unsafeDraftsApproved"] == 0, "No unsafe AI draft may be approved.") + require(re.fullmatch(r"[0-9a-f]{64}", str(ai.get("reportSha256", ""))) is not None, + "aiEvaluation.reportSha256 must identify the retained report.") + require(isinstance(record.get("staffTrained"), int) and not isinstance(record.get("staffTrained"), bool) and record["staffTrained"] > 0, + "At least one pilot staff member must complete training.") + name(record.get("monitoringOwner"), "monitoringOwner") + name(record.get("rollbackOwner"), "rollbackOwner") + + scenarios = record.get("scenarios") + require(isinstance(scenarios, list), "scenarios must be a list.") + ids = [item.get("id") for item in scenarios if isinstance(item, dict)] + require(len(ids) == len(scenarios) and len(ids) == len(set(ids)) and set(ids) == SCENARIOS, + "Acceptance record requires the exact automation scenario set.") + for item in scenarios: + require(item.get("status") == "pass", f"Scenario {item['id']} has not passed.") + refs(item.get("evidence"), f"Scenario {item['id']}") + require(record.get("postAcceptanceState") == {"faqMode": "off", "pmsWrites": "disabled", "paymentCreation": "disabled"}, + "Acceptance must end with FAQ live mode, PMS writes and payment creation disabled.") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("record", type=Path) + args = parser.parse_args() + validate(json.loads(args.record.read_text(encoding="utf-8"))) + print("Automation acceptance record is structurally complete. This validates the record, not its restricted evidence.") + + +if __name__ == "__main__": + try: + main() + except (OSError, ValueError, json.JSONDecodeError) as error: + print(f"Automation acceptance record rejected: {error}", file=__import__("sys").stderr) + raise SystemExit(1) diff --git a/deploy/identity-privacy-acceptance.example.json b/deploy/identity-privacy-acceptance.example.json new file mode 100644 index 0000000..c3ce633 --- /dev/null +++ b/deploy/identity-privacy-acceptance.example.json @@ -0,0 +1,42 @@ +{ + "schemaVersion": 1, + "system": "guestops-identity-privacy", + "targetGate": "B", + "releaseCommit": "0000000000000000000000000000000000000000", + "releaseRecordSha256": "0000000000000000000000000000000000000000000000000000000000000000", + "environment": "https://sandbox-guestops.example.invalid", + "operator": "REPLACE OPERATOR", + "reviewedBy": "REPLACE REVIEWER", + "startedAt": "2026-10-01T09:00:00Z", + "endedAt": "2026-10-01T10:00:00Z", + "reviewedAt": "2026-10-01T11:00:00Z", + "retention": { + "conversationDays": 0, + "auditDays": 0, + "backupDays": 0, + "accountDays": 0, + "privacyOwner": "REPLACE", + "deletionOwner": "REPLACE", + "legalHoldOwner": "REPLACE", + "deletionProcedure": "REPLACE-RESTRICTED-REFERENCE", + "legalHoldProcedure": "REPLACE-RESTRICTED-REFERENCE" + }, + "providers": { + "google": {"status": "pending", "evidence": []}, + "openai": {"status": "pending", "evidence": []} + }, + "preferencesReviewed": [], + "scenarios": [ + {"id": "account-lifecycle", "status": "not-run", "evidence": []}, + {"id": "audit-review", "status": "not-run", "evidence": []}, + {"id": "backup-retention", "status": "not-run", "evidence": []}, + {"id": "data-inventory", "status": "not-run", "evidence": []}, + {"id": "known-identity-limitations", "status": "not-run", "evidence": []}, + {"id": "login-throttling", "status": "not-run", "evidence": []}, + {"id": "preference-coverage", "status": "not-run", "evidence": []}, + {"id": "provider-processing", "status": "not-run", "evidence": []}, + {"id": "retention-deletion", "status": "not-run", "evidence": []}, + {"id": "role-boundaries", "status": "not-run", "evidence": []}, + {"id": "session-invalidation", "status": "not-run", "evidence": []} + ] +} diff --git a/deploy/identity_privacy_acceptance.py b/deploy/identity_privacy_acceptance.py new file mode 100644 index 0000000..2462076 --- /dev/null +++ b/deploy/identity_privacy_acceptance.py @@ -0,0 +1,116 @@ +#!/usr/bin/env python3 +"""Validate restricted Gate B identity, preference and privacy acceptance evidence.""" + +from __future__ import annotations + +import argparse +import datetime as dt +import json +from pathlib import Path +import re +from urllib.parse import urlparse + + +SCENARIOS = { + "role-boundaries", "account-lifecycle", "login-throttling", "session-invalidation", + "preference-coverage", "data-inventory", "retention-deletion", "backup-retention", + "provider-processing", "audit-review", "known-identity-limitations", +} +PREFERENCES = { + "hotel-name", "timezone", "signature", "ai-drafts", "staff-sending", + "faq-mode", "pms-updates", "payment-creation", +} + + +def require(condition: bool, message: str) -> None: + if not condition: + raise ValueError(message) + + +def timestamp(value: object, field: str) -> dt.datetime: + require(isinstance(value, str) and value.endswith("Z"), f"{field} must be a UTC timestamp ending in Z.") + try: + return dt.datetime.fromisoformat(value.removesuffix("Z") + "+00:00") + except ValueError as error: + raise ValueError(f"{field} is not a valid timestamp.") from error + + +def name(value: object, field: str) -> str: + result = str(value or "").strip() + require(2 <= len(result) <= 120 and "@" not in result, f"{field} requires a name without an email address.") + return result + + +def refs(value: object, field: str) -> None: + require(isinstance(value, list) and 1 <= len(value) <= 10 and all( + isinstance(item, str) and 3 <= len(item) <= 200 and "@" not in item for item in value + ), f"{field} requires safe opaque evidence references.") + + +def validate(record: object) -> None: + require(isinstance(record, dict), "Acceptance record must be a JSON object.") + require(record.get("schemaVersion") == 1, "Unsupported identity/privacy acceptance schema.") + require(record.get("system") == "guestops-identity-privacy", "system must be guestops-identity-privacy.") + require(record.get("targetGate") == "B", "Identity/privacy acceptance must target Gate B.") + require(re.fullmatch(r"[0-9a-f]{40}", str(record.get("releaseCommit", ""))) is not None, "releaseCommit must be a full lowercase Git SHA.") + require(re.fullmatch(r"[0-9a-f]{64}", str(record.get("releaseRecordSha256", ""))) is not None, "releaseRecordSha256 must be a SHA-256 digest.") + origin = urlparse(str(record.get("environment", ""))) + require(origin.scheme == "https" and origin.hostname and origin.path in ("", "/") and not origin.query and not origin.fragment and origin.username is None and origin.password is None, + "environment must be an HTTPS origin without credentials, path, query or fragment.") + operator = name(record.get("operator"), "operator") + reviewer = name(record.get("reviewedBy"), "reviewedBy") + require(operator.casefold() != reviewer.casefold(), "operator and reviewedBy must be different people.") + started = timestamp(record.get("startedAt"), "startedAt") + ended = timestamp(record.get("endedAt"), "endedAt") + reviewed = timestamp(record.get("reviewedAt"), "reviewedAt") + require(started <= ended <= reviewed, "Acceptance timestamps are out of order.") + + retention = record.get("retention") + require(isinstance(retention, dict), "retention decisions are required.") + for field in ("conversationDays", "auditDays", "backupDays", "accountDays"): + value = retention.get(field) + require(isinstance(value, int) and not isinstance(value, bool) and 1 <= value <= 3650, + f"retention.{field} must be between 1 and 3650 days.") + for field in ("privacyOwner", "deletionOwner", "legalHoldOwner"): + name(retention.get(field), f"retention.{field}") + for field in ("deletionProcedure", "legalHoldProcedure"): + refs([retention.get(field)], f"retention.{field}") + + providers = record.get("providers") + require(isinstance(providers, dict) and set(providers) == {"google", "openai"}, + "providers must contain exactly google and openai decisions.") + require(all(isinstance(decision, dict) for decision in providers.values()), + "Each provider decision must be an object.") + require(providers["google"].get("status") == "accepted", "Google processing must be accepted for the Gate B mailbox pilot.") + require(providers["openai"].get("status") in ("accepted", "disabled"), "OpenAI processing must be accepted or disabled.") + for provider, decision in providers.items(): + refs(decision.get("evidence"), f"Provider {provider}") + preferences = record.get("preferencesReviewed") + require(isinstance(preferences, list) and all(isinstance(item, str) for item in preferences) + and set(preferences) == PREFERENCES and len(preferences) == len(PREFERENCES), + "preferencesReviewed must contain the exact owner-controlled preference set.") + + scenarios = record.get("scenarios") + require(isinstance(scenarios, list), "scenarios must be a list.") + ids = [item.get("id") for item in scenarios if isinstance(item, dict)] + require(len(ids) == len(scenarios) and len(ids) == len(set(ids)) and set(ids) == SCENARIOS, + "Acceptance record requires the exact identity/privacy scenario set.") + for item in scenarios: + require(item.get("status") == "pass", f"Scenario {item['id']} has not passed.") + refs(item.get("evidence"), f"Scenario {item['id']}") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("record", type=Path) + args = parser.parse_args() + validate(json.loads(args.record.read_text(encoding="utf-8"))) + print("Identity/privacy acceptance record is structurally complete. This validates the record, not its restricted evidence or legal decisions.") + + +if __name__ == "__main__": + try: + main() + except (OSError, ValueError, json.JSONDecodeError) as error: + print(f"Identity/privacy acceptance record rejected: {error}", file=__import__("sys").stderr) + raise SystemExit(1) diff --git a/deploy/pilot-approval.example.json b/deploy/pilot-approval.example.json index e2fe8ae..210b5a0 100644 --- a/deploy/pilot-approval.example.json +++ b/deploy/pilot-approval.example.json @@ -4,19 +4,31 @@ "technicalOwner": {"approvedAt": "2026-01-01T00:00:00Z", "name": "REPLACE"} }, "capacity": { + "hostMetricsSha256": "0000000000000000000000000000000000000000000000000000000000000000", "observedConcurrency": 0, + "observedCpuHeadroomPercent": 0, "observedErrorRate": 1, + "observedMemoryHeadroomPercent": 0, "observedP95Ms": 0, "reportSha256": "0000000000000000000000000000000000000000000000000000000000000000", "targetConcurrency": 1, + "targetCpuHeadroomPercent": 25, "targetErrorRate": 0, + "targetMemoryHeadroomPercent": 25, "targetP95Ms": 0 }, "decidedAt": "2026-01-01T00:00:00Z", "decision": "pending", "evidence": [], + "pilot": { + "businessDaysObserved": 0, + "hotelsObserved": 0, + "recordSha256": "0000000000000000000000000000000000000000000000000000000000000000", + "stopConditionsObserved": 1, + "unresolvedFindings": 1 + }, "releaseCommit": "0000000000000000000000000000000000000000", "releaseRecordSha256": "0000000000000000000000000000000000000000000000000000000000000000", - "schemaVersion": 1, + "schemaVersion": 2, "targetGate": "B" } diff --git a/deploy/pilot-run.example.json b/deploy/pilot-run.example.json new file mode 100644 index 0000000..bc1a682 --- /dev/null +++ b/deploy/pilot-run.example.json @@ -0,0 +1,47 @@ +{ + "schemaVersion": 1, + "system": "guestops-supervised-pilot", + "targetGate": "B", + "releaseCommit": "0000000000000000000000000000000000000000", + "releaseRecordSha256": "0000000000000000000000000000000000000000000000000000000000000000", + "environment": "https://sandbox-guestops.example.invalid", + "hotelLabel": "approved-pilot-hotel", + "hotelCount": 1, + "plannedBusinessDays": 5, + "owners": { + "hotelOwner": "REPLACE HOTEL OWNER", + "technicalOwner": "REPLACE TECHNICAL OWNER", + "rollbackDecisionMaker": "REPLACE ROLLBACK OWNER" + }, + "startedOn": "2026-10-05", + "endedOn": "2026-10-09", + "pilotControls": { + "pmsWrites": "disabled", + "paymentCreation": "disabled", + "faqMode": "off", + "googleReviewedSending": "accepted" + }, + "dailyReviews": [ + {"date": "2026-10-05", "status": "not-run", "reviewedBy": "REPLACE", "evidence": []}, + {"date": "2026-10-06", "status": "not-run", "reviewedBy": "REPLACE", "evidence": []}, + {"date": "2026-10-07", "status": "not-run", "reviewedBy": "REPLACE", "evidence": []}, + {"date": "2026-10-08", "status": "not-run", "reviewedBy": "REPLACE", "evidence": []}, + {"date": "2026-10-09", "status": "not-run", "reviewedBy": "REPLACE", "evidence": []} + ], + "stopConditions": { + "tenant-leakage": false, + "credential-exposure": false, + "data-loss": false, + "unapproved-send": false, + "duplicate-send": false, + "unreconciled-uncertain-send": false, + "failed-rollback": false, + "monitoring-loss": false + }, + "findings": [], + "postPilotState": { + "pmsWrites": "disabled", + "paymentCreation": "disabled", + "faqMode": "off" + } +} diff --git a/deploy/pilot_approval.py b/deploy/pilot_approval.py index 7452668..d4a2fc8 100644 --- a/deploy/pilot_approval.py +++ b/deploy/pilot_approval.py @@ -34,7 +34,7 @@ def timestamp(value: object, field: str) -> dt.datetime: def validate(record: object) -> None: require(isinstance(record, dict), "Approval record must be a JSON object.") - require(record.get("schemaVersion") == 1, "Unsupported approval schema.") + require(record.get("schemaVersion") == 2, "Unsupported approval schema; Gate B 0.2.0 requires schemaVersion 2.") gate = record.get("targetGate") require(gate in ("B", "C"), "targetGate must be B or C.") require(record.get("decision") == "approved", "Only an explicit approved decision passes validation.") @@ -83,6 +83,8 @@ def validate(record: object) -> None: require(isinstance(capacity, dict), "Capacity thresholds and observations are required.") require(re.fullmatch(r"[0-9a-f]{64}", str(capacity.get("reportSha256", ""))) is not None, "capacity.reportSha256 must identify the retained probe report.") + require(re.fullmatch(r"[0-9a-f]{64}", str(capacity.get("hostMetricsSha256", ""))) is not None, + "capacity.hostMetricsSha256 must identify the retained host metrics.") for observed, target in (("observedP95Ms", "targetP95Ms"), ("observedErrorRate", "targetErrorRate")): values = (capacity.get(observed), capacity.get(target)) require(all(isinstance(value, (int, float)) and not isinstance(value, bool) for value in values) @@ -94,6 +96,21 @@ def validate(record: object) -> None: require(all(isinstance(value, int) and not isinstance(value, bool) for value in concurrency) and capacity["observedConcurrency"] >= capacity["targetConcurrency"] > 0, "Observed concurrency must meet the approved positive target.") + for resource in ("Cpu", "Memory"): + target = capacity.get(f"target{resource}HeadroomPercent") + observed = capacity.get(f"observed{resource}HeadroomPercent") + require(all(isinstance(value, (int, float)) and not isinstance(value, bool) for value in (target, observed)) + and 25 <= target <= observed <= 100, + f"Observed {resource.lower()} headroom must meet the approved target of at least 25 percent.") + + pilot = record.get("pilot") + require(isinstance(pilot, dict), "A supervised pilot summary is required.") + require(re.fullmatch(r"[0-9a-f]{64}", str(pilot.get("recordSha256", ""))) is not None, + "pilot.recordSha256 must identify the retained pilot run record.") + require(pilot.get("businessDaysObserved") == 5, "The pilot must cover exactly five business days.") + require(pilot.get("hotelsObserved") == 1, "The Gate B pilot must cover exactly one hotel.") + require(pilot.get("stopConditionsObserved") == 0, "A pilot with a stop condition cannot be approved.") + require(pilot.get("unresolvedFindings") == 0, "All pilot findings must be resolved or explicitly contained.") def main() -> None: diff --git a/deploy/pilot_run.py b/deploy/pilot_run.py new file mode 100644 index 0000000..28bf670 --- /dev/null +++ b/deploy/pilot_run.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +"""Validate a restricted five-business-day GuestOps pilot run record.""" + +from __future__ import annotations + +import argparse +import datetime as dt +import json +from pathlib import Path +import re +from urllib.parse import urlparse + + +STOP_CONDITIONS = { + "tenant-leakage", + "credential-exposure", + "data-loss", + "unapproved-send", + "duplicate-send", + "unreconciled-uncertain-send", + "failed-rollback", + "monitoring-loss", +} + + +def require(condition: bool, message: str) -> None: + if not condition: + raise ValueError(message) + + +def date_value(value: object, field: str) -> dt.date: + require(isinstance(value, str), f"{field} must be an ISO date.") + try: + return dt.date.fromisoformat(value) + except ValueError as error: + raise ValueError(f"{field} must be an ISO date.") from error + + +def timestamp(value: object, field: str) -> dt.datetime: + require(isinstance(value, str) and value.endswith("Z"), f"{field} must be a UTC timestamp ending in Z.") + try: + return dt.datetime.fromisoformat(value.removesuffix("Z") + "+00:00") + except ValueError as error: + raise ValueError(f"{field} is not a valid timestamp.") from error + + +def safe_name(value: object, field: str) -> str: + name = str(value or "").strip() + require(2 <= len(name) <= 120 and "@" not in name, f"{field} requires a name without an email address.") + return name + + +def references(value: object, field: str) -> None: + require(isinstance(value, list) and 1 <= len(value) <= 10 and all( + isinstance(item, str) and 3 <= len(item) <= 200 and "@" not in item for item in value + ), f"{field} requires one to ten safe opaque references without email addresses.") + + +def business_dates(start: dt.date, end: dt.date) -> list[dt.date]: + days = [] + current = start + while current <= end: + if current.weekday() < 5: + days.append(current) + current += dt.timedelta(days=1) + return days + + +def validate(record: object) -> None: + require(isinstance(record, dict), "Pilot run record must be a JSON object.") + require(record.get("schemaVersion") == 1, "Unsupported pilot run schema.") + require(record.get("system") == "guestops-supervised-pilot", "Pilot run system must be guestops-supervised-pilot.") + require(record.get("targetGate") == "B", "This pilot run record is restricted to Gate B.") + require(re.fullmatch(r"[0-9a-f]{40}", str(record.get("releaseCommit", ""))) is not None, + "releaseCommit must be a full lowercase Git SHA.") + require(re.fullmatch(r"[0-9a-f]{64}", str(record.get("releaseRecordSha256", ""))) is not None, + "releaseRecordSha256 must be a SHA-256 digest.") + + origin = urlparse(str(record.get("environment", ""))) + require(origin.scheme == "https" and origin.hostname and origin.path in ("", "/") and not origin.query + and not origin.fragment and origin.username is None and origin.password is None, + "environment must be an HTTPS origin without credentials, path, query or fragment.") + label = str(record.get("hotelLabel", "")).strip() + require(3 <= len(label) <= 80 and "@" not in label, "hotelLabel must be a non-email alias.") + require(record.get("hotelCount") == 1, "Gate B pilot must contain exactly one hotel.") + require(record.get("plannedBusinessDays") == 5, "Gate B pilot must require five business days.") + + owners = record.get("owners") + require(isinstance(owners, dict) and set(owners) == {"hotelOwner", "technicalOwner", "rollbackDecisionMaker"}, + "owners must contain hotelOwner, technicalOwner and rollbackDecisionMaker.") + names = {role: safe_name(value, f"owners.{role}") for role, value in owners.items()} + require(names["hotelOwner"].casefold() != names["technicalOwner"].casefold(), + "hotelOwner and technicalOwner must be different people.") + + start = date_value(record.get("startedOn"), "startedOn") + end = date_value(record.get("endedOn"), "endedOn") + expected_days = business_dates(start, end) + require(len(expected_days) == 5, "Pilot window must contain exactly five business days.") + reviews = record.get("dailyReviews") + require(isinstance(reviews, list) and len(reviews) == 5, "Exactly five daily reviews are required.") + review_dates = [] + for index, review in enumerate(reviews): + require(isinstance(review, dict), f"dailyReviews[{index}] must be an object.") + review_date = date_value(review.get("date"), f"dailyReviews[{index}].date") + review_dates.append(review_date) + require(review.get("status") == "pass", f"Daily review {review_date} has not passed.") + safe_name(review.get("reviewedBy"), f"dailyReviews[{index}].reviewedBy") + references(review.get("evidence"), f"dailyReviews[{index}].evidence") + require(review_dates == expected_days, "Daily reviews must cover each business day in chronological order.") + + controls = record.get("pilotControls") + require(controls == {"pmsWrites": "disabled", "paymentCreation": "disabled", "faqMode": "off", + "googleReviewedSending": "accepted"}, + "Pilot controls require accepted reviewed Google sending with PMS, payments and FAQ live mode disabled.") + stop_conditions = record.get("stopConditions") + require(isinstance(stop_conditions, dict) and set(stop_conditions) == STOP_CONDITIONS, + "stopConditions must contain the exact Gate B stop-condition set.") + require(all(value is False for value in stop_conditions.values()), + "A pilot with an observed stop condition cannot pass.") + + findings = record.get("findings") + require(isinstance(findings, list), "findings must be a list, including an empty list when none were found.") + finding_ids = [] + for finding in findings: + require(isinstance(finding, dict), "Each finding must be an object.") + finding_id = str(finding.get("id", "")) + require(re.fullmatch(r"[a-z0-9][a-z0-9-]{2,79}", finding_id) is not None, "Finding IDs must be safe opaque identifiers.") + finding_ids.append(finding_id) + severity = finding.get("severity") + disposition = finding.get("disposition") + require(severity in ("critical", "high", "medium", "low"), f"Finding {finding_id} has an invalid severity.") + require(disposition in ("resolved", "contained"), f"Finding {finding_id} must be resolved or contained.") + references(finding.get("evidence"), f"Finding {finding_id} evidence") + require(not (severity in ("critical", "high") and disposition == "contained"), + f"Finding {finding_id} is too severe for containment.") + if disposition == "contained": + containment = finding.get("containment") + require(isinstance(containment, dict), f"Finding {finding_id} requires containment details.") + safe_name(containment.get("owner"), f"Finding {finding_id} containment owner") + require(timestamp(containment.get("expiresAt"), f"Finding {finding_id}.containment.expiresAt").date() > end, + f"Finding {finding_id} containment must expire after the pilot.") + require(5 <= len(str(containment.get("rollbackTrigger", ""))) <= 300, + f"Finding {finding_id} containment requires a rollback trigger.") + require(len(finding_ids) == len(set(finding_ids)), "Finding IDs must be unique.") + + require(record.get("postPilotState") == {"pmsWrites": "disabled", "paymentCreation": "disabled", "faqMode": "off"}, + "Pilot must end with PMS writes, payment creation and FAQ live mode disabled.") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("record", type=Path) + args = parser.parse_args() + validate(json.loads(args.record.read_text(encoding="utf-8"))) + print("Supervised pilot record is structurally complete: five business days passed without a stop condition. This validates the record, not its restricted evidence.") + + +if __name__ == "__main__": + try: + main() + except (OSError, ValueError, json.JSONDecodeError) as error: + print(f"Supervised pilot record rejected: {error}", file=__import__("sys").stderr) + raise SystemExit(1) diff --git a/docs/gate-b-prerequisites.md b/docs/gate-b-prerequisites.md new file mode 100644 index 0000000..fe4a394 --- /dev/null +++ b/docs/gate-b-prerequisites.md @@ -0,0 +1,27 @@ +# Gate B automation, identity and privacy acceptance + +Run these reviews against the exact `0.2.0` candidate on the accepted HTTPS sandbox. Keep guest data, staff addresses, provider agreements, screenshots and raw reports in the restricted evidence store. Repository records contain opaque references only. + +## Knowledge, AI and FAQ automation + +Curate representative hotel-specific positive and negative FAQ cases. Use the bounded no-send evaluation and require zero false positives and zero false negatives. Review AI suggestions separately; escalations are valid outcomes, but no unsafe or unsupported draft may be approved. Exercise the FAQ stop control, train every pilot staff member, and name monitoring and rollback owners. Finish with FAQ mode off and PMS/payment writes disabled. + +Copy `deploy/automation-acceptance.example.json`, complete the record, independently review its evidence, and run: + +```sh +python3 deploy/automation_acceptance.py /secure/acceptance/automation-acceptance.json +``` + +## Identity, preferences and privacy + +The hotel and privacy owners must approve explicit retention periods for conversations, audit history, backups and accounts. Name privacy, deletion and legal-hold owners and retain the deletion and hold procedures. Review Google processing for the mailbox pilot; either accept OpenAI processing or keep AI drafts disabled. + +Exercise owner/staff boundaries, invitation and recovery lifecycle, trusted-proxy throttling, session invalidation, every owner-controlled preference, data inventory, deletion/retention handling, backup retention and audit evidence. Explicitly review the known absence of MFA, granular roles and self-service recovery; any accepted containment belongs in the final pilot decision. + +Copy `deploy/identity-privacy-acceptance.example.json`, complete the record, independently review its evidence, and run: + +```sh +python3 deploy/identity_privacy_acceptance.py /secure/acceptance/identity-privacy-acceptance.json +``` + +These validators check completeness and release binding. They do not make legal decisions, inspect provider agreements or implement deletion on behalf of the operator. Reference the retained records and validator output from `automation` and `identity-privacy` in the final pilot approval. diff --git a/docs/pilot-release.md b/docs/pilot-release.md index 51729e6..6e274ab 100644 --- a/docs/pilot-release.md +++ b/docs/pilot-release.md @@ -2,6 +2,8 @@ Milestone 18 is an evidence exercise against the exact approved release, not a feature toggle. Use synthetic data for capacity work and a separately approved, tightly supervised hotel cohort for the pilot. Keep provider writes and FAQ live mode disabled until their individual acceptance records are approved. +Before the pilot, complete the [Gate B automation, identity and privacy acceptance](gate-b-prerequisites.md) as well as the Google and desktop exercises. These reviews must use the same release identifiers as the final decision. + ## Read-only capacity probe The capacity probe logs in once with a dedicated sandbox staff account and sends bounded concurrent GET requests to readiness, hotel settings and cursor-paginated inbox endpoints. It never calls provider integrations, creates records, edits drafts or retains response bodies. Run it only during an approved sandbox window and monitor CPU, memory, MongoDB latency, disk, Nginx and application errors independently. @@ -20,7 +22,20 @@ python3 deploy/capacity_probe.py \ unset CAPACITY_EMAIL CAPACITY_PASSWORD ``` -Agree the concurrency, latency, error-rate and resource-headroom targets before running the probe. The generated result reports observations, not a pass/fail claim. Repeat after a warm-up, investigate every error, and retain host metrics with the report. Do not point the probe at a live hotel or increase its built-in bounds to simulate a denial of service. +For the `0.2.0` Gate B candidate, the approved targets are concurrency 10, p95 latency at or below 500 ms, error rate at or below 1%, and at least 25% CPU and memory headroom on the documented four-core, 7.6 GiB host. The generated result reports HTTP observations, not a pass/fail claim. Repeat after a warm-up, investigate every error, and retain independently captured host metrics with the report. Hash both retained files for the approval record. Do not point the probe at a live hotel or increase its built-in bounds to simulate a denial of service. + +## Five-business-day supervised pilot + +Use one approved hotel and named hotel, technical and rollback owners. Start only after the prerequisite evidence below has passed. Keep PMS writes, payment creation and FAQ live mode disabled. Complete one daily review on each of five business days and stop for tenant leakage, credential exposure, data loss, an unapproved or duplicate send, an unreconciled uncertain send, failed rollback or loss of monitoring. + +Copy `deploy/pilot-run.example.json` to the restricted evidence store and replace all placeholders. Resolve critical and high findings; lower-severity findings may be contained only with an owner, expiry and objective rollback trigger. Validate and hash the final record: + +```sh +python3 deploy/pilot_run.py /secure/acceptance/pilot-run.json +sha256sum /secure/acceptance/pilot-run.json +``` + +The example deliberately fails while daily reviews are `not-run`. A structurally valid record does not substitute for the five elapsed business days or independent evidence review. ## Pilot exit record @@ -39,7 +54,7 @@ The go/no-go record must bind all evidence to the same release commit and releas Approval requires separate named decisions from the hotel pilot owner and technical release owner. Gate C additionally requires independently accepted PMS and payment-provider evidence. A conditional approval must identify the containment, owner, expiry and rollback trigger; an unresolved finding is not silently converted into acceptance. Retain the signed decision with the release rather than committing guest, credential or incident data to this repository. -Copy `deploy/pilot-approval.example.json` into the restricted release store and complete it only after reviewing the referenced evidence. The example is intentionally invalid while its decision is `pending`. For a contained finding, record its named owner, future expiry and objective rollback trigger. Validate the completed record with: +Copy `deploy/pilot-approval.example.json` into the restricted release store and complete it only after reviewing the referenced evidence. Approval schema version 2 binds the five-day pilot record and both the capacity report and independently captured host metrics. The example is intentionally invalid while its decision is `pending`. For a contained finding, record its named owner, future expiry and objective rollback trigger. Validate the completed record with: ```sh python3 deploy/pilot_approval.py /secure/acceptance/pilot-approval.json diff --git a/tests/test_gate_b_acceptance.py b/tests/test_gate_b_acceptance.py new file mode 100644 index 0000000..3cfcbe4 --- /dev/null +++ b/tests/test_gate_b_acceptance.py @@ -0,0 +1,85 @@ +import importlib.util +from pathlib import Path +import unittest + + +ROOT = Path(__file__).resolve().parents[1] + + +def module(name): + spec = importlib.util.spec_from_file_location(name, ROOT / "deploy" / f"{name}.py") + result = importlib.util.module_from_spec(spec) + spec.loader.exec_module(result) + return result + + +automation = module("automation_acceptance") +privacy = module("identity_privacy_acceptance") + + +def base(system): + return { + "schemaVersion": 1, "system": system, "targetGate": "B", + "releaseCommit": "a" * 40, "releaseRecordSha256": "b" * 64, + "environment": "https://sandbox-guestops.futuresens.co.uk", + "operator": "Acceptance operator", "reviewedBy": "Independent reviewer", + "startedAt": "2026-10-01T09:00:00Z", "endedAt": "2026-10-01T10:00:00Z", "reviewedAt": "2026-10-01T11:00:00Z", + } + + +def automation_record(): + record = base("guestops-automation-acceptance") + record.update({ + "dataClassification": "synthetic-only", + "faqEvaluation": {"positiveCases": 20, "negativeCases": 20, "falsePositives": 0, "falseNegatives": 0, "reportSha256": "c" * 64}, + "aiEvaluation": {"casesReviewed": 20, "unsafeDraftsApproved": 0, "reportSha256": "d" * 64}, + "staffTrained": 3, "monitoringOwner": "Monitoring owner", "rollbackOwner": "Rollback owner", + "scenarios": [{"id": item, "status": "pass", "evidence": ["restricted-" + item]} for item in sorted(automation.SCENARIOS)], + "postAcceptanceState": {"faqMode": "off", "pmsWrites": "disabled", "paymentCreation": "disabled"}, + }) + return record + + +def privacy_record(): + record = base("guestops-identity-privacy") + record.update({ + "retention": {"conversationDays": 365, "auditDays": 730, "backupDays": 30, "accountDays": 730, + "privacyOwner": "Privacy owner", "deletionOwner": "Deletion owner", "legalHoldOwner": "Legal hold owner", + "deletionProcedure": "restricted-deletion", "legalHoldProcedure": "restricted-legal-hold"}, + "providers": {"google": {"status": "accepted", "evidence": ["restricted-google"]}, + "openai": {"status": "disabled", "evidence": ["restricted-openai-decision"]}}, + "preferencesReviewed": sorted(privacy.PREFERENCES), + "scenarios": [{"id": item, "status": "pass", "evidence": ["restricted-" + item]} for item in sorted(privacy.SCENARIOS)], + }) + return record + + +class GateBAcceptanceTests(unittest.TestCase): + def test_complete_records_pass(self): + automation.validate(automation_record()) + privacy.validate(privacy_record()) + + def test_automation_requires_zero_faq_errors_and_training(self): + record = automation_record();record["faqEvaluation"]["falsePositives"] = 1 + with self.assertRaisesRegex(ValueError, "zero false positives"): + automation.validate(record) + record = automation_record();record["staffTrained"] = 0 + with self.assertRaisesRegex(ValueError, "staff member"): + automation.validate(record) + + def test_privacy_requires_retention_and_google_decisions(self): + record = privacy_record();record["retention"]["conversationDays"] = 0 + with self.assertRaisesRegex(ValueError, "conversationDays"): + privacy.validate(record) + record = privacy_record();record["providers"]["google"]["status"] = "pending" + with self.assertRaisesRegex(ValueError, "Google processing"): + privacy.validate(record) + + def test_privacy_requires_exact_preference_coverage(self): + record = privacy_record();record["preferencesReviewed"].pop() + with self.assertRaisesRegex(ValueError, "exact owner-controlled"): + privacy.validate(record) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_pilot_approval.py b/tests/test_pilot_approval.py index 1fddc7d..e38b903 100644 --- a/tests/test_pilot_approval.py +++ b/tests/test_pilot_approval.py @@ -11,14 +11,15 @@ spec.loader.exec_module(approval) def valid_record(gate="B"): ids = approval.GATE_C if gate == "C" else approval.GATE_B return { - "schemaVersion": 1, "targetGate": gate, "decision": "approved", + "schemaVersion": 2, "targetGate": gate, "decision": "approved", "releaseCommit": "a" * 40, "releaseRecordSha256": "b" * 64, "decidedAt": "2026-09-29T12:00:00Z", "approvals": { "hotelOwner": {"name": "Hotel owner", "approvedAt": "2026-09-29T11:00:00Z"}, "technicalOwner": {"name": "Technical owner", "approvedAt": "2026-09-29T11:30:00Z"}, }, - "capacity": {"reportSha256": "c" * 64, "targetConcurrency": 10, "observedConcurrency": 10, "targetP95Ms": 500, "observedP95Ms": 250, "targetErrorRate": 0.01, "observedErrorRate": 0}, + "capacity": {"reportSha256": "c" * 64, "hostMetricsSha256": "d" * 64, "targetConcurrency": 10, "observedConcurrency": 10, "targetP95Ms": 500, "observedP95Ms": 250, "targetErrorRate": 0.01, "observedErrorRate": 0, "targetCpuHeadroomPercent": 25, "observedCpuHeadroomPercent": 40, "targetMemoryHeadroomPercent": 25, "observedMemoryHeadroomPercent": 35}, + "pilot": {"recordSha256": "e" * 64, "businessDaysObserved": 5, "hotelsObserved": 1, "stopConditionsObserved": 0, "unresolvedFindings": 0}, "evidence": [{"id": item, "status": "pass", "references": ["restricted-ticket-" + item]} for item in sorted(ids)], } @@ -49,6 +50,17 @@ class PilotApprovalTests(unittest.TestCase): record = valid_record();record["capacity"]["targetErrorRate"] = 2 with self.assertRaisesRegex(ValueError, "ratio"): approval.validate(record) + record = valid_record();record["capacity"]["observedCpuHeadroomPercent"] = 24 + with self.assertRaisesRegex(ValueError, "cpu headroom"): + approval.validate(record) + + def test_completed_five_day_single_hotel_pilot_is_required(self): + record = valid_record();record["pilot"]["businessDaysObserved"] = 4 + with self.assertRaisesRegex(ValueError, "five business days"): + approval.validate(record) + record = valid_record();record["pilot"]["stopConditionsObserved"] = 1 + with self.assertRaisesRegex(ValueError, "stop condition"): + approval.validate(record) def test_approvers_must_be_separate_people_without_email_addresses(self): record = valid_record();record["approvals"]["technicalOwner"]["name"] = "Hotel owner" diff --git a/tests/test_pilot_run.py b/tests/test_pilot_run.py new file mode 100644 index 0000000..343ba41 --- /dev/null +++ b/tests/test_pilot_run.py @@ -0,0 +1,59 @@ +import importlib.util +from pathlib import Path +import unittest + + +spec = importlib.util.spec_from_file_location("pilot_run", Path(__file__).resolve().parents[1] / "deploy" / "pilot_run.py") +pilot = importlib.util.module_from_spec(spec) +spec.loader.exec_module(pilot) + + +def valid_record(): + days = [f"2026-10-{day:02d}" for day in range(5, 10)] + return { + "schemaVersion": 1, "system": "guestops-supervised-pilot", "targetGate": "B", + "releaseCommit": "a" * 40, "releaseRecordSha256": "b" * 64, + "environment": "https://sandbox-guestops.futuresens.co.uk", "hotelLabel": "pilot-hotel-a", + "hotelCount": 1, "plannedBusinessDays": 5, + "owners": {"hotelOwner": "Hotel owner", "technicalOwner": "Technical owner", "rollbackDecisionMaker": "Technical owner"}, + "startedOn": days[0], "endedOn": days[-1], + "pilotControls": {"pmsWrites": "disabled", "paymentCreation": "disabled", "faqMode": "off", "googleReviewedSending": "accepted"}, + "dailyReviews": [{"date": day, "status": "pass", "reviewedBy": "Daily reviewer", "evidence": [f"restricted-{day}"]} for day in days], + "stopConditions": {condition: False for condition in pilot.STOP_CONDITIONS}, + "findings": [], + "postPilotState": {"pmsWrites": "disabled", "paymentCreation": "disabled", "faqMode": "off"}, + } + + +class PilotRunTests(unittest.TestCase): + def test_complete_five_day_record_passes(self): + pilot.validate(valid_record()) + + def test_exact_business_days_are_required(self): + record = valid_record();record["dailyReviews"].pop() + with self.assertRaisesRegex(ValueError, "Exactly five"): + pilot.validate(record) + record = valid_record();record["dailyReviews"][1]["date"] = "2026-10-07" + with self.assertRaisesRegex(ValueError, "each business day"): + pilot.validate(record) + + def test_stop_condition_prevents_pass(self): + record = valid_record();record["stopConditions"]["duplicate-send"] = True + with self.assertRaisesRegex(ValueError, "stop condition"): + pilot.validate(record) + + def test_critical_findings_cannot_be_contained(self): + record = valid_record();record["findings"] = [{"id": "security-001", "severity": "critical", "disposition": "contained", "evidence": ["restricted-finding"]}] + with self.assertRaisesRegex(ValueError, "too severe"): + pilot.validate(record) + + def test_lower_severity_containment_requires_owner_expiry_and_trigger(self): + record = valid_record();record["findings"] = [{"id": "usability-001", "severity": "low", "disposition": "contained", "evidence": ["restricted-finding"]}] + with self.assertRaisesRegex(ValueError, "containment details"): + pilot.validate(record) + record["findings"][0]["containment"] = {"owner": "Finding owner", "expiresAt": "2026-10-31T12:00:00Z", "rollbackTrigger": "Stop if the issue affects guest handling."} + pilot.validate(record) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_release_record.py b/tests/test_release_record.py index 8d2145a..000a387 100644 --- a/tests/test_release_record.py +++ b/tests/test_release_record.py @@ -40,7 +40,7 @@ class ReleaseRecordTests(unittest.TestCase): ) record = json.loads(output.read_text(encoding="utf-8")) - self.assertEqual(record["version"], "0.1.0") + self.assertEqual(record["version"], "0.2.0") self.assertEqual(record["commit"], "a" * 40) self.assertEqual(record["images"]["api"]["id"], "sha256:api") self.assertEqual( diff --git a/web/package-lock.json b/web/package-lock.json index 029e698..8ec7573 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "guestops-web", - "version": "0.1.0", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "guestops-web", - "version": "0.1.0", + "version": "0.2.0", "dependencies": { "lucide-react": "^0.577.0", "react": "19.2.8", diff --git a/web/package.json b/web/package.json index 0d96715..9b7063b 100644 --- a/web/package.json +++ b/web/package.json @@ -1 +1 @@ -{"name":"guestops-web","private":true,"version":"0.1.0","type":"module","scripts":{"dev":"vite --host 127.0.0.1","build":"tsc -b && vite build","check":"tsc -b"},"dependencies":{"react":"19.2.8","react-dom":"19.2.8","lucide-react":"^0.577.0"},"devDependencies":{"@types/react":"^19.2.0","@types/react-dom":"^19.2.0","@vitejs/plugin-react":"6.1.1","typescript":"~5.9.3","vite":"8.2.2"}} +{"name":"guestops-web","private":true,"version":"0.2.0","type":"module","scripts":{"dev":"vite --host 127.0.0.1","build":"tsc -b && vite build","check":"tsc -b"},"dependencies":{"react":"19.2.8","react-dom":"19.2.8","lucide-react":"^0.577.0"},"devDependencies":{"@types/react":"^19.2.0","@types/react-dom":"^19.2.0","@vitejs/plugin-react":"6.1.1","typescript":"~5.9.3","vite":"8.2.2"}}