# Phase 0 — Research This document records the key technical decisions for **101 — Golden Master / Baseline Governance v1 (R1.1–R1.4)**, grounded in existing TenantPilot patterns. ## Existing System Constraints (confirmed in repo) ### Operation runs are tenant-scoped and deduped at DB level - `OperationRunService::ensureRunWithIdentity()` requires a `Tenant` with a non-null `workspace_id`, and always creates runs with `workspace_id` + `tenant_id`. - Active-run idempotency is enforced via the `operation_runs_active_unique` partial unique index (queued/running). **Implication**: Baseline capture/compare must always be executed as **tenant-owned `OperationRun` records**, even though baseline profiles are workspace-owned. ### Findings fingerprinting expects sha256 (64 chars) - `findings.fingerprint` is `string(64)` and unique by `(tenant_id, fingerprint)`. - `DriftHasher` already implements a stable sha256 fingerprint scheme. **Implication**: Any baseline-compare “finding key” should be hashed before storage, and must be stable across repeated compares. ## Decisions ### D-001 — Baseline snapshot storage is workspace-owned (and data-minimized) **Decision**: Store `baseline_snapshots` and `baseline_snapshot_items` as **workspace-owned** tables (`workspace_id` NOT NULL, **no `tenant_id`**) so a golden master snapshot can be used across tenants without requiring access to the source tenant. **Rationale**: - The product intent is a workspace-level standard (“Golden Master”) reusable across multiple tenants. - Treat the snapshot as a **standard artifact**, not tenant evidence, and enforce strict data-minimization so we do not leak tenant-specific content. **Guardrails**: - Snapshot items store only policy identity + stable content hashes and minimal display metadata. - Any tenant identifiers (e.g., “captured from tenant”) live in the **capture `OperationRun.context`** and audit logs, not on workspace-owned snapshot rows. **Alternatives considered**: - Tenant-owned baseline snapshot (include `tenant_id`): rejected because it would require cross-tenant reads of tenant-owned records to compare other tenants, which would either violate tenant isolation or force “must be member of the source tenant” semantics. ### D-002 — OperationRun types and identity inputs **Decision**: - Introduce `OperationRun.type` values: - `baseline_capture` - `baseline_compare` - Use `OperationRunService::ensureRunWithIdentity()` for idempotent start surfaces. **Identity inputs**: - `baseline_capture`: identity inputs include `baseline_profile_id`. - `baseline_compare`: identity inputs include `baseline_profile_id`. **Rationale**: - Guarantees one active run per tenant+baseline profile (matches partial unique index behavior). - Keeps identity stable even if the active snapshot is switched mid-flight; the run context should freeze `baseline_snapshot_id` at enqueue time for determinism. **Alternatives considered**: - Include `baseline_snapshot_id` in identity: rejected for v1 because we primarily want “single active compare per tenant/profile”, not “single active compare per snapshot”. ### D-003 — Precondition failures return 422 and do not create OperationRuns **Decision**: Enforce FR-014 exactly: - The start surface validates preconditions **before** calling `OperationRunService`. - If unmet, return **HTTP 422** with a stable `reason_code` and **do not** create an `OperationRun`. **Rationale**: - Aligns with spec clarifications and avoids polluting Monitoring → Operations with non-startable attempts. ### D-004 — Findings storage uses existing `findings` table; add a source discriminator **Decision**: - Store baseline-compare drift as `Finding::FINDING_TYPE_DRIFT`. - Persist `source = baseline.compare` per FR-015. **Implementation note**: - The current `findings` schema does not have a `source` column. - In Phase 2 implementation we should add `findings.source` (string, **nullable**, default `NULL`) with an index `(tenant_id, source)` and use `source='baseline.compare'`. - Existing findings receive `NULL` — legacy drift-generate findings are queried with `whereNull('source')` or unconditionally. - A future backfill migration may set `source='drift.generate'` for historical findings if needed for reporting. **Alternatives considered**: - Store `source` only in `evidence_jsonb`: workable, but makes filtering and long-term reporting harder and is less explicit. - Non-null default `'drift.generate'`: rejected because retroactively tagging all existing findings requires careful validation and is a separate concern. ### D-005 — Baseline compare scope_key strategy **Decision**: Use `findings.scope_key = 'baseline_profile:' . baseline_profile_id` for baseline-compare findings. **Rationale**: - Keeps a stable grouping key for tenant UI (“Soll vs Ist” for the assigned baseline). - Avoids over-coupling to inventory selection hashes in v1. ### D-006 — Authorization model (404 vs 403) **Decision**: - Membership is enforced as deny-as-not-found (404) via existing membership checks. - Capability denials are 403 after membership is established. **Capabilities**: - Add workspace capabilities: - `workspace_baselines.view` - `workspace_baselines.manage` **Rationale**: - Matches the feature spec’s two-capability requirement. - Keeps baseline governance controlled at workspace plane, while still enforcing tenant membership for tenant-context pages/actions. **Alternatives considered**: - Add a tenant-plane capability for compare start: rejected for v1 to keep to the two-capability spec and avoid introducing a second permission axis for the same action. ## Open Questions (none blocking Phase 1) - None.