Implement Stage A: rule engine, validation, audit, and safety gates

Completes 109 of 121 tasks. Every remaining task needs a tenant connection
(T055, T056, T101-T103) or an Azure Automation account (T115-T121).

  354 offline Pester tests      PASS
  Engine purity (Principle IV)  PASS
  Sanitization (SC-013)         PASS  (156 files)
  Graph module loaded in tests  none  (SC-008 holds)

What landed
  - Four-layer configuration validation with stable finding codes, covering
    every VR-002 and VR-003 condition, plus a 23-fixture invalid-config corpus
  - Run loop, audit records (NDJSON through a single sink), summaries,
    reconciliation, and exit codes 0-6
  - Persistence behind a single write-body builder whose result always has
    exactly one key
  - Invoke-PersonaEngine.ps1 and Edit-PersonaEngineConfig.ps1
  - Six docs, two pipelines, traceability matrix, V-5a and sanitization records

Three deviations from tasks.md, each recorded in its status block

  T033 is not in Resolve-UserPersona. evaluationErrorThreshold is run-level
  state and the rule engine is pure; a counter there would break Principle IV.
  It lives in New-PersonaRunCounter and is applied in the run loop.

  A new src/Engine/ layer holds Invoke-PersonaEngineRun. The entry script
  imports the manifest, which requires Microsoft.Graph.Authentication, so a
  loop living only inside it could not run on a machine without the Graph SDK
  and SC-004 could not be proven at all. The entry script is now a thin
  wrapper and what ships is what is tested.

  The invalid-config corpus is generated by a committed script, with the
  generated fixtures committed too, so a reviewer sees the fixture in the diff.

Defects found by running the code, not by reading it

  Group and role ID lists were double-wrapped: @(Get-PersonaGroupIdPage ...)
  around a comma-returned array collapsed every membership list into one
  bogus space-joined entry. That is a silent false non-match, exactly what
  FR-013 exists to prevent.

  A 403 whose status appears only in the exception message parsed as $null,
  which the retry policy treats as a transport error - five requests per
  account against a tenant already refusing. Status extraction now falls back
  to the message text, bounded to 400-599.

  The sanitization scan walked tracked files only, so it covered 34 of 156
  files and none of this phase's code. It now scans untracked non-ignored
  files too, and a negative control confirms it catches a planted leak.

  Test-Json reports one error per violating location, not first-failure-only
  as the V-5a draft claimed. Record and pin corrected.

Enforcement remains blocked on the V-4 security sign-off.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 21:48:19 -04:00
parent c59c85dd55
commit cdc6bb33d3
124 changed files with 16638 additions and 199 deletions
+202
View File
@@ -0,0 +1,202 @@
# Implementation Plan: Persona Engine
**Branch**: `main` (feature directory `001-persona-engine`) | **Date**: 2026-08-20 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from `/specs/001-persona-engine/spec.md`
## Summary
Deterministic, configuration-driven persona classification for Microsoft Entra ID user objects. The
engine enumerates in-scope users, evaluates each against an ordered JSON rule set, assigns exactly
one persona, and updates a single approved directory attribute only when the calculated value
differs from the stored value.
**Technical approach**: a PowerShell 7 module (`PersonaEngine`) whose rule engine is a pure function
over normalized records, with Graph access, persistence, and presentation isolated behind adapters.
Directory access uses `Invoke-MgGraphRequest` (direct REST over the `Microsoft.Graph.Authentication`
module) so the write body is explicitly constructed and test-assertable. The persona value is stored
in a **directory (schema) extension** on the user object, consumed downstream by dynamic membership
groups. Configuration is validated with the built-in `Test-Json -SchemaFile` against a draft-07
schema. See [research.md](research.md) for the decisions and their rationale.
## Delivery Staging
**Constraint (2026-08-20)**: no Azure Automation account is available. All development and testing
proceeds on a local PowerShell 7 workstation using **user accounts and delegated authentication**.
This changes sequencing, not architecture. The adapter boundaries that make the engine testable
offline (Principle IV) are the same boundaries that make the Automation runtime a late, additive
step — so the deferral costs nothing structurally.
| Stage | Environment | Auth | Status |
| --- | --- | --- | --- |
| **A1** — offline | Local PS7, synthetic fixtures | None | Available now. Covers the rule engine, all four validation layers, and the safety suites. No tenant, no network. |
| **A2** — connected read-only | Local PS7, tenant | Delegated (`Connect-MgGraph -Scopes`) | Available now. Covers enumeration, membership, roles, normalization, presentation, reconciliation, and `-WhatIf`. |
| **A3** — connected write | Local PS7, **test accounts only** | Delegated | Gated on V-4. Test accounts only — the baseline's read-only-during-early-development assumption still stands for the general population. |
| **B** — Automation | Azure Automation PS7 | Managed identity | **Deferred.** Additive: a second authentication adapter, a runbook wrapper, and a schedule. |
**Consequences, stated plainly:**
1. **v1 cannot be declared complete while Stage B is deferred.** The Definition of Done requires an
Azure Automation PowerShell 7 run to pass. Deferring it does not violate the constitution — it
defers *completion*. The correct milestone to claim in the meantime is "Stage A complete", not
"v1 done". Do not quietly redefine done.
2. **`Connect-PersonaGraphManagedIdentity` will ship unexercised.** `Connect-MgGraph -Identity`
cannot run on a workstation. The mitigation is to keep the authentication adapter's surface
minimal — one function, returning the same handle shape as the interactive path, with no
engine-visible difference — so that the untested code is a few lines rather than a subsystem.
3. **Delegated authorization behaves differently from application permissions.** Effective access is
the intersection of the requested scope and the signed-in user's directory roles. This makes V-3
*more* meaningful when run as an ordinary user account, and meaningless when run as a Global
Administrator. See research.md V-3.
4. **Automation-specific risk stays open**: runtime PowerShell version, module availability, and
sandbox behaviour are unverified until Stage B. The one-module dependency decision (OTD-004) is
what keeps that risk small.
## Technical Context
**Language/Version**: PowerShell 7.4 locally. The Automation runtime version is unverified and
remains so until Stage B (verification item V-5b in research.md). Avoid any construct newer than
PS 7.2 so the eventual Automation runtime is not a constraint discovered late.
**Primary Dependencies**: `Microsoft.Graph.Authentication` (token acquisition and
`Invoke-MgGraphRequest`) is the only runtime dependency. `Pester` 5.x and `PSScriptAnalyzer` are
development/CI-only. No full Microsoft Graph SDK dependency — see OTD-004. Module availability in
the Automation sandbox is unverified until Stage B.
**Storage**: JSON configuration file on disk; no database. Persona values live in the directory
itself. Audit output is newline-delimited JSON to a file plus the Automation output stream.
**Testing**: Pester 5.x. Unit and rule-engine suites run fully offline against synthetic fixtures
(SC-008); integration suites require a read-only tenant identity, satisfied in Stage A2 by a
delegated connection; safety suites assert zero writes under `-WhatIf` (SC-004) and single-attribute
write bodies (SC-005). The safety suites mock the write adapter, so they are fully available now and
are **not** gated on Stage A3 or B — the zero-write guarantee is proven against the adapter contract,
not against a tenant.
**Target Platform**: PowerShell 7 on a local workstation (Stages A1A3). The Azure Automation
PowerShell 7 runtime remains the eventual production target but is out of the current stage.
**Project Type**: PowerShell module plus two CLI entry-point scripts.
**Performance Goals**: None fixed. NFR-002 explicitly defers a hard target until representative
tenant testing. The plan requires per-user and total duration to be recorded from the first
connected run so a baseline exists before any target is set.
**Constraints**: Rule engine must be free of Graph, authentication, Automation, and console
dependencies (Principle IV). `-WhatIf` must issue zero writes (Principle III). Write payloads carry
exactly one attribute (Principle III). All artifacts sanitized to placeholders (Principle V,
SC-013).
**Scale/Scope**: In-scope population size is tenant-specific and unknown at planning time. Full
enumeration with pagination is the v1 processing model (OTD-008); delta processing is deferred. The
read-only pilot establishes the population size and run duration baseline.
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-checked after Phase 1 design.*
Evaluated against [constitution.md](../../.specify/memory/constitution.md) v1.0.0.
| Gate | Principle | Pre-research | Post-design | Notes |
| --- | --- | --- | --- | --- |
| Deterministic, single-persona result | I (NON-NEGOTIABLE) | PASS | PASS | Ordered priority evaluation, first-match stop, no clock/random/unordered inputs in the engine. Rejecting `extensionAttributeN` (research OTD-001) removes a population-dependent failure mode that would have broken determinism across a hybrid population. |
| Configuration-driven rules | II | PASS | PASS | No persona, priority, group ID, role ID, or attribute name in source. Four-layer validation ordering preserved in the config contract. |
| Fail-safe, idempotent persistence | III (NON-NEGOTIABLE) | PASS | PASS | `EvaluationError` preserves stored value; `SupportsShouldProcess` on both write paths; changed-values-only comparison; single-attribute body construction isolated in one function. |
| Pure rule engine, offline-tested first | IV | PASS | PASS | Rule engine depends only on normalized records. Build order enforced in the task sequencing below; persistence adapter is last. |
| Explainable, sanitized observability | V | PASS | PASS | Run ID, UPN, Account Object ID, and matched rule ID on every user event; condition-value tracing gated behind `-Debug`; placeholders only in all artifacts. |
**Security and least-privilege constraints**: PASS with a mandatory condition. Research OTD-003
establishes that Microsoft Graph application permissions **cannot** be scoped to an individual user
attribute for the selected mechanism. The constitution anticipates exactly this outcome and makes
the compensating controls mandatory rather than optional; they are carried into the design as
testable requirements (see the persistence contract). This is a documented and approved-by-design
condition, not a constitution violation. Security approval of the compensating controls is a gate
before enforcement, per the Development Workflow section.
**Automation deferral (Stage B)**: PASS. Every principle is satisfiable on a local workstation —
determinism, configuration-driven rules, fail-safe persistence, engine purity, and observability are
all properties of the code, not of the hosting environment. Two constitution items are *deferred, not
waived*: the Definition of Done's Azure Automation PowerShell 7 run, and the release-pipeline stages
that deploy to it. Both are recorded in the Delivery Staging table and gate the v1 completion claim.
**Result**: no unjustified violations. Complexity Tracking is empty.
## Project Structure
### Documentation (this feature)
```text
specs/001-persona-engine/
├── plan.md # This file
├── research.md # Phase 0 output — OTD-001..010 decisions
├── data-model.md # Phase 1 output — entity contracts
├── quickstart.md # Phase 1 output — validation scenarios
├── contracts/ # Phase 1 output
│ ├── persona-engine.schema.json # Configuration JSON Schema (draft-07)
│ ├── cli-invoke-persona-engine.md # Engine CLI contract
│ ├── cli-edit-persona-engine-config.md # Editor CLI contract
│ ├── graph-data-provider.md # Directory read/write contract
│ └── audit-record.md # Structured log record contracts
└── tasks.md # Phase 2 output (/speckit-tasks — NOT created here)
```
### Source Code (repository root)
```text
PersonaEngine.psd1 # Module manifest
PersonaEngine.psm1 # Module loader
Invoke-PersonaEngine.ps1 # Engine entry point (CmdletBinding, SupportsShouldProcess)
Edit-PersonaEngineConfig.ps1 # Configuration validation / editor entry point
config/
├── persona-engine.example.json # Placeholder-only example
└── persona-engine.schema.json # Shipped schema (from contracts/)
src/
├── Configuration/ # Import-PersonaConfiguration, Test-PersonaConfiguration, Resolve-TargetAttribute
├── Authentication/ # Connect-PersonaGraphInteractive, Connect-PersonaGraphManagedIdentity
├── DataProviders/ # Get-PersonaUsers, Get-PersonaGroupMembership, Get-PersonaDirectoryRoles
├── Normalization/ # ConvertTo-PersonaUserRecord, ConvertTo-PersonaMembershipRecord
├── RuleEngine/ # Test-PersonaCondition, Test-PersonaConditionGroup, Test-PersonaRule,
│ # Resolve-UserPersona <-- no Graph/auth/console dependency
├── Persistence/ # Compare-PersonaValue, New-PersonaWriteBody, Set-UserPersonaAttribute
├── Presentation/ # Write-UserPersonaResult, Write-PersonaSummary
└── Audit/ # New-PersonaAuditRecord, Export-PersonaRunReport
tests/
├── Unit/ # Per-function offline tests
├── RuleEngine/ # Rule evaluation matrix against synthetic fixtures
├── Configuration/ # Schema, semantic (VR-002), and safety (VR-003) validation
├── Integration/ # Read-only tenant tests
├── Safety/ # SC-004 zero-write, SC-005 single-attribute-body assertions
└── TestData/ # Obviously fictional synthetic users, memberships, configs
docs/ # Architecture, BusinessRules, ConfigurationReference, Logging,
# SecurityModel, OperationsRunbook
pipelines/ # validate.yml, test.yml, release.yml
```
**Structure Decision**: single PowerShell module with two CLI entry points, matching the layout
already published in [README.md](../../README.md). The directory split is the enforcement mechanism
for Principle IV — `src/RuleEngine/` may import nothing from `src/Authentication/`,
`src/DataProviders/`, `src/Persistence/`, or `src/Presentation/`, and a CI check asserts this.
### Build order (Principle IV, non-negotiable sequencing)
1. Normalized record contracts and synthetic fixtures.
2. Pure rule engine + offline Pester suite (no tenant connectivity).
3. Configuration import, four-layer validation, and non-interactive pipeline mode.
4. `Edit-PersonaEngineConfig.ps1` interactive editor and synthetic rule testing.
5. Graph authentication and **read** adapters; normalization wiring.
6. Presentation, summaries, reconciliation, and structured audit output.
7. Persistence adapter **last**, with `ShouldProcess` and the zero-write/single-attribute suites.
Steps 14 are Stage A1 (offline). Steps 56 are Stage A2 (delegated read-only). Step 7 is built and
fully unit-tested in Stage A1/A2 against a mocked adapter, and only *exercised against the tenant* in
Stage A3, behind V-4. Adding the managed-identity adapter and runbook wrapper is Stage B and touches
nothing in steps 17 — that is the test of whether the boundaries were drawn correctly.
## Complexity Tracking
> No Constitution Check violations. This section is intentionally empty.