Compare commits
2 Commits
4dd33c90e1
...
a3ef408de6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a3ef408de6 | ||
|
|
aa3436fd1d |
@ -1,7 +1,7 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Version>0.1.0</Version>
|
||||
<Version>0.2.0</Version>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||
|
||||
@ -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. |
|
||||
| 17 | Inbox usability and desktop parity | B | In progress | The inbox now uses tenant-scoped stable cursor pagination in pages of 50 and protects unsaved drafts during navigation, conversation selection, filtering and search changes. Inbox, activity, mailbox-health and FAQ-history timestamps use the saved hotel timezone; finish the remaining secondary screens and agreed desktop-parity acceptance. |
|
||||
| 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. |
|
||||
| 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 | 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.
|
||||
|
||||
@ -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).
|
||||
|
||||
|
||||
@ -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.
|
||||
- Stable tenant-scoped inbox pagination beyond the former 500-message view, stronger unsaved-draft navigation guards, and hotel-timezone inbox timestamps.
|
||||
- A bounded read-only sandbox capacity probe plus machine-validated, release-bound supervised-pilot and incident-exercise records.
|
||||
- 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 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.
|
||||
@ -19,7 +20,7 @@ These capabilities still require their separately documented provider, host and
|
||||
### Known limitations and launch conditions
|
||||
|
||||
- Gate A still requires a successful default-branch CI run, durable off-host release archive, target-Debian deployment, persistent storage/key validation, monitoring, and a successful restore/rollback exercise.
|
||||
- Gate B still requires real Google acceptance and supervised staff testing. Before pilot use, safely paginate beyond the 500-conversation limit, protect drafts across every navigation path, make login throttling proxy-aware, and render dates in the saved hotel timezone—or record and approve explicit operational containment.
|
||||
- Gate B still requires real Google acceptance and supervised staff testing, including desktop-parity acceptance of pagination, draft protection, proxy-aware login throttling and saved-hotel-timezone rendering.
|
||||
- Gate C still requires the Rezlynx/Guestline adapter and independently accepted PMS/payment workflows, plus privacy, identity, capacity, and release approvals.
|
||||
- FAQ live mode and all external write actions must remain disabled until their corresponding acceptance gate has passed.
|
||||
|
||||
@ -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.
|
||||
|
||||
28
deploy/automation-acceptance.example.json
Normal file
28
deploy/automation-acceptance.example.json
Normal file
@ -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"}
|
||||
}
|
||||
110
deploy/automation_acceptance.py
Normal file
110
deploy/automation_acceptance.py
Normal file
@ -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)
|
||||
31
deploy/desktop-acceptance.example.json
Normal file
31
deploy/desktop-acceptance.example.json
Normal file
@ -0,0 +1,31 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"system": "guestops-desktop-parity",
|
||||
"dataClassification": "synthetic-only",
|
||||
"desktopBaselineCommit": "18b983bf402ecdded6430fd40bc4d3320587595a",
|
||||
"releaseCommit": "0000000000000000000000000000000000000000",
|
||||
"releaseRecordSha256": "0000000000000000000000000000000000000000000000000000000000000000",
|
||||
"environment": "https://sandbox-guestops.example.invalid",
|
||||
"browser": {
|
||||
"name": "Microsoft Edge",
|
||||
"version": "REPLACE_WITH_FULL_VERSION",
|
||||
"operatingSystem": "Windows 11"
|
||||
},
|
||||
"viewport": {"width": 1440, "height": 900, "deviceScaleFactor": 1},
|
||||
"hotelTimeZone": "Europe/London",
|
||||
"operator": "Acceptance operator",
|
||||
"reviewedBy": "Independent reviewer",
|
||||
"startedAt": "2026-09-29T09:00:00Z",
|
||||
"endedAt": "2026-09-29T10:00:00Z",
|
||||
"reviewedAt": "2026-09-29T11:00:00Z",
|
||||
"scenarios": [
|
||||
{"id": "desktop-layout", "status": "not-run", "evidence": []},
|
||||
{"id": "draft-conversation-guard", "status": "not-run", "evidence": []},
|
||||
{"id": "draft-filter-search-guard", "status": "not-run", "evidence": []},
|
||||
{"id": "draft-route-history-reload-guard", "status": "not-run", "evidence": []},
|
||||
{"id": "inbox-core-workflow", "status": "not-run", "evidence": []},
|
||||
{"id": "pagination-beyond-500", "status": "not-run", "evidence": []},
|
||||
{"id": "role-and-control-parity", "status": "not-run", "evidence": []},
|
||||
{"id": "timezone-and-dst", "status": "not-run", "evidence": []}
|
||||
]
|
||||
}
|
||||
121
deploy/desktop_acceptance.py
Normal file
121
deploy/desktop_acceptance.py
Normal file
@ -0,0 +1,121 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate a restricted GuestOps desktop-parity acceptance record."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import datetime as dt
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
DESKTOP_BASELINE = "18b983bf402ecdded6430fd40bc4d3320587595a"
|
||||
SCENARIOS = {
|
||||
"inbox-core-workflow",
|
||||
"pagination-beyond-500",
|
||||
"draft-conversation-guard",
|
||||
"draft-filter-search-guard",
|
||||
"draft-route-history-reload-guard",
|
||||
"timezone-and-dst",
|
||||
"role-and-control-parity",
|
||||
"desktop-layout",
|
||||
}
|
||||
|
||||
|
||||
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:
|
||||
parsed = dt.datetime.fromisoformat(value.removesuffix("Z") + "+00:00")
|
||||
except ValueError as error:
|
||||
raise ValueError(f"{field} is not a valid timestamp.") from error
|
||||
require(parsed.tzinfo == dt.timezone.utc, f"{field} must be UTC.")
|
||||
return parsed
|
||||
|
||||
|
||||
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 validate(record: object) -> None:
|
||||
require(isinstance(record, dict), "Acceptance record must be a JSON object.")
|
||||
require(record.get("schemaVersion") == 1, "Unsupported acceptance record schema.")
|
||||
require(record.get("system") == "guestops-desktop-parity",
|
||||
"Acceptance record system must be guestops-desktop-parity.")
|
||||
require(record.get("dataClassification") == "synthetic-only",
|
||||
"Desktop acceptance must use synthetic data only.")
|
||||
require(record.get("desktopBaselineCommit") == DESKTOP_BASELINE,
|
||||
"desktopBaselineCommit must identify the reviewed desktop baseline.")
|
||||
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.")
|
||||
|
||||
browser = record.get("browser")
|
||||
require(isinstance(browser, dict) and set(browser) == {"name", "version", "operatingSystem"},
|
||||
"browser must contain exactly name, version and operatingSystem.")
|
||||
for field in ("name", "version", "operatingSystem"):
|
||||
require(2 <= len(str(browser.get(field, "")).strip()) <= 120,
|
||||
f"browser.{field} is required.")
|
||||
viewport = record.get("viewport")
|
||||
require(isinstance(viewport, dict) and set(viewport) == {"width", "height", "deviceScaleFactor"},
|
||||
"viewport must contain exactly width, height and deviceScaleFactor.")
|
||||
require(isinstance(viewport["width"], int) and not isinstance(viewport["width"], bool)
|
||||
and 1280 <= viewport["width"] <= 7680, "Desktop viewport width must be between 1280 and 7680 pixels.")
|
||||
require(isinstance(viewport["height"], int) and not isinstance(viewport["height"], bool)
|
||||
and 720 <= viewport["height"] <= 4320, "Desktop viewport height must be between 720 and 4320 pixels.")
|
||||
require(isinstance(viewport["deviceScaleFactor"], (int, float)) and not isinstance(viewport["deviceScaleFactor"], bool)
|
||||
and 0.5 <= viewport["deviceScaleFactor"] <= 4, "deviceScaleFactor must be between 0.5 and 4.")
|
||||
time_zone = str(record.get("hotelTimeZone", ""))
|
||||
require(time_zone == "UTC" or re.fullmatch(r"[A-Za-z_]+(?:/[A-Za-z0-9_+\-]+)+", time_zone) is not None,
|
||||
"hotelTimeZone must be UTC or an IANA timezone name.")
|
||||
|
||||
operator = safe_name(record.get("operator"), "operator")
|
||||
reviewer = safe_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.")
|
||||
|
||||
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 desktop scenario set.")
|
||||
for item in scenarios:
|
||||
scenario_id = item["id"]
|
||||
require(item.get("status") == "pass", f"Scenario {scenario_id} has not passed.")
|
||||
evidence = item.get("evidence")
|
||||
require(isinstance(evidence, list) and 1 <= len(evidence) <= 10 and all(
|
||||
isinstance(value, str) and 3 <= len(value) <= 200 and "@" not in value for value in evidence
|
||||
), f"Scenario {scenario_id} requires safe opaque evidence references without email addresses.")
|
||||
|
||||
|
||||
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(f"Desktop-parity acceptance record is structurally complete: {len(SCENARIOS)} scenarios passed. This validates the record, not its restricted evidence.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except (OSError, ValueError, json.JSONDecodeError) as error:
|
||||
print(f"Desktop-parity acceptance record rejected: {error}", file=__import__("sys").stderr)
|
||||
raise SystemExit(1)
|
||||
42
deploy/identity-privacy-acceptance.example.json
Normal file
42
deploy/identity-privacy-acceptance.example.json
Normal file
@ -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": []}
|
||||
]
|
||||
}
|
||||
116
deploy/identity_privacy_acceptance.py
Normal file
116
deploy/identity_privacy_acceptance.py
Normal file
@ -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)
|
||||
@ -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"
|
||||
}
|
||||
|
||||
47
deploy/pilot-run.example.json
Normal file
47
deploy/pilot-run.example.json
Normal file
@ -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"
|
||||
}
|
||||
}
|
||||
@ -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:
|
||||
|
||||
163
deploy/pilot_run.py
Normal file
163
deploy/pilot_run.py
Normal file
@ -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)
|
||||
28
docs/desktop-acceptance.md
Normal file
28
docs/desktop-acceptance.md
Normal file
@ -0,0 +1,28 @@
|
||||
# Desktop-parity acceptance
|
||||
|
||||
Run this supervised exercise against the exact HTTPS sandbox release using synthetic conversations and accounts. Compare the web workflow with the reviewed desktop baseline `18b983bf402ecdded6430fd40bc4d3320587595a`; this is workflow and safety parity, not a claim that the interfaces are visually identical.
|
||||
|
||||
Use the browser and workstation configuration intended for the pilot. Record its full browser version, operating system, viewport, scale factor and the hotel's saved IANA timezone. Keep screenshots and recordings in the restricted acceptance store because UI evidence may contain message text or account details. The operator and independent reviewer must be different people.
|
||||
|
||||
## Required scenarios
|
||||
|
||||
| Record ID | Exercise | Passing result |
|
||||
| --- | --- | --- |
|
||||
| `inbox-core-workflow` | Open a synthetic conversation, create and save a draft, use an approved answer, change status and reload. | The same conversation and saved state remain available, and no action sends mail. |
|
||||
| `pagination-beyond-500` | Seed more than 500 tenant-scoped conversations and load successive 50-item pages while another conversation is inserted. | Every original record can be reached once without cross-hotel records, duplicates or skipped records. |
|
||||
| `draft-conversation-guard` | Edit without saving, select another conversation, reject the discard prompt, then accept it. | Rejection retains the draft and selection; acceptance changes conversation and discards only the unsaved edit. |
|
||||
| `draft-filter-search-guard` | Repeat the reject/accept checks while changing status filters and search text. | Rejection preserves the edit and prior view; acceptance applies the requested view change. |
|
||||
| `draft-route-history-reload-guard` | With an unsaved edit, try sidebar navigation, browser Back/Forward and reload or tab close. | In-app navigation and browser history require confirmation; reload or close raises the browser's native unsaved-change warning. |
|
||||
| `timezone-and-dst` | Choose a timezone different from the workstation and inspect inbox, activity, health, mailbox, automation, PMS, payment and team-link times, including values around a daylight-saving transition. | Every operational timestamp follows the saved hotel timezone and represents the same instant consistently. |
|
||||
| `role-and-control-parity` | Exercise owner and staff accounts with provider writes and automation disabled. | Staff cannot access owner functions, and neither role can bypass feature, review or provider controls. |
|
||||
| `desktop-layout` | Complete the workflow at the recorded desktop viewport and scale, including keyboard navigation and browser zoom checks agreed for the pilot. | Primary controls remain visible and usable without clipped dialogs, overlapping content or an inaccessible action. |
|
||||
|
||||
## Record and validation
|
||||
|
||||
Copy `deploy/desktop-acceptance.example.json` into the restricted acceptance store. Replace its release identifiers, environment, workstation details, timestamps and people. Mark each scenario `pass` only after the independent reviewer has checked its evidence. The example is intentionally invalid while scenarios are `not-run`.
|
||||
|
||||
```sh
|
||||
python3 deploy/desktop_acceptance.py /secure/acceptance/desktop-acceptance.json
|
||||
```
|
||||
|
||||
The validator checks structure, release and baseline binding, desktop dimensions, independent review, complete passing scenarios and opaque evidence references. It cannot inspect screenshots or prove browser behavior. Retain the validated record and its checksum, then reference it from `inbox-usability` in the pilot approval record.
|
||||
27
docs/gate-b-prerequisites.md
Normal file
27
docs/gate-b-prerequisites.md
Normal file
@ -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.
|
||||
@ -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
|
||||
@ -48,3 +63,5 @@ python3 deploy/pilot_approval.py /secure/acceptance/pilot-approval.json
|
||||
The validator requires the exact Gate B evidence set, or that set plus independently accepted PMS and payment-provider evidence for Gate C. It also verifies that observed concurrency meets the pre-agreed target and that p95 latency and error rate remain within their pre-agreed bounds. Structural validation does not inspect evidence or authorize rollout by itself.
|
||||
|
||||
Complete the [incident and rollback exercise](incident-exercise.md) before marking `incident-support` as passed. Its record must use the same release identifiers and target gate as this decision. Reference the retained exercise record and validator output; do not substitute a local automated-test result for the supervised exercise.
|
||||
|
||||
Complete the [desktop-parity acceptance exercise](desktop-acceptance.md) before marking `inbox-usability` as passed. Bind it to the same release identifiers and retain its independently reviewed record outside the repository.
|
||||
|
||||
65
tests/test_desktop_acceptance.py
Normal file
65
tests/test_desktop_acceptance.py
Normal file
@ -0,0 +1,65 @@
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
|
||||
|
||||
spec = importlib.util.spec_from_file_location("desktop_acceptance", Path(__file__).resolve().parents[1] / "deploy" / "desktop_acceptance.py")
|
||||
acceptance = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(acceptance)
|
||||
|
||||
|
||||
def valid_record():
|
||||
return {
|
||||
"schemaVersion": 1,
|
||||
"system": "guestops-desktop-parity",
|
||||
"dataClassification": "synthetic-only",
|
||||
"desktopBaselineCommit": acceptance.DESKTOP_BASELINE,
|
||||
"releaseCommit": "a" * 40,
|
||||
"releaseRecordSha256": "b" * 64,
|
||||
"environment": "https://sandbox-guestops.futuresens.co.uk",
|
||||
"browser": {"name": "Microsoft Edge", "version": "140.0.0.0", "operatingSystem": "Windows 11"},
|
||||
"viewport": {"width": 1440, "height": 900, "deviceScaleFactor": 1},
|
||||
"hotelTimeZone": "Europe/London",
|
||||
"operator": "Acceptance operator",
|
||||
"reviewedBy": "Independent reviewer",
|
||||
"startedAt": "2026-09-29T09:00:00Z",
|
||||
"endedAt": "2026-09-29T10:00:00Z",
|
||||
"reviewedAt": "2026-09-29T11:00:00Z",
|
||||
"scenarios": [
|
||||
{"id": scenario, "status": "pass", "evidence": [f"restricted-ticket-{index}"]}
|
||||
for index, scenario in enumerate(sorted(acceptance.SCENARIOS), 1)
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
class DesktopAcceptanceTests(unittest.TestCase):
|
||||
def test_complete_record_passes(self):
|
||||
acceptance.validate(valid_record())
|
||||
|
||||
def test_exact_scenarios_and_desktop_baseline_are_required(self):
|
||||
record = valid_record();record["scenarios"].pop()
|
||||
with self.assertRaisesRegex(ValueError, "exact desktop scenario"):
|
||||
acceptance.validate(record)
|
||||
record = valid_record();record["desktopBaselineCommit"] = "c" * 40
|
||||
with self.assertRaisesRegex(ValueError, "reviewed desktop baseline"):
|
||||
acceptance.validate(record)
|
||||
|
||||
def test_desktop_viewport_and_timezone_are_required(self):
|
||||
record = valid_record();record["viewport"]["width"] = 1024
|
||||
with self.assertRaisesRegex(ValueError, "Desktop viewport width"):
|
||||
acceptance.validate(record)
|
||||
record = valid_record();record["hotelTimeZone"] = "local browser time"
|
||||
with self.assertRaisesRegex(ValueError, "IANA timezone"):
|
||||
acceptance.validate(record)
|
||||
|
||||
def test_independent_review_and_safe_evidence_are_required(self):
|
||||
record = valid_record();record["reviewedBy"] = record["operator"]
|
||||
with self.assertRaisesRegex(ValueError, "different people"):
|
||||
acceptance.validate(record)
|
||||
record = valid_record();record["scenarios"][0]["evidence"] = ["guest@example.invalid"]
|
||||
with self.assertRaisesRegex(ValueError, "safe opaque"):
|
||||
acceptance.validate(record)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
85
tests/test_gate_b_acceptance.py
Normal file
85
tests/test_gate_b_acceptance.py
Normal file
@ -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()
|
||||
@ -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"
|
||||
|
||||
59
tests/test_pilot_run.py
Normal file
59
tests/test_pilot_run.py
Normal file
@ -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()
|
||||
@ -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(
|
||||
|
||||
4
web/package-lock.json
generated
4
web/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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"}}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api, type Hotel } from './api';
|
||||
import { hotelTime } from './time';
|
||||
type Payment={id:string;reference:string;email:string;description:string;amount:number;currency:string;state:string;detail:string;invoiceId:string;version:number;expiresAt:string;updatedAt:string;checkedAt:string|null};
|
||||
type Connection={configured:boolean;createsConfigured:boolean;sandbox:boolean;preview:boolean};
|
||||
type Props={hotel:Hotel;owner:boolean;busy:boolean;run:(f:()=>Promise<void>)=>Promise<void>;onHotel:(h:Hotel)=>void};
|
||||
@ -19,7 +20,7 @@ export function PaymentsPage({hotel,owner,busy,run,onHotel}:Props){
|
||||
<section className="settings-card"><h2>Payment connection</h2><p>{connection?.preview?'Sample workspace: no invoices, emails or payments can be created.':connection?.configured?`NMI ${connection.sandbox?'sandbox':'production'} configuration is available.`:'Your administrator needs to configure this hotel’s NMI merchant account.'}</p><p className="small muted">GuestOps hotel ID: {hotel.id}</p><label className="checkbox-label"><input type="checkbox" checked={hotel.paymentsEnabled||false} disabled={busy||!owner||(!connection?.createsConfigured&&!hotel.paymentsEnabled)} onChange={e=>{const enabled=e.target.checked;run(async()=>{if(enabled&&!window.confirm('Enable reviewed NMI invoice creation after sandbox acceptance? Creating an invoice may email the customer.'))return;onHotel(await api<Hotel>('/payments/controls','PUT',{version:hotel.version,enabled}));});}}/>Allow owner-approved payment invoices</label><p className="small muted">Card details are entered on NMI’s hosted page. GuestOps never asks for a card number.</p></section>
|
||||
<div className="pms-columns"><section className="settings-card"><h2>Prepare a payment request</h2><form onSubmit={propose}><fieldset disabled={busy||!owner||!connection?.configured}><label>Unique payment reference<input value={reference} onChange={e=>setReference(e.target.value)} pattern="[A-Za-z0-9-]+" maxLength={80} required placeholder="WH-2481-DEPOSIT"/></label><label>Customer email<input type="email" value={email} onChange={e=>setEmail(e.target.value)} maxLength={254} required/></label><label>Description<input value={description} onChange={e=>setDescription(e.target.value)} maxLength={250} required placeholder="Deposit for reservation WH-2481"/></label><div className="form-grid"><label>Amount<input type="number" min="0.01" max="100000" step="0.01" required value={amount} onChange={e=>setAmount(e.target.value)}/></label><label>Currency<select value={currency} onChange={e=>setCurrency(e.target.value)}><option>GBP</option><option>EUR</option><option>USD</option></select></label></div><button className="button secondary">Prepare for review</button></fieldset></form><p className="small muted">Preparation saves a proposal only. Verify the agreed amount and booking terms separately; this does not reserve a room.</p></section>
|
||||
<section className="settings-card"><h2>Payment history</h2>{items.length===0&&<p>No payment requests yet.</p>}<div className="pms-history">{items.map(p=><button key={p.id} className={'pms-history-item '+(selected===p.id?'selected':'')} onClick={()=>{setSelected(p.id);setApproved(false);}}><strong>{p.reference} · {p.currency} {p.amount.toFixed(2)}</strong><span>{p.state==='NeedsReview'?'Needs verification':p.state==='Paid'?'Paid · reported by NMI':p.state} · {p.email}</span></button>)}</div></section></div>
|
||||
{current&&<section className="settings-card" aria-label="Payment request review"><h2>{current.state==='Review'?'Review this payment request':'Payment request status'}</h2><dl className="pms-reservation"><div><dt>Reference</dt><dd>{current.reference}</dd></div><div><dt>Customer</dt><dd>{current.email}</dd></div><div><dt>Amount</dt><dd>{current.currency} {current.amount.toFixed(2)}</dd></div><div><dt>Description</dt><dd>{current.description}</dd></div><div><dt>NMI invoice</dt><dd>{current.invoiceId||'Not confirmed'}</dd></div><div><dt>Last verified</dt><dd>{current.checkedAt?new Date(current.checkedAt).toLocaleString():'Not yet verified'}</dd></div></dl><p role="status"><strong>{current.state==='Paid'?'Paid · reported by NMI':current.state}</strong> — {current.detail}</p>
|
||||
{current&&<section className="settings-card" aria-label="Payment request review"><h2>{current.state==='Review'?'Review this payment request':'Payment request status'}</h2><dl className="pms-reservation"><div><dt>Reference</dt><dd>{current.reference}</dd></div><div><dt>Customer</dt><dd>{current.email}</dd></div><div><dt>Amount</dt><dd>{current.currency} {current.amount.toFixed(2)}</dd></div><div><dt>Description</dt><dd>{current.description}</dd></div><div><dt>NMI invoice</dt><dd>{current.invoiceId||'Not confirmed'}</dd></div><div><dt>Last verified</dt><dd>{current.checkedAt?hotelTime(current.checkedAt,hotel.timezone):'Not yet verified'}</dd></div></dl><p role="status"><strong>{current.state==='Paid'?'Paid · reported by NMI':current.state}</strong> — {current.detail}</p>
|
||||
{current.state==='Review'?<><div className="staff-note">Creating the invoice may email this customer a hosted payment link through NMI. GuestOps records the invoice and its status.</div><label className="checkbox-label"><input type="checkbox" checked={approved} onChange={e=>setApproved(e.target.checked)}/>I checked the recipient, amount and currency, and approve NMI emailing this payment request.</label><div className="form-actions"><button className="button secondary" disabled={busy||!owner||connection?.preview} onClick={()=>action('cancel')}>Cancel proposal</button><button className="button primary" disabled={busy||!owner||connection?.preview||!hotel.paymentsEnabled||!connection?.createsConfigured||!approved||Date.now()>new Date(current.expiresAt).getTime()} onClick={()=>action('create')}>Approve and create invoice</button></div><p className="small muted">Approval expires after fifteen minutes. A payment reference cannot be reused.</p></>:!['Cancelled','NotCreated'].includes(current.state)&&<><button className="button secondary" disabled={busy||!owner||connection?.preview||(current.state==='Creating'&&Date.now()-new Date(current.updatedAt).getTime()<300000)} onClick={()=>action('check')}>Verify with NMI</button><p className="small muted">This only reads invoice status. An interrupted creation can be checked after five minutes. Uncertain invoices are never automatically recreated.</p></>}
|
||||
<p className="small muted">A paid invoice is not confirmation of bank settlement or a hotel booking. Refunds, invoice closure and disputes are handled in the merchant portal.</p>
|
||||
</section>}
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api, type Hotel } from './api';
|
||||
import { hotelTime } from './time';
|
||||
type Snapshot={id:string;reservationId:string;confirmation:string;guestName:string;arrival:string;departure:string;status:string;roomType:string;total:string;fetchedAt:string};
|
||||
type Change={id:string;reservationId:string;kind:string;arrival:string;departure:string;note:string;state:string;detail:string;version:number;updatedAt:string;expiresAt:string;before:Snapshot;after:Snapshot|null};
|
||||
type Connection={configured:boolean;writesConfigured:boolean;hotelCode:string;preview:boolean};
|
||||
@ -21,7 +22,7 @@ export function PmsPage({hotel,owner,busy,run,onHotel}:Props){
|
||||
<section className="settings-card"><h2>OHIP connection</h2><p>{connection?.configured?`Connected configuration for property ${connection.hotelCode}. Lookup will verify access.`:connection?.preview?'Sample workspace: real PMS lookup and updates are disabled.':'Your administrator needs to configure this hotel’s OHIP connection.'}</p><p className="small muted">GuestOps hotel ID: {hotel.id}</p><label className="checkbox-label"><input type="checkbox" checked={hotel.pmsUpdatesEnabled||false} disabled={busy||!owner||(!connection?.writesConfigured&&!hotel.pmsUpdatesEnabled)} onChange={e=>{const enabled=e.target.checked;run(async()=>{if(enabled&&!window.confirm('Enable staff-approved PMS updates for this hotel? Only enable this after the configured OHIP sandbox has passed acceptance checks.'))return;onHotel(await api<Hotel>('/pms/controls','PUT',{version:hotel.version,enabled}));});}}/>Allow owner-approved PMS updates</label><p className="small muted">Lookup is available separately. Every change needs review; automatic PMS updates are off.</p></section>
|
||||
<div className="pms-columns"><section className="settings-card"><h2>Find a reservation</h2><form onSubmit={lookup}><label>Exact confirmation number<input value={confirmation} maxLength={80} pattern="[a-zA-Z0-9-]+" required onChange={e=>setConfirmation(e.target.value)} placeholder="For example, 12345678"/></label><button className="button primary" disabled={busy||!connection?.configured}>Look up reservation</button></form>
|
||||
{snapshot&&<><Reservation value={snapshot}/><form onSubmit={propose}><fieldset disabled={busy||!owner}><label>Proposed action<select value={kind} onChange={e=>setKind(e.target.value)}><option value="AddNote">Add an internal reservation note</option><option value="StayDates">Change stay dates</option></select></label>{kind==='AddNote'?<label>Note<textarea rows={4} maxLength={2000} value={note} required onChange={e=>setNote(e.target.value)}/></label>:<div className="form-grid"><label>New arrival<input type="date" value={arrival} required onChange={e=>setArrival(e.target.value)}/></label><label>New departure<input type="date" value={departure} required onChange={e=>setDeparture(e.target.value)}/></label></div>}<button className="button secondary">Prepare for review</button></fieldset></form><p className="small muted">Preparing a proposal does not update the PMS. Lookup and proposals expire after ten minutes.</p></>}
|
||||
</section><section className="settings-card"><h2>Change history</h2>{!changes.length&&<p>No PMS changes have been prepared yet.</p>}<div className="pms-history">{changes.map(c=><button className={'pms-history-item '+(selected===c.id?'selected':'')} key={c.id} onClick={()=>{setSelected(c.id);setChecked(false);}}><strong>{c.before.confirmation} · {c.kind==='AddNote'?'Reservation note':'Stay dates'}</strong><span>{c.state==='NeedsReview'?'Needs verification':c.state} · {new Date(c.updatedAt).toLocaleString()}</span></button>)}</div></section></div>
|
||||
</section><section className="settings-card"><h2>Change history</h2>{!changes.length&&<p>No PMS changes have been prepared yet.</p>}<div className="pms-history">{changes.map(c=><button className={'pms-history-item '+(selected===c.id?'selected':'')} key={c.id} onClick={()=>{setSelected(c.id);setChecked(false);}}><strong>{c.before.confirmation} · {c.kind==='AddNote'?'Reservation note':'Stay dates'}</strong><span>{c.state==='NeedsReview'?'Needs verification':c.state} · {hotelTime(c.updatedAt,hotel.timezone)}</span></button>)}</div></section></div>
|
||||
{current&&<section className="settings-card pms-review" aria-label="PMS change review"><h2>{current.state==='Review'?'Review this PMS change':'PMS change status'}</h2><Reservation value={current.before}/><div className="staff-note">{current.kind==='StayDates'?`Requested stay: ${current.arrival} to ${current.departure}`:`Add internal note: ${current.note}`}</div><p role="status"><strong>{current.state==='NeedsReview'?'Needs verification':current.state}</strong> — {current.detail}</p>{current.after&&<><h3>Latest observed PMS state</h3><Reservation value={current.after}/></>}{current.state==='Review'&&<>{current.kind==='StayDates'&&<label className="checkbox-label"><input type="checkbox" checked={checked} onChange={e=>setChecked(e.target.checked)}/>I checked availability, rate consequences and guest agreement in the PMS. GuestOps does not quote or guarantee a new price here.</label>}<div className="form-actions"><button className="button secondary" disabled={busy||!owner||connection?.preview} onClick={()=>action('cancel')}>Cancel proposal</button><button className="button primary" disabled={busy||!owner||!hotel.pmsUpdatesEnabled||!connection?.writesConfigured||(current.kind==='StayDates'&&!checked)||new Date(current.expiresAt)<new Date()} onClick={()=>action('apply')}>Approve and apply to PMS</button></div></>}{(current.state==='NeedsReview'||current.state==='Applying')&&<><button className="button secondary" disabled={busy||!owner||(current.state==='Applying'&&Date.now()-new Date(current.updatedAt).getTime()<300000)} onClick={()=>action('verify')}>Verify current PMS state</button><p className="small muted">An interrupted update can be checked after five minutes. Verification only reads the PMS; it never repeats the update.</p></>}<p className="small muted">Operation reference: {current.id}</p></section>}
|
||||
</div>;
|
||||
}
|
||||
|
||||
@ -1,17 +1,18 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from './api';
|
||||
import { hotelTime } from './time';
|
||||
type Member={id:string;name:string;email:string;role:string;active:boolean;pending:boolean;version:number;linkPurpose:string;linkExpiresAt:string|null};
|
||||
type Link={userId:string;link:string;expiresAt:string};
|
||||
export function TeamPage({owner}:{owner:boolean}) {
|
||||
export function TeamPage({owner,timeZone}:{owner:boolean;timeZone:string}) {
|
||||
const [members,setMembers]=useState<Member[]>([]),[name,setName]=useState(''),[email,setEmail]=useState(''),[error,setError]=useState(''),[busy,setBusy]=useState(false),[link,setLink]=useState<Link|null>(null),[copied,setCopied]=useState(false);
|
||||
async function refresh(){setMembers(await api<Member[]>('/team'));}
|
||||
useEffect(()=>{if(owner)refresh().catch(e=>setError(e.message));},[owner]);
|
||||
async function act(path:string,body:unknown){if(busy)return;setBusy(true);setError('');setLink(null);setCopied(false);try{const result=await api<Link|null>(path,'POST',body);if(result?.link)setLink(result);await refresh();}catch(e){setError(e instanceof Error?e.message:'Please try again.');}finally{setBusy(false);}}
|
||||
if(!owner)return <div className="page"><h1>Team access</h1><p>Your hotel owner manages staff accounts.</p></div>;
|
||||
return <div className="page settings-page"><div className="page-heading"><span className="eyebrow">A place for everyone</span><h1>Your team</h1><p>Give each colleague their own access to the hotel workspace.</p></div>{error&&<div className="alert" role="alert">{error}</div>}
|
||||
{link&&<section className="settings-card account-link" aria-label="Private account link"><h2>Share this link privately</h2><p><strong>For {members.find(m=>m.id===link.userId)?.email||"the selected colleague"}</strong></p><p>No email has been sent. Verify the colleague's identity and share only with the intended account holder. Anyone with this link can set their password.</p><p>Expires {new Date(link.expiresAt).toLocaleString()}. The link is shown here once.</p><label>Private account link<textarea readOnly rows={3} value={link.link} onFocus={e=>e.target.select()}/></label><div className="form-actions"><button className="button secondary" onClick={()=>setLink(null)}>Dismiss link</button><button className="button primary" onClick={async()=>{try{await navigator.clipboard.writeText(link.link);setCopied(true);}catch{setError('Select and copy the link manually.');}}}>{copied?'Copied':'Copy private link'}</button></div></section>}
|
||||
{link&&<section className="settings-card account-link" aria-label="Private account link"><h2>Share this link privately</h2><p><strong>For {members.find(m=>m.id===link.userId)?.email||"the selected colleague"}</strong></p><p>No email has been sent. Verify the colleague's identity and share only with the intended account holder. Anyone with this link can set their password.</p><p>Expires {hotelTime(link.expiresAt,timeZone)}. The link is shown here once.</p><label>Private account link<textarea readOnly rows={3} value={link.link} onFocus={e=>e.target.select()}/></label><div className="form-actions"><button className="button secondary" onClick={()=>setLink(null)}>Dismiss link</button><button className="button primary" onClick={async()=>{try{await navigator.clipboard.writeText(link.link);setCopied(true);}catch{setError('Select and copy the link manually.');}}}>{copied?'Copied':'Copy private link'}</button></div></section>}
|
||||
<section className="settings-card"><h2>Invite a colleague</h2><p className="muted">Staff can work on guest conversations. Owners manage hotel settings, integrations and approvals.</p><form onSubmit={e=>{e.preventDefault();void act('/team/invite',{name,email});}}><fieldset disabled={busy}><div className="form-grid"><label>Full name<input required minLength={2} maxLength={100} value={name} onChange={e=>setName(e.target.value)}/></label><label>Work email<input type="email" required maxLength={254} value={email} onChange={e=>setEmail(e.target.value)}/></label></div><div className="form-actions"><button className="button primary">Create invitation link</button></div></fieldset></form></section>
|
||||
<section className="settings-card"><h2>Workspace members</h2><div className="team-list">{members.map(m=><article className="team-member" key={m.id}><div><strong>{m.name}</strong><p>{m.email}</p><span className={'status '+(m.active?'Completed':'NeedsAttention')}>{m.role} · {m.active?'Active':m.pending?'Awaiting invitation acceptance':'Disabled'}</span>{m.linkPurpose&&<p className="small muted">{m.linkPurpose} link expires {new Date(m.linkExpiresAt!).toLocaleString()}</p>}</div>{m.role==='Staff'&&<div className="team-actions">{m.pending?<button className="button secondary compact" disabled={busy} onClick={()=>act('/team/invite',{name:m.name,email:m.email})}>New invitation</button>:<button className="button secondary compact" disabled={busy} onClick={()=>act(`/team/${m.id}/${m.active?'reset':'restore'}`,{version:m.version})}>{m.active?'Reset password':'Restore access'}</button>}{m.linkPurpose&&<button className="button secondary compact" disabled={busy} onClick={()=>act(`/team/${m.id}/revoke`,{version:m.version})}>Revoke link</button>}{m.active&&<button className="button secondary compact" disabled={busy} onClick={()=>{if(window.confirm(`Disable access for ${m.name}? Their existing sessions will end.`))void act(`/team/${m.id}/disable`,{version:m.version});}}>Disable access</button>}</div>}</article>)}</div><p className="small muted">New links replace earlier links. Password recovery ends existing sessions once accepted. Owner recovery is handled by your server administrator.</p></section>
|
||||
<section className="settings-card"><h2>Workspace members</h2><div className="team-list">{members.map(m=><article className="team-member" key={m.id}><div><strong>{m.name}</strong><p>{m.email}</p><span className={'status '+(m.active?'Completed':'NeedsAttention')}>{m.role} · {m.active?'Active':m.pending?'Awaiting invitation acceptance':'Disabled'}</span>{m.linkPurpose&&<p className="small muted">{m.linkPurpose} link expires {hotelTime(m.linkExpiresAt!,timeZone)}</p>}</div>{m.role==='Staff'&&<div className="team-actions">{m.pending?<button className="button secondary compact" disabled={busy} onClick={()=>act('/team/invite',{name:m.name,email:m.email})}>New invitation</button>:<button className="button secondary compact" disabled={busy} onClick={()=>act(`/team/${m.id}/${m.active?'reset':'restore'}`,{version:m.version})}>{m.active?'Reset password':'Restore access'}</button>}{m.linkPurpose&&<button className="button secondary compact" disabled={busy} onClick={()=>act(`/team/${m.id}/revoke`,{version:m.version})}>Revoke link</button>}{m.active&&<button className="button secondary compact" disabled={busy} onClick={()=>{if(window.confirm(`Disable access for ${m.name}? Their existing sessions will end.`))void act(`/team/${m.id}/disable`,{version:m.version});}}>Disable access</button>}</div>}</article>)}</div><p className="small muted">New links replace earlier links. Password recovery ends existing sessions once accepted. Owner recovery is handled by your server administrator.</p></section>
|
||||
</div>;
|
||||
}
|
||||
type Setup={preview:boolean;steps:{title:string;detail:string;path:string;complete:boolean;optional:boolean}[]};
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import React, { useEffect, useRef, useState } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { Inbox, BookOpen, Settings, Activity as ActivityIcon, Search, ArrowUpRight, ChevronDown, Check, CheckCheck, Clock3, FileText, LogOut, RefreshCw, ArrowLeft, Plus, X, Mail, ShieldCheck, Save, CircleHelp, Banknote, Building2, ChevronRight } from 'lucide-react';
|
||||
import { api, session, type Session, type Hotel, type Conversation, type ConversationPage, type Knowledge, type Activity, type Mailboxes } from './api';
|
||||
@ -19,6 +19,7 @@ const date = (value: string,timeZone: string) => new Date(value).toLocaleString(
|
||||
function App() {
|
||||
const [auth,setAuth] = useState<Session|null>(null), [error,setError] = useState(''), [notice,setNotice] = useState('');
|
||||
const [hotel,setHotel] = useState<Hotel|null>(null), [page,setPage] = useState(location.pathname === '/' ? '/inbox' : location.pathname), [busy,setBusy] = useState(false);
|
||||
const pageRef=useRef(page);
|
||||
const [conversations,setConversations] = useState<Conversation[]>([]), [knowledge,setKnowledge] = useState<Knowledge[]>([]), [activity,setActivity] = useState<Activity[]>([]), [mailboxes,setMailboxes] = useState<Mailboxes>({ configured:false,items:[] });
|
||||
const [conversationCursor,setConversationCursor]=useState<string|null>(null);
|
||||
const [loaded,setLoaded] = useState(false);
|
||||
@ -27,7 +28,8 @@ function App() {
|
||||
setHotel(h);setConversations(c.items);setConversationCursor(c.nextCursor);setKnowledge(k);setActivity(a);setMailboxes(m);setLoaded(true);
|
||||
}
|
||||
async function moreConversations(){if(!conversationCursor)return;const page=await api<ConversationPage>('/conversations/page?cursor='+encodeURIComponent(conversationCursor));setConversations(old=>[...old,...page.items.filter(item=>!old.some(existing=>existing.id===item.id))]);setConversationCursor(page.nextCursor);}
|
||||
useEffect(() => { session().then(setAuth).catch(e=>setError(e.message)); const expired=()=>{setAuth(null);session().then(setAuth).catch(()=>{});setError('Your session has ended. Sign in again.');}; window.addEventListener('session-expired',expired); const pop=()=>setPage(location.pathname);window.addEventListener('popstate',pop);return()=>{window.removeEventListener('session-expired',expired);window.removeEventListener('popstate',pop);}; },[]);
|
||||
useEffect(()=>{pageRef.current=page;},[page]);
|
||||
useEffect(() => { session().then(setAuth).catch(e=>setError(e.message)); const expired=()=>{setAuth(null);session().then(setAuth).catch(()=>{});setError('Your session has ended. Sign in again.');}; window.addEventListener('session-expired',expired); const pop=()=>{if(!window.dispatchEvent(new Event('workspace-navigate',{cancelable:true}))){history.pushState({},'',pageRef.current);return;}setPage(location.pathname==='/'?'/inbox':location.pathname);};window.addEventListener('popstate',pop);return()=>{window.removeEventListener('session-expired',expired);window.removeEventListener('popstate',pop);}; },[]);
|
||||
useEffect(()=>{if(auth?.user) refresh().catch(e=>setError(e.message));},[auth?.user?.id]);
|
||||
useEffect(()=>{if(!notice)return;const timer=setTimeout(()=>setNotice(''),4500);return()=>clearTimeout(timer);},[notice]);
|
||||
async function run(action:()=>Promise<void>) { if(busy)return; setBusy(true);setError('');try{await action();}catch(e){setError(e instanceof Error?e.message:'Something went wrong.');}finally{setBusy(false);} }
|
||||
@ -51,7 +53,7 @@ function App() {
|
||||
<main className="main">
|
||||
<header className="topbar"><span><span className="breadcrumb">Workspace</span><ChevronRight size={14}/>{page==='/inbox'?'Inbox':page==='/knowledge'?'Hotel knowledge':page==='/activity'?'Activity':page==='/reservations'?'Reservations':page==='/payments'?'Payments':page==='/automation'?'FAQ automation':page==='/team'?'Your team':page==='/setup'?'Hotel setup':page==='/health'?'Workspace health':'Settings'}</span><div className="topbar-right">{auth.preview&&<span className="preview-pill">Preview · sample data</span>}<span className="draft-mode"><span/>{hotel?.autoReplyMode==='Live'?'FAQ auto-replies enabled':hotel?.staffSendingEnabled?'Staff-approved sending':'Draft-only mode'}</span><button className="icon-button" aria-label="Refresh workspace" disabled={busy} onClick={()=>run(refresh)}><RefreshCw size={17}/></button></div></header>
|
||||
{errorBox}{notice&&<div className="toast" role="status"><Check size={17}/>{notice}</div>}
|
||||
{!loaded?<div className="loading"><p>Loading your hotel…</p></div>:page==='/health'?<OperationsPage owner={auth.user.role==='Owner'} go={go}/>:page==='/team'?<TeamPage owner={auth.user.role==='Owner'}/>:page==='/setup'?<OnboardingPage owner={auth.user.role==='Owner'} go={go}/>:page==='/inbox'?<InboxPage hotel={hotel!} mailboxes={mailboxes} conversations={conversations} hasMore={!!conversationCursor} loadMore={()=>run(moreConversations)} knowledge={knowledge} busy={busy} run={run} onUpdate={c=>setConversations(old=>old.map(x=>x.id===c.id?c:x))} notify={setNotice} go={go}/>:page==='/automation'?<AutomationPage hotel={hotel!} owner={auth.user.role==='Owner'} busy={busy} run={run} onHotel={setHotel}/>:page==='/payments'?<PaymentsPage hotel={hotel!} owner={auth.user.role==='Owner'} busy={busy} run={run} onHotel={setHotel}/>:page==='/reservations'?<PmsPage hotel={hotel!} owner={auth.user.role==='Owner'} busy={busy} run={run} onHotel={setHotel}/>:page==='/knowledge'?<KnowledgePage items={knowledge} canEdit={auth.user.role==='Owner'} busy={busy} run={run} onUpdate={item=>setKnowledge(old=>old.some(x=>x.id===item.id)?old.map(x=>x.id===item.id?item:x):[...old,item])} notify={setNotice}/>:page==='/activity'?<div className="page"><PageHeading eyebrow="A clear record" title="Workspace activity" text="Changes made by your team, in one place."/><div className="activity-list">{activity.length?activity.map(a=><div className="activity-row" key={a.id}><span className="activity-icon"><Check size={18}/></span><div><strong>{a.action}</strong><p>{a.userName}</p></div><time>{date(a.at,hotel!.timezone)}</time></div>):<Empty title="No activity yet" text="Changes to your workspace will appear here."/>}</div></div>:<SettingsPage onMailboxes={setMailboxes} hotel={hotel!} mailboxes={mailboxes} preview={auth.preview} owner={auth.user.role==='Owner'} busy={busy} run={run} onSave={h=>{setHotel(h);setNotice('Hotel settings saved.');}}/>}
|
||||
{!loaded?<div className="loading"><p>Loading your hotel…</p></div>:page==='/health'?<OperationsPage owner={auth.user.role==='Owner'} go={go}/>:page==='/team'?<TeamPage owner={auth.user.role==='Owner'} timeZone={hotel!.timezone}/>:page==='/setup'?<OnboardingPage owner={auth.user.role==='Owner'} go={go}/>:page==='/inbox'?<InboxPage hotel={hotel!} mailboxes={mailboxes} conversations={conversations} hasMore={!!conversationCursor} loadMore={()=>run(moreConversations)} knowledge={knowledge} busy={busy} run={run} onUpdate={c=>setConversations(old=>old.map(x=>x.id===c.id?c:x))} notify={setNotice} go={go}/>:page==='/automation'?<AutomationPage hotel={hotel!} owner={auth.user.role==='Owner'} busy={busy} run={run} onHotel={setHotel}/>:page==='/payments'?<PaymentsPage hotel={hotel!} owner={auth.user.role==='Owner'} busy={busy} run={run} onHotel={setHotel}/>:page==='/reservations'?<PmsPage hotel={hotel!} owner={auth.user.role==='Owner'} busy={busy} run={run} onHotel={setHotel}/>:page==='/knowledge'?<KnowledgePage items={knowledge} canEdit={auth.user.role==='Owner'} busy={busy} run={run} onUpdate={item=>setKnowledge(old=>old.some(x=>x.id===item.id)?old.map(x=>x.id===item.id?item:x):[...old,item])} notify={setNotice}/>:page==='/activity'?<div className="page"><PageHeading eyebrow="A clear record" title="Workspace activity" text="Changes made by your team, in one place."/><div className="activity-list">{activity.length?activity.map(a=><div className="activity-row" key={a.id}><span className="activity-icon"><Check size={18}/></span><div><strong>{a.action}</strong><p>{a.userName}</p></div><time>{date(a.at,hotel!.timezone)}</time></div>):<Empty title="No activity yet" text="Changes to your workspace will appear here."/>}</div></div>:<SettingsPage onMailboxes={setMailboxes} hotel={hotel!} mailboxes={mailboxes} preview={auth.preview} owner={auth.user.role==='Owner'} busy={busy} run={run} onSave={h=>{setHotel(h);setNotice('Hotel settings saved.');}}/>}
|
||||
</main>
|
||||
</div>;
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user