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:
@@ -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 A1–A3). 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 1–4 are Stage A1 (offline). Steps 5–6 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 1–7 — that is the test of whether the boundaries were drawn correctly.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
> No Constitution Check violations. This section is intentionally empty.
|
||||
Reference in New Issue
Block a user