diff --git a/app/Console/Commands/NightingaleDemoCohortCommand.php b/app/Console/Commands/NightingaleDemoCohortCommand.php new file mode 100644 index 00000000..5fa8a542 --- /dev/null +++ b/app/Console/Commands/NightingaleDemoCohortCommand.php @@ -0,0 +1,98 @@ +argument('action'); + if (! in_array($action, ['preview', 'apply', 'verify', 'suspend'], true)) { + $this->error('Action must be preview, apply, verify, or suspend.'); + + return self::INVALID; + } + + $unitId = null; + if (in_array($action, ['preview', 'apply'], true)) { + $unitId = filter_var( + $this->option('unit-id'), + FILTER_VALIDATE_INT, + ['options' => ['min_range' => 1]], + ); + if ($unitId === false) { + $this->error('A positive --unit-id is required.'); + + return self::INVALID; + } + } + + if ($action === 'apply' + && ! hash_equals('apply-five-synthetic-nightingale-demo-accounts', (string) $this->option('confirm'))) { + $this->error('Apply requires the exact cohort confirmation phrase.'); + + return self::INVALID; + } + if ($action === 'suspend' + && ! hash_equals('suspend-five-synthetic-nightingale-demo-accounts', (string) $this->option('confirm'))) { + $this->error('Suspend requires the exact cohort confirmation phrase.'); + + return self::INVALID; + } + + try { + $result = match ($action) { + 'preview' => $provisioner->preview($unitId), + 'apply' => $provisioner->apply( + $unitId, + (string) $this->secret('Nightingale demo password (input is hidden)'), + ), + 'verify' => $provisioner->verify(), + 'suspend' => $provisioner->suspend(), + }; + } catch (Throwable $exception) { + $message = $exception->getMessage(); + $this->error(str_starts_with($message, 'nightingale_demo_') + ? $message + : 'nightingale_demo_cohort_operation_failed'); + + return self::FAILURE; + } + + if ($this->option('json')) { + $this->line((string) json_encode( + $result, + JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE, + )); + + return self::SUCCESS; + } + + $this->table([ + 'Field', + 'Value', + ], collect($result)->map(function (mixed $value, string $key): array { + if (is_array($value)) { + $value = json_encode($value, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + } elseif (is_bool($value)) { + $value = $value ? 'true' : 'false'; + } + + return [$key, $value ?? 'null']; + })->values()->all()); + + return self::SUCCESS; + } +} diff --git a/app/Http/Requests/Patient/TokenRequest.php b/app/Http/Requests/Patient/TokenRequest.php index d3899552..3691d5d1 100644 --- a/app/Http/Requests/Patient/TokenRequest.php +++ b/app/Http/Requests/Patient/TokenRequest.php @@ -2,6 +2,8 @@ namespace App\Http\Requests\Patient; +use App\Nightingale\Demo\NightingaleDemoCohort; +use Closure; use Illuminate\Foundation\Http\FormRequest; class TokenRequest extends FormRequest @@ -15,7 +17,22 @@ public function authorize(): bool public function rules(): array { return [ - 'email' => ['required', 'string', 'lowercase', 'email:rfc', 'max:254'], + // `email` remains the compatibility wire name. Exact Nightingale + // demo aliases are admitted only for the code-owned synthetic + // cohort; no general patient username realm is created here. + 'email' => [ + 'required', + 'string', + 'lowercase', + 'max:254', + static function (string $attribute, mixed $value, Closure $fail): void { + if (! is_string($value) + || (filter_var($value, FILTER_VALIDATE_EMAIL) === false + && ! NightingaleDemoCohort::isLoginAlias($value))) { + $fail("The {$attribute} field must be a valid email address or demo login."); + } + }, + ], 'password' => ['required', 'string', 'max:1024'], 'device' => ['sometimes', 'array'], 'device.uuid' => ['sometimes', 'uuid'], diff --git a/app/Nightingale/Demo/NightingaleDemoCohort.php b/app/Nightingale/Demo/NightingaleDemoCohort.php new file mode 100644 index 00000000..26c73e83 --- /dev/null +++ b/app/Nightingale/Demo/NightingaleDemoCohort.php @@ -0,0 +1,214 @@ + + */ + public const MEMBERS = [ + 'demo1' => [ + 'display_name' => 'Nightingale Demo Patient 1', + 'patient_ref' => self::REFERENCE_SAMPLE_PATIENT_REF, + 'pathway_key' => 'drgcp-heart-failure-671d63b4d61b', + 'ms_drg' => '293', + 'drg_title' => 'Heart Failure and Shock without CC/MCC', + 'stage_count' => 5, + 'milestone_count' => 41, + ], + 'demo2' => [ + 'display_name' => 'Nightingale Demo Patient 2', + 'patient_ref' => 'demo-nightingale-investor-02', + 'pathway_key' => 'drgcp-simple-pneumonia-pleurisy-337d0f29a350', + 'ms_drg' => '195', + 'drg_title' => 'Simple Pneumonia and Pleurisy without CC/MCC', + 'stage_count' => 5, + 'milestone_count' => 44, + ], + 'demo3' => [ + 'display_name' => 'Nightingale Demo Patient 3', + 'patient_ref' => 'demo-nightingale-investor-03', + 'pathway_key' => 'drgcp-major-joint-replacement-hipknee-lower-extremity-'.'a7fc97e65adc', + 'ms_drg' => '470', + 'drg_title' => 'Major Hip and Knee Joint Replacement or Reattachment of Lower Extremity without MCC', + 'stage_count' => 5, + 'milestone_count' => 49, + ], + 'demo4' => [ + 'display_name' => 'Nightingale Demo Patient 4', + 'patient_ref' => 'demo-nightingale-investor-04', + 'pathway_key' => 'drgcp-appendectomy-'.'5b2df7e00bf7', + 'ms_drg' => '399', + 'drg_title' => 'Appendix Procedures without CC/MCC', + 'stage_count' => 5, + 'milestone_count' => 36, + ], + 'demo5' => [ + 'display_name' => 'Nightingale Demo Patient 5', + 'patient_ref' => 'demo-nightingale-investor-05', + 'pathway_key' => 'drgcp-vaginal-delivery-'.'2fd506169d41', + 'ms_drg' => '807', + 'drg_title' => 'Vaginal Delivery without Sterilization or D&C without CC/MCC', + 'stage_count' => 5, + 'milestone_count' => 44, + ], + ]; + + public static function isLoginAlias(string $value): bool + { + return isset(self::MEMBERS[$value]); + } + + /** @return list */ + public static function loginAliases(): array + { + return array_keys(self::MEMBERS); + } + + /** @param array $preferences */ + public static function preferencesAreOwned(array $preferences, ?string $alias = null): bool + { + $provisioning = $preferences['provisioning'] ?? null; + if (! is_array($provisioning) + || ($provisioning['product'] ?? null) !== self::PRODUCT + || ($provisioning['environment_class'] ?? null) !== self::ENVIRONMENT_CLASS + || ($provisioning['owner'] ?? null) !== self::OWNER + || ($provisioning['cohort_version'] ?? null) !== self::VERSION + || ($provisioning['clinical_use_permitted'] ?? null) !== false + || ($provisioning['synthetic'] ?? null) !== true) { + return false; + } + + $candidate = $provisioning['demo_username'] ?? null; + + return is_string($candidate) + && self::isLoginAlias($candidate) + && ($alias === null || $candidate === $alias) + && ($candidate !== 'demo1' || self::referenceSampleLineageIsExact($provisioning)); + } + + /** @param array $provisioning */ + public static function referenceSampleLineageIsExact(array $provisioning): bool + { + $adoption = $provisioning['reference_sample_adoption'] ?? null; + + return is_array($adoption) + && ($adoption['adopted_from_owner'] ?? null) === self::REFERENCE_SAMPLE_OWNER + && ($adoption['adopted_patient_ref'] ?? null) === self::REFERENCE_SAMPLE_PATIENT_REF + && ($adoption['source_template_product'] ?? null) === self::REFERENCE_SOURCE_PRODUCT + && ($adoption['source_template_owner'] ?? null) === self::REFERENCE_SOURCE_OWNER + && ($adoption['source_mode'] ?? null) === self::REFERENCE_SAMPLE_MODE; + } + + /** @param array $preferences */ + public static function disclosurePolicyVersionFor(array $preferences): ?string + { + return self::preferencesAreOwned($preferences) + ? self::RELEASE_POLICY_VERSION + : null; + } + + /** + * Restrict a principal query to one exact, command-owned synthetic alias. + * + * @param Builder $query + * @return Builder + */ + public static function constrainPrincipalQuery(Builder $query, string $alias): Builder + { + $query + ->where('principal_type', 'patient') + ->where('status', 'active') + ->where('is_active', true) + ->whereRaw("preferences #>> '{provisioning,product}' = ?", [self::PRODUCT]) + ->whereRaw("preferences #>> '{provisioning,environment_class}' = ?", [self::ENVIRONMENT_CLASS]) + ->whereRaw("preferences #>> '{provisioning,owner}' = ?", [self::OWNER]) + ->whereRaw("preferences #>> '{provisioning,cohort_version}' = ?", [self::VERSION]) + ->whereRaw("preferences #>> '{provisioning,demo_username}' = ?", [$alias]) + ->whereRaw("preferences #>> '{provisioning,clinical_use_permitted}' = ?", ['false']) + ->whereRaw("preferences #>> '{provisioning,synthetic}' = ?", ['true']); + + if ($alias === 'demo1') { + $query + ->whereRaw( + "preferences #>> '{provisioning,reference_sample_adoption,adopted_from_owner}' = ?", + [self::REFERENCE_SAMPLE_OWNER], + ) + ->whereRaw( + "preferences #>> '{provisioning,reference_sample_adoption,adopted_patient_ref}' = ?", + [self::REFERENCE_SAMPLE_PATIENT_REF], + ) + ->whereRaw( + "preferences #>> '{provisioning,reference_sample_adoption,source_template_product}' = ?", + [self::REFERENCE_SOURCE_PRODUCT], + ) + ->whereRaw( + "preferences #>> '{provisioning,reference_sample_adoption,source_template_owner}' = ?", + [self::REFERENCE_SOURCE_OWNER], + ) + ->whereRaw( + "preferences #>> '{provisioning,reference_sample_adoption,source_mode}' = ?", + [self::REFERENCE_SAMPLE_MODE], + ); + } + + return $query; + } +} diff --git a/app/Nightingale/Demo/NightingaleDemoCohortProvisioner.php b/app/Nightingale/Demo/NightingaleDemoCohortProvisioner.php new file mode 100644 index 00000000..e04028d9 --- /dev/null +++ b/app/Nightingale/Demo/NightingaleDemoCohortProvisioner.php @@ -0,0 +1,1468 @@ + 'patient-today.v1', + 'pathway' => 'patient-pathway.v1', + 'pathway_events' => 'patient-pathway-events.v1', + 'discharge_readiness' => 'patient-discharge-readiness.v1', + 'rounds_summary' => 'patient-rounds-summary.v1', + 'care_team' => 'patient-care-team.v1', + ]; + + private const REQUIRED_TABLES = [ + 'prod.units', + 'prod.encounters', + 'care_pathways.catalog_releases', + 'care_pathways.definitions', + 'care_pathways.versions', + 'care_pathways.drg_codebook_entries', + 'care_pathways.drg_mappings', + 'care_pathways.stage_definitions', + 'care_pathways.milestone_definitions', + 'patient_experience.principals', + 'patient_experience.identity_links', + 'patient_experience.encounter_access_grants', + 'patient_experience.enrollment_challenges', + 'patient_experience.sessions', + 'patient_experience.access_audit_events', + 'patient_experience.notification_devices', + 'patient_experience.release_policy_versions', + 'patient_experience.source_projection_cursors', + 'patient_experience.encounter_projections', + 'patient_experience.notification_outbox', + 'personal_access_tokens', + ]; + + private const DEMO_NOTICE = 'DEMO — NOT FOR CLINICAL USE. This is synthetic information for a product demonstration. It is not a medical record, care instruction, diagnosis, order, or promise. For urgent help, use the bedside call button or speak with staff.'; + + public function __construct( + private readonly Application $app, + private readonly PatientHmac $hmac, + private readonly PatientProjectionContentGuard $contentGuard, + private readonly SyntheticPatientProjectionProvisioner $syntheticContent, + ) {} + + /** @return array */ + public function preview(int $unitId): array + { + $this->assertFoundationAvailable(requireMutationPermission: false); + $unit = $this->resolveUnit($unitId, false); + $catalog = $this->catalogBindings(false); + $referenceSampleState = $this->referenceSampleState($unit, false); + $existing = $this->existingCounts(); + + return $this->result( + committed: false, + action: 'preview', + unit: $unit, + catalog: $catalog, + existing: $existing, + referenceSampleState: $referenceSampleState, + ); + } + + /** @return array */ + public function apply(int $unitId, string $password): array + { + $this->assertFoundationAvailable(requireMutationPermission: true); + $this->assertPassword($password); + + return DB::transaction(function () use ($unitId, $password): array { + if (! $this->app->environment('testing')) { + DB::statement('SET TRANSACTION ISOLATION LEVEL SERIALIZABLE'); + } + DB::select('SELECT pg_advisory_xact_lock(hashtext(?))', [ + NightingaleDemoCohort::VERSION, + ]); + + $unit = $this->resolveUnit($unitId, true); + $catalog = $this->catalogBindings(true); + $this->referenceSampleState($unit, true); + $policy = $this->resolvePolicy(); + + foreach (NightingaleDemoCohort::MEMBERS as $alias => $scenario) { + $binding = $catalog['members'][$alias]; + $encounter = $this->resolveEncounter($unit, $alias, $scenario); + $principal = $this->resolvePrincipal($alias, $scenario, $binding); + $identity = $this->resolveIdentity($principal, $encounter, $alias); + $grant = $this->resolveGrant($principal, $identity, $encounter, $alias); + $this->revokeSessionsForCredentialRotation($principal); + $principal->password = $password; + $principal->last_authenticated_at = null; + $principal->save(); + + foreach (self::KINDS as $kind) { + $this->resolveProjection( + principal: $principal, + grant: $grant, + encounter: $encounter, + policy: $policy, + alias: $alias, + scenario: $scenario, + binding: $binding, + kind: $kind, + ); + } + } + + $ownedPrincipalIds = $this->ownedPrincipals(forUpdate: false)->modelKeys(); + if (PatientSession::query() + ->whereIn('principal_id', $ownedPrincipalIds) + ->where('status', 'active') + ->exists() + || PersonalAccessToken::query() + ->where('tokenable_type', PatientPrincipal::class) + ->whereIn('tokenable_id', $ownedPrincipalIds) + ->exists()) { + throw new RuntimeException('nightingale_demo_credential_rotation_incomplete'); + } + + $verification = $this->verifyWithinTransaction($catalog); + + return $this->result( + committed: true, + action: 'applied', + unit: $unit, + catalog: $catalog, + existing: $verification, + referenceSampleState: 'adopted_into_demo1', + ); + }, 3); + } + + /** @return array */ + public function verify(): array + { + $this->assertFoundationAvailable(requireMutationPermission: false); + $catalog = $this->catalogBindings(false); + $verification = DB::transaction( + fn (): array => $this->verifyWithinTransaction($catalog), + 3, + ); + + return [ + 'committed' => false, + 'action' => 'verified', + 'cohort_version' => NightingaleDemoCohort::VERSION, + 'catalog_release_uuid' => $catalog['release']['catalog_release_uuid'], + 'accounts' => $verification, + 'demo1_reference_sample' => 'adopted_into_demo1', + 'credential_material_emitted' => false, + 'clinical_use_permitted' => false, + ]; + } + + /** @return array */ + public function suspend(): array + { + $this->assertFoundationAvailable(requireMutationPermission: true); + + return DB::transaction(function (): array { + DB::select('SELECT pg_advisory_xact_lock(hashtext(?))', [ + NightingaleDemoCohort::VERSION, + ]); + + $principals = $this->ownedPrincipals(forUpdate: true); + if ($principals->count() !== count(NightingaleDemoCohort::MEMBERS)) { + throw new RuntimeException('nightingale_demo_cohort_principal_cardinality_invalid'); + } + + foreach ($principals as $principal) { + $this->revokeSessionsForCredentialRotation($principal); + PatientEncounterAccessGrant::query() + ->where('principal_id', $principal->getKey()) + ->whereRaw("metadata->>'owner' = ?", [NightingaleDemoCohort::OWNER]) + ->lockForUpdate() + ->update(['status' => 'suspended']); + $principal->forceFill([ + 'status' => 'suspended', + 'is_active' => false, + 'password' => null, + 'last_authenticated_at' => null, + ])->save(); + } + + return [ + 'committed' => true, + 'action' => 'suspended', + 'cohort_version' => NightingaleDemoCohort::VERSION, + 'accounts_suspended' => $principals->count(), + 'credential_material_emitted' => false, + 'clinical_use_permitted' => false, + ]; + }, 3); + } + + private function assertFoundationAvailable(bool $requireMutationPermission): void + { + if (DB::getDriverName() !== 'pgsql') { + throw new RuntimeException('nightingale_demo_cohort_requires_postgresql'); + } + if ($requireMutationPermission + && ! $this->app->environment('testing') + && ! (bool) config('nightingale.demo_cohort.provisioning_enabled', false)) { + throw new RuntimeException('nightingale_demo_cohort_provisioning_disabled'); + } + if ($this->app->environment('local') && ! $this->databaseHostIsLocal()) { + throw new RuntimeException('nightingale_demo_cohort_refuses_remote_database_from_local_runtime'); + } + + foreach (self::REQUIRED_TABLES as $table) { + if (! Schema::hasTable($table)) { + throw new RuntimeException('nightingale_demo_cohort_schema_missing'); + } + } + + $this->hmac->assertAvailable(); + if (trim((string) config('app.key')) === '') { + throw new RuntimeException('nightingale_demo_cohort_encryption_key_unavailable'); + } + } + + private function databaseHostIsLocal(): bool + { + $connection = (string) config('database.default'); + $host = Str::lower(trim((string) config("database.connections.{$connection}.host"))); + + return $host === '' || in_array($host, [ + 'localhost', + '127.0.0.1', + '::1', + 'zephyrus-test-pg', + ], true); + } + + private function assertPassword(string $password): void + { + if (strlen($password) < 8 + || strlen($password) > 128 + || trim($password) !== $password + || preg_match('/[\x00-\x1F\x7F]/', $password) === 1) { + throw new RuntimeException('nightingale_demo_cohort_password_invalid'); + } + } + + private function resolveUnit(int $unitId, bool $forUpdate): Unit + { + if ($unitId < 1) { + throw new RuntimeException('nightingale_demo_cohort_unit_invalid'); + } + + $query = Unit::query()->whereKey($unitId)->where('is_deleted', false); + + return ($forUpdate ? $query->lockForUpdate() : $query)->first() + ?? throw new RuntimeException('nightingale_demo_cohort_unit_unavailable'); + } + + private function referenceSampleState(Unit $unit, bool $forUpdate): string + { + $encounterQuery = Encounter::query() + ->where('patient_ref', NightingaleDemoCohort::REFERENCE_SAMPLE_PATIENT_REF); + if ($forUpdate) { + $encounterQuery->lockForUpdate(); + } + $encounters = $encounterQuery->get(); + if ($encounters->count() !== 1) { + throw new RuntimeException('nightingale_demo_reference_sample_encounter_not_exact'); + } + $encounter = $encounters->sole(); + if ($encounter->created_by !== NightingaleDemoCohort::REFERENCE_SAMPLE_OWNER + || (int) $encounter->unit_id !== (int) $unit->getKey() + || $encounter->bed_id !== null + || $encounter->admitted_at === null + || (int) $encounter->acuity_tier !== 2 + || $encounter->status !== 'active' + || $encounter->discharged_at !== null + || $encounter->is_deleted) { + throw new RuntimeException('nightingale_demo_reference_sample_encounter_changed'); + } + + $principalQuery = PatientPrincipal::query() + ->where(function ($query): void { + $query + ->whereRaw( + "preferences #>> '{provisioning,owner}' = ?", + [NightingaleDemoCohort::REFERENCE_SAMPLE_OWNER], + ) + ->orWhere(function ($owned): void { + $owned + ->whereRaw( + "preferences #>> '{provisioning,product}' = ?", + [NightingaleDemoCohort::PRODUCT], + ) + ->whereRaw( + "preferences #>> '{provisioning,owner}' = ?", + [NightingaleDemoCohort::OWNER], + ) + ->whereRaw( + "preferences #>> '{provisioning,cohort_version}' = ?", + [NightingaleDemoCohort::VERSION], + ) + ->whereRaw( + "preferences #>> '{provisioning,demo_username}' = ?", + ['demo1'], + ); + }); + }); + if ($forUpdate) { + $principalQuery->lockForUpdate(); + } + $principals = $principalQuery->get(); + if ($principals->count() !== 1) { + throw new RuntimeException('nightingale_demo_reference_sample_principal_not_exact'); + } + $principal = $principals->sole(); + $state = 'ready_for_demo1_adoption'; + if (NightingaleDemoCohort::preferencesAreOwned((array) $principal->preferences, 'demo1')) { + if ($principal->display_name !== NightingaleDemoCohort::MEMBERS['demo1']['display_name'] + || $principal->email !== 'demo1@nightingale.demo.invalid') { + throw new RuntimeException('nightingale_demo_reference_sample_adoption_changed'); + } + $state = 'adopted_into_demo1'; + } else { + $this->assertPristineReferencePrincipal($principal); + } + + $expectedModifiedBy = $state === 'adopted_into_demo1' + ? NightingaleDemoCohort::OWNER + : NightingaleDemoCohort::REFERENCE_SAMPLE_OWNER; + if ($encounter->modified_by !== $expectedModifiedBy) { + throw new RuntimeException('nightingale_demo_reference_sample_encounter_changed'); + } + + return $state; + } + + private function assertPristineReferencePrincipal(PatientPrincipal $principal): void + { + $preferences = (array) $principal->preferences; + $provisioning = $preferences['provisioning'] ?? null; + if ($principal->principal_type !== 'patient' + || $principal->display_name !== NightingaleDemoCohort::REFERENCE_SAMPLE_DISPLAY_NAME + || $principal->status !== 'pending' + || $principal->is_active + || $principal->email !== null + || $principal->phone_e164 !== null + || $principal->password !== null + || $principal->email_verified_at !== null + || $principal->phone_verified_at !== null + || $principal->last_authenticated_at !== null + || $principal->locked_at !== null + || $principal->closed_at !== null + || ($preferences['synthetic'] ?? null) !== true + || ($preferences['product'] ?? null) !== NightingaleDemoCohort::PRODUCT + || ! is_array($provisioning) + || ($provisioning['owner'] ?? null) !== NightingaleDemoCohort::REFERENCE_SAMPLE_OWNER + || ($provisioning['mode'] ?? null) !== NightingaleDemoCohort::REFERENCE_SAMPLE_MODE + || ($provisioning['source_template_product'] ?? null) !== NightingaleDemoCohort::REFERENCE_SOURCE_PRODUCT + || ($provisioning['source_template_owner'] ?? null) !== NightingaleDemoCohort::REFERENCE_SOURCE_OWNER + || $principal->identityLinks()->exists() + || $principal->encounterAccessGrants()->exists() + || $principal->enrollmentChallenges()->exists() + || $principal->patientSessions()->exists() + || $principal->accessAuditEvents()->exists() + || $principal->notificationDevices()->exists() + || $principal->notificationOutboxMessages()->exists() + || $principal->tokens()->exists()) { + throw new RuntimeException('nightingale_demo_reference_sample_principal_changed'); + } + } + + /** + * @return array{ + * release: array, + * members: array> + * } + */ + private function catalogBindings(bool $forUpdate): array + { + $releaseQuery = DB::table('care_pathways.catalog_releases') + ->where('catalog_release_uuid', NightingaleDemoCohort::CATALOG_RELEASE_UUID) + ->where('dataset_key', NightingaleDemoCohort::CATALOG_DATASET_KEY); + if ($forUpdate) { + $releaseQuery->lockForUpdate(); + } + $releases = $releaseQuery->get(); + if ($releases->count() !== 1) { + throw new RuntimeException('nightingale_demo_catalog_release_not_exact'); + } + $release = $releases->sole(); + if ($release->grouper_version !== NightingaleDemoCohort::CATALOG_GROUPER_VERSION + || (int) $release->pathway_count !== NightingaleDemoCohort::CATALOG_PATHWAY_COUNT + || $release->state !== 'inactive' + || (bool) $release->clinical_signoff_complete + || (int) $release->clinical_signoff_count !== 0 + || $release->activated_at !== null + || $release->withdrawn_at !== null) { + throw new RuntimeException('nightingale_demo_catalog_release_state_changed'); + } + + $members = []; + foreach (NightingaleDemoCohort::MEMBERS as $alias => $scenario) { + $query = DB::table('care_pathways.definitions as definitions') + ->join('care_pathways.versions as versions', 'versions.pathway_definition_id', '=', 'definitions.pathway_definition_id') + ->join('care_pathways.drg_mappings as mappings', 'mappings.pathway_version_id', '=', 'versions.pathway_version_id') + ->join('care_pathways.drg_codebook_entries as codebook', 'codebook.drg_codebook_entry_id', '=', 'mappings.drg_codebook_entry_id') + ->where('definitions.pathway_key', $scenario['pathway_key']) + ->where('versions.catalog_release_id', $release->catalog_release_id) + ->where('codebook.catalog_release_id', $release->catalog_release_id) + ->where('codebook.ms_drg', $scenario['ms_drg']) + ->select([ + 'definitions.pathway_key', + 'definitions.canonical_name', + 'versions.pathway_version_id', + 'versions.pathway_version_uuid', + 'versions.source_digest', + 'versions.content_digest', + 'versions.release_disposition', + 'versions.clinical_signoff_status', + 'versions.institutional_approval_status', + 'versions.activation_status', + 'mappings.mapping_role', + 'codebook.ms_drg', + 'codebook.title as drg_title', + 'codebook.entry_digest as drg_entry_digest', + ]); + if ($forUpdate) { + $query->lockForUpdate(); + } + $rows = $query->get(); + if ($rows->count() !== 1) { + throw new RuntimeException("nightingale_demo_catalog_{$alias}_binding_not_exact"); + } + $row = $rows->sole(); + $stageCount = DB::table('care_pathways.stage_definitions') + ->where('pathway_version_id', $row->pathway_version_id) + ->where('review_state', 'draft') + ->count(); + $milestoneCount = DB::table('care_pathways.milestone_definitions') + ->where('pathway_version_id', $row->pathway_version_id) + ->where('review_state', 'draft') + ->count(); + + if ($row->drg_title !== $scenario['drg_title'] + || $row->release_disposition !== 'Ready for institutional clinician signoff' + || $row->clinical_signoff_status !== 'Not clinically approved — institutional SME signoff required' + || $row->institutional_approval_status !== 'not_reviewed' + || $row->activation_status !== 'inactive' + || $row->mapping_role === 'excluded' + || $stageCount !== $scenario['stage_count'] + || $milestoneCount !== $scenario['milestone_count']) { + throw new RuntimeException("nightingale_demo_catalog_{$alias}_binding_changed"); + } + + $members[$alias] = [ + 'pathway_version_id' => (int) $row->pathway_version_id, + 'pathway_version_uuid' => (string) $row->pathway_version_uuid, + 'pathway_key' => (string) $row->pathway_key, + 'canonical_name' => (string) $row->canonical_name, + 'source_digest' => (string) $row->source_digest, + 'content_digest' => (string) $row->content_digest, + 'ms_drg' => (string) $row->ms_drg, + 'drg_title' => (string) $row->drg_title, + 'drg_entry_digest' => (string) $row->drg_entry_digest, + 'stage_count' => $stageCount, + 'milestone_count' => $milestoneCount, + 'clinical_approval' => 'not_approved_demo_binding_only', + ]; + } + + return [ + 'release' => [ + 'catalog_release_id' => (int) $release->catalog_release_id, + 'catalog_release_uuid' => (string) $release->catalog_release_uuid, + 'dataset_key' => (string) $release->dataset_key, + 'state' => (string) $release->state, + 'clinical_signoff_complete' => false, + ], + 'members' => $members, + ]; + } + + /** + * @param array $scenario + */ + private function resolveEncounter( + Unit $unit, + string $alias, + array $scenario, + ): Encounter { + $matches = Encounter::query() + ->where('patient_ref', $scenario['patient_ref']) + ->lockForUpdate() + ->get(); + if ($matches->count() > 1) { + throw new RuntimeException("nightingale_demo_{$alias}_encounter_ambiguous"); + } + $encounter = $matches->first(); + $isReferenceSample = $alias === 'demo1' + && $encounter instanceof Encounter + && $encounter->created_by === NightingaleDemoCohort::REFERENCE_SAMPLE_OWNER; + if ($encounter instanceof Encounter + && $encounter->created_by !== NightingaleDemoCohort::OWNER + && ! $isReferenceSample) { + throw new RuntimeException("nightingale_demo_{$alias}_encounter_foreign_owned"); + } + + if (! $encounter instanceof Encounter) { + if ($alias === 'demo1') { + throw new RuntimeException('nightingale_demo_reference_sample_encounter_missing'); + } + + return Encounter::query()->create([ + 'patient_ref' => $scenario['patient_ref'], + 'unit_id' => $unit->getKey(), + 'bed_id' => null, + 'admitted_at' => now()->subDays(2), + 'expected_discharge_date' => now()->addDays(2)->toDateString(), + 'acuity_tier' => 2, + 'status' => 'active', + 'created_by' => NightingaleDemoCohort::OWNER, + 'modified_by' => NightingaleDemoCohort::OWNER, + 'is_deleted' => false, + ]); + } + + if ((int) $encounter->unit_id !== (int) $unit->getKey() + || $encounter->bed_id !== null + || $encounter->admitted_at === null + || (int) $encounter->acuity_tier !== 2 + || $encounter->status !== 'active' + || $encounter->discharged_at !== null + || $encounter->is_deleted + || ($isReferenceSample + ? ! in_array($encounter->modified_by, [ + NightingaleDemoCohort::REFERENCE_SAMPLE_OWNER, + NightingaleDemoCohort::OWNER, + ], true) + : $encounter->modified_by !== NightingaleDemoCohort::OWNER)) { + throw new RuntimeException("nightingale_demo_{$alias}_encounter_changed"); + } + + if ($isReferenceSample + && $encounter->modified_by === NightingaleDemoCohort::REFERENCE_SAMPLE_OWNER) { + $encounter->forceFill([ + 'modified_by' => NightingaleDemoCohort::OWNER, + ])->save(); + } + + return $encounter->fresh(); + } + + /** + * @param array $scenario + * @param array $binding + */ + private function resolvePrincipal( + string $alias, + array $scenario, + array $binding, + ): PatientPrincipal { + $principalUuid = $this->uuid($alias.'/principal'); + $matches = PatientPrincipal::query() + ->where(function ($query) use ($principalUuid, $alias): void { + $query + ->where('principal_uuid', $principalUuid) + ->orWhereRaw("preferences #>> '{provisioning,demo_username}' = ?", [$alias]); + if ($alias === 'demo1') { + $query->orWhereRaw( + "preferences #>> '{provisioning,owner}' = ?", + [NightingaleDemoCohort::REFERENCE_SAMPLE_OWNER], + ); + } + }) + ->lockForUpdate() + ->get(); + if ($matches->count() > 1) { + throw new RuntimeException("nightingale_demo_{$alias}_principal_ambiguous"); + } + + $preferences = $this->principalPreferences($alias, $binding); + $principal = $matches->first(); + if (! $principal instanceof PatientPrincipal) { + if ($alias === 'demo1') { + throw new RuntimeException('nightingale_demo_reference_sample_principal_missing'); + } + + return PatientPrincipal::query()->create([ + 'principal_uuid' => $principalUuid, + 'principal_type' => 'patient', + 'display_name' => $scenario['display_name'], + 'email' => $alias.'@nightingale.demo.invalid', + 'status' => 'active', + 'is_active' => true, + 'preferences' => $preferences, + 'locale' => 'en-US', + 'timezone' => 'America/New_York', + ]); + } + + $isPristineReference = $alias === 'demo1' + && (($principal->preferences['provisioning']['owner'] ?? null) + === NightingaleDemoCohort::REFERENCE_SAMPLE_OWNER); + if ($isPristineReference) { + $this->assertPristineReferencePrincipal($principal); + $principal->forceFill([ + 'display_name' => $scenario['display_name'], + 'email' => $alias.'@nightingale.demo.invalid', + 'status' => 'active', + 'is_active' => true, + 'preferences' => $preferences, + 'locked_at' => null, + 'closed_at' => null, + ])->save(); + + return $principal; + } + + $principalUuidMatches = $alias === 'demo1' + && NightingaleDemoCohort::referenceSampleLineageIsExact( + (array) ($principal->preferences['provisioning'] ?? []), + ) + ? true + : (string) $principal->principal_uuid === $principalUuid; + if (! $principalUuidMatches + || $principal->principal_type !== 'patient' + || $principal->display_name !== $scenario['display_name'] + || $principal->email !== $alias.'@nightingale.demo.invalid' + || ! NightingaleDemoCohort::preferencesAreOwned((array) $principal->preferences, $alias) + || $this->canonicalJson((array) $principal->preferences) !== $this->canonicalJson($preferences)) { + throw new RuntimeException("nightingale_demo_{$alias}_principal_changed"); + } + + $principal->forceFill([ + 'status' => 'active', + 'is_active' => true, + 'locked_at' => null, + 'closed_at' => null, + ])->save(); + + return $principal; + } + + /** @param array $binding */ + private function principalPreferences(string $alias, array $binding): array + { + return [ + 'provisioning' => [ + 'product' => NightingaleDemoCohort::PRODUCT, + 'environment_class' => NightingaleDemoCohort::ENVIRONMENT_CLASS, + 'owner' => NightingaleDemoCohort::OWNER, + 'cohort_version' => NightingaleDemoCohort::VERSION, + 'demo_username' => $alias, + 'synthetic' => true, + 'clinical_use_permitted' => false, + 'automatic_enrollment' => false, + 'catalog_binding' => [ + 'catalog_release_uuid' => NightingaleDemoCohort::CATALOG_RELEASE_UUID, + 'pathway_version_uuid' => $binding['pathway_version_uuid'], + 'pathway_key' => $binding['pathway_key'], + 'ms_drg' => $binding['ms_drg'], + 'drg_title' => $binding['drg_title'], + 'stage_count' => $binding['stage_count'], + 'milestone_count' => $binding['milestone_count'], + 'clinical_approval' => 'not_approved_demo_binding_only', + ], + ...($alias === 'demo1' ? [ + 'reference_sample_adoption' => [ + 'adopted_from_owner' => NightingaleDemoCohort::REFERENCE_SAMPLE_OWNER, + 'adopted_patient_ref' => NightingaleDemoCohort::REFERENCE_SAMPLE_PATIENT_REF, + 'source_template_product' => NightingaleDemoCohort::REFERENCE_SOURCE_PRODUCT, + 'source_template_owner' => NightingaleDemoCohort::REFERENCE_SOURCE_OWNER, + 'source_mode' => NightingaleDemoCohort::REFERENCE_SAMPLE_MODE, + ], + ] : []), + ], + 'presentation' => [ + 'demo_notice' => NightingaleDemoCohort::DEMO_NOTICE, + ], + ]; + } + + private function resolveIdentity( + PatientPrincipal $principal, + Encounter $encounter, + string $alias, + ): PatientIdentityLink { + $uuid = $this->uuid($alias.'/identity-link'); + $digest = $this->hmac->digest( + 'nightingale-demo-identity-v1', + NightingaleDemoCohort::SOURCE_SYSTEM_KEY."\0".$encounter->patient_ref, + ); + $matches = PatientIdentityLink::query() + ->where(function ($query) use ($uuid, $digest): void { + $query->where('identity_link_uuid', $uuid)->orWhere('source_subject_digest', $digest); + }) + ->lockForUpdate() + ->get(); + if ($matches->count() > 1) { + throw new RuntimeException("nightingale_demo_{$alias}_identity_ambiguous"); + } + $identity = $matches->first(); + if (! $identity instanceof PatientIdentityLink) { + return PatientIdentityLink::query()->create([ + 'identity_link_uuid' => $uuid, + 'principal_id' => $principal->getKey(), + 'source_system_key' => NightingaleDemoCohort::SOURCE_SYSTEM_KEY, + 'encrypted_source_subject' => (string) $encounter->patient_ref, + 'encryption_key_version' => NightingaleDemoCohort::ENCRYPTION_KEY_VERSION, + 'source_subject_digest' => $digest, + 'digest_algorithm' => 'hmac-sha256', + 'linkage_method' => 'encounter_enrollment', + 'status' => 'verified', + 'assurance_level' => 'synthetic-demo-code-owned', + 'provenance' => [ + 'owner' => NightingaleDemoCohort::OWNER, + 'cohort_version' => NightingaleDemoCohort::VERSION, + 'synthetic' => true, + 'clinical_use_permitted' => false, + ], + 'verified_at' => now(), + ]); + } + + if ((int) $identity->principal_id !== (int) $principal->getKey() + || (string) $identity->identity_link_uuid !== $uuid + || $identity->source_system_key !== NightingaleDemoCohort::SOURCE_SYSTEM_KEY + || $identity->source_subject_digest !== $digest + || $identity->status !== 'verified' + || $identity->revoked_at !== null + || ($identity->provenance['owner'] ?? null) !== NightingaleDemoCohort::OWNER + || ($identity->provenance['synthetic'] ?? null) !== true + || $identity->encrypted_source_subject !== (string) $encounter->patient_ref) { + throw new RuntimeException("nightingale_demo_{$alias}_identity_changed"); + } + + return $identity; + } + + private function resolveGrant( + PatientPrincipal $principal, + PatientIdentityLink $identity, + Encounter $encounter, + string $alias, + ): PatientEncounterAccessGrant { + $uuid = $this->uuid($alias.'/grant'); + $encounterUuid = $this->uuid($alias.'/encounter'); + $digest = $this->hmac->digest( + 'nightingale-demo-encounter-v1', + NightingaleDemoCohort::SOURCE_SYSTEM_KEY."\0prod.encounters/".$encounter->getKey(), + ); + $matches = PatientEncounterAccessGrant::query() + ->where(function ($query) use ($uuid, $encounterUuid, $digest): void { + $query + ->where('grant_uuid', $uuid) + ->orWhere('encounter_uuid', $encounterUuid) + ->orWhere('source_encounter_ref_digest', $digest); + }) + ->lockForUpdate() + ->get(); + if ($matches->count() > 1) { + throw new RuntimeException("nightingale_demo_{$alias}_grant_ambiguous"); + } + $metadata = [ + 'owner' => NightingaleDemoCohort::OWNER, + 'cohort_version' => NightingaleDemoCohort::VERSION, + 'product' => NightingaleDemoCohort::PRODUCT, + 'environment_class' => NightingaleDemoCohort::ENVIRONMENT_CLASS, + 'demo_username' => $alias, + 'synthetic' => true, + 'clinical_use_permitted' => false, + ]; + $grant = $matches->first(); + if (! $grant instanceof PatientEncounterAccessGrant) { + return PatientEncounterAccessGrant::query()->create([ + 'grant_uuid' => $uuid, + 'principal_id' => $principal->getKey(), + 'identity_link_id' => $identity->getKey(), + 'encounter_uuid' => $encounterUuid, + 'source_encounter_id' => $encounter->getKey(), + 'encrypted_source_encounter_ref' => 'prod.encounters/'.$encounter->getKey(), + 'source_encounter_ref_digest' => $digest, + 'source_system_key' => NightingaleDemoCohort::SOURCE_SYSTEM_KEY, + 'relationship' => 'self', + 'scopes' => ['today:read', 'pathway:read', 'care_team:read'], + 'purpose_of_use' => 'patient_access', + 'status' => 'active', + 'valid_from' => now()->subSecond(), + 'issued_by_actor_type' => 'system', + 'issued_by_actor_ref' => NightingaleDemoCohort::OWNER, + 'grant_reason' => 'Synthetic Nightingale investor demonstration only; not for clinical use.', + 'version' => 1, + 'metadata' => $metadata, + ]); + } + + if ((int) $grant->principal_id !== (int) $principal->getKey() + || (int) $grant->identity_link_id !== (int) $identity->getKey() + || (int) $grant->source_encounter_id !== (int) $encounter->getKey() + || (string) $grant->grant_uuid !== $uuid + || (string) $grant->encounter_uuid !== $encounterUuid + || $grant->source_encounter_ref_digest !== $digest + || $grant->source_system_key !== NightingaleDemoCohort::SOURCE_SYSTEM_KEY + || $grant->relationship !== 'self' + || $grant->purpose_of_use !== 'patient_access' + || $this->canonicalJson((array) $grant->metadata) !== $this->canonicalJson($metadata) + || $grant->encrypted_source_encounter_ref !== 'prod.encounters/'.$encounter->getKey()) { + throw new RuntimeException("nightingale_demo_{$alias}_grant_changed"); + } + + $grant->forceFill([ + 'status' => 'active', + 'revoked_at' => null, + 'revoked_by_actor_type' => null, + 'revoked_by_actor_ref' => null, + 'revocation_reason' => null, + ])->save(); + + return $grant; + } + + private function resolvePolicy(): PatientReleasePolicyVersion + { + $uuid = $this->uuid('shared/release-policy'); + $rules = [ + 'owner' => NightingaleDemoCohort::OWNER, + 'cohort_version' => NightingaleDemoCohort::VERSION, + 'synthetic_only' => true, + 'clinical_use_permitted' => false, + 'permitted_relationships' => ['self'], + 'required_notice' => NightingaleDemoCohort::DEMO_NOTICE, + ]; + $matches = PatientReleasePolicyVersion::query() + ->where('version', NightingaleDemoCohort::RELEASE_POLICY_VERSION) + ->lockForUpdate() + ->get(); + if ($matches->count() > 1) { + throw new RuntimeException('nightingale_demo_release_policy_ambiguous'); + } + $policy = $matches->first(); + if (! $policy instanceof PatientReleasePolicyVersion) { + return PatientReleasePolicyVersion::query()->create([ + 'policy_uuid' => $uuid, + 'version' => NightingaleDemoCohort::RELEASE_POLICY_VERSION, + 'status' => 'active', + 'disclosure_matrix_version' => 'nightingale-synthetic-demo-disclosure.v1', + 'content_contract_version' => 'patient-projection.v1', + 'rules' => $rules, + 'approved_by_actor_ref' => NightingaleDemoCohort::OWNER, + 'approved_at' => Carbon::parse('2026-07-27T00:00:00Z'), + 'effective_from' => Carbon::parse('2026-07-27T00:00:00Z'), + ]); + } + + if ((string) $policy->policy_uuid !== $uuid + || $policy->status !== 'active' + || $policy->disclosure_matrix_version !== 'nightingale-synthetic-demo-disclosure.v1' + || $policy->content_contract_version !== 'patient-projection.v1' + || $policy->approved_by_actor_ref !== NightingaleDemoCohort::OWNER + || $policy->approved_at === null + || $policy->effective_from === null + || $policy->effective_to !== null + || $this->canonicalJson((array) $policy->rules) !== $this->canonicalJson($rules)) { + throw new RuntimeException('nightingale_demo_release_policy_changed'); + } + + return $policy; + } + + /** + * @param array $scenario + * @param array $binding + */ + private function resolveProjection( + PatientPrincipal $principal, + PatientEncounterAccessGrant $grant, + Encounter $encounter, + PatientReleasePolicyVersion $policy, + string $alias, + array $scenario, + array $binding, + string $kind, + ): PatientEncounterProjection { + $content = $this->content($alias, $scenario, $kind); + $contentDigest = $this->contentGuard->digest($kind, self::SCHEMA_VERSIONS[$kind], $content); + $sourceObservedAt = Carbon::instance($encounter->admitted_at); + $provenance = [ + 'projection_method' => 'code_owned_synthetic_investor_demo', + 'source_class' => 'synthetic_scenario_with_catalog_binding', + 'input_classes' => ['synthetic_demo_scenario', 'inactive_catalog_binding'], + 'review_state' => 'released_for_synthetic_demo_only', + 'producer_version' => NightingaleDemoCohort::PROJECTION_PRODUCER_VERSION, + 'trace_digest' => $this->hmac->digest( + 'nightingale-demo-projection-trace-v1', + $alias.'|'.$kind.'|'.$binding['pathway_version_uuid'].'|'.$contentDigest, + ), + ]; + $uncertainty = [ + 'level' => 'unknown', + 'explanation' => 'This is synthetic demonstration content. Real care plans can change and require release by a care team.', + 'can_change' => true, + 'reviewed_at' => '2026-07-27T00:00:00+00:00', + ]; + $this->contentGuard->assertSafe($kind, $content, $provenance, $uncertainty, ['self']); + + $cursorUuid = $this->uuid($alias.'/cursor/'.$kind); + $cursorDigest = $this->hmac->digest( + 'nightingale-demo-projection-cursor-v1', + $alias.'|'.$kind.'|'.$binding['pathway_version_uuid'].'|'.$contentDigest, + ); + $cursor = PatientProjectionCursor::query() + ->where('cursor_uuid', $cursorUuid) + ->lockForUpdate() + ->first(); + if (! $cursor instanceof PatientProjectionCursor) { + $cursor = PatientProjectionCursor::query()->create([ + 'cursor_uuid' => $cursorUuid, + 'source_system_key' => NightingaleDemoCohort::SOURCE_SYSTEM_KEY, + 'projection_kind' => $kind, + 'cursor_digest' => $cursorDigest, + 'source_version' => NightingaleDemoCohort::PROJECTION_PRODUCER_VERSION, + 'status' => 'projected', + 'source_observed_at' => $sourceObservedAt, + 'projected_at' => now(), + 'metadata' => [ + 'owner' => NightingaleDemoCohort::OWNER, + 'cohort_version' => NightingaleDemoCohort::VERSION, + 'demo_username' => $alias, + 'synthetic' => true, + 'clinical_use_permitted' => false, + 'catalog_binding_digest' => hash('sha256', $this->canonicalJson($binding)), + ], + ]); + } elseif ($cursor->source_system_key !== NightingaleDemoCohort::SOURCE_SYSTEM_KEY + || $cursor->projection_kind !== $kind + || $cursor->cursor_digest !== $cursorDigest + || $cursor->source_version !== NightingaleDemoCohort::PROJECTION_PRODUCER_VERSION + || $cursor->status !== 'projected' + || ($cursor->metadata['owner'] ?? null) !== NightingaleDemoCohort::OWNER) { + throw new RuntimeException("nightingale_demo_{$alias}_{$kind}_cursor_changed"); + } + + $projectionUuid = $this->uuid($alias.'/projection/'.$kind); + $projection = PatientEncounterProjection::query() + ->where('projection_uuid', $projectionUuid) + ->lockForUpdate() + ->first(); + if (! $projection instanceof PatientEncounterProjection) { + return PatientEncounterProjection::query()->create([ + 'projection_uuid' => $projectionUuid, + 'access_grant_id' => $grant->getKey(), + 'release_policy_version_id' => $policy->getKey(), + 'projection_cursor_id' => $cursor->getKey(), + 'projection_kind' => $kind, + 'projection_sequence' => 1, + 'content' => $content, + 'content_schema_version' => self::SCHEMA_VERSIONS[$kind], + 'content_digest' => $contentDigest, + 'source_version' => NightingaleDemoCohort::PROJECTION_PRODUCER_VERSION, + 'provenance' => $provenance, + 'source_observed_at' => $sourceObservedAt, + 'generated_at' => now(), + 'released_at' => now(), + 'freshness_class' => 'unknown', + 'uncertainty' => $uncertainty, + 'required_scope' => PatientProjectionDisclosureService::REQUIRED_SCOPES[$kind], + 'permitted_relationships' => ['self'], + 'release_state' => 'released', + ]); + } + + if ((int) $projection->access_grant_id !== (int) $grant->getKey() + || (int) $projection->release_policy_version_id !== (int) $policy->getKey() + || (int) $projection->projection_cursor_id !== (int) $cursor->getKey() + || $projection->projection_kind !== $kind + || (int) $projection->projection_sequence !== 1 + || $projection->content_schema_version !== self::SCHEMA_VERSIONS[$kind] + || ! hash_equals($contentDigest, (string) $projection->content_digest) + || $this->canonicalJson((array) $projection->content) !== $this->canonicalJson($content) + || $this->canonicalJson((array) $projection->provenance) !== $this->canonicalJson($provenance) + || $this->canonicalJson((array) $projection->uncertainty) !== $this->canonicalJson($uncertainty) + || $projection->release_state !== 'released' + || $projection->released_at === null + || $projection->required_scope !== PatientProjectionDisclosureService::REQUIRED_SCOPES[$kind] + || array_values((array) $projection->permitted_relationships) !== ['self']) { + throw new RuntimeException("nightingale_demo_{$alias}_{$kind}_projection_changed"); + } + + return $projection; + } + + /** + * @param array $scenario + * @return array + */ + private function content(string $alias, array $scenario, string $kind): array + { + $seed = NightingaleDemoCohort::VERSION.'/'.$alias; + $content = $this->syntheticContent->contentFor($seed, $kind); + $content['notices'] = array_values(array_unique([ + self::DEMO_NOTICE, + ...((array) ($content['notices'] ?? [])), + ])); + $content['summary'] = 'Synthetic demo scenario: MS-DRG ' + .$scenario['ms_drg'].' — '.$scenario['drg_title'].'. ' + .'The linked catalog pathway is inactive and not institutionally approved. ' + .$content['summary']; + + if ($kind === 'pathway') { + $content['current_stage'] = 'Synthetic demonstration stage 2 of 5'; + $content['stages'] = []; + for ($index = 1; $index <= $scenario['stage_count']; $index++) { + $status = $index === 1 ? 'completed' : ($index === 2 ? 'current' : 'planned'); + $content['stages'][] = [ + 'stage_uuid' => $this->uuid($alias.'/content/pathway/stage/'.$index), + 'title' => "Synthetic demonstration stage {$index} of {$scenario['stage_count']}", + 'status' => $status, + 'summary' => 'A patient-safe demonstration checkpoint. A real care team would review and release current details.', + 'expected_range' => $index <= 2 ? 'Demonstration: current stay' : 'Demonstration: timing not set', + 'timing_confidence' => 'unknown', + 'can_change' => $status !== 'completed', + ]; + } + $content['milestones'] = []; + for ($index = 1; $index <= $scenario['milestone_count']; $index++) { + $status = $index === 1 ? 'completed' : ($index === 2 ? 'current' : 'planned'); + $content['milestones'][] = [ + 'milestone_uuid' => $this->uuid($alias.'/content/pathway/milestone/'.$index), + 'title' => "Synthetic pathway checkpoint {$index} of {$scenario['milestone_count']}", + 'status' => $status, + 'detail' => 'Demonstration content only. This checkpoint does not describe or direct real care.', + 'timing' => 'Timing is intentionally unspecified for this demonstration.', + 'timing_confidence' => 'unknown', + 'can_change' => $status !== 'completed', + ]; + } + } + + return $content; + } + + private function revokeSessionsForCredentialRotation(PatientPrincipal $principal): void + { + $sessions = PatientSession::query() + ->where('principal_id', $principal->getKey()) + ->where('status', 'active') + ->lockForUpdate() + ->get(); + foreach ($sessions as $session) { + $principal->tokens() + ->whereIn('name', [ + 'patient-access:'.$session->session_uuid, + 'patient-refresh:'.$session->session_uuid, + ]) + ->delete(); + $session->forceFill([ + 'status' => 'revoked', + 'revoked_at' => now(), + 'revocation_reason' => 'nightingale_demo_credential_rotation', + 'refresh_token_id' => null, + ])->save(); + } + PersonalAccessToken::query() + ->where('tokenable_type', PatientPrincipal::class) + ->where('tokenable_id', $principal->getKey()) + ->delete(); + } + + /** + * @param array{release: array, members: array>} $catalog + * @return array> + */ + private function verifyWithinTransaction(array $catalog): array + { + $policies = PatientReleasePolicyVersion::query() + ->where('version', NightingaleDemoCohort::RELEASE_POLICY_VERSION) + ->get(); + $expectedPolicyRules = [ + 'owner' => NightingaleDemoCohort::OWNER, + 'cohort_version' => NightingaleDemoCohort::VERSION, + 'synthetic_only' => true, + 'clinical_use_permitted' => false, + 'permitted_relationships' => ['self'], + 'required_notice' => NightingaleDemoCohort::DEMO_NOTICE, + ]; + if ($policies->count() !== 1) { + throw new RuntimeException('nightingale_demo_release_policy_cardinality_invalid'); + } + $policy = $policies->sole(); + if ((string) $policy->policy_uuid !== $this->uuid('shared/release-policy') + || $policy->status !== 'active' + || $policy->disclosure_matrix_version !== 'nightingale-synthetic-demo-disclosure.v1' + || $policy->content_contract_version !== 'patient-projection.v1' + || $policy->approved_by_actor_ref !== NightingaleDemoCohort::OWNER + || $policy->approved_at === null + || $policy->effective_from === null + || $policy->effective_to !== null + || $this->canonicalJson((array) $policy->rules) !== $this->canonicalJson($expectedPolicyRules)) { + throw new RuntimeException('nightingale_demo_release_policy_not_ready'); + } + + $principals = $this->ownedPrincipals(forUpdate: false); + if ($principals->count() !== count(NightingaleDemoCohort::MEMBERS)) { + throw new RuntimeException('nightingale_demo_cohort_principal_cardinality_invalid'); + } + $results = []; + $unitIds = []; + foreach (NightingaleDemoCohort::MEMBERS as $alias => $scenario) { + $principalMatches = $principals->filter( + fn (PatientPrincipal $principal): bool => (($principal->preferences['provisioning']['demo_username'] ?? null) === $alias), + ); + if ($principalMatches->count() !== 1) { + throw new RuntimeException("nightingale_demo_{$alias}_principal_cardinality_invalid"); + } + $principal = $principalMatches->sole(); + if ($principal->status !== 'active' + || ! $principal->is_active + || ! is_string($principal->password) + || strlen($principal->password) < 40 + || Hash::needsRehash($principal->password) + || ! NightingaleDemoCohort::preferencesAreOwned((array) $principal->preferences, $alias)) { + throw new RuntimeException("nightingale_demo_{$alias}_principal_not_ready"); + } + + $identities = PatientIdentityLink::query() + ->where('principal_id', $principal->getKey()) + ->get(); + $grants = PatientEncounterAccessGrant::query() + ->where('principal_id', $principal->getKey()) + ->get(); + if ($identities->count() !== 1 || $grants->count() !== 1) { + throw new RuntimeException("nightingale_demo_{$alias}_authorization_cardinality_invalid"); + } + $identity = $identities->sole(); + $expectedIdentityUuid = $this->uuid($alias.'/identity-link'); + $expectedIdentityDigest = $this->hmac->digest( + 'nightingale-demo-identity-v1', + NightingaleDemoCohort::SOURCE_SYSTEM_KEY."\0".$scenario['patient_ref'], + ); + if ((string) $identity->identity_link_uuid !== $expectedIdentityUuid + || $identity->source_system_key !== NightingaleDemoCohort::SOURCE_SYSTEM_KEY + || $identity->source_subject_digest !== $expectedIdentityDigest + || $identity->encrypted_source_subject !== $scenario['patient_ref'] + || $identity->status !== 'verified' + || $identity->verified_at === null + || $identity->revoked_at !== null + || ($identity->provenance['owner'] ?? null) !== NightingaleDemoCohort::OWNER + || ($identity->provenance['cohort_version'] ?? null) !== NightingaleDemoCohort::VERSION + || ($identity->provenance['synthetic'] ?? null) !== true + || ($identity->provenance['clinical_use_permitted'] ?? null) !== false) { + throw new RuntimeException("nightingale_demo_{$alias}_identity_not_ready"); + } + $grant = $grants->sole(); + $expectedGrantUuid = $this->uuid($alias.'/grant'); + $expectedEncounterUuid = $this->uuid($alias.'/encounter'); + $expectedGrantDigest = $this->hmac->digest( + 'nightingale-demo-encounter-v1', + NightingaleDemoCohort::SOURCE_SYSTEM_KEY."\0prod.encounters/".$grant->source_encounter_id, + ); + if ($grant->status !== 'active' + || (int) $grant->identity_link_id !== (int) $identity->getKey() + || (int) $grant->source_encounter_id < 1 + || (string) $grant->grant_uuid !== $expectedGrantUuid + || (string) $grant->encounter_uuid !== $expectedEncounterUuid + || $grant->source_encounter_ref_digest !== $expectedGrantDigest + || $grant->source_system_key !== NightingaleDemoCohort::SOURCE_SYSTEM_KEY + || $grant->relationship !== 'self' + || $grant->purpose_of_use !== 'patient_access' + || $grant->revoked_at !== null + || ($grant->metadata['owner'] ?? null) !== NightingaleDemoCohort::OWNER + || ($grant->metadata['cohort_version'] ?? null) !== NightingaleDemoCohort::VERSION + || ($grant->metadata['demo_username'] ?? null) !== $alias + || ($grant->metadata['synthetic'] ?? null) !== true + || ($grant->metadata['clinical_use_permitted'] ?? null) !== false) { + throw new RuntimeException("nightingale_demo_{$alias}_grant_not_ready"); + } + $encounters = Encounter::query() + ->whereKey($grant->source_encounter_id) + ->where('patient_ref', $scenario['patient_ref']) + ->where('status', 'active') + ->where('is_deleted', false) + ->get(); + $encounter = $encounters->count() === 1 ? $encounters->sole() : null; + $expectedEncounterOwner = $alias === 'demo1' + ? NightingaleDemoCohort::REFERENCE_SAMPLE_OWNER + : NightingaleDemoCohort::OWNER; + if (! $encounter instanceof Encounter + || $encounter->created_by !== $expectedEncounterOwner + || $encounter->modified_by !== NightingaleDemoCohort::OWNER + || $encounter->bed_id !== null + || $encounter->admitted_at === null + || (int) $encounter->acuity_tier !== 2 + || $encounter->discharged_at !== null + || ($alias === 'demo1' + && ! NightingaleDemoCohort::referenceSampleLineageIsExact( + (array) ($principal->preferences['provisioning'] ?? []), + ))) { + throw new RuntimeException("nightingale_demo_{$alias}_encounter_not_ready"); + } + $unitIds[] = (int) $encounter->unit_id; + $binding = $catalog['members'][$alias]; + $projections = PatientEncounterProjection::query() + ->where('access_grant_id', $grant->getKey()) + ->get(); + if ($projections->count() !== count(self::KINDS) + || $projections->pluck('projection_kind')->sort()->values()->all() + !== collect(self::KINDS)->sort()->values()->all()) { + throw new RuntimeException("nightingale_demo_{$alias}_projection_cardinality_invalid"); + } + foreach ($projections as $projection) { + $kind = (string) $projection->projection_kind; + $expectedContent = $this->content($alias, $scenario, $kind); + $expectedContentDigest = $this->contentGuard->digest( + $kind, + self::SCHEMA_VERSIONS[$kind], + $expectedContent, + ); + $expectedProvenance = [ + 'projection_method' => 'code_owned_synthetic_investor_demo', + 'source_class' => 'synthetic_scenario_with_catalog_binding', + 'input_classes' => ['synthetic_demo_scenario', 'inactive_catalog_binding'], + 'review_state' => 'released_for_synthetic_demo_only', + 'producer_version' => NightingaleDemoCohort::PROJECTION_PRODUCER_VERSION, + 'trace_digest' => $this->hmac->digest( + 'nightingale-demo-projection-trace-v1', + $alias.'|'.$kind.'|'.$binding['pathway_version_uuid'].'|'.$expectedContentDigest, + ), + ]; + $expectedUncertainty = [ + 'level' => 'unknown', + 'explanation' => 'This is synthetic demonstration content. Real care plans can change and require release by a care team.', + 'can_change' => true, + 'reviewed_at' => '2026-07-27T00:00:00+00:00', + ]; + $projectionPolicy = $projection->releasePolicyVersion; + $cursor = $projection->cursor; + $expectedCursorDigest = $this->hmac->digest( + 'nightingale-demo-projection-cursor-v1', + $alias.'|'.$kind.'|'.$binding['pathway_version_uuid'].'|'.$expectedContentDigest, + ); + if ((string) $projection->projection_uuid !== $this->uuid($alias.'/projection/'.$kind) + || $projection->release_state !== 'released' + || $projection->released_at === null + || $projection->supersedes_projection_id !== null + || (int) $projection->projection_sequence !== 1 + || $projection->content_schema_version !== self::SCHEMA_VERSIONS[$kind] + || $projection->source_version !== NightingaleDemoCohort::PROJECTION_PRODUCER_VERSION + || $projection->required_scope !== PatientProjectionDisclosureService::REQUIRED_SCOPES[$kind] + || array_values((array) $projection->permitted_relationships) !== ['self'] + || ! hash_equals($expectedContentDigest, (string) $projection->content_digest) + || $this->canonicalJson((array) $projection->content) !== $this->canonicalJson($expectedContent) + || $this->canonicalJson((array) $projection->provenance) !== $this->canonicalJson($expectedProvenance) + || $this->canonicalJson((array) $projection->uncertainty) !== $this->canonicalJson($expectedUncertainty) + || ! $projectionPolicy instanceof PatientReleasePolicyVersion + || (int) $projectionPolicy->getKey() !== (int) $policy->getKey() + || ! $cursor instanceof PatientProjectionCursor + || (string) $cursor->cursor_uuid !== $this->uuid($alias.'/cursor/'.$kind) + || $cursor->source_system_key !== NightingaleDemoCohort::SOURCE_SYSTEM_KEY + || $cursor->projection_kind !== $kind + || $cursor->cursor_digest !== $expectedCursorDigest + || $cursor->source_version !== NightingaleDemoCohort::PROJECTION_PRODUCER_VERSION + || $cursor->status !== 'projected' + || ($cursor->metadata['owner'] ?? null) !== NightingaleDemoCohort::OWNER + || ($cursor->metadata['cohort_version'] ?? null) !== NightingaleDemoCohort::VERSION + || ($cursor->metadata['demo_username'] ?? null) !== $alias + || ($cursor->metadata['synthetic'] ?? null) !== true + || ($cursor->metadata['clinical_use_permitted'] ?? null) !== false + || ($cursor->metadata['catalog_binding_digest'] ?? null) + !== hash('sha256', $this->canonicalJson($binding))) { + throw new RuntimeException("nightingale_demo_{$alias}_{$kind}_projection_not_ready"); + } + $this->contentGuard->assertSafe( + $kind, + (array) $projection->content, + (array) $projection->provenance, + (array) $projection->uncertainty, + array_values((array) $projection->permitted_relationships), + ); + } + + $results[$alias] = [ + 'principal_state' => 'active_synthetic_demo', + 'identity_links' => 1, + 'encounter_grants' => 1, + 'operational_encounters' => 1, + 'released_synthetic_projections' => count(self::KINDS), + 'ms_drg' => $binding['ms_drg'], + 'drg_title' => $binding['drg_title'], + 'catalog_stage_count' => $binding['stage_count'], + 'catalog_milestone_count' => $binding['milestone_count'], + 'clinical_use_permitted' => false, + 'credential_hash_present' => true, + 'credential_material_emitted' => false, + ]; + } + + $principalIds = $principals->modelKeys(); + $grantIds = PatientEncounterAccessGrant::query() + ->whereIn('principal_id', $principalIds) + ->pluck('access_grant_id'); + if (count(array_unique($principals->pluck('principal_uuid')->all())) !== 5 + || PatientIdentityLink::query() + ->whereIn('principal_id', $principalIds) + ->distinct('source_subject_digest') + ->count('source_subject_digest') !== 5 + || PatientEncounterAccessGrant::query() + ->whereIn('principal_id', $principalIds) + ->distinct('encounter_uuid') + ->count('encounter_uuid') !== 5 + || PatientEncounterAccessGrant::query() + ->whereIn('principal_id', $principalIds) + ->distinct('source_encounter_id') + ->count('source_encounter_id') !== 5 + || PatientEncounterProjection::query() + ->whereIn('access_grant_id', $grantIds) + ->count() !== 30 + || PatientProjectionCursor::query() + ->whereRaw("metadata->>'owner' = ?", [NightingaleDemoCohort::OWNER]) + ->whereRaw("metadata->>'cohort_version' = ?", [NightingaleDemoCohort::VERSION]) + ->count() !== 30 + || PatientPrincipal::query() + ->whereRaw( + "preferences #>> '{provisioning,owner}' = ?", + [NightingaleDemoCohort::REFERENCE_SAMPLE_OWNER], + ) + ->count() !== 0 + || Encounter::query() + ->where('patient_ref', NightingaleDemoCohort::REFERENCE_SAMPLE_PATIENT_REF) + ->count() !== 1 + || count(array_unique($unitIds)) !== 1 + || ($unitIds[0] ?? 0) < 1) { + throw new RuntimeException('nightingale_demo_cohort_cross_account_collision'); + } + + return $results; + } + + private function ownedPrincipals(bool $forUpdate) + { + $query = PatientPrincipal::query() + ->whereRaw("preferences #>> '{provisioning,owner}' = ?", [NightingaleDemoCohort::OWNER]) + ->whereRaw("preferences #>> '{provisioning,cohort_version}' = ?", [NightingaleDemoCohort::VERSION]); + + return ($forUpdate ? $query->lockForUpdate() : $query)->get(); + } + + /** @return array */ + private function existingCounts(): array + { + $principalIds = $this->ownedPrincipals(forUpdate: false)->modelKeys(); + + return [ + 'principals' => count($principalIds), + 'identity_links' => $principalIds === [] ? 0 : PatientIdentityLink::query()->whereIn('principal_id', $principalIds)->count(), + 'encounter_grants' => $principalIds === [] ? 0 : PatientEncounterAccessGrant::query()->whereIn('principal_id', $principalIds)->count(), + 'sessions' => $principalIds === [] ? 0 : PatientSession::query()->whereIn('principal_id', $principalIds)->count(), + 'tokens' => $principalIds === [] ? 0 : PersonalAccessToken::query() + ->where('tokenable_type', PatientPrincipal::class) + ->whereIn('tokenable_id', $principalIds) + ->count(), + 'projections' => $principalIds === [] ? 0 : PatientEncounterProjection::query() + ->whereIn( + 'access_grant_id', + PatientEncounterAccessGrant::query()->whereIn('principal_id', $principalIds)->select('access_grant_id'), + ) + ->count(), + ]; + } + + /** + * @param array{release: array, members: array>} $catalog + * @param array $existing + * @return array + */ + private function result( + bool $committed, + string $action, + Unit $unit, + array $catalog, + array $existing, + string $referenceSampleState, + ): array { + return [ + 'committed' => $committed, + 'action' => $action, + 'cohort_version' => NightingaleDemoCohort::VERSION, + 'member_count' => count(NightingaleDemoCohort::MEMBERS), + 'login_handles' => NightingaleDemoCohort::loginAliases(), + 'unit_id' => (int) $unit->getKey(), + 'catalog_release_uuid' => $catalog['release']['catalog_release_uuid'], + 'catalog_state' => $catalog['release']['state'], + 'catalog_clinical_signoff_complete' => false, + 'accounts' => $existing, + 'demo1_reference_sample' => $referenceSampleState, + 'credential_material_emitted' => false, + 'clinical_use_permitted' => false, + ]; + } + + private function uuid(string $name): string + { + return Uuid::uuid5( + Uuid::NAMESPACE_URL, + 'https://zephyrus.acumenus.net/nightingale/demo-cohort/v1/'.$name, + )->toString(); + } + + /** @param array $value */ + private function canonicalJson(array $value): string + { + return json_encode($this->canonicalize($value), JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } + + private function canonicalize(mixed $value): mixed + { + if (! is_array($value)) { + return $value; + } + if (array_is_list($value)) { + return array_map(fn (mixed $item): mixed => $this->canonicalize($item), $value); + } + ksort($value); + + return array_map(fn (mixed $item): mixed => $this->canonicalize($item), $value); + } +} diff --git a/app/Policies/Patient/PatientEncounterProjectionPolicy.php b/app/Policies/Patient/PatientEncounterProjectionPolicy.php index 5b8075a6..f9c3d626 100644 --- a/app/Policies/Patient/PatientEncounterProjectionPolicy.php +++ b/app/Policies/Patient/PatientEncounterProjectionPolicy.php @@ -4,6 +4,7 @@ use App\Models\Patient\PatientEncounterProjection; use App\Models\Patient\PatientPrincipal; +use App\Nightingale\Demo\NightingaleDemoCohort; use Illuminate\Support\Facades\Gate; class PatientEncounterProjectionPolicy @@ -12,6 +13,9 @@ public function view(PatientPrincipal $principal, PatientEncounterProjection $pr { $grant = $projection->accessGrant; $policy = $projection->releasePolicyVersion; + $expectedPolicyVersion = NightingaleDemoCohort::disclosurePolicyVersionFor( + (array) $principal->preferences, + ) ?? (string) config('nightingale.policy_version'); return (bool) $principal->is_active && $principal->status === 'active' @@ -23,7 +27,7 @@ public function view(PatientPrincipal $principal, PatientEncounterProjection $pr && $projection->released_at !== null && $projection->released_at->isPast() && $policy !== null - && $policy->version === (string) config('nightingale.policy_version') + && $policy->version === $expectedPolicyVersion && $policy->status === 'active' && $policy->approved_at !== null && $policy->effective_from !== null diff --git a/app/Services/Patient/PatientAuthService.php b/app/Services/Patient/PatientAuthService.php index 52a032c6..a2bc9ce7 100644 --- a/app/Services/Patient/PatientAuthService.php +++ b/app/Services/Patient/PatientAuthService.php @@ -7,6 +7,7 @@ use App\Models\Patient\PatientIdentityLink; use App\Models\Patient\PatientPrincipal; use App\Models\Patient\PatientSession; +use App\Nightingale\Demo\NightingaleDemoCohort; use Carbon\CarbonImmutable; use Illuminate\Http\Request; use Illuminate\Support\Collection; @@ -170,10 +171,13 @@ public function enroll(array $input, Request $request): array */ public function authenticate(string $email, string $password, array $device, Request $request): array { - $normalizedEmail = Str::lower(trim($email)); - $principal = PatientPrincipal::query() - ->whereRaw('lower(email) = ?', [$normalizedEmail]) - ->first(); + $normalizedIdentifier = Str::lower(trim($email)); + $isDemoAlias = NightingaleDemoCohort::isLoginAlias($normalizedIdentifier); + $principal = $isDemoAlias + ? $this->resolveNightingaleDemoPrincipal($normalizedIdentifier) + : PatientPrincipal::query() + ->whereRaw('lower(email) = ?', [$normalizedIdentifier]) + ->first(); $validPassword = $this->passwordMatches($password, (string) ($principal?->password ?? '')); @@ -215,10 +219,27 @@ public function authenticate(string $email, string $password, array $device, Req $principal->forceFill(['last_authenticated_at' => now()])->save(); return DB::transaction( - fn (): array => $this->issueNewSession($principal, $device, $request, 'password'), + fn (): array => $this->issueNewSession( + $principal, + $device, + $request, + $isDemoAlias ? 'nightingale_demo_password' : 'password', + ), ); } + private function resolveNightingaleDemoPrincipal(string $alias): ?PatientPrincipal + { + $matches = NightingaleDemoCohort::constrainPrincipalQuery( + PatientPrincipal::query(), + $alias, + )->limit(2)->get(); + + // Duplicate or partial ownership is indistinguishable from an unknown + // account at the public boundary and must fail closed. + return $matches->count() === 1 ? $matches->sole() : null; + } + /** @return array */ public function refresh(PatientPrincipal $principal, PersonalAccessToken $token, Request $request): array { @@ -512,7 +533,11 @@ private function issueNewSession( 'app_version' => $device['app_version'] ?? null, 'os_version' => $device['os_version'] ?? null, 'auth_method' => $authMethod, - 'assurance_level' => $authMethod === 'enrollment' ? 'enrollment_verified' : 'password', + 'assurance_level' => match ($authMethod) { + 'enrollment' => 'enrollment_verified', + 'nightingale_demo_password' => 'synthetic_demo', + default => 'password', + }, 'client_instance_digest' => $this->clientFingerprint($device, $request), 'user_agent_digest' => hash('sha256', (string) $request->userAgent()), 'ip_address' => $request->ip(), diff --git a/app/Services/Patient/Projection/PatientProjectionDisclosureService.php b/app/Services/Patient/Projection/PatientProjectionDisclosureService.php index 5a1ae4c6..044e28a4 100644 --- a/app/Services/Patient/Projection/PatientProjectionDisclosureService.php +++ b/app/Services/Patient/Projection/PatientProjectionDisclosureService.php @@ -7,6 +7,7 @@ use App\Models\Patient\PatientEncounterProjection; use App\Models\Patient\PatientPrincipal; use App\Models\Patient\PatientReleasePolicyVersion; +use App\Nightingale\Demo\NightingaleDemoCohort; use App\Services\Patient\PatientAccessAuditRecorder; use Illuminate\Http\Request; use Illuminate\Support\Facades\DB; @@ -77,9 +78,13 @@ private function discloseWithinTransaction( return null; } + $policyVersion = NightingaleDemoCohort::disclosurePolicyVersionFor( + (array) $principal->preferences, + ) ?? (string) config('nightingale.policy_version'); + $policy = PatientReleasePolicyVersion::query() ->effective() - ->where('version', (string) config('nightingale.policy_version')) + ->where('version', $policyVersion) ->first(); if ($policy === null) { diff --git a/config/nightingale.php b/config/nightingale.php index 8670f610..37a43f00 100644 --- a/config/nightingale.php +++ b/config/nightingale.php @@ -317,4 +317,19 @@ 'patient_disclosure_enabled' => false, 'patient_mutation_enabled' => false, 'production_enabled' => false, + + /* + |-------------------------------------------------------------------------- + | Synthetic investor-demo cohort + |-------------------------------------------------------------------------- + | + | Provisioning is a separately authorized, CLI-only mutation. It is + | disabled in every runtime unless the deployed operator deliberately + | enables it. This key does not register routes, activate a real-patient + | capability, or weaken the serving gates above. + | + */ + 'demo_cohort' => [ + 'provisioning_enabled' => (bool) env('NIGHTINGALE_DEMO_PROVISIONING_ENABLED', false), + ], ]; diff --git a/docs/evidence/nightingale/investor-demo-cohort-planning-2026-07-27/README.md b/docs/evidence/nightingale/investor-demo-cohort-planning-2026-07-27/README.md new file mode 100644 index 00000000..9415aa5b --- /dev/null +++ b/docs/evidence/nightingale/investor-demo-cohort-planning-2026-07-27/README.md @@ -0,0 +1,114 @@ +# Nightingale investor-demo cohort planning evidence + +**Observed:** 2026-07-27 + +**Database access:** PostgreSQL TLS session, repeatable-read read-only transaction + +**Mutation:** none + +**Patient data retained:** none + +## Purpose + +This evidence records the source-backed catalog facts used to design five synthetic +Nightingale investor-demo patients. It is not clinical approval, catalog activation, +patient-content release, account provisioning, pilot authorization, or deployment. + +## Aggregate catalog observations + +The read-only transaction observed: + +| Fact | Value | +| ----------------------------------------- | ----: | +| Catalog releases | 1 | +| Pathway versions | 250 | +| Stage definitions | 1,250 | +| Milestone definitions | 9,601 | +| Source-candidate staff-reference sections | 7,000 | +| Ready for institutional clinician signoff | 96 | +| Specialist review with limitations | 148 | +| Needs pathway redesign | 6 | +| Clinically signed-off releases | 0 | +| Active releases | 0 | + +The sole release was `inactive`, institutionally `not_reviewed`, and not clinically +signed off. Every observed stage and milestone definition was `draft`. No canonical +catalog row was changed. + +## Exact selected scenario facts + +| Demo handle | Pathway key | MS-DRG | Exact codebook title | Stages | Milestones | +| ----------- | -------------------------------------------------------------------- | ------ | ----------------------------------------------------------------------------------- | -----: | ---------: | +| `demo1` | `drgcp-heart-failure-671d63b4d61b` | `293` | Heart Failure and Shock without CC/MCC | 5 | 41 | +| `demo2` | `drgcp-simple-pneumonia-pleurisy-337d0f29a350` | `195` | Simple Pneumonia and Pleurisy without CC/MCC | 5 | 44 | +| `demo3` | `drgcp-major-joint-replacement-hipknee-lower-extremity-a7fc97e65adc` | `470` | Major Hip and Knee Joint Replacement or Reattachment of Lower Extremity without MCC | 5 | 49 | +| `demo4` | `drgcp-appendectomy-5b2df7e00bf7` | `399` | Appendix Procedures without CC/MCC | 5 | 36 | +| `demo5` | `drgcp-vaginal-delivery-2fd506169d41` | `807` | Vaginal Delivery without Sterilization or D&C without CC/MCC | 5 | 44 | + +All five versions were marked ready for institutional clinician signoff while retaining +the explicit status that institutional SME signoff is required. The counts above are +reconciliation guards for the future command; they do not authorize copying draft +definitions into patient responses. + +## Safety disposition + +- Use the exact code/title/pathway bindings as catalog lineage only. +- Generate separate patient-safe synthetic demo projections. +- Display `DEMO — NOT FOR CLINICAL USE` persistently. +- Do not expose raw staff-reference sections or unapproved clinical prose. +- Do not modify or activate the canonical catalog. +- Supply the user-provided password only as a runtime secret; do not retain it in evidence. +- Implement the demo login only in the canonical clients at + `hummingbird/iosPatientApp` and `hummingbird/androidPatientApp`; do not restore + either superseded `nightingale/*App` scaffold. +- Treat merged PR #111 as the owner of the salvaged foundation, privacy, governance, and + identity-uniqueness artifacts; do not duplicate them in the cohort stream. +- Before any write, require tested preview/apply/verify/suspend behavior, atomic + five-member provisioning, isolation checks, rollback, exact-SHA CI, and governed + deployment. + +## Existing synthetic reference-sample observation + +Read-only production inspection also found one inactive-credential Nightingale reference +patient derived from the former Hummingbird Patient template: + +- one active, non-discharged, non-deleted encounter in unit 85 with patient reference + `demo-nightingale-reference-inpatient` and owner + `nightingale-reference-patient-provisioner-v1`; +- one pending, inactive synthetic Nightingale principal with no email, phone, password, + verification, authentication, lock, or closure state; +- source-template product `hummingbird_patient`, source-template owner + `hummingbird-patient-reference-identity-provisioner-v1`, and mode + `operator-authorized-production-sample-clone`; and +- zero identity links, access grants, enrollment challenges, sessions, access-audit + events, notification devices, notification outbox rows, or access tokens. + +The implementation adopts this exact sample in place as `demo1`; it does not create a +replacement sample. It preserves the principal/encounter identifiers and source lineage, +and fails before cohort writes if the reference encounter, principal, or any dependent +state has changed. These observations are synthetic lineage evidence, not patient data or +authorization to provision. + +## Local implementation-verification checkpoint + +- Backend five-account isolation: all five aliases authenticate against the existing + patient token route, each sees all six own projections, and all 20 directed + cross-account pathway substitutions return the same content-free 404. +- Backend patient regression: 208 Patient feature tests completed with no failures and + 3,569 assertions after rebasing directly onto the protected-main PR #111 merge; the + warnings are the repository's existing missing-local-`.env` warning, not failed + assertions. +- Reference adoption: creation/replay/suspend/re-apply pass; changed encounter lifecycle, + contact data, or an existing identity dependency fails before cohort writes. +- iOS: 67 unit tests, nine canonical UI journeys, the Release build, the patient + artifact boundary, and the Release transport check passed from + `hummingbird/iosPatientApp`. +- Android: debug and release JVM test matrices and all 16 connected instrumentation + tests passed from `hummingbird/androidPatientApp` on an API 35 + 1080×2400/420 dpi emulator. The run also exposed and verified the fix for shared + list-scroll state carrying across patient tabs. +- App identity: the merged PR #111 uniqueness guard reports seven unique iOS identifiers + and two unique Android identifiers in the reconciled checkout. + +No production mutation, account activation, deploy, or live credential verification has +occurred at this checkpoint. diff --git a/docs/plans/nightingale-investor-demo-patient-cohort-2026-07-27.md b/docs/plans/nightingale-investor-demo-patient-cohort-2026-07-27.md new file mode 100644 index 00000000..9422bdcf --- /dev/null +++ b/docs/plans/nightingale-investor-demo-patient-cohort-2026-07-27.md @@ -0,0 +1,470 @@ +# Nightingale Five-Patient Investor-Demo Cohort Implementation Plan + +**Date:** 2026-07-27 + +**Status:** implementation active; production provisioning incomplete + +**Scope:** five synthetic Nightingale demo accounts, five isolated inpatient scenarios, +five exact MS-DRG bindings, safe in-place adoption of the existing reference sample, and +the canonical iOS and Android investor-demo journeys + +**Clinical-use status:** **DEMO — NOT FOR CLINICAL USE** + +**Canonical patient apps:** + +- `hummingbird/iosPatientApp` (renamed in place to Nightingale and already distributed + through TestFlight); and +- `hummingbird/androidPatientApp` (the matching Nightingale Android client). + +**Superseded app paths:** `nightingale/iosApp` and `nightingale/androidApp` are not +implementation targets and must not be restored. + +**Merged upstream foundation:** PR #111 is now on protected `main` and owns the salvaged +activation/disclosure gates, governance documents, privacy manifest, foundation config +keys, and app-identity uniqueness guard. This cohort commit is rebased directly on that +merge and adds no duplicate copy of those artifacts. + +## 1. Outcome and fixed decisions + +The investor-demo cohort must provide five repeatable Nightingale sign-ins: + +| Login handle | Synthetic patient label | Exact pathway key | MS-DRG | Authoritative codebook title | Draft stages | Draft milestones | +| ------------ | -------------------------- | -------------------------------------------------------------------- | ------ | ----------------------------------------------------------------------------------- | -----------: | ---------------: | +| `demo1` | Nightingale Demo Patient 1 | `drgcp-heart-failure-671d63b4d61b` | `293` | Heart Failure and Shock without CC/MCC | 5 | 41 | +| `demo2` | Nightingale Demo Patient 2 | `drgcp-simple-pneumonia-pleurisy-337d0f29a350` | `195` | Simple Pneumonia and Pleurisy without CC/MCC | 5 | 44 | +| `demo3` | Nightingale Demo Patient 3 | `drgcp-major-joint-replacement-hipknee-lower-extremity-a7fc97e65adc` | `470` | Major Hip and Knee Joint Replacement or Reattachment of Lower Extremity without MCC | 5 | 49 | +| `demo4` | Nightingale Demo Patient 4 | `drgcp-appendectomy-5b2df7e00bf7` | `399` | Appendix Procedures without CC/MCC | 5 | 36 | +| `demo5` | Nightingale Demo Patient 5 | `drgcp-vaginal-delivery-2fd506169d41` | `807` | Vaginal Delivery without Sterilization or D&C without CC/MCC | 5 | 44 | + +The shared password was supplied directly by the user. It is intentionally absent from +this document and every repository artifact. Provisioning accepts it only through a +non-echoing runtime prompt, hashes it through Laravel's configured password hasher, and +never accepts it as a command option or echoes, serializes, audits, or logs it. + +The five scenarios provide medical, surgical, orthopedic, and obstetric breadth. Each +login owns exactly one synthetic principal, identity link, encounter grant, operational +encounter, and pathway binding. No demo account may enumerate, read, mutate, message on, +or obtain an existence signal for another account's resources. + +## 2. Source and clinical-governance disposition + +Read-only production reconciliation found one 250-pathway catalog release: + +- release state: `inactive`; +- institutional approval: `not_reviewed`; +- clinical signoff: incomplete; +- 1,250 stage definitions, all `draft`; +- 9,601 milestone definitions, all `draft`; +- 7,000 sections, all `source_candidate` and `staff_reference`; +- 96 versions marked ready for institutional clinician signoff; +- 148 versions requiring specialist review with documented limitations; and +- 6 versions requiring pathway redesign. + +The five selected versions are in the first group and have complete stage/milestone +inventories. That makes them useful reconciliation inputs, not approved patient content. +The demo implementation therefore has two deliberately separate layers: + +1. **Catalog binding evidence** pins the exact pathway key, version UUID, release UUID, + MS-DRG code/title, catalog fingerprints, stage count, and milestone count. +2. **Patient-safe synthetic demo projections** contain purpose-written, allowlisted, + conspicuously labeled demonstration copy. + +Raw section prose, unapproved stage labels/explanations, milestone detail, evidence claims, +source excerpts, review notes, staff-reference fields, or inferred care recommendations +must not cross into a patient response. The canonical catalog is never activated or +modified by demo provisioning. + +## 3. Product and realm boundary + +The cohort is a Nightingale demonstration facility, not a pilot and not a real-patient +realm. Its product-owned metadata must include at least: + +- `product = nightingale`; +- `environment_class = synthetic_investor_demo`; +- `owner = nightingale-demo-cohort-provisioner-v1`; +- `cohort_version = nightingale-investor-demo-cohort-v1`; +- the non-secret login handle; +- the exact code-owned scenario key; +- `synthetic = true`; +- `clinical_use_permitted = false`; and +- `automatic_enrollment = false`. + +Demo accounts may reuse the already-hardened patient identity, grant, session, audit, and +projection kernel only through an explicit Nightingale demo adapter. They must never enter +`prod.users`, staff roles, `/api/mobile/v1`, Hummingbird Staff tokens, or staff endpoints. +Ordinary patient email authentication remains unchanged. Only principals with the exact +owner/product/cohort metadata may authenticate through the short demo handle. + +### 3.1 Existing reference-sample adoption + +`demo1` is not a newly invented sixth sample. It adopts, in place, the existing +production Nightingale reference patient that was derived from the former Hummingbird +Patient template. The adoption contract is fail-closed: + +- require exactly one active, non-discharged, non-deleted encounter with patient reference + `demo-nightingale-reference-inpatient`, the exact reference owner, and the explicitly + selected unit; +- require exactly one reference principal with the exact synthetic Nightingale product, + reference owner, source mode, and Hummingbird source-template lineage; +- require the principal to remain pending/inactive with no email, phone, password, + verification, authentication, lock, or closure state; +- require zero identity links, encounter grants, enrollment challenges, sessions, audit + events, notification devices, notification outbox rows, or access tokens; +- preserve the principal primary key, principal UUID, encounter primary key, encounter + `created_by`, and source-template lineage during adoption; +- record explicit, code-owned adoption lineage inside `demo1` provisioning metadata; +- reject a missing, duplicate, moved, discharged, previously linked, credentialed, or + otherwise changed reference sample before any cohort write; and +- on replay, require the exact recorded adoption lineage rather than silently treating + any `demo1` principal as equivalent. + +`demo2` through `demo5` remain deterministic cohort-owned creations. No production apply +may create a replacement `demo1` when the governed reference sample is absent or changed. + +## 4. Provisioning command contract + +Implement one product-owned command family: + +```text +nightingale:demo-cohort preview +nightingale:demo-cohort apply +nightingale:demo-cohort verify +nightingale:demo-cohort suspend +``` + +The exact Artisan signature may use separate commands or a required action argument, but +the following behavior is mandatory. + +### 4.1 Preview + +- Default to read-only preview. +- Require PostgreSQL and every expected schema/table. +- Resolve exactly one eligible operational unit under a code-owned or explicit reviewed + unit identifier. +- Reconcile all five catalog definitions by immutable pathway key plus exact MS-DRG code + and title. +- Require exactly one catalog release and reject catalog, codebook, mapping, count, or + approval-state drift. +- Report only actions, counts, opaque UUIDs where necessary, and non-sensitive states. +- Never request, read, hash, or validate the password. +- Make zero writes, sequences included. + +### 4.2 Apply + +- Require an explicit `--commit` or equivalent confirmation phrase. +- Require a non-echoing runtime prompt or equivalently protected secret provider; reject + command-line password options and repository-backed secret files. +- Reject empty, whitespace, control-character, newline, oversize, known-placeholder, or + policy-invalid password material without printing it. +- Require a deployed-runtime safety flag and exact environment/host checks. +- Acquire one cohort advisory transaction lock and use serializable or equivalent + retry-safe transactions. +- Resolve all five catalog rows before any write and lock every existing command-owned + cohort row needed for deterministic reconciliation. +- Create missing cohort rows or converge only exact command-owned rows. +- Stop on foreign ownership, ambiguous cardinality, cross-linkage, unexpected sessions, + unexpected projections, wrong DRG binding, altered product metadata, or partial cohort + state. +- Hash the password once per principal through Laravel's configured hasher; never persist + plaintext or a reversible credential. +- Commit all five accounts as one atomic cohort change. A failure for any member leaves + all five unchanged. +- Return only a redacted verification summary. + +### 4.3 Verify + +Verification must prove: + +- exactly five owned principals and login aliases; +- aliases are exactly `demo1` through `demo5`; +- all principals are active, synthetic, and Nightingale-owned; +- five non-null password hashes pass structural/rehash checks without disclosing hashes; +- no plaintext or supplied-secret fragment is present in JSON metadata, encrypted source + fields, audit metadata, command output, logs, projections, or catalog tables; +- exactly five identity links, grants, operational encounters, and catalog bindings; +- one and only one principal per grant and one grant per synthetic encounter; +- no shared encounter UUID, identity digest, source digest, token family, session, or + projection across accounts; +- exact DRG code/title/pathway-key reconciliation and expected definition counts; +- the canonical catalog remains inactive and unchanged; +- each account sees exactly its six patient-safe projection kinds; +- every projection carries the demo/non-clinical-use notice and code-owned provenance; +- zero raw staff-reference section values appear in projection content; +- token, refresh, session-list, logout, revocation, and cross-account denial work; +- audit events contain no clinical prose, credential, source identifier, or other + account's handle; and +- rerunning verify is read-only. + +### 4.4 Suspend + +- Default to preview. +- Require a separate exact confirmation and the same runtime/ownership checks as apply. +- Revoke all demo sessions and tokens before disabling principals and grants. +- Disable only rows owned by the exact cohort version; never delete or alter foreign rows. +- Preserve append-only projection, audit, and catalog lineage. +- Verify token denial and zero effective grants after revocation. +- Support an independently tested re-apply path that rotates password hashes and creates + no duplicate patient, encounter, link, grant, policy, projection, or binding. + +## 5. Authentication contract + +The token request must accept one neutral login field in the Nightingale-owned contract. +During compatibility: + +- RFC email input continues to use the existing case-normalized email lookup. +- A value matching `^demo[1-5]$` may use the demo-alias lookup. +- Demo-alias lookup must additionally require exact Nightingale product, owner, cohort, + synthetic, active, and non-clinical-use metadata. +- No broader username lookup is allowed. +- Unknown, disabled, foreign-owned, duplicated, malformed, or wrong-password cases return + the same generic credential denial. +- Timing protection uses the same dummy hash path for unknown aliases. +- The response never reveals whether the alias, principal, DRG, encounter, or cohort + exists. +- Authentication audit records a safe method class such as + `nightingale_demo_password`, never the submitted alias or password. + +The compatibility API must not be relabeled as a production-ready Nightingale contract. +A dedicated `/api/nightingale/v1` route remains subject to its existing contract, +authorization, activation, and migration gates. + +## 6. Patient-safe projection set + +Each demo account requires these six released, synthetic projection kinds: + +1. `today`; +2. `pathway`; +3. `pathway_events`; +4. `discharge_readiness`; +5. `rounds_summary`; and +6. `care_team`. + +Every screen and payload begins with or persistently exposes: + +> DEMO — NOT FOR CLINICAL USE. This is synthetic information for a product +> demonstration. It is not a medical record, care instruction, diagnosis, order, or +> promise. For urgent help, use the bedside call button or speak with staff. + +The pathway projection should be scenario-distinct while remaining synthetic. It must: + +- display the exact MS-DRG code and codebook title only in a clearly labeled + `Synthetic demo scenario` context; +- explain that the catalog pathway is draft and institutionally unapproved; +- provide a complete, navigable synthetic stage/milestone journey; +- mark timing as estimated or unknown and explicitly changeable; +- avoid medication doses, procedure instructions, diagnostic assertions, risk scores, + predicted outcomes, discharge promises, or personalized recommendations; +- preserve accessible non-color state cues; +- supply patient-safe clarification prompts; and +- avoid claiming comprehension, consent, education completion, or clinical approval. + +“Full pathway” for this cohort means every patient-facing demo stage and milestone in the +released synthetic projection is present and navigable, and its catalog binding proves the +complete underlying definition counts. It does not mean raw draft/staff content is copied. + +## 7. Canonical native implementations and investor journey + +The existing, feature-complete Nightingale iOS target at +`hummingbird/iosPatientApp` and its Android counterpart at +`hummingbird/androidPatientApp` are the only native implementation targets for this +cohort. No parallel app, target, bundle/application identifier, signing identity, or store +record may be created. In particular, this stream must not build or restore either +superseded `nightingale/*App` scaffold. + +Both clients must use the existing patient token route and preserve normal email +authentication while allowing only exact, normalized `demo1` through `demo5` handles. +The bounded demo journey is: + +1. welcome and `DEMO` environment identity; +2. login-handle and password fields with password-manager-compatible semantics; +3. generic progress/failure states and no account-existence disclosure; +4. protected storage of session material only, never password persistence; +5. Today landing page with persistent demo/urgent-help framing; +6. My Path overview, stage list, milestone detail, uncertainty, and synthetic DRG context; +7. Care Team roles and safe contact routes; +8. non-urgent message entry only if the governed demo handoff is explicitly implemented; +9. session inventory and logout/revocation; and +10. privacy cover plus local removal. + +The credential must not be compiled into UI tests. Simulator and emulator tests use +synthetic test-only credentials, while live TestFlight/device verification receives the +real demo credential only at runtime. Screenshots, videos, accessibility hierarchies, +XCTest/Android test results, and logs must be scanned for the password, tokens, internal +IDs, and sensitive payloads before retention. + +Required device evidence: + +- iPhone simulator and Android API 35 emulator launch from the canonical projects; +- exact-alias acceptance and confusable/suffix/prefix rejection in both clients; +- canonical iOS and Android debug/unit tests, plus release-unit compilation; +- iPhone standard and accessibility text sizes; +- Android compact/standard phone viewport and scalable-font behavior; +- iOS light/dark, landscape, reduced motion, increased contrast, and privacy cover; +- Android light/dark, portrait/landscape, reduced-motion equivalent, and secure-surface + behavior; +- five-account sequential sign-in/sign-out in each canonical app; +- stale/expired access token refresh; +- server-side session revocation while app is open; +- wrong password, unknown alias, disabled account, network loss, timeout, ambiguous + response, and service-unavailable behavior; and +- cross-account deep-link/resource substitution denial. + +## 8. Automated test matrix + +### 8.1 Provisioning + +- clean creation; +- exact replay; +- password rotation without duplicate rows; +- preview immutability; +- missing secret; +- secret supplied through forbidden command argument; +- wrong environment/host; +- missing table; +- missing/duplicate/changed catalog release; +- missing/duplicate/wrong pathway key; +- wrong DRG code/title or mapping; +- changed stage/milestone count; +- foreign-owned principal/link/grant/encounter/projection; +- one missing member in a partial cohort; +- duplicate alias; +- cross-account grant or identity link; +- existing session/token during unsafe convergence; +- transaction failure on member five proves rollback of members one through four; +- concurrent apply serialization; and +- suspend/re-apply convergence. + +### 8.2 Authentication and authorization + +- all five handles authenticate with the runtime secret; +- email authentication remains backward compatible; +- alias case, whitespace, Unicode confusable, suffix/prefix, and out-of-range variants + deny generically; +- unknown alias and wrong password follow dummy-hash timing and identical response shape; +- inactive/locked/revoked/foreign metadata deny; +- duplicate metadata alias fails closed; +- staff token cannot enter patient realm; +- patient token cannot enter staff/mobile realm; +- each token lists only its own encounter; +- all 20 directed cross-account resource substitutions deny without existence leakage; +- refresh-family replay revokes the family; +- logout, remote revocation, idle expiry, and absolute expiry deny subsequent reads; and +- rate limits apply equally to email and alias credentials. + +### 8.3 Content and pathway safety + +- exactly six projection kinds per account; +- exact scenario-to-DRG mapping; +- demo notice in every projection; +- no raw catalog section text; +- no source identifier, staff note, diagnosis assertion, order, dose, risk score, or + outcome promise; +- every stage/milestone has stable UUID, title, state, and changeability semantics; +- no unsupported vocabulary; +- full stage/milestone navigation; +- freshness and uncertainty states; +- correction/retraction/withhold handling; +- inactive canonical catalog remains unchanged; and +- patient projection guard and release-policy gates remain enforced. + +### 8.4 Security and operations + +- repository and history secret scan; +- command-output/log/audit redaction tests; +- SQL injection and JSON-path injection attempts through aliases; +- mass-assignment denial; +- advisory-lock/concurrency test; +- IDOR/property tests; +- dependency and static analysis; +- DAST against the bounded demo surface; +- database backup and rollback rehearsal; +- kill-switch and account-revocation rehearsal; +- production read-after-write cardinality verification; and +- exact-SHA CI plus immutable deploy artifact verification. + +## 9. Production execution gates + +Production apply is allowed only when all of these are true: + +- implementation and rollback are reviewed; +- focused and full tests pass; +- no plaintext secret appears in the diff or repository history; +- the branch is reconciled with current `main`; +- exact-SHA CI is green; +- `./deploy.sh --check` passes from clean, synchronized protected `main`; +- any required migration has separate path-scoped authorization, preview, logical backup, + execution, and verification; +- the runtime secret is supplied non-interactively without shell history or process-list + exposure; +- the existing single inactive sample has an explicit preserve/reconcile disposition; +- post-apply verify passes all cardinality and isolation assertions; and +- the cohort remains labeled demo-only with no real-patient or clinical-release claim. + +The canonical deployment path is `./deploy.sh`; ad hoc SSH deployment, direct production +`git pull`, and blanket migrations are forbidden. Provisioning is a distinct governed +post-deploy action and must retain its redacted receipt separately from deployment logs. + +## 10. Completion definition + +This cohort item may be checked only when: + +- five accounts exist and authenticate as specified; +- five exact DRG bindings reconcile to the authoritative catalog; +- each account has a complete synthetic patient-safe journey; +- one-to-one identity/encounter isolation and all cross-account denial tests pass; +- both canonical native apps pass their simulator/emulator journeys and the iOS app + passes its TestFlight journey; +- credential secrecy and one-way hashing are proven; +- preview, verify, suspend, and re-apply are proven; +- production state is verified after application; +- checklist, devlog, evidence, PR, and exact-SHA CI are current; and +- no claim of clinical approval, real-patient readiness, pilot authorization, or + production Nightingale activation is made. + +## 11. Current implementation checkpoint and live checklist + +The cohort stream has been replayed as one isolated commit onto current `main`; the +superseded #92 foundation history is not part of the branch. Current implemented +elements are: + +- exact code-owned `demo1` through `demo5` aliases and five DRG/catalog bindings; +- PostgreSQL-only preview, atomic apply, read-only verify, and reversible suspend; +- runtime-only hidden password input, one-way hashing, and session/token revocation on + credential rotation; +- five principals, identities, encounters, grants, and six synthetic projections per + account; +- exact owner/product/cohort predicates for alias authentication and disclosure-policy + selection; +- patient-safe demo notices, synthetic content generation, and raw catalog-prose + exclusion; +- exact, fail-closed in-place adoption of the existing reference patient as `demo1`, + preserving its principal/encounter identity and Hummingbird-template lineage; +- the canonical iOS and Android sign-in forms admitting only the five exact demo aliases + in addition to email-shaped identifiers; and +- backend, iOS, and Android tests for alias boundaries and token-route compatibility. + +| Work section | Status | Current evidence | +| ---------------------------------------------------------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------ | +| Code-owned five-member scenario and exact DRG manifest | Complete | Five exact pathway keys, codebook titles, stage counts, and milestone counts are pinned in code and tests. | +| Patient-safe projection generation and catalog-content separation | Complete | Six synthetic projections per account; persistent notice and raw catalog-prose exclusion are asserted. | +| Exact alias authentication and account-isolation boundary | Complete | Email compatibility plus exact aliases; all 20 directed cross-account substitutions return generic denial. | +| Atomic PostgreSQL preview/apply/verify/suspend implementation | Complete | Serializable/advisory-locked apply, read-only preview/verify, rotation, suspend, and re-apply pass within 208 Patient tests/3,569 assertions. | +| Runtime-secret and command safety | Complete | Hidden prompt only, no password/secret/credential CLI option, exact distinct confirmations, one-way hashing, and redacted results. | +| Existing reference sample adopted as `demo1` | Complete | Primary identity and encounter are preserved; changed lifecycle, contact data, or pre-existing linkage fails before cohort writes. | +| Canonical iOS exact-alias implementation and simulator regression | Complete | 67 unit tests, nine UI journeys, Release build, artifact boundary, and transport check pass in `hummingbird/iosPatientApp`. | +| Canonical Android exact-alias implementation and JVM regression | Complete | Debug/release unit, lint, build, artifact-boundary, and transport checks pass in `hummingbird/androidPatientApp`. | +| Canonical Android API 35 connected-emulator regression | Complete | All 16 tests pass at 1080×2400/420 dpi; tab changes reset shared content scroll to keep revision and urgent context visible. | +| PR #111 reconciliation without duplicate salvaged artifacts | Complete | Cohort is rebased directly on the protected-main #111 merge; all live/foundation config keys remain and only the default-off demo gate is added. | +| Successor PR, exact-SHA CI, protected-main merge, and canonical deployment | Pending | No production deployment or provisioning claim is made yet. | +| Production preview, atomic apply to unit 85, five live logins, read-after-write, and kill test | Pending | Production remains unchanged beyond the pre-existing inactive reference sample. | + +Remaining gates before production apply are: + +1. obtain exact-SHA CI on the successor PR; +2. merge the successor PR through protected `main`; +3. deploy through `./deploy.sh --check` then `./deploy.sh`; +4. run production preview against the explicit synthetic-demo unit 85; +5. perform one confirmed production apply using the non-echoing runtime prompt; +6. execute read-after-write verification and all five live logins; +7. retain only redacted cardinality, isolation, and rollback evidence; and +8. keep the suspend command immediately available as the cohort kill switch. diff --git a/hummingbird/androidPatientApp/ASSET_PROVENANCE.md b/hummingbird/androidPatientApp/ASSET_PROVENANCE.md index ae2be7f4..0470b91e 100644 --- a/hummingbird/androidPatientApp/ASSET_PROVENANCE.md +++ b/hummingbird/androidPatientApp/ASSET_PROVENANCE.md @@ -1,4 +1,4 @@ -# Hummingbird Patient Android background asset provenance +# Nightingale Android background asset provenance Last verified: 2026-07-21 diff --git a/hummingbird/androidPatientApp/app/src/androidTest/java/net/acumenus/nightingale/PatientAuthenticationSmokeTest.kt b/hummingbird/androidPatientApp/app/src/androidTest/java/net/acumenus/nightingale/PatientAuthenticationSmokeTest.kt index 4ade8251..0f5e5a56 100644 --- a/hummingbird/androidPatientApp/app/src/androidTest/java/net/acumenus/nightingale/PatientAuthenticationSmokeTest.kt +++ b/hummingbird/androidPatientApp/app/src/androidTest/java/net/acumenus/nightingale/PatientAuthenticationSmokeTest.kt @@ -1,9 +1,12 @@ package net.acumenus.nightingale import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performScrollTo +import androidx.compose.ui.test.performTextInput import org.junit.Rule import org.junit.Test @@ -21,4 +24,22 @@ class PatientAuthenticationSmokeTest { composeRule.onNodeWithText("Invitation ID").performScrollTo().assertIsDisplayed() composeRule.onNodeWithText("Continue securely").performScrollTo().assertIsDisplayed() } + + @Test + fun exactDemoAliasReachesTheExistingPatientSignInBoundary() { + composeRule.onNodeWithText("Sign in").performScrollTo().performClick() + composeRule.onNodeWithTag("patient-login-identifier") + .performScrollTo() + .performTextInput("demo1") + composeRule.onNodeWithTag("patient-login-password") + .performScrollTo() + .performTextInput("synthetic-demo-emulator-test-password") + composeRule.onNodeWithTag("patient-login-submit") + .performScrollTo() + .performClick() + + composeRule.onNodeWithText( + "Patient sign-in is not enabled in this build. Ask your care team for current information.", + ).performScrollTo().assertIsDisplayed() + } } diff --git a/hummingbird/androidPatientApp/app/src/main/java/net/acumenus/nightingale/PatientAppViewModel.kt b/hummingbird/androidPatientApp/app/src/main/java/net/acumenus/nightingale/PatientAppViewModel.kt index 81b97c3c..8af7f004 100644 --- a/hummingbird/androidPatientApp/app/src/main/java/net/acumenus/nightingale/PatientAppViewModel.kt +++ b/hummingbird/androidPatientApp/app/src/main/java/net/acumenus/nightingale/PatientAppViewModel.kt @@ -20,6 +20,22 @@ import net.acumenus.nightingale.data.PatientPreferencesUpdate import net.acumenus.nightingale.data.PatientSessionCoordinator import net.acumenus.nightingale.data.PatientSessionOutcome +internal object PatientLoginIdentifier { + private val demoAliases = setOf( + "demo1", + "demo2", + "demo3", + "demo4", + "demo5", + ) + + fun isAcceptedForSignIn(value: String): Boolean { + val normalized = value.trim().lowercase() + + return normalized in demoAliases || "@" in normalized + } +} + internal class PatientAppViewModel( private val apiEnabled: Boolean, launchState: PatientLaunchState = PatientLaunchState(), @@ -101,7 +117,12 @@ internal class PatientAppViewModel( when { email.isBlank() || password.isBlank() -> { state = state.copy(session = signedOut.copy( - status = PatientAuthStatus.ValidationError("Enter both your email and password."), + status = PatientAuthStatus.ValidationError("Enter your email or demo login, and your password."), + )) + } + !PatientLoginIdentifier.isAcceptedForSignIn(email) -> { + state = state.copy(session = signedOut.copy( + status = PatientAuthStatus.ValidationError("Enter a valid email or demo login."), )) } !apiEnabled -> unavailable( @@ -116,7 +137,10 @@ internal class PatientAppViewModel( val passwordChars = password.toCharArray() execute("Signing in securely") { try { - coordinator.signIn(email.trim(), passwordChars) + coordinator.signIn( + email.trim().lowercase(), + passwordChars, + ) } finally { passwordChars.fill('\u0000') } diff --git a/hummingbird/androidPatientApp/app/src/main/java/net/acumenus/nightingale/ui/PatientAuthenticationScreen.kt b/hummingbird/androidPatientApp/app/src/main/java/net/acumenus/nightingale/ui/PatientAuthenticationScreen.kt index df139310..5b737ee0 100644 --- a/hummingbird/androidPatientApp/app/src/main/java/net/acumenus/nightingale/ui/PatientAuthenticationScreen.kt +++ b/hummingbird/androidPatientApp/app/src/main/java/net/acumenus/nightingale/ui/PatientAuthenticationScreen.kt @@ -33,6 +33,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.focus.FocusDirection import androidx.compose.ui.platform.LocalFocusManager +import androidx.compose.ui.platform.testTag import androidx.compose.ui.semantics.LiveRegionMode import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.liveRegion @@ -310,8 +311,10 @@ private fun SignInForm(onSignIn: (String, String) -> Unit) { OutlinedTextField( value = email, onValueChange = { email = it }, - modifier = Modifier.fillMaxWidth(), - label = { Text("Email") }, + modifier = Modifier + .fillMaxWidth() + .testTag("patient-login-identifier"), + label = { Text("Email or demo login") }, singleLine = true, keyboardOptions = KeyboardOptions( keyboardType = KeyboardType.Email, @@ -324,7 +327,9 @@ private fun SignInForm(onSignIn: (String, String) -> Unit) { OutlinedTextField( value = password, onValueChange = { password = it }, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .testTag("patient-login-password"), label = { Text("Password") }, singleLine = true, visualTransformation = PasswordVisualTransformation(), @@ -344,7 +349,9 @@ private fun SignInForm(onSignIn: (String, String) -> Unit) { onSignIn(email, password) password = "" }, - modifier = Modifier.fillMaxWidth(), + modifier = Modifier + .fillMaxWidth() + .testTag("patient-login-submit"), ) { Text("Sign in") } diff --git a/hummingbird/androidPatientApp/app/src/main/java/net/acumenus/nightingale/ui/PatientExperienceScreen.kt b/hummingbird/androidPatientApp/app/src/main/java/net/acumenus/nightingale/ui/PatientExperienceScreen.kt index 41402562..77024a1e 100644 --- a/hummingbird/androidPatientApp/app/src/main/java/net/acumenus/nightingale/ui/PatientExperienceScreen.kt +++ b/hummingbird/androidPatientApp/app/src/main/java/net/acumenus/nightingale/ui/PatientExperienceScreen.kt @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons import androidx.compose.material.icons.outlined.Groups @@ -80,6 +81,7 @@ internal fun PatientExperienceScreen( onSignOut: () -> Unit, ) { var selectedEducation by remember { mutableStateOf(null) } + val contentListState = remember(selectedDestination) { LazyListState() } val scene = when (selectedDestination) { PatientDestination.TODAY -> PatientScene.TODAY PatientDestination.PATH -> PatientScene.PATHWAY @@ -168,6 +170,7 @@ internal fun PatientExperienceScreen( }, ) { contentPadding -> LazyColumn( + state = contentListState, modifier = Modifier .fillMaxSize() .padding(contentPadding) diff --git a/hummingbird/androidPatientApp/app/src/test/java/net/acumenus/nightingale/PatientAppViewModelTest.kt b/hummingbird/androidPatientApp/app/src/test/java/net/acumenus/nightingale/PatientAppViewModelTest.kt index 3ba9bdc7..d631d51a 100644 --- a/hummingbird/androidPatientApp/app/src/test/java/net/acumenus/nightingale/PatientAppViewModelTest.kt +++ b/hummingbird/androidPatientApp/app/src/test/java/net/acumenus/nightingale/PatientAppViewModelTest.kt @@ -20,6 +20,18 @@ import org.junit.Test @OptIn(ExperimentalCoroutinesApi::class) class PatientAppViewModelTest { + @Test + fun signInIdentifierAcceptsOnlyFiveExactDemoAliasesOrAnEmailShape() { + listOf("demo1", "demo2", "demo3", "demo4", "demo5").forEach { alias -> + assertTrue(PatientLoginIdentifier.isAcceptedForSignIn(alias)) + assertTrue(PatientLoginIdentifier.isAcceptedForSignIn(" ${alias.uppercase()} ")) + } + assertTrue(PatientLoginIdentifier.isAcceptedForSignIn("patient@example.test")) + listOf("", "demo0", "demo6", "demo01", "demo1", "demo1.example.test").forEach { value -> + assertTrue(!PatientLoginIdentifier.isAcceptedForSignIn(value)) + } + } + @Test fun startsSignedOutInInvitationMode() { val viewModel = PatientAppViewModel(apiEnabled = false) @@ -80,6 +92,43 @@ class PatientAppViewModelTest { assertTrue(viewModel.state.messaging is PatientMessagingState.Unavailable) } + @Test + fun exactDemoAliasIsNormalizedAndUsesTheExistingPatientCredentialExchange() = runTest { + val dispatcher = StandardTestDispatcher(testScheduler) + val api = FakePatientApiGateway() + val viewModel = PatientAppViewModel( + apiEnabled = true, + coordinator = coordinator(api), + scope = this, + workDispatcher = dispatcher, + ) + + viewModel.selectAuthMode(PatientAuthMode.SIGN_IN) + viewModel.submitSignIn(" DEMO1 ", "synthetic-demo-test-password") + advanceUntilIdle() + + assertTrue(viewModel.state.session is PatientSessionState.Ready) + assertEquals(listOf("demo1"), api.passwordIdentifiers) + } + + @Test + fun invalidDemoLikeIdentifierNeverReachesTheCredentialExchange() { + val api = FakePatientApiGateway() + val viewModel = PatientAppViewModel( + apiEnabled = true, + coordinator = coordinator(api), + ) + + viewModel.selectAuthMode(PatientAuthMode.SIGN_IN) + viewModel.submitSignIn("demo6", "synthetic-demo-test-password") + + val signedOut = viewModel.state.session as PatientSessionState.SignedOut + val validation = signedOut.status as PatientAuthStatus.ValidationError + assertEquals("Enter a valid email or demo login.", validation.message) + assertEquals(0, api.passwordExchangeCalls) + assertTrue(api.passwordIdentifiers.isEmpty()) + } + @Test fun rejectedCredentialSignInReturnsPatientSafeFailure() = runTest { val dispatcher = StandardTestDispatcher(testScheduler) diff --git a/hummingbird/androidPatientApp/app/src/test/java/net/acumenus/nightingale/data/PatientSessionCoordinatorTest.kt b/hummingbird/androidPatientApp/app/src/test/java/net/acumenus/nightingale/data/PatientSessionCoordinatorTest.kt index 2eab1291..80068ce8 100644 --- a/hummingbird/androidPatientApp/app/src/test/java/net/acumenus/nightingale/data/PatientSessionCoordinatorTest.kt +++ b/hummingbird/androidPatientApp/app/src/test/java/net/acumenus/nightingale/data/PatientSessionCoordinatorTest.kt @@ -460,6 +460,7 @@ internal class FakePatientApiGateway( sessionListUnauthorizedOnce: Boolean = false, ) : PatientApiGateway { var passwordExchangeCalls = 0 + val passwordIdentifiers = mutableListOf() var enrollmentCalls = 0 var profileCalls = 0 var refreshCalls = 0 @@ -493,6 +494,7 @@ internal class FakePatientApiGateway( device: PatientDeviceDescriptor, ): PatientEnvelope { passwordExchangeCalls += 1 + passwordIdentifiers += email passwordFailureStatus?.let { status -> throw PatientApiException(status, "sign_in_failed", "Sign-in failed") } diff --git a/hummingbird/iosPatientApp/Nightingale/DesignSystem/PatientComponents.swift b/hummingbird/iosPatientApp/Nightingale/DesignSystem/PatientComponents.swift index 9b90051a..d0fbf19a 100644 --- a/hummingbird/iosPatientApp/Nightingale/DesignSystem/PatientComponents.swift +++ b/hummingbird/iosPatientApp/Nightingale/DesignSystem/PatientComponents.swift @@ -149,7 +149,7 @@ struct PatientPresentationPreferenceNotice: View { choices.append("reduced motion") } let joinedChoices = ListFormatter.localizedString(byJoining: choices) - return "Hummingbird Patient is using \(joinedChoices). Your device accessibility settings can make text larger." + return "Nightingale is using \(joinedChoices). Your device accessibility settings can make text larger." } } diff --git a/hummingbird/iosPatientApp/Nightingale/DesignSystem/PatientPhotoBackground.swift b/hummingbird/iosPatientApp/Nightingale/DesignSystem/PatientPhotoBackground.swift index 86452ba5..4598a5f8 100644 --- a/hummingbird/iosPatientApp/Nightingale/DesignSystem/PatientPhotoBackground.swift +++ b/hummingbird/iosPatientApp/Nightingale/DesignSystem/PatientPhotoBackground.swift @@ -136,7 +136,7 @@ struct PatientLoadingStateView: View { .accessibilityHidden(true) Text("Opening your care view") .font(.title3.bold()) - Text("Only information released to Hummingbird Patient will appear.") + Text("Only information released to Nightingale will appear.") .font(.subheadline) .foregroundStyle(.secondary) .multilineTextAlignment(.center) @@ -148,7 +148,7 @@ struct PatientLoadingStateView: View { in: RoundedRectangle(cornerRadius: 22) ) .accessibilityElement(children: .combine) - .accessibilityLabel("Opening your care view. Only information released to Hummingbird Patient will appear.") + .accessibilityLabel("Opening your care view. Only information released to Nightingale will appear.") } .transition( reduceMotion || presentationPreferences.reducedMotion diff --git a/hummingbird/iosPatientApp/Nightingale/Features/Account/PatientPreferencesView.swift b/hummingbird/iosPatientApp/Nightingale/Features/Account/PatientPreferencesView.swift index 29b8bdc2..984463a2 100644 --- a/hummingbird/iosPatientApp/Nightingale/Features/Account/PatientPreferencesView.swift +++ b/hummingbird/iosPatientApp/Nightingale/Features/Account/PatientPreferencesView.swift @@ -27,7 +27,7 @@ struct PatientPreferencesView: View { Form { Section { - Text("Choose how Hummingbird Patient presents non-clinical account information. These choices never change your care plan, clinical orders, or urgent-help instructions.") + Text("Choose how Nightingale presents non-clinical account information. These choices never change your care plan, clinical orders, or urgent-help instructions.") .font(.subheadline) .foregroundStyle(.secondary) } @@ -74,7 +74,7 @@ struct PatientPreferencesView: View { Label(message, systemImage: "checkmark.shield.fill") .foregroundStyle(.secondary) if textSize == .extraLarge && highContrast { - Text("Extra Large text and high contrast are applied in Hummingbird Patient. Your device accessibility settings remain in effect.") + Text("Extra Large text and high contrast are applied in Nightingale. Your device accessibility settings remain in effect.") .font(.footnote) .foregroundStyle(.secondary) .accessibilityIdentifier("patient-preferences-applied-accessibility") diff --git a/hummingbird/iosPatientApp/Nightingale/Features/Account/PatientSessionManagementView.swift b/hummingbird/iosPatientApp/Nightingale/Features/Account/PatientSessionManagementView.swift index 21b2f69f..3a21048b 100644 --- a/hummingbird/iosPatientApp/Nightingale/Features/Account/PatientSessionManagementView.swift +++ b/hummingbird/iosPatientApp/Nightingale/Features/Account/PatientSessionManagementView.swift @@ -15,7 +15,7 @@ struct PatientSessionManagementView: View { PatientScreenHeader( eyebrow: "Account security", title: "Manage devices", - subtitle: "Review the devices currently signed in to Hummingbird Patient." + subtitle: "Review the devices currently signed in to Nightingale." ) content @@ -101,7 +101,7 @@ struct PatientSessionManagementView: View { scene: .empty, icon: "iphone.slash", title: "No active devices", - message: "No active Hummingbird Patient devices are available to show." + message: "No active Nightingale devices are available to show." ) } else { VStack(spacing: 14) { @@ -119,7 +119,7 @@ struct PatientSessionManagementView: View { PatientCard { Label( - "Signing out a device ends its Hummingbird Patient session. It does not change your hospital care or bedside support.", + "Signing out a device ends its Nightingale session. It does not change your hospital care or bedside support.", systemImage: "info.circle.fill" ) .font(.subheadline) @@ -170,9 +170,9 @@ struct PatientSessionManagementView: View { private func confirmationMessage(for session: PatientSessionSummary) -> String { if session.current { - return "Signing out this device immediately closes Hummingbird Patient here and returns you to the welcome screen." + return "Signing out this device immediately closes Nightingale here and returns you to the welcome screen." } - return "This signs out \(session.safeDeviceName) from Hummingbird Patient. It will not sign out this device." + return "This signs out \(session.safeDeviceName) from Nightingale. It will not sign out this device." } } diff --git a/hummingbird/iosPatientApp/Nightingale/Features/Authentication/PatientWelcomeView.swift b/hummingbird/iosPatientApp/Nightingale/Features/Authentication/PatientWelcomeView.swift index ae652215..4f4b3820 100644 --- a/hummingbird/iosPatientApp/Nightingale/Features/Authentication/PatientWelcomeView.swift +++ b/hummingbird/iosPatientApp/Nightingale/Features/Authentication/PatientWelcomeView.swift @@ -88,7 +88,7 @@ struct PatientWelcomeView: View { .font(.system(size: 42)) .foregroundStyle(PatientPalette.blue) .accessibilityHidden(true) - Text("Hummingbird Patient") + Text("Nightingale") .font(.largeTitle.bold()) .foregroundStyle(PatientPalette.ink) Text("Understand what is happening today, what may come next, and who is helping with your care.") @@ -99,12 +99,13 @@ struct PatientWelcomeView: View { private var signInFields: some View { VStack(spacing: 14) { - TextField("Email", text: $email) - .textContentType(.emailAddress) + TextField("Email or demo login", text: $email) + .textContentType(.username) .keyboardType(.emailAddress) .textInputAutocapitalization(.never) .autocorrectionDisabled() .patientField() + .accessibilityIdentifier("patient-login-identifier") SecureField("Password", text: $password) .textContentType(.password) .patientField() @@ -158,7 +159,8 @@ struct PatientWelcomeView: View { private var canSubmit: Bool { if mode == .signIn { - return email.contains("@") && !password.isEmpty + return PatientLoginIdentifier.isAcceptedForSignIn(email) + && !password.isEmpty } return UUID(uuidString: challengeUUID) != nil && challengeToken.count >= 32 diff --git a/hummingbird/iosPatientApp/Nightingale/Features/Shell/PatientTabShellView.swift b/hummingbird/iosPatientApp/Nightingale/Features/Shell/PatientTabShellView.swift index 5c8be34d..a3c3dd5f 100644 --- a/hummingbird/iosPatientApp/Nightingale/Features/Shell/PatientTabShellView.swift +++ b/hummingbird/iosPatientApp/Nightingale/Features/Shell/PatientTabShellView.swift @@ -61,7 +61,7 @@ struct PatientTabShellView: View { .accessibilityIdentifier("manage-devices") Button("Sign out", role: .destructive, action: signOut) } message: { - Text("Choose account settings or manage the devices signed in to Hummingbird Patient.") + Text("Choose account settings or manage the devices signed in to Nightingale.") } .tint(PatientPalette.blue) .sheet(isPresented: $isManagingDevices, onDismiss: { diff --git a/hummingbird/iosPatientApp/Nightingale/Networking/PatientAPIClient.swift b/hummingbird/iosPatientApp/Nightingale/Networking/PatientAPIClient.swift index 25a7b36b..bc858d2e 100644 --- a/hummingbird/iosPatientApp/Nightingale/Networking/PatientAPIClient.swift +++ b/hummingbird/iosPatientApp/Nightingale/Networking/PatientAPIClient.swift @@ -266,7 +266,7 @@ final class PatientAPIClient: PatientAPIService, @unchecked Sendable { init(baseURL: URL) { precondition( PatientAPIBoundary.validatedBaseURL(baseURL.absoluteString) != nil, - "Hummingbird Patient rejected an unsafe API origin." + "Nightingale rejected an unsafe API origin." ) self.baseURL = baseURL self.session = PatientURLSessionFactory.ephemeral() @@ -280,7 +280,7 @@ final class PatientAPIClient: PatientAPIService, @unchecked Sendable { init(baseURL: URL, session: URLSession) { precondition( PatientAPIBoundary.validatedBaseURL(baseURL.absoluteString) != nil, - "Hummingbird Patient rejected an unsafe API origin." + "Nightingale rejected an unsafe API origin." ) self.baseURL = baseURL self.session = session diff --git a/hummingbird/iosPatientApp/Nightingale/Networking/PatientAPIModels.swift b/hummingbird/iosPatientApp/Nightingale/Networking/PatientAPIModels.swift index 20b64b86..3a23f692 100644 --- a/hummingbird/iosPatientApp/Nightingale/Networking/PatientAPIModels.swift +++ b/hummingbird/iosPatientApp/Nightingale/Networking/PatientAPIModels.swift @@ -1,6 +1,25 @@ import Foundation import UIKit +enum PatientLoginIdentifier { + private static let demoAliases: Set = [ + "demo1", + "demo2", + "demo3", + "demo4", + "demo5", + ] + + static func isAcceptedForSignIn(_ value: String) -> Bool { + let normalized = value + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + + return demoAliases.contains(normalized) + || normalized.contains("@") + } +} + struct PatientEnvelope: Codable, Equatable { let data: Payload let meta: PatientEnvelopeMeta diff --git a/hummingbird/iosPatientApp/Nightingale/Privacy/PatientPrivacyCoverView.swift b/hummingbird/iosPatientApp/Nightingale/Privacy/PatientPrivacyCoverView.swift index 3bee7e12..685cb68b 100644 --- a/hummingbird/iosPatientApp/Nightingale/Privacy/PatientPrivacyCoverView.swift +++ b/hummingbird/iosPatientApp/Nightingale/Privacy/PatientPrivacyCoverView.swift @@ -10,7 +10,7 @@ struct PatientPrivacyCoverView: View { .font(.system(size: 48)) .foregroundStyle(PatientPalette.blue) .accessibilityHidden(true) - Text("Hummingbird Patient") + Text("Nightingale") .font(.title.bold()) .foregroundStyle(PatientPalette.ink) Text("Your care information is covered while the app is not active.") diff --git a/hummingbird/iosPatientApp/NightingaleTests/PatientAPIClientTests.swift b/hummingbird/iosPatientApp/NightingaleTests/PatientAPIClientTests.swift index f0837431..5536b9a6 100644 --- a/hummingbird/iosPatientApp/NightingaleTests/PatientAPIClientTests.swift +++ b/hummingbird/iosPatientApp/NightingaleTests/PatientAPIClientTests.swift @@ -92,6 +92,30 @@ final class PatientAPIClientTests: XCTestCase { XCTAssertTrue(requests.allSatisfy { $0.value(forHTTPHeaderField: "Cache-Control") == "no-store" }) } + func testDemoAliasUsesTheExistingPatientTokenRouteAndCompatibilityField() async throws { + PatientURLProtocolStub.install { request in + XCTAssertEqual(request.url?.path, "/api/patient/v1/auth/token") + let body = try XCTUnwrap(Self.bodyData(from: request)) + let json = try XCTUnwrap(JSONSerialization.jsonObject(with: body) as? [String: Any]) + XCTAssertEqual(json["email"] as? String, "demo1") + XCTAssertEqual(json["password"] as? String, "synthetic-demo-test-password") + return Self.success( + request, + json: Self.tokenEnvelope(access: "demo-access", refresh: "demo-refresh") + ) + } + + let pair = try await client.signIn( + email: "demo1", + password: "synthetic-demo-test-password", + device: .current + ) + + XCTAssertEqual(pair.accessToken, "demo-access") + XCTAssertEqual(pair.refreshToken, "demo-refresh") + XCTAssertEqual(PatientURLProtocolStub.recordedRequests().count, 1) + } + func testReadOnlyExperienceRequestsUseBearerAccessAndDecodeThePatientProjections() async throws { let encounterUUID = "019f0000-0000-7000-8000-000000000010" PatientURLProtocolStub.install { request in diff --git a/hummingbird/iosPatientApp/NightingaleTests/PatientAPIModelTests.swift b/hummingbird/iosPatientApp/NightingaleTests/PatientAPIModelTests.swift index b79cd523..47fcecd9 100644 --- a/hummingbird/iosPatientApp/NightingaleTests/PatientAPIModelTests.swift +++ b/hummingbird/iosPatientApp/NightingaleTests/PatientAPIModelTests.swift @@ -4,6 +4,22 @@ import XCTest final class PatientAPIModelTests: XCTestCase { private let decoder = JSONDecoder() + func testSignInIdentifierAcceptsOnlyTheFiveExactDemoAliasesOrAnEmailShape() { + for alias in ["demo1", "demo2", "demo3", "demo4", "demo5"] { + XCTAssertTrue(PatientLoginIdentifier.isAcceptedForSignIn(alias)) + XCTAssertTrue(PatientLoginIdentifier.isAcceptedForSignIn(" \(alias.uppercased()) ")) + } + + XCTAssertTrue(PatientLoginIdentifier.isAcceptedForSignIn("sample@example.test")) + + for rejected in ["", "demo0", "demo6", "demo01", "demo1", "demo1.example.test"] { + XCTAssertFalse( + PatientLoginIdentifier.isAcceptedForSignIn(rejected), + "Unexpectedly accepted \(rejected)" + ) + } + } + func testProfileEnvelopeDecodesCurrentBackendShapeAndNullVersion() throws { let json = #""" { diff --git a/hummingbird/iosPatientApp/NightingaleUITests/PatientReferenceJourneyUITests.swift b/hummingbird/iosPatientApp/NightingaleUITests/PatientReferenceJourneyUITests.swift index fe71541a..9ff19dfa 100644 --- a/hummingbird/iosPatientApp/NightingaleUITests/PatientReferenceJourneyUITests.swift +++ b/hummingbird/iosPatientApp/NightingaleUITests/PatientReferenceJourneyUITests.swift @@ -131,10 +131,10 @@ final class PatientReferenceJourneyUITests: XCTestCase { app.launchEnvironment["HBP_PATIENT_API_BASE_URL"] = "https://127.0.0.1:1" app.launch() - let email = app.textFields["Email"] - XCTAssertTrue(email.waitForExistence(timeout: 5)) - email.tap() - email.typeText("sample@example.test") + let loginIdentifier = app.descendants(matching: .any)["patient-login-identifier"] + XCTAssertTrue(loginIdentifier.waitForExistence(timeout: 5)) + loginIdentifier.tap() + loginIdentifier.typeText("sample@example.test") let password = app.secureTextFields["Password"] password.tap() password.typeText("synthetic-password") @@ -148,6 +148,27 @@ final class PatientReferenceJourneyUITests: XCTestCase { attachScreenshot(named: "Authentication-Error") } + func testExactDemoLoginAliasEnablesCanonicalNightingaleSignIn() { + app.terminate() + app = XCUIApplication() + app.launchEnvironment["HBP_SYNTHETIC_REFERENCE"] = "0" + app.launchEnvironment["HBP_PATIENT_API_ENABLED"] = "1" + app.launchEnvironment["HBP_PATIENT_API_BASE_URL"] = "https://127.0.0.1:1" + app.launch() + + let loginIdentifier = app.descendants(matching: .any)["patient-login-identifier"] + XCTAssertTrue(loginIdentifier.waitForExistence(timeout: 5)) + loginIdentifier.tap() + loginIdentifier.typeText("demo1") + + let password = app.secureTextFields["Password"] + password.tap() + password.typeText("synthetic-demo-ui-test-password") + + XCTAssertTrue(app.buttons["Sign in securely"].isEnabled) + XCTAssertTrue(app.staticTexts["Nightingale"].exists) + } + private func attachScreenshot(named name: String) { let attachment = XCTAttachment(screenshot: app.screenshot()) attachment.name = name diff --git a/hummingbird/iosPatientApp/NightingaleUITests/PatientSessionManagementUITests.swift b/hummingbird/iosPatientApp/NightingaleUITests/PatientSessionManagementUITests.swift index 9dc3ac38..396400a7 100644 --- a/hummingbird/iosPatientApp/NightingaleUITests/PatientSessionManagementUITests.swift +++ b/hummingbird/iosPatientApp/NightingaleUITests/PatientSessionManagementUITests.swift @@ -32,7 +32,7 @@ final class PatientSessionManagementUITests: XCTestCase { XCTAssertTrue(app.staticTexts["Sign out Home tablet?"].waitForExistence(timeout: 2)) XCTAssertTrue( - app.staticTexts["This signs out Home tablet from Hummingbird Patient. It will not sign out this device."].exists + app.staticTexts["This signs out Home tablet from Nightingale. It will not sign out this device."].exists ) app.buttons.matching(identifier: "confirm-other-session-revocation").firstMatch.tap() @@ -51,7 +51,7 @@ final class PatientSessionManagementUITests: XCTestCase { XCTAssertTrue(app.staticTexts["Sign out here?"].waitForExistence(timeout: 2)) XCTAssertTrue( - app.staticTexts["Signing out this device immediately closes Hummingbird Patient here and returns you to the welcome screen."].exists + app.staticTexts["Signing out this device immediately closes Nightingale here and returns you to the welcome screen."].exists ) attachScreenshot(named: "Manage-Devices-Current-Confirmation-Accessibility") app.buttons.matching(identifier: "confirm-current-session-revocation").firstMatch.tap() diff --git a/tests/Feature/Patient/NightingaleDemoAuthenticationTest.php b/tests/Feature/Patient/NightingaleDemoAuthenticationTest.php new file mode 100644 index 00000000..c1b0ba0d --- /dev/null +++ b/tests/Feature/Patient/NightingaleDemoAuthenticationTest.php @@ -0,0 +1,193 @@ +enablePatientAuth(); + $demo = $this->demoPrincipal('demo1'); + $email = $this->ordinaryPrincipal(); + + $demoResponse = $this->postJson('/api/patient/v1/auth/token', [ + 'email' => 'demo1', + 'password' => 'Synthetic-Test-Credential-Only-91!', + 'device' => ['platform' => 'ios'], + ])->assertOk() + ->assertJsonPath('data.token_type', 'Bearer'); + + $session = PatientSession::query() + ->where('session_uuid', (string) $demoResponse->json('data.session_uuid')) + ->firstOrFail(); + $this->assertSame($demo->getKey(), $session->principal_id); + $this->assertSame('nightingale_demo_password', $session->auth_method); + $this->assertSame('synthetic_demo', $session->assurance_level); + $this->assertDatabaseHas('patient_experience.access_audit_events', [ + 'principal_id' => $demo->getKey(), + 'patient_session_id' => $session->getKey(), + 'event_type' => 'patient.auth.token_issued', + 'outcome' => 'succeeded', + ]); + + $this->postJson('/api/patient/v1/auth/token', [ + 'email' => $email->email, + 'password' => 'Synthetic-Email-Credential-Only-82!', + ])->assertOk(); + } + + public function test_alias_is_exact_and_out_of_range_or_confusable_values_fail_validation(): void + { + $this->enablePatientAuth(); + + foreach (['demo0', 'demo6', 'demo01', 'demo1'] as $alias) { + $this->postJson('/api/patient/v1/auth/token', [ + 'email' => $alias, + 'password' => 'Synthetic-Test-Credential-Only-91!', + ])->assertUnprocessable() + ->assertJsonPath('error.code', 'validation_failed') + ->assertJsonStructure(['errors' => ['email']]); + } + + // This is a syntactically valid email, not a demo alias. It follows + // the ordinary unknown-email path without broadening alias matching. + $this->postJson('/api/patient/v1/auth/token', [ + 'email' => 'demo1@example.test', + 'password' => 'Synthetic-Test-Credential-Only-91!', + ])->assertUnauthorized() + ->assertJsonPath('error.code', 'invalid_credentials'); + } + + public function test_foreign_inactive_or_duplicate_aliases_deny_as_invalid_credentials(): void + { + $this->enablePatientAuth(); + $this->demoPrincipal('demo1', ['owner' => 'foreign-owner']); + $this->demoPrincipal('demo2', status: 'suspended'); + $this->demoPrincipal('demo3'); + $this->demoPrincipal('demo3'); + + foreach (['demo1', 'demo2', 'demo3', 'demo4'] as $alias) { + $this->postJson('/api/patient/v1/auth/token', [ + 'email' => $alias, + 'password' => 'Synthetic-Test-Credential-Only-91!', + ])->assertUnauthorized() + ->assertJsonPath('error.code', 'invalid_credentials') + ->assertJsonPath('error.message', 'The patient credentials could not be verified.'); + } + + $this->assertSame(0, PatientSession::query()->count()); + } + + public function test_wrong_demo_password_does_not_record_alias_or_principal(): void + { + $this->enablePatientAuth(); + $principal = $this->demoPrincipal('demo5'); + + $this->postJson('/api/patient/v1/auth/token', [ + 'email' => 'demo5', + 'password' => 'Wrong-Synthetic-Test-Credential-39!', + ])->assertUnauthorized() + ->assertJsonPath('error.code', 'invalid_credentials'); + + $event = PatientAccessAuditEvent::query() + ->where('event_type', 'patient.auth.token_denied') + ->firstOrFail(); + $this->assertNull($event->principal_id); + $this->assertStringNotContainsString('demo5', json_encode($event->toArray(), JSON_THROW_ON_ERROR)); + $this->assertSame(0, $principal->tokens()->count()); + } + + public function test_demo1_requires_the_exact_reference_sample_adoption_lineage(): void + { + $this->enablePatientAuth(); + $this->demoPrincipal('demo1', [ + 'reference_sample_adoption' => [ + 'adopted_from_owner' => NightingaleDemoCohort::REFERENCE_SAMPLE_OWNER, + 'adopted_patient_ref' => NightingaleDemoCohort::REFERENCE_SAMPLE_PATIENT_REF, + 'source_template_product' => NightingaleDemoCohort::REFERENCE_SOURCE_PRODUCT, + 'source_template_owner' => 'unexpected-source-owner', + 'source_mode' => NightingaleDemoCohort::REFERENCE_SAMPLE_MODE, + ], + ]); + + $this->postJson('/api/patient/v1/auth/token', [ + 'email' => 'demo1', + 'password' => 'Synthetic-Test-Credential-Only-91!', + ])->assertUnauthorized() + ->assertJsonPath('error.code', 'invalid_credentials') + ->assertJsonPath('error.message', 'The patient credentials could not be verified.'); + $this->assertSame(0, PatientSession::query()->count()); + } + + /** @param array $overrides */ + private function demoPrincipal( + string $alias, + array $overrides = [], + string $status = 'active', + ): PatientPrincipal { + $provisioning = array_merge([ + 'product' => NightingaleDemoCohort::PRODUCT, + 'environment_class' => NightingaleDemoCohort::ENVIRONMENT_CLASS, + 'owner' => NightingaleDemoCohort::OWNER, + 'cohort_version' => NightingaleDemoCohort::VERSION, + 'demo_username' => $alias, + 'clinical_use_permitted' => false, + 'synthetic' => true, + ...($alias === 'demo1' ? [ + 'reference_sample_adoption' => [ + 'adopted_from_owner' => NightingaleDemoCohort::REFERENCE_SAMPLE_OWNER, + 'adopted_patient_ref' => NightingaleDemoCohort::REFERENCE_SAMPLE_PATIENT_REF, + 'source_template_product' => NightingaleDemoCohort::REFERENCE_SOURCE_PRODUCT, + 'source_template_owner' => NightingaleDemoCohort::REFERENCE_SOURCE_OWNER, + 'source_mode' => NightingaleDemoCohort::REFERENCE_SAMPLE_MODE, + ], + ] : []), + ], $overrides); + + return PatientPrincipal::query()->create([ + 'principal_uuid' => (string) Str::uuid7(), + 'principal_type' => 'patient', + 'display_name' => 'Synthetic Nightingale Authentication Test', + 'email' => 'synthetic-'.$alias.'-'.Str::lower(Str::random(8)).'@example.test', + 'password' => 'Synthetic-Test-Credential-Only-91!', + 'status' => $status, + 'is_active' => $status === 'active', + 'preferences' => ['provisioning' => $provisioning], + 'locale' => 'en-US', + 'timezone' => 'America/New_York', + ]); + } + + private function ordinaryPrincipal(): PatientPrincipal + { + return PatientPrincipal::query()->create([ + 'principal_uuid' => (string) Str::uuid7(), + 'principal_type' => 'patient', + 'display_name' => 'Ordinary Synthetic Patient', + 'email' => 'ordinary-'.Str::lower(Str::random(8)).'@example.test', + 'password' => 'Synthetic-Email-Credential-Only-82!', + 'status' => 'active', + 'is_active' => true, + 'locale' => 'en-US', + 'timezone' => 'America/New_York', + ]); + } + + private function enablePatientAuth(): void + { + config([ + 'nightingale.enabled' => true, + 'nightingale.features.token_exchange' => true, + ]); + } +} diff --git a/tests/Feature/Patient/NightingaleDemoCohortCommandTest.php b/tests/Feature/Patient/NightingaleDemoCohortCommandTest.php new file mode 100644 index 00000000..c887f466 --- /dev/null +++ b/tests/Feature/Patient/NightingaleDemoCohortCommandTest.php @@ -0,0 +1,66 @@ +getDefinition(); + + $this->assertTrue($definition->hasArgument('action')); + $this->assertTrue($definition->hasOption('unit-id')); + $this->assertTrue($definition->hasOption('confirm')); + $this->assertTrue($definition->hasOption('json')); + $this->assertFalse($definition->hasOption('password')); + $this->assertFalse($definition->hasOption('secret')); + $this->assertFalse($definition->hasOption('credential')); + } + + public function test_unknown_action_fails_before_provisioning(): void + { + $this->artisan('nightingale:demo-cohort', [ + 'action' => 'delete', + ]) + ->expectsOutput('Action must be preview, apply, verify, or suspend.') + ->assertExitCode(Command::INVALID); + } + + public function test_preview_and_apply_require_an_explicit_positive_unit(): void + { + $this->artisan('nightingale:demo-cohort', [ + 'action' => 'preview', + ]) + ->expectsOutput('A positive --unit-id is required.') + ->assertExitCode(Command::INVALID); + + $this->artisan('nightingale:demo-cohort', [ + 'action' => 'apply', + '--unit-id' => '0', + ]) + ->expectsOutput('A positive --unit-id is required.') + ->assertExitCode(Command::INVALID); + } + + public function test_mutating_actions_require_their_exact_distinct_confirmation_phrases(): void + { + $this->artisan('nightingale:demo-cohort', [ + 'action' => 'apply', + '--unit-id' => '85', + '--confirm' => 'suspend-five-synthetic-nightingale-demo-accounts', + ]) + ->expectsOutput('Apply requires the exact cohort confirmation phrase.') + ->assertExitCode(Command::INVALID); + + $this->artisan('nightingale:demo-cohort', [ + 'action' => 'suspend', + '--confirm' => 'apply-five-synthetic-nightingale-demo-accounts', + ]) + ->expectsOutput('Suspend requires the exact cohort confirmation phrase.') + ->assertExitCode(Command::INVALID); + } +} diff --git a/tests/Feature/Patient/NightingaleDemoCohortProvisionerTest.php b/tests/Feature/Patient/NightingaleDemoCohortProvisionerTest.php new file mode 100644 index 00000000..5dc2ef92 --- /dev/null +++ b/tests/Feature/Patient/NightingaleDemoCohortProvisionerTest.php @@ -0,0 +1,772 @@ +unit = Unit::query()->create([ + 'name' => 'Synthetic Nightingale Demo Unit', + 'abbreviation' => 'NG-DEMO', + 'type' => 'med_surg', + 'staffed_bed_count' => 8, + 'ratio_floor' => 4, + 'is_deleted' => false, + ]); + $this->seedReferenceSample(); + $this->seedExactCatalog(); + } + + public function test_preview_reconciles_exact_catalog_and_makes_no_cohort_write(): void + { + $before = $this->cohortCounts(); + $result = $this->provisioner()->preview($this->unit->getKey()); + + $this->assertFalse($result['committed']); + $this->assertSame('preview', $result['action']); + $this->assertSame(NightingaleDemoCohort::loginAliases(), $result['login_handles']); + $this->assertSame(NightingaleDemoCohort::CATALOG_RELEASE_UUID, $result['catalog_release_uuid']); + $this->assertSame('inactive', $result['catalog_state']); + $this->assertFalse($result['catalog_clinical_signoff_complete']); + $this->assertFalse($result['credential_material_emitted']); + $this->assertFalse($result['clinical_use_permitted']); + $this->assertSame('ready_for_demo1_adoption', $result['demo1_reference_sample']); + $this->assertSame($before, $this->cohortCounts()); + } + + public function test_apply_atomically_creates_five_isolated_accounts_and_six_projections_each(): void + { + $password = 'Synthetic-Provisioner-Test-Only-51!'; + $result = $this->provisioner()->apply($this->unit->getKey(), $password); + + $this->assertTrue($result['committed']); + $this->assertSame('applied', $result['action']); + $this->assertSame('adopted_into_demo1', $result['demo1_reference_sample']); + $this->assertSame(5, PatientPrincipal::query() + ->whereRaw("preferences #>> '{provisioning,owner}' = ?", [NightingaleDemoCohort::OWNER]) + ->count()); + $this->assertSame(4, Encounter::query() + ->where('created_by', NightingaleDemoCohort::OWNER) + ->count()); + $this->assertSame(1, Encounter::query() + ->where('created_by', NightingaleDemoCohort::REFERENCE_SAMPLE_OWNER) + ->where('patient_ref', NightingaleDemoCohort::REFERENCE_SAMPLE_PATIENT_REF) + ->count()); + $this->assertSame(5, PatientIdentityLink::query() + ->whereRaw("provenance->>'owner' = ?", [NightingaleDemoCohort::OWNER]) + ->count()); + $this->assertSame(5, PatientEncounterAccessGrant::query() + ->whereRaw("metadata->>'owner' = ?", [NightingaleDemoCohort::OWNER]) + ->count()); + $this->assertSame(30, PatientEncounterProjection::query() + ->where('source_version', NightingaleDemoCohort::PROJECTION_PRODUCER_VERSION) + ->count()); + $this->assertSame(1, PatientReleasePolicyVersion::query() + ->where('version', NightingaleDemoCohort::RELEASE_POLICY_VERSION) + ->count()); + $this->assertSame($this->referencePrincipal->getKey(), $this->principal('demo1')->getKey()); + $this->assertSame( + (string) $this->referencePrincipal->principal_uuid, + (string) $this->principal('demo1')->principal_uuid, + ); + $this->assertSame($this->referenceEncounter->getKey(), Encounter::query() + ->where('patient_ref', NightingaleDemoCohort::REFERENCE_SAMPLE_PATIENT_REF) + ->sole() + ->getKey()); + $this->assertSame( + NightingaleDemoCohort::REFERENCE_SAMPLE_OWNER, + Encounter::query() + ->where('patient_ref', NightingaleDemoCohort::REFERENCE_SAMPLE_PATIENT_REF) + ->sole() + ->created_by, + ); + $this->assertSame( + NightingaleDemoCohort::OWNER, + Encounter::query() + ->where('patient_ref', NightingaleDemoCohort::REFERENCE_SAMPLE_PATIENT_REF) + ->sole() + ->modified_by, + ); + $this->assertSame( + NightingaleDemoCohort::REFERENCE_SOURCE_PRODUCT, + $this->principal('demo1') + ->preferences['provisioning']['reference_sample_adoption']['source_template_product'], + ); + + foreach (NightingaleDemoCohort::MEMBERS as $alias => $scenario) { + $principal = $this->principal($alias); + $this->assertTrue(Hash::check($password, (string) $principal->password)); + $this->assertSame($scenario['pathway_key'], $principal->preferences['provisioning']['catalog_binding']['pathway_key']); + $this->assertSame($scenario['ms_drg'], $principal->preferences['provisioning']['catalog_binding']['ms_drg']); + $this->assertFalse($principal->preferences['provisioning']['clinical_use_permitted']); + $grant = PatientEncounterAccessGrant::query() + ->where('principal_id', $principal->getKey()) + ->sole(); + $this->assertSame(6, PatientEncounterProjection::query() + ->where('access_grant_id', $grant->getKey()) + ->where('release_state', 'released') + ->count()); + $pathway = PatientEncounterProjection::query() + ->where('access_grant_id', $grant->getKey()) + ->where('projection_kind', 'pathway') + ->sole(); + $this->assertCount($scenario['stage_count'], $pathway->content['stages']); + $this->assertCount($scenario['milestone_count'], $pathway->content['milestones']); + $this->assertContains( + 'DEMO — NOT FOR CLINICAL USE. This is synthetic information for a product demonstration. It is not a medical record, care instruction, diagnosis, order, or promise. For urgent help, use the bedside call button or speak with staff.', + $pathway->content['notices'], + ); + } + + $this->assertSame(5, PatientEncounterAccessGrant::query() + ->whereRaw("metadata->>'owner' = ?", [NightingaleDemoCohort::OWNER]) + ->distinct('encounter_uuid') + ->count('encounter_uuid')); + $this->assertSame(0, PatientSession::query()->count()); + $this->assertSame(0, PersonalAccessToken::query()->count()); + } + + public function test_replay_rotates_password_revokes_sessions_and_creates_no_duplicates(): void + { + $firstPassword = 'Synthetic-First-Provisioner-Test-61!'; + $secondPassword = 'Synthetic-Second-Provisioner-Test-72!'; + $service = $this->provisioner(); + $service->apply($this->unit->getKey(), $firstPassword); + + config([ + 'nightingale.enabled' => true, + 'nightingale.features.token_exchange' => true, + 'nightingale.features.profile' => true, + ]); + $response = $this->postJson('/api/patient/v1/auth/token', [ + 'email' => 'demo1', + 'password' => $firstPassword, + ])->assertOk(); + $accessToken = (string) $response->json('data.access_token'); + $sessionUuid = (string) $response->json('data.session_uuid'); + + $firstCounts = $this->cohortCounts(); + $service->apply($this->unit->getKey(), $secondPassword); + $afterCounts = $this->cohortCounts(); + $this->assertSame($firstCounts['principals'], $afterCounts['principals']); + $this->assertSame($firstCounts['identity_links'], $afterCounts['identity_links']); + $this->assertSame($firstCounts['grants'], $afterCounts['grants']); + $this->assertSame($firstCounts['encounters'], $afterCounts['encounters']); + $this->assertSame($firstCounts['projections'], $afterCounts['projections']); + $this->assertSame($firstCounts['sessions'], $afterCounts['sessions']); + $this->assertSame(0, $afterCounts['tokens']); + $principal = $this->principal('demo1'); + $this->assertFalse(Hash::check($firstPassword, (string) $principal->password)); + $this->assertTrue(Hash::check($secondPassword, (string) $principal->password)); + $session = PatientSession::query()->where('session_uuid', $sessionUuid)->sole(); + $this->assertSame('revoked', $session->status); + $this->assertSame('nightingale_demo_credential_rotation', $session->revocation_reason); + $this->assertSame(0, PersonalAccessToken::query()->count()); + + $this->app['auth']->forgetGuards(); + $this->withToken($accessToken) + ->getJson('/api/patient/v1/me') + ->assertUnauthorized(); + } + + public function test_verify_and_disclosure_are_scoped_to_exact_demo_policy(): void + { + $this->provisioner()->apply( + $this->unit->getKey(), + 'Synthetic-Disclosure-Test-Only-83!', + ); + $verification = $this->provisioner()->verify(); + $this->assertSame('verified', $verification['action']); + $this->assertSame(NightingaleDemoCohort::loginAliases(), array_keys($verification['accounts'])); + + config([ + 'nightingale.enabled' => true, + 'nightingale.features.token_exchange' => true, + 'nightingale.features.pathway' => true, + ]); + $token = $this->postJson('/api/patient/v1/auth/token', [ + 'email' => 'demo4', + 'password' => 'Synthetic-Disclosure-Test-Only-83!', + ])->assertOk()->json('data.access_token'); + $grant = PatientEncounterAccessGrant::query() + ->where('principal_id', $this->principal('demo4')->getKey()) + ->sole(); + + $this->app['auth']->forgetGuards(); + $this->withToken((string) $token) + ->getJson('/api/patient/v1/encounters/'.$grant->encounter_uuid.'/pathway') + ->assertOk() + ->assertJsonPath('data.kind', 'pathway') + ->assertJsonPath( + 'data.content.summary', + fn (string $summary): bool => str_contains($summary, 'MS-DRG 399') + && str_contains($summary, 'not institutionally approved'), + ) + ->assertJsonCount(5, 'data.content.stages') + ->assertJsonCount(36, 'data.content.milestones'); + } + + public function test_verify_fails_closed_when_an_owned_principal_has_an_extra_identity(): void + { + $service = $this->provisioner(); + $service->apply( + $this->unit->getKey(), + 'Synthetic-Verify-Identity-Guard-85!', + ); + PatientIdentityLink::query()->create([ + 'identity_link_uuid' => (string) Str::uuid7(), + 'principal_id' => $this->principal('demo2')->getKey(), + 'source_system_key' => 'unexpected-secondary-source', + 'encrypted_source_subject' => 'unexpected-secondary-subject', + 'encryption_key_version' => 'test-only-v1', + 'source_subject_digest' => hash('sha256', 'unexpected-secondary-subject'), + 'linkage_method' => 'manual_review', + 'status' => 'pending', + 'provenance' => [ + 'owner' => 'unexpected-secondary-source', + ], + ]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('nightingale_demo_demo2_authorization_cardinality_invalid'); + $service->verify(); + } + + public function test_verify_rejects_an_unexpected_append_only_superseding_projection(): void + { + $service = $this->provisioner(); + $service->apply( + $this->unit->getKey(), + 'Synthetic-Verify-Projection-Guard-96!', + ); + $projection = PatientEncounterProjection::query() + ->where('access_grant_id', PatientEncounterAccessGrant::query() + ->where('principal_id', $this->principal('demo1')->getKey()) + ->value('access_grant_id')) + ->where('projection_kind', 'today') + ->sole(); + $supersedingProjection = $projection->replicate(); + $supersedingProjection->projection_uuid = (string) Str::uuid7(); + $supersedingProjection->projection_sequence = 2; + $supersedingProjection->supersedes_projection_id = $projection->getKey(); + $supersedingProjection->save(); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('nightingale_demo_demo1_projection_cardinality_invalid'); + $service->verify(); + } + + public function test_all_five_accounts_disclose_six_own_projections_and_deny_twenty_cross_account_substitutions(): void + { + $password = 'Synthetic-Five-Account-Isolation-Test-84!'; + $this->provisioner()->apply($this->unit->getKey(), $password); + config([ + 'nightingale.enabled' => true, + 'nightingale.features.token_exchange' => true, + 'nightingale.features.encounters' => true, + 'nightingale.features.today' => true, + 'nightingale.features.pathway' => true, + 'nightingale.features.rounds_summary' => true, + 'nightingale.features.care_team' => true, + ]); + + $accounts = []; + foreach (NightingaleDemoCohort::loginAliases() as $alias) { + $token = (string) $this->postJson('/api/patient/v1/auth/token', [ + 'email' => $alias, + 'password' => $password, + ])->assertOk()->json('data.access_token'); + + $this->app['auth']->forgetGuards(); + $encounters = $this->withToken($token) + ->getJson('/api/patient/v1/encounters') + ->assertOk() + ->assertJsonCount(1, 'data.encounters'); + $accounts[$alias] = [ + 'token' => $token, + 'encounter_uuid' => (string) $encounters->json('data.encounters.0.encounter_uuid'), + ]; + } + + $projectionRoutes = [ + 'today' => 'today', + 'pathway' => 'pathway', + 'pathway/events' => 'pathway_events', + 'discharge-readiness' => 'discharge_readiness', + 'rounds/summary' => 'rounds_summary', + 'care-team' => 'care_team', + ]; + foreach ($accounts as $account) { + foreach ($projectionRoutes as $suffix => $kind) { + $this->app['auth']->forgetGuards(); + $this->withToken($account['token']) + ->getJson("/api/patient/v1/encounters/{$account['encounter_uuid']}/{$suffix}") + ->assertOk() + ->assertJsonPath('data.kind', $kind) + ->assertJsonPath( + 'data.content.notices.0', + 'DEMO — NOT FOR CLINICAL USE. This is synthetic information for a product demonstration. It is not a medical record, care instruction, diagnosis, order, or promise. For urgent help, use the bedside call button or speak with staff.', + ); + } + } + + $crossAccountAttempts = 0; + foreach ($accounts as $attackerAlias => $attacker) { + foreach ($accounts as $ownerAlias => $owner) { + if ($attackerAlias === $ownerAlias) { + continue; + } + + $crossAccountAttempts++; + $this->app['auth']->forgetGuards(); + $response = $this->withToken($attacker['token']) + ->getJson("/api/patient/v1/encounters/{$owner['encounter_uuid']}/pathway"); + $response->assertNotFound() + ->assertJsonPath('data', null) + ->assertJsonPath('error.code', 'not_found') + ->assertJsonPath('error.message', 'The requested resource was not found.'); + $this->assertStringNotContainsString($owner['encounter_uuid'], $response->getContent()); + $this->assertStringNotContainsString($ownerAlias, $response->getContent()); + } + } + + $this->assertSame(20, $crossAccountAttempts); + $this->assertSame(20, PatientAccessAuditEvent::query() + ->where('event_type', 'patient.projection.disclosure_denied') + ->where('reason_code', 'projection_not_available') + ->whereNull('access_grant_id') + ->count()); + } + + public function test_catalog_drift_fails_before_any_cohort_write(): void + { + $versionId = DB::table('care_pathways.definitions as definitions') + ->join('care_pathways.versions as versions', 'versions.pathway_definition_id', '=', 'definitions.pathway_definition_id') + ->where('definitions.pathway_key', NightingaleDemoCohort::MEMBERS['demo1']['pathway_key']) + ->value('versions.pathway_version_id'); + DB::table('care_pathways.stage_definitions')->insert([ + 'stage_uuid' => (string) Str::uuid7(), + 'pathway_version_id' => $versionId, + 'stable_key' => 'unexpected_extra_stage', + 'display_order' => 99, + 'approved_label' => 'Unexpected extra draft stage', + 'expected_range' => '{}', + 'review_state' => 'draft', + 'content_digest' => hash('sha256', 'unexpected-extra-stage'), + ]); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('nightingale_demo_catalog_demo1_binding_changed'); + try { + $this->provisioner()->apply( + $this->unit->getKey(), + 'Synthetic-Catalog-Guard-Test-94!', + ); + } finally { + $this->assertSame([ + 'principals' => 0, + 'identity_links' => 0, + 'grants' => 0, + 'encounters' => 0, + 'projections' => 0, + 'sessions' => 0, + 'tokens' => 0, + ], $this->cohortCounts()); + } + } + + public function test_foreign_owned_collision_rolls_back_all_five_members(): void + { + Encounter::query()->create([ + 'patient_ref' => NightingaleDemoCohort::MEMBERS['demo5']['patient_ref'], + 'unit_id' => $this->unit->getKey(), + 'admitted_at' => now()->subDay(), + 'acuity_tier' => 2, + 'status' => 'active', + 'created_by' => 'foreign-test-source', + 'is_deleted' => false, + ]); + + try { + $this->provisioner()->apply( + $this->unit->getKey(), + 'Synthetic-Atomicity-Guard-Test-15!', + ); + $this->fail('Foreign member five unexpectedly permitted cohort provisioning.'); + } catch (RuntimeException $exception) { + $this->assertSame('nightingale_demo_demo5_encounter_foreign_owned', $exception->getMessage()); + } + + $this->assertSame(0, PatientPrincipal::query() + ->whereRaw("preferences #>> '{provisioning,owner}' = ?", [NightingaleDemoCohort::OWNER]) + ->count()); + $this->assertSame(2, Encounter::query()->count()); + $this->assertDatabaseHas('prod.encounters', [ + 'patient_ref' => NightingaleDemoCohort::MEMBERS['demo5']['patient_ref'], + 'created_by' => 'foreign-test-source', + ]); + } + + public function test_reference_principal_with_existing_identity_or_contact_data_fails_before_cohort_write(): void + { + $this->referencePrincipal->forceFill([ + 'email' => 'preexisting-reference@example.invalid', + ])->save(); + PatientIdentityLink::query()->create([ + 'identity_link_uuid' => (string) Str::uuid7(), + 'principal_id' => $this->referencePrincipal->getKey(), + 'source_system_key' => 'preexisting-reference-source', + 'encrypted_source_subject' => 'preexisting-reference-subject', + 'encryption_key_version' => 'test-only-v1', + 'source_subject_digest' => hash('sha256', 'preexisting-reference-subject'), + 'linkage_method' => 'manual_review', + 'status' => 'pending', + 'provenance' => [ + 'owner' => 'preexisting-reference-source', + ], + ]); + + try { + $this->provisioner()->apply( + $this->unit->getKey(), + 'Synthetic-Reference-Principal-Guard-48!', + ); + $this->fail('A non-pristine reference principal was unexpectedly adopted.'); + } catch (RuntimeException $exception) { + $this->assertSame( + 'nightingale_demo_reference_sample_principal_changed', + $exception->getMessage(), + ); + } + + $this->assertSame([ + 'principals' => 0, + 'identity_links' => 0, + 'grants' => 0, + 'encounters' => 0, + 'projections' => 0, + 'sessions' => 0, + 'tokens' => 0, + ], $this->cohortCounts()); + $this->assertSame('preexisting-reference@example.invalid', $this->referencePrincipal->fresh()->email); + $this->assertSame(1, $this->referencePrincipal->identityLinks()->count()); + } + + public function test_reference_encounter_with_changed_unit_or_lifecycle_fails_before_cohort_write(): void + { + $otherUnit = Unit::query()->create([ + 'name' => 'Synthetic Foreign Unit', + 'abbreviation' => 'NG-OTHER', + 'type' => 'med_surg', + 'staffed_bed_count' => 4, + 'ratio_floor' => 4, + 'is_deleted' => false, + ]); + $this->referenceEncounter->forceFill([ + 'unit_id' => $otherUnit->getKey(), + 'status' => 'discharged', + 'discharged_at' => now(), + ])->save(); + + try { + $this->provisioner()->apply( + $this->unit->getKey(), + 'Synthetic-Reference-Encounter-Guard-59!', + ); + $this->fail('A changed reference encounter was unexpectedly adopted.'); + } catch (RuntimeException $exception) { + $this->assertSame( + 'nightingale_demo_reference_sample_encounter_changed', + $exception->getMessage(), + ); + } + + $this->assertSame([ + 'principals' => 0, + 'identity_links' => 0, + 'grants' => 0, + 'encounters' => 0, + 'projections' => 0, + 'sessions' => 0, + 'tokens' => 0, + ], $this->cohortCounts()); + $this->assertSame($otherUnit->getKey(), $this->referenceEncounter->fresh()->unit_id); + $this->assertSame('discharged', $this->referenceEncounter->fresh()->status); + } + + public function test_changed_owned_encounter_fails_closed_and_rolls_back_earlier_password_rotations(): void + { + $service = $this->provisioner(); + $firstPassword = 'Synthetic-Owned-Encounter-First-60!'; + $secondPassword = 'Synthetic-Owned-Encounter-Second-71!'; + $service->apply($this->unit->getKey(), $firstPassword); + Encounter::query() + ->where('patient_ref', NightingaleDemoCohort::MEMBERS['demo4']['patient_ref']) + ->sole() + ->forceFill([ + 'status' => 'discharged', + 'discharged_at' => now(), + ])->save(); + $before = $this->cohortCounts(); + + try { + $service->apply($this->unit->getKey(), $secondPassword); + $this->fail('A changed command-owned encounter was unexpectedly converged.'); + } catch (RuntimeException $exception) { + $this->assertSame('nightingale_demo_demo4_encounter_changed', $exception->getMessage()); + } + + $this->assertSame($before, $this->cohortCounts()); + foreach (NightingaleDemoCohort::loginAliases() as $alias) { + $principal = $this->principal($alias)->fresh(); + $this->assertTrue(Hash::check($firstPassword, (string) $principal->password)); + $this->assertFalse(Hash::check($secondPassword, (string) $principal->password)); + } + $this->assertSame( + 'discharged', + Encounter::query() + ->where('patient_ref', NightingaleDemoCohort::MEMBERS['demo4']['patient_ref']) + ->sole() + ->status, + ); + } + + public function test_suspend_removes_credentials_and_effective_access_then_reapply_recovers(): void + { + $service = $this->provisioner(); + $service->apply($this->unit->getKey(), 'Synthetic-Suspend-Test-Only-26!'); + $result = $service->suspend(); + $this->assertSame(5, $result['accounts_suspended']); + $this->assertSame(5, PatientPrincipal::query()->where('status', 'suspended')->count()); + $this->assertSame(5, PatientEncounterAccessGrant::query()->where('status', 'suspended')->count()); + $this->assertSame(0, PatientPrincipal::query()->whereNotNull('password')->count()); + + config([ + 'nightingale.enabled' => true, + 'nightingale.features.token_exchange' => true, + ]); + $this->postJson('/api/patient/v1/auth/token', [ + 'email' => 'demo1', + 'password' => 'Synthetic-Suspend-Test-Only-26!', + ])->assertUnauthorized() + ->assertJsonPath('error.code', 'invalid_credentials'); + + $service->apply($this->unit->getKey(), 'Synthetic-Reapply-Test-Only-37!'); + $this->postJson('/api/patient/v1/auth/token', [ + 'email' => 'demo1', + 'password' => 'Synthetic-Reapply-Test-Only-37!', + ])->assertOk(); + $this->assertSame(30, PatientEncounterProjection::query() + ->where('source_version', NightingaleDemoCohort::PROJECTION_PRODUCER_VERSION) + ->count()); + } + + private function provisioner(): NightingaleDemoCohortProvisioner + { + return $this->app->make(NightingaleDemoCohortProvisioner::class); + } + + private function principal(string $alias): PatientPrincipal + { + return PatientPrincipal::query() + ->whereRaw("preferences #>> '{provisioning,demo_username}' = ?", [$alias]) + ->sole(); + } + + private function seedReferenceSample(): void + { + $this->referenceEncounter = Encounter::query()->create([ + 'patient_ref' => NightingaleDemoCohort::REFERENCE_SAMPLE_PATIENT_REF, + 'unit_id' => $this->unit->getKey(), + 'bed_id' => null, + 'admitted_at' => now()->subDays(2), + 'expected_discharge_date' => now()->addDays(2)->toDateString(), + 'acuity_tier' => 2, + 'status' => 'active', + 'created_by' => NightingaleDemoCohort::REFERENCE_SAMPLE_OWNER, + 'modified_by' => NightingaleDemoCohort::REFERENCE_SAMPLE_OWNER, + 'is_deleted' => false, + ]); + $this->referencePrincipal = PatientPrincipal::query()->create([ + 'principal_uuid' => (string) Str::uuid7(), + 'principal_type' => 'patient', + 'display_name' => NightingaleDemoCohort::REFERENCE_SAMPLE_DISPLAY_NAME, + 'email' => null, + 'phone_e164' => null, + 'password' => null, + 'status' => 'pending', + 'is_active' => false, + 'preferences' => [ + 'synthetic' => true, + 'product' => NightingaleDemoCohort::PRODUCT, + 'provisioning' => [ + 'owner' => NightingaleDemoCohort::REFERENCE_SAMPLE_OWNER, + 'mode' => NightingaleDemoCohort::REFERENCE_SAMPLE_MODE, + 'source_template_product' => NightingaleDemoCohort::REFERENCE_SOURCE_PRODUCT, + 'source_template_owner' => NightingaleDemoCohort::REFERENCE_SOURCE_OWNER, + ], + ], + 'locale' => 'en-US', + 'timezone' => 'America/New_York', + ]); + } + + /** @return array */ + private function cohortCounts(): array + { + $principalIds = PatientPrincipal::query() + ->whereRaw("preferences #>> '{provisioning,owner}' = ?", [NightingaleDemoCohort::OWNER]) + ->pluck('principal_id'); + $grantIds = PatientEncounterAccessGrant::query() + ->whereIn('principal_id', $principalIds) + ->pluck('access_grant_id'); + + return [ + 'principals' => $principalIds->count(), + 'identity_links' => PatientIdentityLink::query()->whereIn('principal_id', $principalIds)->count(), + 'grants' => $grantIds->count(), + 'encounters' => Encounter::query()->where('created_by', NightingaleDemoCohort::OWNER)->count(), + 'projections' => PatientEncounterProjection::query()->whereIn('access_grant_id', $grantIds)->count(), + 'sessions' => PatientSession::query()->whereIn('principal_id', $principalIds)->count(), + 'tokens' => PersonalAccessToken::query() + ->where('tokenable_type', PatientPrincipal::class) + ->whereIn('tokenable_id', $principalIds) + ->count(), + ]; + } + + private function seedExactCatalog(): void + { + $releaseId = DB::table('care_pathways.catalog_releases')->insertGetId([ + 'catalog_release_uuid' => NightingaleDemoCohort::CATALOG_RELEASE_UUID, + 'dataset_key' => NightingaleDemoCohort::CATALOG_DATASET_KEY, + 'source_csv_sha256' => str_repeat('a', 64), + 'verification_workbook_sha256' => str_repeat('b', 64), + 'declared_baseline_sha256' => str_repeat('c', 64), + 'grouper_version' => NightingaleDemoCohort::CATALOG_GROUPER_VERSION, + 'grouper_effective_start' => '2026-04-01', + 'grouper_effective_end' => '2026-09-30', + 'pathway_count' => NightingaleDemoCohort::CATALOG_PATHWAY_COUNT, + 'pathway_drg_association_count' => 802, + 'unique_drg_code_count' => 770, + 'claim_count' => 10123, + 'source_count' => 811, + 'change_count' => 324, + 'evidence_verified_count' => 96, + 'evidence_limitations_count' => 154, + 'signoff_queue_count' => 96, + 'specialist_review_count' => 148, + 'redesign_count' => 6, + 'clinical_signoff_count' => 0, + 'volume_control_total' => 1000, + 'coverage_control_percent' => 100, + 'state' => 'inactive', + 'clinical_signoff_complete' => false, + 'source_controls' => '{}', + 'adopted_by' => 'synthetic-provisioner-test', + 'adopted_at' => now(), + ], 'catalog_release_id'); + + foreach (NightingaleDemoCohort::MEMBERS as $index => $scenario) { + $ordinal = ((int) substr($index, -1)); + $definitionId = DB::table('care_pathways.definitions')->insertGetId([ + 'pathway_uuid' => (string) Str::uuid7(), + 'pathway_key' => $scenario['pathway_key'], + 'canonical_name' => 'Synthetic canonical '.$scenario['ms_drg'], + 'mdc_label' => 'Synthetic', + 'care_type' => 'Medical', + 'source_service_line' => 'Synthetic demo', + 'lifecycle_state' => 'candidate', + ], 'pathway_definition_id'); + $versionId = DB::table('care_pathways.versions')->insertGetId([ + 'pathway_version_uuid' => (string) Str::uuid7(), + 'pathway_definition_id' => $definitionId, + 'catalog_release_id' => $releaseId, + 'semantic_version' => '43.1-test-'.$ordinal, + 'source_rank' => $ordinal, + 'evidence_status' => 'Evidence verified — automated independent review complete', + 'verification_confidence' => 'High', + 'source_specificity' => 'High', + 'unresolved_flags' => '[]', + 'release_disposition' => 'Ready for institutional clinician signoff', + 'clinical_signoff_status' => 'Not clinically approved — institutional SME signoff required', + 'institutional_approval_status' => 'not_reviewed', + 'activation_status' => 'inactive', + 'source_digest' => hash('sha256', 'source-'.$index), + 'content_digest' => hash('sha256', 'content-'.$index), + 'raw_snapshot' => '{}', + ], 'pathway_version_id'); + $entryId = DB::table('care_pathways.drg_codebook_entries')->insertGetId([ + 'entry_uuid' => (string) Str::uuid7(), + 'catalog_release_id' => $releaseId, + 'ms_drg' => $scenario['ms_drg'], + 'title' => $scenario['drg_title'], + 'mdc' => '00', + 'type_code' => 'M', + 'type_label' => 'Synthetic test', + 'entry_digest' => hash('sha256', 'drg-'.$index), + ], 'drg_codebook_entry_id'); + DB::table('care_pathways.drg_mappings')->insert([ + 'pathway_version_id' => $versionId, + 'drg_codebook_entry_id' => $entryId, + 'mapping_role' => 'candidate', + ]); + + for ($stage = 1; $stage <= $scenario['stage_count']; $stage++) { + DB::table('care_pathways.stage_definitions')->insert([ + 'stage_uuid' => (string) Str::uuid7(), + 'pathway_version_id' => $versionId, + 'stable_key' => 'demo_stage_'.$stage, + 'display_order' => $stage, + 'approved_label' => 'Unreleased synthetic test stage '.$stage, + 'expected_range' => '{}', + 'review_state' => 'draft', + 'content_digest' => hash('sha256', $index.'-stage-'.$stage), + ]); + } + for ($milestone = 1; $milestone <= $scenario['milestone_count']; $milestone++) { + DB::table('care_pathways.milestone_definitions')->insert([ + 'milestone_uuid' => (string) Str::uuid7(), + 'pathway_version_id' => $versionId, + 'stable_key' => 'demo_milestone_'.$milestone, + 'title' => 'Unreleased synthetic test milestone '.$milestone, + 'phase' => 'synthetic', + 'sequence' => $milestone, + 'predecessor_keys' => '[]', + 'expected_range' => '{}', + 'review_state' => 'draft', + ]); + } + } + } +}