T003-T018b: Add workspace_baselines.view/manage capabilities, role mappings, baseline_capture/baseline_compare operation labels, severity summary keys, 5 migrations, 4 models, 4 factories, BaselineScope, BaselineReasonCodes, BaselineProfileStatus badge domain + mapper.
5.6 KiB
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 aTenantwith a non-nullworkspace_id, and always creates runs withworkspace_id+tenant_id.- Active-run idempotency is enforced via the
operation_runs_active_uniquepartial 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.fingerprintisstring(64)and unique by(tenant_id, fingerprint).DriftHasheralready 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.contextand 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.typevalues:baseline_capturebaseline_compare
- Use
OperationRunService::ensureRunWithIdentity()for idempotent start surfaces.
Identity inputs:
baseline_capture: identity inputs includebaseline_profile_id.baseline_compare: identity inputs includebaseline_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_idat enqueue time for determinism.
Alternatives considered:
- Include
baseline_snapshot_idin 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_codeand do not create anOperationRun.
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.compareper FR-015.
Implementation note:
- The current
findingsschema does not have asourcecolumn. - In Phase 2 implementation we should add
findings.source(string, nullable, defaultNULL) with an index(tenant_id, source)and usesource='baseline.compare'. - Existing findings receive
NULL— legacy drift-generate findings are queried withwhereNull('source')or unconditionally. - A future backfill migration may set
source='drift.generate'for historical findings if needed for reporting.
Alternatives considered:
- Store
sourceonly inevidence_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.viewworkspace_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.