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
@@ -0,0 +1,127 @@
# Contract: Structured Audit Records
Serialized form of the audit trail (FR-022, NFR-005, Principle V). Format is newline-delimited JSON,
one record per line (OTD-006). All emission goes through a single `Write-PersonaAuditRecord` sink so
a future transport can be added without touching call sites.
## Common envelope
Every record carries:
| Field | Type | Notes |
| --- | --- | --- |
| `timestamp` | string (ISO 8601 UTC) | |
| `recordType` | string | `RunStart`, `UserEvent`, `Summary`, `RunComplete`, `EngineDefect` |
| `runId` | string (GUID) | Constant for the run (NFR-005) |
| `engineVersion` | string | |
| `configVersion` | string | |
| `configurationHash` | string | SHA-256 of the configuration file |
| `mode` | string | `Preview` or `Enforce` |
## `UserEvent`
Emitted once per processed user. 100% of these records carry `runId`, `userPrincipalName`, and
`accountObjectId` (SC-006).
```json
{
"timestamp": "2026-08-20T09:14:02.187Z",
"recordType": "UserEvent",
"runId": "<RUN-ID>",
"engineVersion": "1.0.0",
"configVersion": "1.4.0",
"configurationHash": "<SHA256>",
"mode": "Preview",
"accountObjectId": "<ACCOUNT-OBJECT-ID>",
"userPrincipalName": "<USER>@<PRIMARY-DOMAIN>",
"outcome": "Matched",
"matchedRuleId": "RULE-0100-TIER0",
"storedPersona": "Employee",
"calculatedPersona": "Tier0-Admin",
"previousValue": "Employee",
"action": "WouldUpdate",
"rulesEvaluated": 4,
"durationMs": 38,
"evaluationErrorReason": null
}
```
**Field requirements**
| Field | Requirement |
| --- | --- |
| `outcome` | Exactly one of `Matched`, `Unclassified`, `EvaluationError` (SC-001) |
| `matchedRuleId` | Non-null on every `Matched` record (SC-006) |
| `previousValue` | **Captured at write time on every `Updated` record.** This is what makes OTD-010 rollback possible; omitting it in v1 makes rollback impossible retroactively |
| `evaluationErrorReason` | Non-null exactly when `outcome` is `EvaluationError` |
**Prohibited fields**: access tokens, `Authorization` headers, secrets, and full Graph responses
MUST NEVER appear in any record.
**Condition tracing**: a `conditionTrace` array may be added **only** under `-Debug`
(`logging.traceConditionValues`). It contains diagnostic condition-level values and is therefore
gated by acknowledgement (VR-003).
## `Summary`
Emitted every `summaryInterval` users and once at completion.
```json
{
"recordType": "Summary",
"runId": "<RUN-ID>",
"summaryType": "Interim",
"processed": 250,
"matched": 231,
"unclassified": 14,
"evaluationError": 5,
"unchanged": 220,
"wouldUpdate": 11,
"updated": 0,
"updateFailed": 0,
"reconciliationPassed": true,
"ruleCounts": [
{ "ruleId": "RULE-0100-TIER0", "name": "Tier 0 administrators", "enabled": true, "matches": 3 }
]
}
```
`reconciliationPassed` is `processed == matched + unclassified + evaluationError` (FR-021, SC-007).
`ruleCounts` lists **all** business rules, including disabled and zero-match rules — an absent rule
is indistinguishable from a rule that never fired, and operators need that distinction.
## `RunComplete`
```json
{
"recordType": "RunComplete",
"runId": "<RUN-ID>",
"startedUtc": "2026-08-20T09:00:00.000Z",
"completedUtc": "2026-08-20T09:12:44.913Z",
"durationMs": 764913,
"processed": 4820,
"matched": 4611,
"unclassified": 190,
"evaluationError": 19,
"unchanged": 4400,
"wouldUpdate": 211,
"updated": 0,
"updateFailed": 0,
"reconciliationPassed": true,
"exitCode": 0
}
```
## `EngineDefect`
Emitted when reconciliation fails (FR-021) or an internal invariant is violated. Severity is always
`Error`. A failed reconciliation is a defect in the engine, not a property of the data, and is
reported as such rather than being folded into ordinary counters.
## Sanitization (SC-013)
Committed artifacts — this contract, examples, fixtures, tests, and documentation — use placeholders
only: `<ORGANIZATION-NAME>`, `<PRIMARY-DOMAIN>`, `<TENANT-ID>`, `<ACCOUNT-OBJECT-ID>`,
`<GROUP-OBJECT-ID>`, `<APPROVED-PERSONA-ATTRIBUTE-NAME>`, `<AUTOMATION-ACCOUNT-NAME>`,
`<LOG-OUTPUT-PATH>`, `<RUN-ID>`. Runtime records naturally contain real UPNs and Object IDs — which
are approved for logs — but no such value may ever be committed to this repository.
@@ -0,0 +1,90 @@
# Contract: `Edit-PersonaEngineConfig.ps1`
Configuration validation, interactive editing, synthetic rule testing, and pipeline enforcement
(FR-023 FR-026).
## Signature
```powershell
[CmdletBinding(SupportsShouldProcess = $true)]
param(
[Parameter(Mandatory)][string] $ConfigPath,
[Parameter()][switch] $ValidateOnly,
[Parameter()][switch] $NonInteractive,
[Parameter()][string] $SchemaPath,
[Parameter()][string] $OutputPath,
[Parameter()][switch] $TreatWarningsAsErrors,
[Parameter()][string] $TestDataPath
)
```
## Parameter contract
| Parameter | Behaviour |
| --- | --- |
| `-ConfigPath` | Required. Configuration to validate or edit. |
| `-ValidateOnly` | Validate and report; never enter the editor. |
| `-NonInteractive` | Pipeline mode. **MUST NOT prompt and MUST NOT hang** (SC-010). Returns an exit code. |
| `-SchemaPath` | Override the shipped schema. |
| `-OutputPath` | Save-As target; leaves the input file untouched. |
| `-TreatWarningsAsErrors` | Escalates `Warning` findings to blocking (VR-005). |
| `-TestDataPath` | Synthetic sample users for offline rule testing (FR-025). No tenant connectivity. |
## Validation layers (VR-001, ordered, fail-fast between layers)
| Layer | Mechanism | Example findings |
| --- | --- | --- |
| 1. Syntax | `ConvertFrom-Json` | Malformed JSON |
| 2. Schema | `Test-Json -SchemaFile` (draft-07, OTD-005) | Missing required field, wrong type, bad enum |
| 3. Semantic | PowerShell checks | Every condition in VR-002 |
| 4. Safety | PowerShell checks | Every condition in VR-003 |
A layer that produces `Error` findings stops the sequence — running semantic checks over a
structurally invalid document yields noise, not signal.
### Layer 2 error-handling requirement
`Test-Json` reports schema failure by writing errors rather than returning `$false` in several
PowerShell versions. The wrapper MUST invoke it with `-ErrorAction SilentlyContinue -ErrorVariable`
and convert collected errors into `ValidationFinding` objects, so layer 2 emits the same structured
shape as every other layer (VR-004).
## Finding contract
Every finding carries `Severity`, `Code`, `Location` (JSON path or rule ID), `Description`,
`SuggestedResolution`, and `Layer`. Finding codes are stable and namespaced by layer:
```text
PE-SYN-nnn syntax
PE-SCH-nnn schema
PE-SEM-nnn semantic (one code per VR-002 condition)
PE-SAF-nnn safety (one code per VR-003 condition)
```
Stability matters: pipelines and runbooks will match on these codes.
## Exit codes
| Code | Condition |
| --- | --- |
| `0` | Valid; no blocking findings |
| `1` | One or more `Error` findings |
| `2` | `Warning` findings present with `-TreatWarningsAsErrors` |
| `3` | Configuration file not found or unreadable |
| `4` | Schema file not found or itself invalid |
## Save contract (FR-026)
1. Re-validate the edited document in full.
2. Block the save on any `Error` finding.
3. Write a timestamped backup — or require `-OutputPath` — before replacing an existing file.
4. Overwriting the only valid configuration without a backup is a safety finding (VR-003), not
merely a warning.
## Invariants (test-asserted)
| Invariant | Assertion |
| --- | --- |
| Non-interactive never prompts | Runs to completion with stdin closed; no prompt, no hang (SC-010) |
| Every VR-002/VR-003 condition detected | One test per condition, each asserting code, severity, and location (SC-009) |
| Offline | Full validation and synthetic rule testing complete with no network access (SC-008) |
@@ -0,0 +1,90 @@
# Contract: `Invoke-PersonaEngine.ps1`
The engine entry point. Retrieval, evaluation, reporting, and controlled persistence.
## Signature
```powershell
[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
param(
[Parameter(Mandatory)][string] $ConfigPath,
[Parameter()][guid] $UserObjectId,
[Parameter()][string] $OutputPath,
[Parameter()][guid] $CorrelationId
)
```
`SupportsShouldProcess` supplies `-WhatIf` and `-Confirm`. `-Verbose` and `-Debug` are common
parameters and are **not** declared.
## Parameter contract
| Parameter | Required | Behaviour |
| --- | --- | --- |
| `-ConfigPath` | Yes | Path to the JSON configuration. Validated through all four layers before any connection is made (FR-002). |
| `-WhatIf` | No | **The approved no-write control.** Reads, evaluation, comparison, console output, summaries, and audit records all behave identically to enforcement; zero write requests are issued (FR-017, SC-004). |
| `-UserObjectId` | No | Single-user execution for validation. Skips enumeration; retrieves one user. |
| `-OutputPath` | No | Overrides the configured audit output path where permitted. |
| `-CorrelationId` | No | Supplied run identifier. Generated when absent. Appears on every audit record. |
| `-Verbose` | No | Operational detail. **MUST NOT** alter write behaviour. |
| `-Debug` | No | Enables condition-value tracing (Principle V). **MUST NOT** imply read-only — a `-Debug` run without `-WhatIf` writes. |
## Mode determination
```text
$PSCmdlet.ShouldProcess() returns $false -> Preview mode -> no write request constructed or sent
$PSCmdlet.ShouldProcess() returns $true -> Enforce mode -> write permitted, subject to FR-016
```
Mode MUST be derived from `ShouldProcess` alone. A separate boolean "preview" flag is prohibited —
two sources of truth for the write gate is precisely the defect class Principle III exists to
prevent.
## Write gate (FR-016)
A write is issued only when **all** hold:
1. Evaluation completed successfully (`Outcome != EvaluationError`).
2. `CalculatedPersona != StoredPersona` (ordinal comparison, case-sensitive for change detection).
3. The target attribute is non-blank and present in `approvedWritableAttributes`.
4. `ShouldProcess` returned `$true` for this user.
Failing any of these yields `Unchanged`, `WouldUpdate`, or `Skipped` — never a silent write.
## Output contract
- **Per user, immediately after evaluation** (FR-018, SC-012): one console line carrying UPN,
Account Object ID, outcome, matched rule ID, stored value, calculated value, and action.
- **Every `summaryInterval` users** (FR-019): a table of all business rules with match counts, plus
outcome totals and a reconciliation check.
- **At completion**: a final summary regardless of interval, including when the interval is `0`
(FR-020).
- **Reconciliation** at every summary: `Processed = Matched + Unclassified + EvaluationError`
(FR-021). A mismatch is logged as an engine defect, at `Error` severity.
- **Audit records**: see [audit-record.md](audit-record.md).
## Exit codes
| Code | Condition |
| --- | --- |
| `0` | Successful run; no fatal processing errors |
| `1` | Configuration validation failure |
| `2` | Authentication / authorization failure |
| `3` | User enumeration failure |
| `4` | Fatal required data-provider failure |
| `5` | Reconciliation failure |
| `6` | Unexpected fatal engine error |
Every code MUST be reachable and returned for its documented condition (SC-011). A per-user
`EvaluationError` does **not** by itself terminate the run; the final status reports the affected
count and applies `evaluationErrorThreshold` when configured.
## Invariants (test-asserted)
| Invariant | Assertion |
| --- | --- |
| Zero writes under `-WhatIf` | The write adapter is mocked; call count is `0` over a full synthetic population (SC-004) |
| Single-attribute body | Every captured request body has exactly one key, equal to `engine.targetAttribute` (SC-005) |
| Idempotence | Second consecutive run over unchanged input issues zero writes (SC-002) |
| Determinism | Same fixture set, shuffled input order, identical results (SC-003) |
| Exactly one outcome | Every processed user appears in exactly one outcome bucket (SC-001) |
@@ -0,0 +1,124 @@
# Contract: Directory Data Provider
The only component permitted to talk to Microsoft Graph. Implements OTD-002, OTD-004, and OTD-007.
Everything below the normalization boundary is invisible to the rule engine (Principle IV).
**Transport**: `Invoke-MgGraphRequest` from `Microsoft.Graph.Authentication` (OTD-004). No
resource-specific SDK modules.
## Authentication (FR-003)
| Function | Environment | Mechanism |
| --- | --- | --- |
| `Connect-PersonaGraphManagedIdentity` | Azure Automation | `Connect-MgGraph -Identity` |
| `Connect-PersonaGraphInteractive` | Local development | `Connect-MgGraph -Scopes <read scopes>` |
Both return an opaque connection handle. No token, header, or secret is ever returned to a caller,
logged, or written to an audit record.
## Permissions (OTD-002)
| Function | Application permission |
| --- | --- |
| `Get-PersonaUsers` | `User.Read.All` (or `User.ReadWrite.All` for the enforcement identity) |
| `Get-PersonaGroupMembership` | `GroupMember.Read.All` |
| `Get-PersonaDirectoryRoles` | `RoleManagement.Read.Directory` |
| `Set-UserPersonaAttribute` | `User.ReadWrite.All` |
`Directory.Read.All` is prohibited — materially broader than the three read permissions combined.
## Read operations
### `Get-PersonaUsers`
```text
GET /v1.0/users?$select=<fields>&$top=999
```
- `$select` carries the FR-005 baseline (`id`, `userPrincipalName`, `displayName`, `userType`,
`accountEnabled`, `companyName`, `jobTitle`, `department`) plus the configured target attribute and
any property referenced by an enabled rule. Unused properties are not requested.
- The persona directory extension is selected by its full name,
`extension_<EXTENSION-APP-ID>_<APPROVED-PERSONA-ATTRIBUTE-NAME>`.
- Pagination follows `@odata.nextLink` until absent (FR-004). A truncated enumeration MUST raise —
never return a partial population as if complete.
- `-UserObjectId` switches to `GET /v1.0/users/{id}?$select=...`.
### `Get-PersonaGroupMembership`
```text
GET /v1.0/users/{id}/memberOf # direct
GET /v1.0/users/{id}/transitiveMemberOf # transitive
```
Mode comes from the condition, falling back to `engine.defaultMembershipMode` (RE-007).
**Return contract**: always a `MembershipRecord`. On failure it returns a record with
`RetrievalSucceeded = $false` and a `FailureReason` — it MUST NOT return an empty list, and MUST NOT
throw past the per-user boundary. This single behaviour is what makes FR-013 work: unknown membership
becomes `EvaluationError`, never a false non-match.
### `Get-PersonaDirectoryRoles`
```text
GET /v1.0/roleManagement/directory/roleAssignments?$filter=principalId eq '<id>'
```
Eligible (PIM) assignments are out of scope for v1 unless authorization is confirmed.
### Caching
Group and role data reusable across users is cached for the run's lifetime (NFR-002). The cache is
keyed by group or role Object ID and is **never** persisted between runs — a stale cache would make
results depend on run history, breaking Principle I.
## Write operation
### `New-PersonaWriteBody`
The **only** function permitted to construct a write body (OTD-003 control 3).
```powershell
# Returns exactly one key.
@{ "<engine.targetAttribute>" = "<CalculatedPersona>" }
```
Contract:
- Throws if the attribute name is not `engine.targetAttribute`.
- Throws if the attribute is absent from `approvedWritableAttributes`.
- Returns a hashtable whose `Count` is exactly `1`. Tests assert on this directly (SC-005).
### `Set-UserPersonaAttribute`
```text
PATCH /v1.0/users/{id}
Content-Type: application/json
<body from New-PersonaWriteBody>
```
- Callable **only** when `ShouldProcess` returned `$true`. Under `-WhatIf` this function is not
reached — the caller does not construct a request at all (SC-004). Preview mode is an absence of a
call, not a suppressed call.
- A failure returns `UpdateFailed` for that user and does not terminate the run.
## Retry policy (OTD-007)
| Aspect | Value |
| --- | --- |
| Retryable | 429, 500, 502, 503, 504, transport timeout |
| Never retried | 400, 401, 403, 404, 409 |
| `Retry-After` | Honoured when present; overrides computed backoff |
| Attempts | Max 5 |
| Backoff | Exponential from 1s, full jitter, per-delay cap 60s |
| Logging | Attempt number, status code, and delay on every retry |
Exhausted retries on **required** data produce `EvaluationError` for the affected user (FR-013).
Exhausted retries during enumeration are fatal (exit code `3`).
## Prohibited in this layer
- Logging tokens, `Authorization` headers, or full response bodies (Principle V).
- Returning raw Graph objects past `ConvertTo-Persona*Record`.
- Any reference to rule, persona, or condition concepts — this layer moves data, it does not decide.
@@ -0,0 +1,278 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://example.invalid/persona-engine.schema.json",
"title": "Persona Engine Configuration",
"description": "Draft-07 by decision OTD-005: validated with the built-in Test-Json -SchemaFile cmdlet, whose validator reliably supports draft-04/06/07 only. Do not introduce 2019-09 or 2020-12 constructs. This schema is validation layer 2 of 4; semantic rules (VR-002) and safety rules (VR-003) are enforced in PowerShell, not here.",
"type": "object",
"required": ["configVersion", "engine", "dataSources", "personas", "rules"],
"additionalProperties": false,
"properties": {
"configVersion": {
"type": "string",
"pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$",
"description": "Semantic version of this configuration. A downgrade is a safety violation (VR-003)."
},
"metadata": {
"type": "object",
"additionalProperties": true,
"properties": {
"owner": { "type": "string" },
"changeReference": { "type": "string" },
"description": { "type": "string" }
}
},
"engine": {
"type": "object",
"required": ["targetAttribute", "approvedWritableAttributes"],
"additionalProperties": false,
"properties": {
"targetAttribute": {
"type": "string",
"minLength": 1,
"description": "The single attribute the engine may write. Must also appear in approvedWritableAttributes (semantic layer). Example placeholder: extension_<EXTENSION-APP-ID>_<APPROVED-PERSONA-ATTRIBUTE-NAME>"
},
"approvedWritableAttributes": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": { "type": "string", "minLength": 1 }
},
"maxConditionDepth": {
"type": "integer",
"minimum": 1,
"maximum": 10,
"default": 5,
"description": "RE-004. The ceiling of 10 is a hard limit; the configured value may be lower."
},
"summaryInterval": {
"type": "integer",
"minimum": 0,
"default": 25,
"description": "FR-020. Zero suppresses interim summaries; a final summary is always produced."
},
"defaultMembershipMode": {
"type": "string",
"enum": ["direct", "transitive"],
"default": "direct"
},
"evaluationErrorThreshold": {
"type": "integer",
"minimum": 0,
"description": "Optional. Count of EvaluationError results above which the run reports failure."
}
}
},
"dataSources": {
"type": "object",
"required": ["groups", "roles"],
"additionalProperties": false,
"properties": {
"groups": {
"type": "object",
"required": ["enabled"],
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" },
"membershipMode": { "type": "string", "enum": ["direct", "transitive"] }
}
},
"roles": {
"type": "object",
"required": ["enabled"],
"additionalProperties": false,
"properties": {
"enabled": { "type": "boolean" },
"includeEligible": {
"type": "boolean",
"default": false,
"description": "Out of scope for v1 unless authorization is confirmed and the provider is implemented."
}
}
}
}
},
"logging": {
"type": "object",
"additionalProperties": false,
"properties": {
"destination": {
"type": "string",
"enum": ["file", "stream", "both"],
"default": "both",
"description": "OTD-006. Additional transports are added behind the sink function, not by widening this enum without a version change."
},
"path": {
"type": "string",
"description": "Placeholder in committed artifacts: <LOG-OUTPUT-PATH>"
},
"traceConditionValues": {
"type": "boolean",
"default": false,
"description": "Diagnostic only. Enabling this without explicit acknowledgement is a safety finding (VR-003)."
},
"acknowledgeConditionTracing": {
"type": "boolean",
"default": false,
"description": "Explicit acknowledgement that condition-value tracing writes evaluated attribute values into audit records. Required by VR-003 whenever traceConditionValues is true. Kept in the configuration rather than passed as a command-line flag so the acknowledgement is reviewable in the change that enables tracing."
}
}
},
"personas": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": {
"type": "string",
"minLength": 1,
"not": { "enum": ["EvaluationError"] }
},
"description": "Defined persona catalogue. EvaluationError is an execution result and must never be declared as a persona."
},
"rules": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/definitions/rule" }
}
},
"definitions": {
"rule": {
"type": "object",
"required": ["id", "name", "description", "enabled", "priority", "persona", "match"],
"additionalProperties": false,
"properties": {
"id": { "type": "string", "minLength": 1 },
"name": { "type": "string", "minLength": 1 },
"description": { "type": "string", "minLength": 1 },
"enabled": { "type": "boolean" },
"priority": { "type": "integer", "minimum": 0 },
"persona": {
"type": "string",
"minLength": 1,
"not": { "enum": ["Unclassified", "EvaluationError"] },
"description": "Unclassified is a processing result, not a rule outcome (VR-002)."
},
"match": { "$ref": "#/definitions/conditionGroup" },
"tags": { "type": "array", "items": { "type": "string" } },
"owner": { "type": "string" },
"changeReference": { "type": "string" },
"effectiveDate": {
"type": "string",
"format": "date",
"description": "Metadata only in v1. It must not gate evaluation — a date-dependent decision would break determinism (Principle I)."
},
"notes": { "type": "string" },
"testCases": {
"type": "array",
"items": {
"type": "object",
"required": ["name", "expectedMatch"],
"additionalProperties": true,
"properties": {
"name": { "type": "string" },
"expectedMatch": { "type": "boolean" },
"user": { "type": "object" }
}
}
}
}
},
"conditionGroup": {
"type": "object",
"required": ["operator", "conditions"],
"additionalProperties": false,
"properties": {
"operator": { "type": "string", "enum": ["all", "any"] },
"conditions": {
"type": "array",
"minItems": 1,
"items": {
"anyOf": [
{ "$ref": "#/definitions/conditionGroup" },
{ "$ref": "#/definitions/condition" }
]
}
}
}
},
"condition": {
"type": "object",
"required": ["type", "operator"],
"additionalProperties": false,
"properties": {
"type": { "type": "string", "enum": ["property", "membership", "role"] },
"property": { "type": "string", "minLength": 1 },
"operator": {
"type": "string",
"enum": [
"equals", "notEquals", "contains", "notContains",
"startsWith", "endsWith", "matchesRegex",
"in", "notIn", "isNull", "isNotNull",
"memberOf", "notMemberOf"
]
},
"value": { "type": "string" },
"values": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": { "type": "string" }
},
"groupObjectIds": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": { "$ref": "#/definitions/guid" }
},
"roleIds": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": { "type": "string", "minLength": 1 }
},
"membershipMode": { "type": "string", "enum": ["direct", "transitive"] },
"caseSensitive": {
"type": "boolean",
"default": false,
"description": "Reserved. Condition-level case sensitivity is out of scope for v1; the schema accepts the key so a later version does not require a breaking change."
}
},
"allOf": [
{
"if": { "properties": { "type": { "const": "property" } }, "required": ["type"] },
"then": { "required": ["property"] }
},
{
"if": { "properties": { "type": { "const": "membership" } }, "required": ["type"] },
"then": { "required": ["groupObjectIds"] }
},
{
"if": { "properties": { "type": { "const": "role" } }, "required": ["type"] },
"then": { "required": ["roleIds"] }
},
{
"if": {
"properties": { "operator": { "enum": ["in", "notIn"] } },
"required": ["operator"]
},
"then": { "required": ["values"] }
},
{
"if": {
"properties": { "operator": { "enum": ["isNull", "isNotNull"] } },
"required": ["operator"]
},
"then": {
"allOf": [
{ "not": { "required": ["value"] } },
{ "not": { "required": ["values"] } }
]
}
}
]
},
"guid": {
"type": "string",
"pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
}
}
}
+228
View File
@@ -0,0 +1,228 @@
# Data Model: Persona Engine
**Date**: 2026-08-20 | **Spec**: [spec.md](spec.md) | **Plan**: [plan.md](plan.md)
Normalized in-memory contracts. These are the objects the rule engine sees. Per Principle IV the
rule engine MUST NOT receive raw directory responses — normalization is the boundary.
All types are plain `PSCustomObject` shapes. Field types are PowerShell types.
---
## UserRecord
Produced by `ConvertTo-PersonaUserRecord`. Consumed by the rule engine, presentation, and audit.
| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `AccountObjectId` | `string` (GUID) | Yes | Immutable identity key. Approved for logs. |
| `UserPrincipalName` | `string` | Yes | Approved for logs. |
| `DisplayName` | `string` | No | Diagnostics only. |
| `UserType` | `string` | No | `Member` / `Guest`. |
| `AccountEnabled` | `bool` | Yes | Disabled accounts remain in scope (FR-011). |
| `Properties` | `hashtable` | Yes | Case-insensitive map of evaluable property name → value. Populated from FR-005 selection plus any property a rule references. Absent property returns `$null`. |
| `StoredPersona` | `string` | No | Current value of the target attribute; `$null` when unset. |
| `Membership` | `MembershipRecord` | Yes | Never `$null`; an unattempted lookup is represented by an empty record with `RetrievalSucceeded = $true` and `Mode = 'None'`. |
**Validation rules**
- `AccountObjectId` and `UserPrincipalName` MUST be non-empty; a record failing this is an
upstream defect and MUST raise, not silently skip.
- `Properties` lookups are case-insensitive (RE-006).
- A `$null` or absent value in `Properties` is treated as empty for ordinary string comparisons and
MUST NOT fail evaluation (FR-012).
---
## MembershipRecord
Produced by `ConvertTo-PersonaMembershipRecord`. This type carries the most safety-critical fields
in the model.
**Revised 2026-08-20 during implementation.** The original design held a single `Mode` field
(`Direct` / `Transitive` / `None`) alongside one `GroupObjectIds` set. That cannot satisfy RE-007,
which makes membership mode a **per-condition** choice: a rule set may legitimately ask for
transitive membership in one rule and direct membership in another, and a single-mode record can
only answer one of them — every user became an `EvaluationError` on the other. The defect was
caught by running the shipped example configuration, which mixes both modes, against the fixtures.
The record now holds three independently-retrieved facets.
| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `DirectGroupObjectIds` | `string[]` | Yes | May be empty. |
| `DirectRetrieved` | `bool` | Yes | **`$false` means "unknown", never "not a member".** |
| `DirectFailureReason` | `string` | No | Populated only when `DirectRetrieved` is `$false`. |
| `TransitiveGroupObjectIds` | `string[]` | Yes | May be empty. |
| `TransitiveRetrieved` | `bool` | Yes | Same semantics as `DirectRetrieved`. |
| `TransitiveFailureReason` | `string` | No | |
| `DirectoryRoleIds` | `string[]` | Yes | May be empty. |
| `RolesRetrieved` | `bool` | Yes | Same semantics. |
| `RolesFailureReason` | `string` | No | |
**Validation rules**
- Every `*Retrieved` flag defaults to `$false`. A condition MUST read the flag for the facet it
actually queries, and a `$false` MUST yield `Unknown`, propagating to `EvaluationError` (FR-013).
- Facets are independent: a failed transitive lookup MUST NOT make direct-membership conditions
unevaluable. Collapsing them would turn one slow endpoint into a tenant-wide outage.
- An empty identifier collection with its facet `Retrieved = $true` is a legitimate "member of
nothing" and evaluates normally.
- A facet MUST NOT be both retrieved and carry a failure reason; the constructor throws.
- Mode selection is exact. A condition asking for transitive membership MUST NOT be answered from
direct data (false negatives on nested groups) and vice versa (false positives).
---
## BusinessRule
Deserialized from configuration. Never constructed in source (Principle II).
| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `Id` | `string` | Yes | Unique across the rule set (VR-002). |
| `Name` | `string` | Yes | |
| `Description` | `string` | Yes | |
| `Enabled` | `bool` | Yes | Disabled rules are skipped and excluded from the enabled count. |
| `Priority` | `int` | Yes | Unique; lower evaluates first (RE-002). |
| `Persona` | `string` | Yes | MUST be a defined persona; MUST NOT be `Unclassified` or `EvaluationError` (VR-002). |
| `Match` | `ConditionGroup` | Yes | Root condition group. |
| `Tags` | `string[]` | No | |
| `Owner` | `string` | No | |
| `ChangeReference` | `string` | No | |
| `EffectiveDate` | `string` | No | Metadata only in v1 — MUST NOT gate evaluation, as a date-dependent decision would break Principle I. |
| `Notes` | `string` | No | |
| `TestCases` | `object[]` | No | Consumed by the editor's synthetic testing (FR-025). |
---
## ConditionGroup / Condition
Recursive structure bounded by the configured depth (RE-004: default 5, min 1, ceiling 10).
**ConditionGroup**
| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `Operator` | `string` | Yes | `all` or `any`. |
| `Conditions` | `(Condition\|ConditionGroup)[]` | Yes | MUST be non-empty. |
**Condition (leaf)**
| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `Type` | `string` | Yes | `property`, `membership`, or `role`. |
| `Property` | `string` | For `property` | MUST be a supported property name (VR-002). |
| `Operator` | `string` | Yes | One of RE-005. |
| `Value` | `string` | Conditional | Required for comparison operators; MUST be absent for `isNull` / `isNotNull` (VR-002). |
| `Values` | `string[]` | Conditional | Required for `in` / `notIn`. |
| `GroupObjectIds` | `string[]` | For `membership` | MUST be non-empty (VR-002). |
| `RoleIds` | `string[]` | For `role` | MUST be non-empty. |
| `MembershipMode` | `string` | No | `direct` or `transitive`; defaults to the engine setting (RE-007). |
**Evaluation result values**: every condition evaluates to `True`, `False`, or **`Unknown`**.
`Unknown` is what makes FR-013 expressible.
**Propagation rules** (these are the whole safety argument — implement exactly):
| Group | Contains `Unknown` | Result |
| --- | --- | --- |
| `all` | plus any `False` | `False` — a definite non-match wins; the unknown cannot rescue it |
| `all` | plus only `True` | `Unknown` |
| `any` | plus any `True` | `True` — a definite match wins |
| `any` | plus only `False` | `Unknown` |
An `Unknown` at the rule root yields `EvaluationError` for that user.
---
## PersonaDecisionResult
Produced by `Resolve-UserPersona`. The engine's authoritative per-user output.
| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `AccountObjectId` | `string` | Yes | |
| `UserPrincipalName` | `string` | Yes | |
| `Outcome` | `string` | Yes | `Matched`, `Unclassified`, or `EvaluationError` — exactly one (SC-001). |
| `MatchedRuleId` | `string` | When `Matched` | `$null` otherwise. |
| `CalculatedPersona` | `string` | Yes | The persona, `Unclassified`, or `$null` when `EvaluationError`. |
| `StoredPersona` | `string` | No | Copied from the `UserRecord`. |
| `Action` | `string` | Yes | `Unchanged`, `WouldUpdate`, `Updated`, `UpdateFailed`, or `Skipped`. |
| `EvaluationErrorReason` | `string` | When `EvaluationError` | |
| `RulesEvaluated` | `int` | Yes | Count until first match or exhaustion. |
| `DurationMs` | `int` | Yes | Per-user timing (NFR-002). |
| `ConditionTrace` | `object[]` | No | Populated only under `-Debug` (Principle V). |
**State transitions for `Action`**
```text
EvaluationError ─────────────────────────────► Skipped (FR-014, no write ever)
Calculated == Stored ────────────────────────► Unchanged
Calculated != Stored, preview mode ──────────► WouldUpdate (FR-017, no request issued)
Calculated != Stored, enforce, write ok ─────► Updated
Calculated != Stored, enforce, write fails ──► UpdateFailed
```
`Unclassified` follows the same comparison path as any other calculated value — it is a legitimate
value to write if the configuration approves it, and is reported distinctly either way.
---
## Configuration
The complete ordered decision process. Structure is normative in
[contracts/persona-engine.schema.json](contracts/persona-engine.schema.json).
| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `configVersion` | `string` | Yes | Downgrade is a safety violation (VR-003). |
| `engine.targetAttribute` | `string` | Yes | MUST appear in `approvedWritableAttributes`. |
| `engine.approvedWritableAttributes` | `string[]` | Yes | MUST be non-empty. |
| `engine.maxConditionDepth` | `int` | No | Default 5, min 1, max 10 (RE-004). |
| `engine.summaryInterval` | `int` | No | Default 25; `0` suppresses interim summaries (FR-020). |
| `engine.defaultMembershipMode` | `string` | No | `direct` or `transitive`. |
| `dataSources.groups.enabled` | `bool` | Yes | Enabled group rules with this `false` is a safety violation (VR-003). |
| `dataSources.roles.enabled` | `bool` | Yes | |
| `personas` | `string[]` | Yes | Defined persona catalogue. `EvaluationError` MUST NOT appear. |
| `logging.*` | `object` | No | Destination and path (OTD-006). |
| `rules` | `BusinessRule[]` | Yes | MUST contain at least one enabled rule (VR-002). |
**Derived at load**: `ConfigurationHash` (SHA-256 of the canonical file bytes) — recorded on every
run record (NFR-005).
---
## ValidationFinding
Produced by all four validation layers (VR-004).
| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `Severity` | `string` | Yes | `Error`, `Warning`, `Information`. |
| `Code` | `string` | Yes | Stable finding code, e.g. `PE-SEM-012`. |
| `Location` | `string` | Yes | JSON path or rule ID. |
| `Description` | `string` | Yes | |
| `SuggestedResolution` | `string` | Yes | |
| `Layer` | `string` | Yes | `Syntax`, `Schema`, `Semantic`, `Safety`. |
`Error` blocks execution and saving; `Warning` blocks only under `-TreatWarningsAsErrors` (VR-005).
---
## RunRecord
One per execution. See [contracts/audit-record.md](contracts/audit-record.md) for the serialized
form.
| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `RunId` | `string` (GUID) | Yes | Supplied via `-CorrelationId` or generated. |
| `StartedUtc` / `CompletedUtc` | `datetime` | Yes | |
| `Mode` | `string` | Yes | `Preview` or `Enforce`. |
| `EngineVersion` | `string` | Yes | |
| `ConfigVersion` / `ConfigurationHash` | `string` | Yes | |
| `Processed` / `Matched` / `Unclassified` / `EvaluationError` | `int` | Yes | Reconciliation: `Processed = Matched + Unclassified + EvaluationError` (FR-021). |
| `Unchanged` / `WouldUpdate` / `Updated` / `UpdateFailed` | `int` | Yes | |
| `ExitCode` | `int` | Yes | Per the CLI contract. |
A failed reconciliation MUST be logged as an engine defect, not merely reported.
+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.
+213
View File
@@ -0,0 +1,213 @@
# Quickstart: Persona Engine Validation
**Date**: 2026-08-20 | **Spec**: [spec.md](spec.md) | **Plan**: [plan.md](plan.md)
Runnable scenarios that prove the feature works. Ordered by the build sequence in
[plan.md](plan.md) — each stage is validatable before the next exists. Scenarios 13 require no
tenant, no credentials, and no network.
Structural details live in [data-model.md](data-model.md) and [contracts/](contracts/); this guide
does not repeat them.
## Prerequisites
**Current constraint**: no Azure Automation account. Everything runs locally on PowerShell 7 with
user accounts and delegated authentication. Scenario 5 is deferred; Scenarios 14 and 6 are all
available now.
| Scenario | Stage | Requirement |
| --- | --- | --- |
| 13 (offline) | A1 | PowerShell 7.4, Pester 5.x, PSScriptAnalyzer. No tenant, no network. |
| 4 (read-only) | A2 | An app registration with admin consent for the three delegated scopes, and a **non-privileged** user account to sign in with. Closes V-1 (read), V-3. |
| 5 (automation) | B | **Deferred** — no Automation account available. Closes V-3b, V-5b when it lands. |
| 6 (enforcement) | A3 | Delegated `User.ReadWrite.All`, **written security sign-off (V-4)**, reviewed `-WhatIf` evidence, and **purpose-created test accounts** as the write targets. |
### Local connection
```powershell
Connect-MgGraph -Scopes 'User.Read.All','GroupMember.Read.All','RoleManagement.Read.Directory'
```
Sign in as an ordinary user account, not a Global Administrator — see the caution in Scenario 4.
---
## Scenario 1 — Rule engine determinism, offline
Proves SC-001, SC-003, SC-008 and the `Unknown` propagation table in
[data-model.md](data-model.md).
```bash
pwsh -NoProfile -Command "Invoke-Pester ./tests/RuleEngine -Output Detailed"
```
**Expected**: all pass with no network access. Specifically:
- Every synthetic user yields exactly one outcome.
- Shuffling fixture order changes nothing.
- A rule matching at priority 10 wins over one matching at 20, and evaluation stops.
- A `MembershipRecord` with `RetrievalSucceeded = $false` yields `EvaluationError`, never a
non-match.
- Depth beyond `maxConditionDepth` is rejected rather than silently truncated.
**Disconnect the network and re-run.** Identical results, or SC-008 is not met.
---
## Scenario 2 — Configuration validation
Proves SC-009, SC-010 and the four-layer ordering.
```bash
pwsh -NoProfile -File ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.example.json -ValidateOnly -NonInteractive
```
**Expected**: exit code `0`, no findings.
Then run the invalid-configuration corpus in `tests/TestData/InvalidConfigs/` — one file per VR-002
and VR-003 condition:
```bash
pwsh -NoProfile -Command "Invoke-Pester ./tests/Configuration -Output Detailed"
```
**Expected**: each file produces its documented finding code, severity, and location; blocking
findings return a non-zero exit code with **no prompt and no hang** (SC-010).
---
## Scenario 3 — Safety invariants
Proves SC-004 and SC-005 with the write adapter mocked. This suite is the reason the persistence
adapter is built last.
```bash
pwsh -NoProfile -Command "Invoke-Pester ./tests/Safety -Output Detailed"
```
**Expected**:
- Full synthetic population under `-WhatIf`: write adapter call count is exactly `0` — asserted, not
inspected (SC-004).
- Every captured request body has exactly one key, equal to `engine.targetAttribute` (SC-005).
- `New-PersonaWriteBody` throws for any other attribute name and for an attribute absent from
`approvedWritableAttributes`.
- A `-Debug` run **without** `-WhatIf` still reaches the write path — `-Debug` is not a safety
control.
---
## Scenario 4 — Read-only tenant preview (User Story 1)
The first connected run. Uses a read-only identity, so it is safe by construction rather than by
correct behaviour.
> **Sign in as a non-privileged account.** V-3 asks whether the three scopes are *sufficient*.
> A Global Administrator answers yes regardless — the scope narrows the token, but the account's
> directory roles still grant broad read access, so the run succeeds whether or not the permission
> set is correct. Running this as GA produces a green result that means nothing.
```bash
pwsh -NoProfile -File ./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json -WhatIf -Verbose
```
**Expected**:
- A result line appears for every in-scope user, visible before the next user is processed (SC-012).
- Differences report as `WouldUpdate` with stored value, calculated value, and matched rule ID.
- Interim summaries at the configured interval; a final summary always; reconciliation passes at
every summary (SC-007).
- Zero write requests — confirm independently in the Entra sign-in and audit logs, not only from
console output.
Single-user check first, before the full population:
```bash
pwsh -NoProfile -File ./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json -UserObjectId <ACCOUNT-OBJECT-ID> -WhatIf
```
**Also close V-1 here**: run the single-user check against a cloud-only user, a currently-synced
user, and a formerly-synced user. Confirm the persona extension **reads** on all three. The write
half of V-1 is closed in Scenario 6.
**Idempotence check** (SC-002): run twice unchanged. The second run reports the same counts and zero
additional proposed changes.
---
## Scenario 5 — Azure Automation PowerShell 7 *(deferred — Stage B)*
**Not runnable in the current stage.** No Automation account is available. Recorded here so it is
not lost, and so the Stage B entry cost stays visible.
Proves NFR-001, NFR-008 and closes V-3b and V-5b.
1. Import the module and publish the runbook with the schedule **disabled**.
2. Run the runbook with `-WhatIf` using the managed identity.
3. Record the runtime's exact PowerShell version.
4. Confirm `Test-Json -SchemaFile` behaves as observed locally in V-5a — its error-reporting
behaviour varies by version, and the layer-2 wrapper depends on it (OTD-005). **Run this first**;
it is the cheapest item with the highest chance of surprising you.
5. Confirm the three read scopes work as *application* permissions on the managed identity (V-3b).
**Expected**: the run completes with only `Microsoft.Graph.Authentication` imported, and output
matches the equivalent local `-WhatIf` run.
**Until this scenario passes, v1 is not complete.** The Definition of Done requires an Automation
PowerShell 7 run. Stage A completion is a real milestone and worth claiming — but it is not v1.
---
## Scenario 6 — Enforcement (User Story 8)
**Gated.** Do not run until all of these hold:
- [ ] V-4 security sign-off on the OTD-003 compensating controls, in writing
- [ ] `-WhatIf` impact evidence from Scenario 4 reviewed and approved
- [ ] Scenarios 15 passing
- [ ] Kill switch and rollback procedure documented
- [ ] OTD-001 OTD-005 closed
```bash
pwsh -NoProfile -File ./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json
```
**Expected**:
- Only changed values are written; unchanged users produce no request (SC-002).
- Every write body contains exactly one attribute (SC-005).
- Every `Updated` audit record carries `previousValue` — without it, rollback is impossible
retroactively (OTD-010).
- `EvaluationError` users are skipped with their stored persona intact (FR-014).
**Closes V-1 (write half)** and **V-2**: confirm the dynamic membership group built on the persona
extension populates, and that a Conditional Access policy assigned to that group applies.
---
## Verification item coverage
| Item | Closed by | Available now? |
| --- | --- | --- |
| V-1 | Scenario 4 (read) + Scenario 6 (write) | Yes |
| V-2 | Scenario 6 | Yes |
| V-3 | Scenario 4, as a non-privileged account | Yes |
| V-3b | Scenario 5 | **No — Stage B** |
| V-4 | Out-of-band security review — **gate on Scenario 6** | Yes (a conversation, not a tenant) |
| V-4a | Investigation; no scenario | Yes |
| V-5a | Scenario 2, behaviour pinned in a unit test | Yes |
| V-5b | Scenario 5 | **No — Stage B** |
## Exit code check (SC-011)
Every documented exit code must be reachable. Cover them deliberately rather than incidentally:
| Code | How to trigger |
| --- | --- |
| `0` | Scenario 4 |
| `1` | Any invalid configuration from Scenario 2 |
| `2` | Run with an unauthorized or expired identity |
| `3` | Fault injection on enumeration |
| `4` | Fault injection on a required data provider |
| `5` | Fault injection on the counter path (reconciliation defect) |
| `6` | Fault injection on an unhandled engine path |
+322
View File
@@ -0,0 +1,322 @@
# Phase 0 Research: Persona Engine
**Date**: 2026-08-20 | **Spec**: [spec.md](spec.md) | **Plan**: [plan.md](plan.md)
Resolves the Clarification Register in [spec.md](spec.md). OTD-001 through OTD-005 are
**persistence-blocking** and are decided here. OTD-006 through OTD-010 receive provisional decisions
sufficient to plan implementation.
Every decision below that depends on a tenant-specific fact carries a **verification item (V-n)**.
Per the constitution, attribute-level write authorization "MUST be verified, never assumed" — the
decisions state what the product documentation says, and the V-items state what the team must prove
in its own tenant before enforcement is enabled.
---
## OTD-001 — Persona attribute mechanism
**Decision**: Store the persona in a **directory (schema) extension** single-valued string property
on the `user` resource, registered against a dedicated application registration in the tenant. The
property is referenced as `extension_<EXTENSION-APP-ID>_<APPROVED-PERSONA-ATTRIBUTE-NAME>`.
**Rationale**:
- **Writable via Graph for cloud-mastered users** with an ordinary `PATCH /users/{id}`, and readable
via `$select` on the extension property name.
- **Consumable by Conditional Access.** CA assigns policy by user and group, not by user attribute,
so the consumption path is: persona attribute → dynamic membership group rule → CA assignment.
Dynamic membership rules support custom extension properties in the form
`user.extension_<appId>_<propertyName>`, provided the property is **single-valued** and the
extension belongs to an application in the tenant. Both conditions hold here.
- **Not population-dependent.** Unlike `extensionAttributeN`, it does not fail on accounts with an
external origin (see rejected alternative A).
**Alternatives considered**:
| Alternative | Verdict | Reason |
| --- | --- | --- |
| **A. `onPremisesExtensionAttributes.extensionAttributeN`** (extensionAttribute115) | **Rejected** | Updates via Graph succeed only for objects that have always been mastered in Entra. Accounts that were ever synced from on-premises AD — or that originated in Exchange Online — fail with *"Unable to update the specified properties for objects that have originated within an external service."* In a hybrid or formerly-hybrid tenant this produces write failures determined by an account's history rather than by its rule match, which is a direct hazard to Principle I (deterministic) and Principle III (fail-safe). Remediation would require an Exchange Online PowerShell write path — a second persistence mechanism and a second permission surface. |
| **B. Directory (schema) extension** | **Selected** | See rationale above. |
| **C. Custom security attribute** | **Rejected as primary; retained as the security-first alternative** | This is the *only* mechanism offering genuine attribute-scoped authorization (see OTD-003), which makes it attractive. But custom security attributes are **not exposed to the dynamic group evaluation engine** and cannot be referenced in dynamic membership rules, so they cannot drive the CA consumption path that motivates the persona value. They are also not returned by default and require a separate permission and role. Choosing C trades the feature's primary downstream use for a stronger write boundary. |
**Consequences**: the compensating controls in OTD-003 become mandatory, because alternative B has
no attribute-scoped authorization.
**Verification items**:
- **V-1** — Register the extension application and property in a non-production tenant or an
isolated attribute name; confirm read via `$select` and write via `PATCH` for: a cloud-only user, a
currently-synced user, and a formerly-synced user. Directory extensions are not on-premises-mastered
properties, so all three are expected to succeed — but this must be proven, not assumed, because the
whole reason A was rejected is an origin-dependent write restriction.
- **V-2** — Confirm a dynamic membership group rule referencing the extension property evaluates and
populates as expected, and that a CA policy assigned to that group applies.
---
## OTD-002 — Least-privilege Microsoft Graph permissions
**Decision**: application (managed identity) permissions, granted only as each capability is enabled:
| Capability | Permission | Notes |
| --- | --- | --- |
| Read users and the persona extension | `User.Read.All` | Extension property returned via `$select`. |
| Read group membership (`memberOf` conditions) | `GroupMember.Read.All` | Sufficient for `/users/{id}/memberOf` and `/transitiveMemberOf`. Narrower than `Group.Read.All`. |
| Read directory role assignments | `RoleManagement.Read.Directory` | For role-based conditions. |
| Write the persona attribute (enforcement only) | `User.ReadWrite.All` | **Supersedes** `User.Read.All`; grant only to the enforcement identity, and only after security approval of the OTD-003 controls. |
Application permissions are the Stage B (Automation, managed identity) form. Stage A uses the same
four as **delegated** scopes on an interactive connection.
**Rationale**: each permission maps to exactly one enabled capability, so a tenant that disables
group or role conditions grants strictly less. `Directory.Read.All` is deliberately **rejected** — it
is materially broader than the three read permissions combined and would grant visibility well
outside the enumerated data sources.
**Local (Stage A) equivalent — delegated scopes.** With no Automation account, the same three
capabilities are requested as delegated scopes on an interactive connection:
```powershell
Connect-MgGraph -Scopes 'User.Read.All','GroupMember.Read.All','RoleManagement.Read.Directory'
```
These delegated scopes require one-time admin consent for the app registration used locally; after
that, an ordinary user account can hold them. **Effective access is the intersection of the granted
scope and the signed-in user's directory roles** — which is precisely why V-3 must be run as a
non-privileged account (see below).
**Alternatives considered**: `Directory.ReadWrite.All` (rejected — grossly over-broad);
`User.ManageIdentities.All` (not applicable); delegated-only operation (rejected — unattended
Automation requires application permissions; delegated remains the local development path per FR-003).
**Verification items**:
- **V-3** — During the read-only pilot, grant only the three read permissions and confirm every
enabled rule evaluates without a permission error. Any `EvaluationError` attributable to
authorization identifies a missing-but-required permission and must be resolved before enforcement.
**Stage A method**: connect with the three delegated scopes above while signed in as an **ordinary,
non-privileged user account**. Running this as a Global Administrator invalidates the test — the
scope narrows the token, but the account's directory roles still grant broad read access, so the
run would succeed regardless of whether the three permissions are actually sufficient. This item is
closeable now and does not need Automation.
- **V-3b** *(Stage B, deferred)* — Repeat as **application** permissions on the managed identity.
Delegated and application authorization are evaluated differently, so a passing V-3 is strong
evidence but not proof for the unattended path.
---
## OTD-003 — Can write authorization be restricted to the single target attribute?
**Decision**: **No — not for the mechanism selected in OTD-001.** Microsoft Graph application
permissions have no per-property scope: `User.ReadWrite.All` authorizes writes to every writable
property of every user in the tenant. There is no supported way to grant "write only
`extension_<app>_<persona>`".
Therefore the compensating controls are **mandatory and testable**, not advisory:
1. `Set-UserPersonaAttribute` accepts only the configured target attribute; any other name is a
terminating error.
2. The target attribute MUST appear in `approvedWritableAttributes`; validation rejects all others
(VR-002).
3. A single dedicated function, `New-PersonaWriteBody`, constructs the request body, and it emits a
hashtable containing exactly one key.
4. Unit and integration tests assert on the **request body**, not on observed behaviour (SC-005).
5. Code owners gate every change to persistence, `approvedWritableAttributes`, and the target
attribute.
6. Directory audit logs are monitored for property writes by the engine's service principal other
than the target attribute.
**The one mechanism that *would* satisfy attribute-level authorization**: custom security attributes
(OTD-001 alternative C). Their assignment is governed by attribute sets: a principal is granted
`Attribute Assignment Administrator` **scoped to a specific attribute set**, plus the separate
`CustomSecAttributeAssignment.ReadWrite.All` permission — and notably, Global Administrator does not
hold this access by default. That is a real, enforced boundary rather than a compensating control.
It was rejected only because custom security attributes cannot feed dynamic groups (OTD-001).
**Stage A makes these controls *more* important, not less.** Local delegated writes run as the
signed-in operator, whose directory roles are typically far broader than the eventual service
principal's. During Stage A3 the compensating controls are the **only** thing standing between the
engine and an unintended property write, because the authorization boundary is effectively "whatever
the operator can do." Two additional Stage A rules follow:
- Stage A3 writes target **purpose-created test accounts only**. The baseline's read-only-during-
early-development assumption continues to hold for the general population.
- Never sign in with a standing privileged account for a write run. Elevate for the session, and
expect the directory audit log to attribute the write to the operator rather than to a service
principal — which is exactly why Stage A3 evidence does not substitute for Stage B evidence.
**This trade-off requires explicit security sign-off.** The decision record for security review is:
*accept tenant-wide user-write permission plus six compensating controls, in exchange for a persona
value that Conditional Access can actually consume.*
**Verification items**:
- **V-4** — Confirm with the security owner, in writing, that the compensating-control set is
accepted in place of attribute-scoped authorization. This is a **gate before enforcement**, per the
constitution's Definition of Done.
- **V-4a** — Investigate whether an Administrative Unit-scoped role assignment can narrow the
enforcement identity's write scope to a subset of the user population. This narrows *which users*,
never *which attribute*, so it is a partial mitigation at best; do not present it as closing OTD-003.
---
## OTD-004 — Directory access approach
**Decision**: **Direct REST via `Invoke-MgGraphRequest`**, with `Microsoft.Graph.Authentication` as
the only runtime module. No resource-specific SDK modules (`Microsoft.Graph.Users`,
`Microsoft.Graph.Groups`, etc.).
**Rationale**:
- **Explicit request bodies.** The constitution requires the write payload to contain exactly one
attribute and requires tests to inspect that body. `Invoke-MgGraphRequest -Method PATCH -Body` makes
the body a first-class, assertable value. SDK cmdlets construct bodies internally from parameter
binding, which makes SC-005 far harder to prove.
- **Dynamic extension properties.** The persona property name is configuration-driven and unknown at
authoring time. Passing an arbitrary `extension_<appId>_<name>` key is natural in a hashtable body
and awkward through typed cmdlet parameters.
- **Automation footprint.** One small module to import instead of the SDK's large module set, which
reduces cold-start time, import failures, and version drift in the Automation PS7 environment
(NFR-008).
- Managed-identity and interactive token acquisition are still handled by `Connect-MgGraph`, so
nothing is reimplemented.
**Alternatives considered**: full Graph SDK cmdlets (rejected — heavy, opaque bodies, version drift);
raw `Invoke-RestMethod` with hand-rolled token acquisition (rejected — reimplements managed-identity
token handling and refresh for no benefit).
**Consequence**: pagination (`@odata.nextLink`), throttling, and error shaping are the engine's
responsibility. They are handled once, in the data-provider layer — see OTD-007 and
[contracts/graph-data-provider.md](contracts/graph-data-provider.md).
---
## OTD-005 — JSON Schema validation approach
**Decision**: the built-in **`Test-Json -SchemaFile`** cmdlet, with the schema authored to
**JSON Schema draft-07**.
**Rationale**: `Test-Json` ships with PowerShell 6.1+ and therefore needs no module import in either
the local or Automation PS7 environment — the strongest possible answer to NFR-008 and OTD-005's
"compatible locally and in automation" requirement. Its underlying validator is the Newtonsoft JSON
Schema implementation, whose reliable coverage is draft-04/06/07; **draft 2019-09 and 2020-12
constructs must not be used** in the schema.
**Implementation notes**:
- `Test-Json` signals failure by writing errors rather than simply returning `$false` in several
PowerShell versions. `Test-PersonaConfiguration` MUST wrap it with
`-ErrorAction SilentlyContinue -ErrorVariable` and translate the collected errors into
`Validation Finding` objects (VR-004), so that layer 2 produces structured findings like every other
layer.
- Schema validation is layer 2 of four. It cannot express the semantic rules in VR-002 (duplicate
priorities, depth limits, cross-field constraints), which is why layers 3 and 4 exist as PowerShell
checks. Do not attempt to push semantic rules into the schema.
**Alternatives considered**: bundling a third-party schema library (rejected — an extra Automation
dependency for capability the platform already provides); hand-written structural validation only
(rejected — VR-001 mandates a schema layer, and a schema is also the editor's contract).
**Verification items**:
- **V-5a** *(Stage A, closeable now)* — Execute `Test-Json -SchemaFile` against the draft-07 schema
on the local PowerShell 7.4 workstation. Record the exact behaviour on failure: whether it returns
`$false`, writes a non-terminating error, or throws. The layer-2 wrapper is built against **this
observed behaviour**, and the observation is pinned in a unit test so a runtime change is caught
rather than discovered.
- **V-5b** *(Stage B, deferred)* — Repeat inside the Azure Automation PowerShell 7 runtime and record
its exact PowerShell version. If the behaviour differs from V-5a, the wrapper handles both — do not
assume parity. This is the single highest-value item to run on day one of Stage B.
---
## Non-blocking decisions (OTD-006 OTD-010)
These do not block persistence. They are decided far enough to implement v1 without rework.
### OTD-006 — Structured log destination and transport
**Decision**: newline-delimited JSON (one audit record per line) written to a configurable file path,
plus the Automation output stream. Emission goes through a single `Write-PersonaAuditRecord` sink
function so a Log Analytics or Event Hub transport can be added later without touching call sites.
Log Analytics ingestion is **deferred**, not designed out.
**Rationale**: NDJSON is append-safe, streamable, trivially ingestible later, and needs no
dependency. The sink indirection is what keeps the deferral cheap.
### OTD-007 — Retry policy
**Decision**: bounded exponential backoff with full jitter in the data-provider layer.
- **Retryable**: HTTP 429, 500, 502, 503, 504, and transport-level timeouts.
- **Never retried**: 400, 401, 403, 404, 409 — these are configuration, authorization, or logic
defects and retrying masks them.
- **`Retry-After` honoured** whenever present; it overrides the computed backoff.
- **Max 5 attempts**, base delay 1s, exponential with full jitter, per-delay cap 60s.
- Every retry logs attempt number, status code, and delay. Exhausting retries on **required** data
yields `EvaluationError` for the affected user (FR-013) — never a silent non-match.
**Rationale**: satisfies NFR-003 while keeping Principle III intact: the failure mode of exhausted
retries is preserve-and-report, not assume-false.
### OTD-008 — Full versus incremental processing
**Decision**: v1 performs **full enumeration** with pagination. Delta query is deferred and out of
scope for v1 (already recorded in spec Out of Scope). Revisit only when pilot timings justify it.
### OTD-009 — Schedule and concurrency lock
**Decision**: hourly is the candidate cadence, deployment-configurable, and the schedule ships
**disabled** (per the release pipeline). Concurrency control for v1 is the Automation account's own
job behaviour plus a run-start check that fails fast if another job for the same runbook is running.
A durable distributed lock is deferred.
**Stage A status**: not applicable. With no Automation account there is no schedule and no
concurrency surface — runs are manual and serial by construction. Nothing here needs building until
Stage B, and nothing in Stages A1A3 depends on it.
**Rationale**: overlapping runs are idempotent by construction (Principle III) — the harm is wasted
Graph quota and confusing audit output, not incorrect data — so a lightweight check is proportionate
for v1.
### OTD-010 — Rollback
**Decision**: rollback is driven by the audit trail. Every write record carries the **previous
value**, the calculated value, the matched rule ID, and the run ID (NFR-005), which makes a run
reversible by replaying prior values from its audit output. A replay utility is deferred to v1.1; the
**record shape that makes it possible is v1** and is fixed in
[contracts/audit-record.md](contracts/audit-record.md).
**Rationale**: the cheap, decisive part is capturing the previous value at write time. Miss that in
v1 and rollback becomes impossible retroactively.
---
## Verification checklist
| ID | Item | Stage | Blocks |
| --- | --- | --- | --- |
| V-1 | Extension property read/write proven for cloud-only, synced, and formerly-synced users | A2 (read) / A3 (write) | Persistence implementation |
| V-2 | Dynamic group rule on the extension property populates; CA assignment applies | A3 | Downstream value of the feature |
| V-3 | Delegated pilot completes with only the three scopes, signed in as a **non-privileged** account | A2 | Enforcement |
| V-3b | Same, as application permissions on the managed identity | **B — deferred** | Unattended enforcement |
| V-4 | Written security sign-off on compensating controls in place of attribute-scoped write | Out-of-band | **Enforcement (constitution gate)** |
| V-4a | Administrative Unit scoping investigated as partial mitigation | Any | Nothing (informational) |
| V-5a | `Test-Json -SchemaFile` failure behaviour observed and pinned locally | A1 | Layer-2 wrapper implementation |
| V-5b | Same, confirmed in the Automation runtime, with PS version recorded | **B — deferred** | Configuration validation sign-off |
**Closeable in the current stage**: V-1 (read half), V-3, V-4a, V-5a — and V-4, which needs a
conversation rather than a tenant. **Deferred with Automation**: V-3b, V-5b.
## Sources
- [Manage rules for dynamic membership groups in Microsoft Entra ID](https://learn.microsoft.com/en-us/entra/identity/users/groups-dynamic-membership)
- [Creating dynamic groups using custom security attributes](https://learn.microsoft.com/en-us/answers/questions/5763638/creating-dynamic-groups-using-custom-security-attr)
- [Conditional Access: Users, Groups, Agents, and Workload Identities](https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-conditional-access-users-groups)
- [onPremisesExtensionAttributes resource type](https://learn.microsoft.com/en-us/graph/api/resources/onpremisesextensionattributes?view=graph-rest-1.0)
- [Update user — Microsoft Graph v1.0](https://learn.microsoft.com/en-us/graph/api/user-update?view=graph-rest-1.0)
- [Why is it not possible to update extension attributes of former hybrid users via Graph API?](https://learn.microsoft.com/en-us/answers/questions/1850101/why-is-it-not-possible-to-update-extension-attribu)
- [Add custom data to resources using extensions](https://learn.microsoft.com/en-us/graph/extensibility-overview)
- [What are custom security attributes in Microsoft Entra ID?](https://learn.microsoft.com/en-us/entra/fundamentals/custom-security-attributes-overview)
- [Manage access to custom security attributes in Microsoft Entra ID](https://learn.microsoft.com/en-us/entra/fundamentals/custom-security-attributes-manage)
- [Assign, update, list, or remove custom security attributes for a user](https://learn.microsoft.com/en-us/entra/identity/users/users-custom-security-attributes)
+13 -11
View File
@@ -310,20 +310,22 @@ Candidate business classifications — **not** hard-coded engine behaviour: `Gue
Open items carried from the baseline (§24). These are implementation research items to be resolved in `plan.md` / `research.md` or an ADR — **not** unanswered business requirements. OTD-001 through OTD-005 must be closed before persistence implementation.
**Updated 2026-08-20 (T114): OTD-001 through OTD-007 and OTD-010 are resolved in [research.md](research.md). The persistence gate is lifted for Stage A3 test accounts, and remains closed for the general population until V-4 security sign-off is recorded.**
| ID | Item | Status |
| --- | --- | --- |
| OTD-001 | Exact persona attribute mechanism — data type, read/update method, discoverability, Conditional Access compatibility | [NEEDS CLARIFICATION] Blocks persistence |
| OTD-002 | Exact least-privilege directory permissions for users, groups, roles, and the selected attribute | [NEEDS CLARIFICATION] Blocks persistence |
| OTD-003 | Whether write authorization can be restricted to the individual target attribute; if not, compensating controls plus security approval | [NEEDS CLARIFICATION] Blocks persistence |
| OTD-004 | Directory access approach — SDK cmdlets, direct REST, or a controlled combination | [NEEDS CLARIFICATION] Blocks persistence |
| OTD-005 | JSON Schema validation approach compatible with PowerShell 7 locally and in automation | [NEEDS CLARIFICATION] Blocks persistence |
| OTD-006 | Structured-log destination and transport | Open |
| OTD-007 | Retry policy — retryable status codes, max attempts, backoff, jitter, logging | Open |
| OTD-008 | Full versus incremental processing roadmap | Open |
| OTD-009 | Production schedule and concurrency lock to prevent overlapping runs | Open |
| OTD-010 | Rollback implementation — pre-change audit values or another approved mechanism | Open |
| OTD-001 | Exact persona attribute mechanism — data type, read/update method, discoverability, Conditional Access compatibility | **Resolved** — directory (schema) extension property on an app registration, addressable as `user.extension_<appId>_<name>` in dynamic group rules. `extensionAttributeN` rejected (unavailable for cloud writes on ever-synced and Exchange-originated objects); custom security attributes rejected (not exposed to the dynamic group engine). See research.md OTD-001. |
| OTD-002 | Exact least-privilege directory permissions for users, groups, roles, and the selected attribute | **Resolved**`User.Read.All`, `GroupMember.Read.All`, `RoleManagement.Read.Directory`; `User.ReadWrite.All` for enforcement only. Scopes are requested per enabled-rule need, not unconditionally. See research.md OTD-002. |
| OTD-003 | Whether write authorization can be restricted to the individual target attribute; if not, compensating controls plus security approval | **Resolved: it cannot.** Graph application permissions have no per-property write scope. Six compensating controls are mandatory and implemented; see [docs/SecurityModel.md](../../docs/SecurityModel.md). **Security approval (V-4) is still outstanding and gates enforcement.** |
| OTD-004 | Directory access approach — SDK cmdlets, direct REST, or a controlled combination | **Resolved** — direct REST via `Invoke-MgGraphRequest`, so request bodies are explicit values that tests can assert on. This is what makes SC-005 provable. Only `Microsoft.Graph.Authentication` is a runtime dependency. |
| OTD-005 | JSON Schema validation approach compatible with PowerShell 7 locally and in automation | **Resolved locally**`Test-Json -SchemaFile`, draft-07 only. Failure behaviour observed and pinned in [V-5a](verification/V-5a.md); note that an unparseable schema returns `$true`. **V-5b (Automation runtime) remains open.** |
| OTD-006 | Structured-log destination and transport | **Resolved** — NDJSON through a single sink, `Write-PersonaAuditRecord`. Additional transports are added behind that function, not by widening call sites. See [docs/Logging.md](../../docs/Logging.md). |
| OTD-007 | Retry policy — retryable status codes, max attempts, backoff, jitter, logging | **Resolved** — retry 429/500/502/503/504 and status-less transport failures; never 400/401/403/404/409; honour `Retry-After`; max 5 attempts; exponential backoff with full jitter capped at 60s. See research.md OTD-007. |
| OTD-008 | Full versus incremental processing roadmap | Open — full enumeration only in v1. |
| OTD-009 | Production schedule and concurrency lock to prevent overlapping runs | Open — deferred with Stage B (T120). Until then the schedule is the lock; see [docs/OperationsRunbook.md](../../docs/OperationsRunbook.md). |
| OTD-010 | Rollback implementation — pre-change audit values or another approved mechanism | **Data captured; tool not built.** `previousValue` is recorded at write time on every `Updated` record, which is the part that cannot be reconstructed retroactively. The rollback tool itself is out of scope for v1. |
**Mandatory research item**: determine the selected persona attribute mechanism and document the exact authorization boundary for updating it. If individual-attribute authorization is unavailable, document compensating controls before implementation approval.
**Mandatory research item**: determine the selected persona attribute mechanism and document the exact authorization boundary for updating it. If individual-attribute authorization is unavailable, document compensating controls before implementation approval.**Done.** The mechanism is a directory extension property; the authorization boundary is *the whole user object*, because Graph offers nothing narrower; the compensating controls are documented in [docs/SecurityModel.md](../../docs/SecurityModel.md) and each one is test-enforced. Implementation approval for enforcement still requires the V-4 sign-off.
---
+481
View File
@@ -0,0 +1,481 @@
---
description: "Task list for Persona Engine implementation"
---
# Tasks: Persona Engine
**Input**: Design documents from `/specs/001-persona-engine/`
**Prerequisites**: [plan.md](plan.md), [spec.md](spec.md), [research.md](research.md),
[data-model.md](data-model.md), [contracts/](contracts/), [quickstart.md](quickstart.md)
**Tests**: **Included and mandatory.** Not an optional TDD preference here — SC-004, SC-005, SC-008,
and SC-009 are written as test assertions, and constitution Principle IV requires the rule engine to
pass offline Pester tests before any Graph integration exists.
**Organization**: Grouped by user story. Story phases are ordered by the constitution's
**non-negotiable build order**, not strictly by priority — see the note below.
## Format: `[ID] [P?] [Story] Description`
- **[P]**: Can run in parallel (different files, no dependencies on incomplete tasks)
- **[Story]**: US1US9, mapping to the user stories in spec.md
- Every task names an exact file path
## Path Conventions
Single PowerShell module at repository root: `src/`, `tests/`, `config/`, `docs/`, `pipelines/`,
per the Project Structure section of [plan.md](plan.md).
## Implementation status — 2026-08-20
**109 of 121 tasks complete.** All twelve remaining tasks require something this workstation does not
have: a tenant connection (T055, T056, T101T103) or an Azure Automation account (Phase 13,
T115T121). Nothing offline-implementable is outstanding.
```
354 offline Pester tests PASS
Engine purity (Principle IV) PASS
Sanitization (SC-013) PASS 156 files
Graph module loaded during tests False (SC-008 holds)
```
Three deviations from the task list as written, each made while implementing and each for a reason
worth recording:
**T033 does not live in `Resolve-UserPersona`.** The task named that file, but `evaluationErrorThreshold`
is run-level state and the rule engine is pure — a counter there would break Principle IV. It is
implemented in `New-PersonaRunCounter` and applied in the run loop, which is what "and the run summary
path" in the task description points at.
**A new `src/Engine/` layer exists** holding `Invoke-PersonaEngineRun`, which was not in the planned
structure. `Invoke-PersonaEngine.ps1` imports the manifest, which requires `Microsoft.Graph.Authentication`;
a run loop living only inside that script could not be executed on a machine without the Graph SDK,
so SC-004 — zero writes under `-WhatIf` across a full population — could not be proven at all. The
entry script is now a thin wrapper and the loop is testable. What ships and what is tested are the
same code.
**T057's corpus is generated, not hand-written.** `tests/TestData/InvalidConfigs/New-InvalidConfigCorpus.ps1`
produces 23 fixtures, each a valid configuration with exactly one defect. The generated files are
committed so a reviewer sees the fixture in the diff rather than a script that produces it.
## Ordering note (read before starting)
Constitution Principle IV fixes the build order: pure rule engine first, persistence last. This
**overrides** strict priority ordering, so the P1 stories are sequenced US2 → US3 → US1 rather than
US1 first. US1 (preview) is the headline MVP story but cannot be built before the engine it previews.
Stage labels refer to the Delivery Staging table in [plan.md](plan.md): **A1** offline, **A2**
delegated read-only, **A3** delegated write to test accounts, **B** Azure Automation (deferred — no
Automation account available).
---
## Phase 1: Setup (Shared Infrastructure) — Stage A1
**Purpose**: Repository scaffolding and tooling. No behaviour.
- [X] T001 Create the directory tree (`src/{Configuration,Authentication,DataProviders,Normalization,RuleEngine,Persistence,Presentation,Audit}`, `tests/{Unit,RuleEngine,Configuration,Integration,Safety,TestData}`, `config/`, `docs/`, `pipelines/`) per the Project Structure section of specs/001-persona-engine/plan.md
- [X] T002 Create the module manifest PersonaEngine.psd1 with `PowerShellVersion = '7.2'` and `RequiredModules = @('Microsoft.Graph.Authentication')`
- [X] T003 Create the module loader PersonaEngine.psm1 that dot-sources every `src/**/*.ps1` and exports only public functions
- [X] T004 [P] Add PSScriptAnalyzer settings in PSScriptAnalyzerSettings.psd1 enabling PSUseShouldProcessForStateChangingFunctions and PSAvoidUsingPlainTextForPassword
- [X] T005 [P] Add Pester configuration in tests/PesterConfiguration.ps1 with separate tags for Offline, Integration, and Safety suites
- [X] T006 [P] Copy specs/001-persona-engine/contracts/persona-engine.schema.json to config/persona-engine.schema.json as the shipped schema
- [X] T007 [P] Create the placeholder-only sample configuration config/persona-engine.example.json using `<APPROVED-PERSONA-ATTRIBUTE-NAME>` and `<GROUP-OBJECT-ID>` tokens only
- [X] T008 [P] Write the sanitization scan script tests/Test-Sanitization.ps1 that fails on real-looking GUIDs, domains, UPNs, or secrets in any tracked file (SC-013)
---
## Phase 2: Foundational (Blocking Prerequisites) — Stage A1
**Purpose**: The normalized contracts and guardrails every story depends on.
**⚠️ CRITICAL**: No user story work begins until this phase is complete.
- [X] T009 Implement `New-PersonaUserRecord` in src/Normalization/New-PersonaUserRecord.ps1 producing the UserRecord shape in data-model.md, with case-insensitive `Properties` lookup and mandatory `AccountObjectId`/`UserPrincipalName`
- [X] T010 Implement `New-PersonaMembershipRecord` in src/Normalization/New-PersonaMembershipRecord.ps1 with three independently-retrieved facets (direct, transitive, roles), every `*Retrieved` flag defaulting to `$false` so an unset record is never mistaken for "not a member" (revised during implementation — see data-model.md)
- [X] T011 [P] Implement `New-PersonaValidationFinding` in src/Configuration/New-PersonaValidationFinding.ps1 emitting Severity, Code, Location, Description, SuggestedResolution, Layer (VR-004)
- [X] T012 [P] Create synthetic fixtures in tests/TestData/Users/ covering enabled, disabled, guest, null-property, and missing-property accounts — obviously fictional, placeholders only
- [X] T013 [P] Create synthetic membership fixtures in tests/TestData/Memberships/ including a partial-failure record (one facet failed) and a total-failure record
- [X] T014 [P] Write Pester contract tests for both record types in tests/Unit/RecordContracts.Tests.ps1
- [X] T015 Write the engine-purity CI check tests/Test-EnginePurity.ps1 asserting no file under src/RuleEngine/ references Graph, `Connect-`, `Invoke-MgGraphRequest`, `Write-Host`, or any function from src/Authentication|DataProviders|Persistence|Presentation (Principle IV)
**Checkpoint**: normalized contracts exist and purity is enforced by CI. Story work can begin.
---
## Phase 3: User Story 2 — Define classification rules without changing code (Priority: P1) 🎯 Engine core
**Goal**: Ordered, JSON-defined rules evaluate against normalized records with no code changes and no
tenant.
**Independent Test**: Author a multi-rule configuration, evaluate it against synthetic fixtures
offline with the network disconnected, and confirm the expected persona for each fixture.
### Tests for User Story 2
> Write these first and confirm they fail before implementing.
- [X] T016 [P] [US2] Operator matrix tests for all thirteen RE-005 operators in tests/RuleEngine/Operators.Tests.ps1, including case-insensitivity (RE-006) and null-as-empty (FR-012)
- [X] T017 [P] [US2] Ordering tests in tests/RuleEngine/Ordering.Tests.ps1 asserting ascending priority evaluation, first-match stop, and that a priority-10 match beats a priority-20 match
- [X] T018 [P] [US2] Composition tests in tests/RuleEngine/Composition.Tests.ps1 for nested `all`/`any` within the depth limit, and rejection beyond `maxConditionDepth` and the hard ceiling of 10 (RE-004)
- [X] T019 [P] [US2] Determinism tests in tests/RuleEngine/Determinism.Tests.ps1 asserting identical results across shuffled fixture order and repeated runs (SC-003)
- [X] T020 [P] [US2] Unclassified test in tests/RuleEngine/Unclassified.Tests.ps1 asserting that zero matches with all rules evaluating successfully yields `Unclassified` (FR-010)
### Implementation for User Story 2
- [X] T021 [US2] Implement `Import-PersonaConfiguration` in src/Configuration/Import-PersonaConfiguration.ps1 converting JSON to BusinessRule/ConditionGroup/Condition objects and computing the SHA-256 `ConfigurationHash`
- [X] T022 [US2] Implement `Test-PersonaCondition` in src/RuleEngine/Test-PersonaCondition.ps1 covering all RE-005 operators, with regex patterns validated before execution (RE-006)
- [X] T023 [US2] Implement `Test-PersonaConditionGroup` in src/RuleEngine/Test-PersonaConditionGroup.ps1 with `all`/`any` composition and depth enforcement
- [X] T024 [US2] Implement `Test-PersonaRule` in src/RuleEngine/Test-PersonaRule.ps1 evaluating a rule's root condition group
- [X] T025 [US2] Implement `Resolve-UserPersona` in src/RuleEngine/Resolve-UserPersona.ps1 with priority sort, first-match stop, `Unclassified` fallback, `RulesEvaluated`, and `DurationMs`
- [X] T026 [US2] Implement membership and role condition types in src/RuleEngine/Test-PersonaCondition.ps1 reading only from the MembershipRecord, honouring per-condition `direct`/`transitive` mode (RE-007)
**Checkpoint**: the rule engine classifies synthetic users offline. Disconnect the network and re-run
tests/RuleEngine — SC-008 holds or the phase is not done.
---
## Phase 4: User Story 3 — Preserve existing values when evaluation cannot be trusted (Priority: P1)
**Goal**: Unretrievable required data yields `EvaluationError`, preserves the stored persona, and
never becomes a silent non-match.
**Independent Test**: Inject a group-lookup failure for one synthetic user; that user receives
`EvaluationError`, no write is attempted, and the run continues.
### Tests for User Story 3
- [X] T027 [P] [US3] Tri-state propagation tests in tests/RuleEngine/UnknownPropagation.Tests.ps1 covering all four rows of the propagation table in data-model.md
- [X] T028 [P] [US3] Preservation tests in tests/RuleEngine/EvaluationError.Tests.ps1 asserting stored persona is retained, `Action` is `Skipped`, and processing continues to the next user (FR-014)
- [X] T029 [P] [US3] Regression test in tests/RuleEngine/UnknownNotFalse.Tests.ps1 asserting a failed membership lookup never satisfies `notMemberOf` — the specific misclassification hazard FR-013 exists to prevent
### Implementation for User Story 3
- [X] T030 [US3] Extend condition evaluation in src/RuleEngine/Test-PersonaCondition.ps1 to return `True`/`False`/`Unknown` instead of a boolean
- [X] T031 [US3] Implement the propagation rules in src/RuleEngine/Test-PersonaConditionGroup.ps1: `all` with any `False` is `False`; `all` with only `True` plus `Unknown` is `Unknown`; `any` with any `True` is `True`; `any` with only `False` plus `Unknown` is `Unknown`
- [X] T032 [US3] Map a root-level `Unknown` to the `EvaluationError` outcome with `EvaluationErrorReason` in src/RuleEngine/Resolve-UserPersona.ps1
- [X] T033 [US3] Add the `evaluationErrorThreshold` counter and final-status effect in src/RuleEngine/Resolve-UserPersona.ps1 and the run summary path
**Checkpoint**: unknown data degrades to preserve-and-report. Principle III is satisfied in the pure
engine, before any tenant exists.
---
## Phase 5: User Story 1 — Preview classification without changing the directory (Priority: P1) 🎯 MVP
**Goal**: Every in-scope user is retrieved, evaluated, and reported with stored value, calculated
value, and matched rule — with zero directory writes.
**Independent Test**: Run with `-WhatIf` against the tenant using a delegated read-only connection;
verify a result appears for every account and the write adapter receives zero calls.
**Stage**: A2. Requires an app registration with admin consent for the three delegated scopes.
### Tests for User Story 1
- [X] T034 [P] [US1] Zero-write safety test in tests/Safety/WhatIfZeroWrites.Tests.ps1 mocking the write adapter and asserting call count is exactly 0 across the full synthetic population (SC-004)
- [X] T035 [P] [US1] Mode-derivation test in tests/Safety/ShouldProcessGate.Tests.ps1 asserting `-Debug` without `-WhatIf` still reaches the write path — `-Debug` is not a safety control
- [X] T036 [P] [US1] Pagination test in tests/Unit/Pagination.Tests.ps1 asserting `@odata.nextLink` is followed to exhaustion and a truncated enumeration raises rather than returning a partial population
- [X] T037 [P] [US1] Idempotence test in tests/Safety/Idempotence.Tests.ps1 asserting a second consecutive run over unchanged input proposes zero changes (SC-002)
- [X] T038 [P] [US1] Exactly-one-outcome test in tests/Unit/OutcomeExclusivity.Tests.ps1 asserting every processed user lands in exactly one bucket (SC-001)
### Implementation for User Story 1
- [X] T039 [P] [US1] Implement `Connect-PersonaGraphInteractive` in src/Authentication/Connect-PersonaGraphInteractive.ps1 requesting only `User.Read.All`, `GroupMember.Read.All`, `RoleManagement.Read.Directory`, returning an opaque handle that never exposes a token
- [X] T040 [P] [US1] Implement the retry helper `Invoke-PersonaGraphRequest` in src/DataProviders/Invoke-PersonaGraphRequest.ps1 per the OTD-007 table: retry 429/500/502/503/504/timeout, never 400/401/403/404/409, honour `Retry-After`, max 5 attempts, exponential backoff with full jitter capped at 60s
- [X] T041 [US1] Implement `Get-PersonaUsers` in src/DataProviders/Get-PersonaUsers.ps1 with `$select` built from FR-005 plus rule-referenced properties plus the target attribute, `$top=999`, and full `@odata.nextLink` pagination
- [X] T042 [US1] Add single-user retrieval to src/DataProviders/Get-PersonaUsers.ps1 for the `-UserObjectId` path
- [X] T043 [US1] Implement `Get-PersonaGroupMembership` in src/DataProviders/Get-PersonaGroupMembership.ps1 selecting `memberOf` or `transitiveMemberOf` by mode, returning a MembershipRecord with `RetrievalSucceeded = $false` on failure rather than throwing or returning an empty list
- [X] T044 [US1] Implement `Get-PersonaDirectoryRoles` in src/DataProviders/Get-PersonaDirectoryRoles.ps1 via `/roleManagement/directory/roleAssignments`
- [X] T045 [US1] Implement the run-scoped group/role cache in src/DataProviders/PersonaDataCache.ps1, never persisted between runs
- [X] T046 [US1] Implement `ConvertTo-PersonaUserRecord` in src/Normalization/ConvertTo-PersonaUserRecord.ps1 mapping raw Graph responses to UserRecord, including the persona extension property
- [X] T047 [US1] Implement `ConvertTo-PersonaMembershipRecord` in src/Normalization/ConvertTo-PersonaMembershipRecord.ps1
- [X] T048 [US1] Implement `Compare-PersonaValue` in src/Persistence/Compare-PersonaValue.ps1 performing ordinal comparison of stored versus calculated (FR-015)
- [X] T049 [US1] Implement `Write-UserPersonaResult` in src/Presentation/Write-UserPersonaResult.ps1 emitting one line per user immediately with UPN, Account Object ID, outcome, matched rule ID, stored value, calculated value, and action (FR-018, SC-012)
- [X] T050 [US1] Create Invoke-PersonaEngine.ps1 with `CmdletBinding(SupportsShouldProcess, ConfirmImpact='High')` and the parameter set from contracts/cli-invoke-persona-engine.md
- [X] T051 [US1] Derive execution mode solely from `$PSCmdlet.ShouldProcess()` in Invoke-PersonaEngine.ps1 — no separate preview boolean, per the contract's prohibition on two sources of truth for the write gate
- [X] T052 [US1] Implement the `WouldUpdate` reporting path in Invoke-PersonaEngine.ps1 so preview reports intended changes without constructing a request (FR-017)
- [X] T053 [US1] Implement exit codes 06 in Invoke-PersonaEngine.ps1 per the contract table
- [X] T054 [US1] Record per-user and total duration in Invoke-PersonaEngine.ps1 to establish the NFR-002 baseline no target yet exists for
- [ ] T055 [US1] Run quickstart Scenario 4 as a **non-privileged** account and record results in specs/001-persona-engine/verification/V-3.md — a Global Administrator run invalidates this item
- [ ] T056 [US1] Close the V-1 read half against a cloud-only, a currently-synced, and a formerly-synced account; record in specs/001-persona-engine/verification/V-1.md
**Checkpoint**: 🎯 **MVP complete.** Full classification visibility against the live tenant with zero
write risk. Everything after this point adds observability, safety tooling, or enforcement.
---
## Phase 6: User Story 5 — Validate and edit configuration safely (Priority: P2)
**Goal**: Four-layer validation with structured findings, plus an interactive editor that cannot save
an invalid configuration.
**Independent Test**: Run the invalid-configuration corpus and confirm each file produces its
documented finding code, severity, and location.
### Tests for User Story 5
- [X] T057 [P] [US5] Build the invalid-configuration corpus in tests/TestData/InvalidConfigs/ — one file per VR-002 and VR-003 condition
- [X] T058 [P] [US5] Semantic validation tests in tests/Configuration/Semantic.Tests.ps1 asserting code, severity, and location for every VR-002 condition (SC-009)
- [X] T059 [P] [US5] Safety validation tests in tests/Configuration/Safety.Tests.ps1 asserting every VR-003 condition
- [X] T060 [P] [US5] Layer-ordering test in tests/Configuration/LayerOrdering.Tests.ps1 asserting a structurally invalid document stops before semantic checks run
### Implementation for User Story 5
- [X] T061 [US5] Close V-5a: observe and record whether `Test-Json -SchemaFile` returns `$false`, writes a non-terminating error, or throws on this PowerShell build; record in specs/001-persona-engine/verification/V-5a.md and pin the observation in tests/Configuration/TestJsonBehaviour.Tests.ps1
- [X] T062 [US5] Implement validation layer 1 (syntax) in src/Configuration/Test-PersonaConfiguration.ps1
- [X] T063 [US5] Implement validation layer 2 (schema) in src/Configuration/Test-PersonaConfiguration.ps1 wrapping `Test-Json -SchemaFile` with `-ErrorAction SilentlyContinue -ErrorVariable` and converting collected errors into ValidationFinding objects per the V-5a observation
- [X] T064 [US5] Implement validation layer 3 (semantic) in src/Configuration/Test-PersonaConfigurationSemantic.ps1 covering every VR-002 condition with stable `PE-SEM-nnn` codes
- [X] T065 [US5] Implement validation layer 4 (safety) in src/Configuration/Test-PersonaConfigurationSafety.ps1 covering every VR-003 condition with stable `PE-SAF-nnn` codes
- [X] T066 [US5] Implement `Resolve-TargetAttribute` in src/Configuration/Resolve-TargetAttribute.ps1 rejecting any attribute absent from `approvedWritableAttributes`
- [X] T067 [US5] Create Edit-PersonaEngineConfig.ps1 with the parameter set from contracts/cli-edit-persona-engine-config.md
- [X] T068 [US5] Implement the interactive editor loop in Edit-PersonaEngineConfig.ps1 with re-validation before save
- [X] T069 [US5] Implement the timestamped backup and Save-As path in Edit-PersonaEngineConfig.ps1 (FR-026)
- [X] T070 [US5] Implement synthetic rule testing via `-TestDataPath` in Edit-PersonaEngineConfig.ps1, reusing the rule engine with no tenant connectivity (FR-025)
**Checkpoint**: invalid configurations cannot reach the engine or overwrite a good file.
---
## Phase 7: User Story 6 — Block invalid configuration in a pipeline (Priority: P2)
**Goal**: Non-interactive validation that returns exit codes and never prompts.
**Independent Test**: Run `-ValidateOnly -NonInteractive` against an invalid file with stdin closed;
confirm a non-zero exit code, no prompt, and no hang.
**Depends on**: US5's validator (T062T065). If pipeline gating is needed sooner than the interactive
editor, build T062T065 and T071T073 first and defer T067T070.
### Tests for User Story 6
- [X] T071 [P] [US6] Non-interactive test in tests/Configuration/NonInteractive.Tests.ps1 running with stdin closed and asserting no prompt and no hang (SC-010)
- [X] T072 [P] [US6] Exit-code test in tests/Configuration/ExitCodes.Tests.ps1 covering codes 04 from the editor contract
### Implementation for User Story 6
- [X] T073 [US6] Implement `-NonInteractive` and `-ValidateOnly` short-circuits in Edit-PersonaEngineConfig.ps1 that never call a prompting cmdlet
- [X] T074 [US6] Implement `-TreatWarningsAsErrors` escalation in Edit-PersonaEngineConfig.ps1 (VR-005)
- [X] T075 [US6] Implement the editor exit codes 04 in Edit-PersonaEngineConfig.ps1
- [X] T076 [US6] Add the validation stage to pipelines/validate.yml invoking sanitization, PSScriptAnalyzer, engine purity, schema validation, and the offline Pester suites
**Checkpoint**: CI blocks a bad configuration before it can reach a tenant.
---
## Phase 8: User Story 4 — Observe progress and reconcile results (Priority: P2)
**Goal**: Interim and final summaries with a reconciliation check that treats a mismatch as an engine
defect.
**Independent Test**: Run over a fixture population with `summaryInterval` set to 5, then to 0;
confirm interim summaries appear at the interval, are suppressed at 0, and a final summary appears in
both cases.
### Tests for User Story 4
- [X] T077 [P] [US4] Interval-semantics tests in tests/Unit/SummaryInterval.Tests.ps1 covering default 25, a custom interval, and the `0` case that still produces a final summary (FR-020)
- [X] T078 [P] [US4] Reconciliation tests in tests/Unit/Reconciliation.Tests.ps1 asserting `Processed = Matched + Unclassified + EvaluationError` at every summary and that a forced mismatch raises an engine defect (SC-007)
### Implementation for User Story 4
- [X] T079 [US4] Implement `Write-PersonaSummary` in src/Presentation/Write-PersonaSummary.ps1 rendering all business rules with match counts — including disabled and zero-match rules, so an absent rule is distinguishable from one that never fired
- [X] T080 [US4] Implement interval-triggered interim summaries in Invoke-PersonaEngine.ps1 (FR-019)
- [X] T081 [US4] Implement `Test-PersonaReconciliation` in src/Presentation/Test-PersonaReconciliation.ps1 and invoke it at every summary (FR-021)
- [X] T082 [US4] Emit an `EngineDefect` record and exit code 5 on reconciliation failure in Invoke-PersonaEngine.ps1
**Checkpoint**: operators can watch a long run and trust the counters.
---
## Phase 9: User Story 7 — Audit any classification decision (Priority: P2)
**Goal**: Structured, audit-friendly records that explain every decision and make rollback possible.
**Independent Test**: Run over fixtures and confirm 100% of user events carry run ID, UPN, and
Account Object ID, and 100% of matched results carry a rule ID.
### Tests for User Story 7
- [X] T083 [P] [US7] Completeness tests in tests/Unit/AuditCompleteness.Tests.ps1 asserting SC-006 across every record
- [X] T084 [P] [US7] Redaction tests in tests/Unit/AuditRedaction.Tests.ps1 asserting no token, `Authorization` header, secret, or raw Graph response can appear in any record
- [X] T085 [P] [US7] Schema tests in tests/Unit/AuditRecordShape.Tests.ps1 validating each record type against contracts/audit-record.md
### Implementation for User Story 7
- [X] T086 [US7] Implement `New-PersonaAuditRecord` in src/Audit/New-PersonaAuditRecord.ps1 building the common envelope plus each `recordType`
- [X] T087 [US7] Implement the single sink `Write-PersonaAuditRecord` in src/Audit/Write-PersonaAuditRecord.ps1 emitting NDJSON to file and/or stream per `logging.destination`, as the only emission point so a future transport needs no call-site changes
- [X] T088 [US7] Implement `Export-PersonaRunReport` in src/Audit/Export-PersonaRunReport.ps1 producing the `RunComplete` record with all counters and the exit code
- [X] T089 [US7] Wire the run ID from `-CorrelationId` or a generated GUID through every record in Invoke-PersonaEngine.ps1 (NFR-005)
- [X] T090 [US7] Record `configVersion` and `configurationHash` on every record in src/Audit/New-PersonaAuditRecord.ps1
**Checkpoint**: every decision is explainable after the fact.
---
## Phase 10: User Story 9 — Trace the values behind every rule decision (Priority: P3)
**Goal**: Condition-level diagnostic tracing, available only when explicitly enabled.
**Independent Test**: Run one user with `-Debug` and confirm a condition trace appears; run without
`-Debug` and confirm no condition values are emitted anywhere.
### Tests for User Story 9
- [X] T091 [P] [US9] Gating tests in tests/Unit/ConditionTrace.Tests.ps1 asserting `conditionTrace` is absent without `-Debug` and present with it
- [X] T092 [P] [US9] Acknowledgement test in tests/Configuration/TraceAcknowledgement.Tests.ps1 asserting `traceConditionValues` without explicit acknowledgement produces a VR-003 safety finding
### Implementation for User Story 9
- [X] T093 [US9] Populate `ConditionTrace` on the decision result in src/RuleEngine/Resolve-UserPersona.ps1, built only when tracing is active
- [X] T094 [US9] Add the `conditionTrace` array to user events in src/Audit/New-PersonaAuditRecord.ps1, gated on `-Debug` or `logging.traceConditionValues`
**Checkpoint**: rule authors can debug a decision without loosening default logging.
---
## Phase 11: User Story 8 — Enforce changes in production (Priority: P3) 🔒 Gated
**Goal**: Write the calculated persona, only when changed, only the approved attribute.
**Independent Test**: Against test accounts, confirm only changed values are written, every request
body has exactly one key, and every `Updated` record carries `previousValue`.
**⚠️ Do not begin the tenant-facing tasks (T101T103) until**: V-4 security sign-off is recorded,
`-WhatIf` impact evidence from Scenario 4 is reviewed, and Phases 110 pass. Tasks T095T100 are
offline and may proceed at any time.
### Tests for User Story 8
- [X] T095 [P] [US8] Single-attribute body test in tests/Safety/WriteBody.Tests.ps1 asserting every captured body has exactly one key equal to `engine.targetAttribute` (SC-005)
- [X] T096 [P] [US8] Rejection tests in tests/Safety/WriteBodyRejection.Tests.ps1 asserting `New-PersonaWriteBody` throws for any other attribute name and for an attribute absent from `approvedWritableAttributes`
- [X] T097 [P] [US8] Write-gate tests in tests/Safety/WriteGate.Tests.ps1 covering all four FR-016 conditions, including that an `EvaluationError` user is never written
### Implementation for User Story 8
- [X] T098 [US8] Implement `New-PersonaWriteBody` in src/Persistence/New-PersonaWriteBody.ps1 as the only function permitted to construct a write body, returning a hashtable whose `Count` is exactly 1
- [X] T099 [US8] Implement `Set-UserPersonaAttribute` in src/Persistence/Set-UserPersonaAttribute.ps1 issuing `PATCH /v1.0/users/{id}`, reachable only when `ShouldProcess` returned true
- [X] T100 [US8] Capture `previousValue` at write time into the audit record in src/Persistence/Set-UserPersonaAttribute.ps1 — missing this in v1 makes OTD-010 rollback impossible retroactively
- [ ] T101 [US8] Record the V-4 security sign-off in specs/001-persona-engine/verification/V-4.md before any enforcement run
- [ ] T102 [US8] Close the V-1 write half against purpose-created test accounts of each origin type; append to specs/001-persona-engine/verification/V-1.md
- [ ] T103 [US8] Close V-2 by building a dynamic membership group on the persona extension and confirming a CA policy assigned to it applies; record in specs/001-persona-engine/verification/V-2.md
**Checkpoint**: enforcement works against test accounts with every safety invariant proven.
---
## Phase 12: Polish & Cross-Cutting Concerns
- [X] T104 [P] Add comment-based help to every public function across src/ (NFR-004)
- [X] T105 [P] Write docs/Architecture.md describing the adapter boundaries and why the rule engine is pure
- [X] T106 [P] Write docs/ConfigurationReference.md documenting every schema field and finding code
- [X] T107 [P] Write docs/SecurityModel.md recording the OTD-003 trade-off, the six compensating controls, and the V-4 sign-off
- [X] T108 [P] Write docs/OperationsRunbook.md including the kill switch and the rollback procedure
- [X] T109 [P] Write docs/BusinessRules.md and docs/Logging.md
- [X] T110 [P] Add the test stage to pipelines/test.yml publishing Pester results
- [X] T111 Build the FR/NFR traceability matrix in specs/001-persona-engine/traceability.md mapping every requirement ID to its implementing task and test
- [X] T112 Verify every exit code 06 is reachable via fault injection in tests/Unit/ExitCodes.Tests.ps1 (SC-011)
- [X] T113 Run tests/Test-Sanitization.ps1 across all tracked files and record the result in specs/001-persona-engine/verification/sanitization.md (SC-013)
- [X] T114 Update specs/001-persona-engine/spec.md Clarification Register to mark OTD-001 through OTD-005 resolved, citing research.md
---
## Phase 13: Stage B — Azure Automation (DEFERRED)
**Blocked**: no Automation account is available. Listed so the remaining entry cost stays visible and
nothing is lost. Nothing in Phases 112 depends on these.
- [ ] T115 Implement `Connect-PersonaGraphManagedIdentity` in src/Authentication/Connect-PersonaGraphManagedIdentity.ps1 returning the same handle shape as the interactive path
- [ ] T116 Close V-5b: confirm `Test-Json -SchemaFile` behaviour in the Automation runtime and record its exact PowerShell version in specs/001-persona-engine/verification/V-5b.md — run this first, it is the cheapest item most likely to surprise
- [ ] T117 Close V-3b: confirm the three read scopes work as application permissions on the managed identity; record in specs/001-persona-engine/verification/V-3b.md
- [ ] T118 Create the runbook wrapper pipelines/runbook/Invoke-PersonaEngineRunbook.ps1
- [ ] T119 Build pipelines/release.yml deploying to Automation with the schedule shipped **disabled**, a `-WhatIf` validation stage, and an approval gate before enforcement
- [ ] T120 Implement the run-start concurrency check in Invoke-PersonaEngine.ps1 per OTD-009
- [ ] T121 Run quickstart Scenario 5 and record results in specs/001-persona-engine/verification/Scenario5.md — **this is what allows v1 to be declared complete**
---
## Dependencies
```text
Phase 1 (Setup)
└─> Phase 2 (Foundational) ← blocks everything
└─> Phase 3 US2 Rule engine ← blocks US3, US1
└─> Phase 4 US3 Fail-safe ← blocks US1
└─> Phase 5 US1 Preview 🎯 MVP
├─> Phase 6 US5 Validation + editor
│ └─> Phase 7 US6 Pipeline mode
├─> Phase 8 US4 Summaries
│ └─> Phase 9 US7 Audit
│ └─> Phase 10 US9 Tracing
└─> Phase 11 US8 Enforcement 🔒 (also gated on V-4)
└─> Phase 12 Polish
└─> Phase 13 Stage B (deferred)
```
**Story independence, honestly stated**: US2 and US3 are genuinely independent of everything except
the foundation. US1 depends on both — the constitution's build order makes that unavoidable, and
pretending otherwise would produce a task list that cannot be executed in the mandated sequence.
US4US9 are independent of each other and may proceed in any order once US1 lands, except that US9
reads the audit shape US7 defines.
## Parallel opportunities
| Phase | Parallel set |
| --- | --- |
| 1 | T004, T005, T006, T007, T008 |
| 2 | T011, T012, T013, T014 |
| 3 | T016T020 (all tests, separate files) |
| 4 | T027, T028, T029 |
| 5 | T034T038 (tests); then T039, T040 |
| 6 | T057T060 |
| 7 | T071, T072 |
| 8 | T077, T078 |
| 9 | T083, T084, T085 |
| 10 | T091, T092 |
| 11 | T095, T096, T097 |
| 12 | T104T110 |
After Phase 5, three tracks can run concurrently: validation/editor (Phases 67),
observability (Phases 810), and the offline half of enforcement (T095T100).
## Implementation strategy
**MVP = Phases 15** (T001T056). Delivers complete classification visibility against the live tenant
with zero write capability, closes V-1 (read) and V-3, and establishes the performance baseline. This
is a genuinely useful deliverable on its own: it answers "what would this classify my tenant as?"
without touching anything.
**Increment 2 = Phases 67**. Configuration safety and CI gating — the prerequisite for letting anyone
other than the author edit rules.
**Increment 3 = Phases 810**. Observability, reconciliation, and audit. Required before enforcement
is defensible, because the `-WhatIf` impact evidence the constitution demands is only as good as the
output that produces it.
**Increment 4 = Phase 11**, behind the V-4 gate, against test accounts only.
**Stage B (Phase 13)** converts the result into an unattended service. Until T121 passes, the correct
status to report is **"Stage A complete"**, not "v1 done" — the Definition of Done requires an
Automation PowerShell 7 run.
## Task summary
| Phase | Story | Tasks | Count |
| --- | --- | --- | --- |
| 1 Setup | — | T001T008 | 8 |
| 2 Foundational | — | T009T015 | 7 |
| 3 | US2 (P1) | T016T026 | 11 |
| 4 | US3 (P1) | T027T033 | 7 |
| 5 | US1 (P1) 🎯 | T034T056 | 23 |
| 6 | US5 (P2) | T057T070 | 14 |
| 7 | US6 (P2) | T071T076 | 6 |
| 8 | US4 (P2) | T077T082 | 6 |
| 9 | US7 (P2) | T083T090 | 8 |
| 10 | US9 (P3) | T091T094 | 4 |
| 11 | US8 (P3) 🔒 | T095T103 | 9 |
| 12 Polish | — | T104T114 | 11 |
| 13 Stage B | — | T115T121 | 7 (deferred) |
| **Total** | | | **121** |
+114
View File
@@ -0,0 +1,114 @@
# Requirements traceability
Every functional requirement, non-functional requirement, and success criterion in
[spec.md](spec.md), mapped to the code that implements it and the test that holds it there.
A row with no test is a requirement nobody is checking. Those are listed explicitly at the bottom
rather than left out, because an incomplete matrix that looks complete is worse than no matrix.
**Status as at 2026-08-20**: 354 offline tests passing; engine purity and sanitization gates passing;
no tenant-dependent item verified.
## Functional requirements
| ID | Requirement | Implementation | Test |
| --- | --- | --- | --- |
| FR-001 | Load JSON configuration | `Import-PersonaConfiguration` | `LayerOrdering.Tests.ps1` |
| FR-002 | Validate before connecting | `Test-PersonaConfiguration`; entry script exits 1 before `Connect-` | `LayerOrdering.Tests.ps1`, `ExitCodes.Tests.ps1` |
| FR-003 | Adapter-isolated authentication | `Connect-PersonaGraphInteractive` | Purity gate; `ShouldProcessGate.Tests.ps1` |
| FR-004 | Enumerate all users with pagination | `Get-PersonaUsers` | `Pagination.Tests.ps1` |
| FR-005 | Select only required properties | `Get-PersonaRequiredProperties` | `Pagination.Tests.ps1` |
| FR-006 | Retrieve and cache related data | `Get-PersonaGroupMembership`, `Get-PersonaCachedMembership` | `Pagination.Tests.ps1`, `OutcomeExclusivity.Tests.ps1` |
| FR-007 | Normalize before evaluation | `ConvertTo-PersonaUserRecord`, `ConvertTo-PersonaMembershipRecord` | `RecordContracts.Tests.ps1` |
| FR-008 | Evaluate rules in priority order | `Resolve-UserPersona` | `Ordering.Tests.ps1` |
| FR-009 | Stop at first match | `Resolve-UserPersona` | `Ordering.Tests.ps1` |
| FR-010 | `Unclassified` when nothing matches | `Resolve-UserPersona` | `Unclassified.Tests.ps1` |
| FR-011 | Disabled accounts stay in scope | `New-PersonaUserRecord` exposes `AccountEnabled` | `Operators.Tests.ps1` |
| FR-012 | Null treated as empty | `Test-PersonaCondition` | `Operators.Tests.ps1` |
| FR-013 | Unretrievable group data yields `EvaluationError` | Tri-state evaluation; facet retrieval flags | `UnknownNotFalse.Tests.ps1`, `UnknownPropagation.Tests.ps1` |
| FR-014 | Preserve stored value on failure | `Compare-PersonaValue` sets `Skipped` first | `EvaluationError.Tests.ps1`, `WriteGate.Tests.ps1` |
| FR-015 | Compare stored and calculated | `Compare-PersonaValue`, ordinal | `WriteGate.Tests.ps1` |
| FR-016 | Write only changed values, four conditions | `Compare-PersonaValue` + run-loop gate | `WriteGate.Tests.ps1` |
| FR-017 | Preview issues no write request | `Invoke-PersonaEngineRun` gate | `WhatIfZeroWrites.Tests.ps1` |
| FR-018 | Immediate per-user output | `Write-UserPersonaResult` | Exercised by every run-loop suite |
| FR-019 | Periodic summary | `Write-PersonaSummary`, interval check | `SummaryInterval.Tests.ps1` |
| FR-020 | Interval semantics, final always shown | `Invoke-PersonaEngineRun` | `SummaryInterval.Tests.ps1` |
| FR-021 | Reconciliation at every summary | `Test-PersonaReconciliation` | `Reconciliation.Tests.ps1` |
| FR-022 | Structured audit records | `New-PersonaAuditRecord`, `Write-PersonaAuditRecord` | `AuditRecordShape.Tests.ps1` |
| FR-023 | Configuration editor | `Edit-PersonaEngineConfig.ps1` | `ExitCodes.Tests.ps1` (editor) |
| FR-024 | Non-interactive validation with exit codes | `-NonInteractive` short-circuit | `NonInteractive.Tests.ps1` |
| FR-025 | Synthetic rule testing, no tenant | `Invoke-SyntheticRuleTest` | `NonInteractive.Tests.ps1` |
| FR-026 | Validate and back up before save | `Save-PersonaConfiguration` | `Safety.Tests.ps1` (`PE-SAF-007`) |
## Rule engine requirements
| ID | Requirement | Implementation | Test |
| --- | --- | --- | --- |
| RE-001 | Required rule fields | Schema `definitions/rule` | `LayerOrdering.Tests.ps1` |
| RE-002 | Unique priorities, lower first | `Resolve-UserPersona`; `PE-SEM-002` | `Ordering.Tests.ps1`, `Semantic.Tests.ps1` |
| RE-003 | `all` / `any` with nesting | `Test-PersonaConditionGroup` | `Composition.Tests.ps1` |
| RE-004 | Depth limit and hard ceiling | `Test-PersonaConditionGroup`; `PE-SEM-012`, `PE-SEM-013` | `Composition.Tests.ps1`, `Semantic.Tests.ps1` |
| RE-005 | Thirteen operators | `Test-PersonaCondition` | `Operators.Tests.ps1` |
| RE-006 | Case-insensitive; regex validated first | `Test-PersonaCondition`; `PE-SEM-016` | `Operators.Tests.ps1`, `Semantic.Tests.ps1` |
| RE-007 | Per-condition membership mode | Three-facet `MembershipRecord` | `RecordContracts.Tests.ps1`, `UnknownPropagation.Tests.ps1` |
| RE-008 | Combined identity sources | `Get-PersonaRequiredFacets` | `OutcomeExclusivity.Tests.ps1` |
| RE-009 | Special accounts by Object ID | Example configuration; no hard-coded path | Purity gate |
## Validation requirements
| ID | Requirement | Implementation | Test |
| --- | --- | --- | --- |
| VR-001 | Four ordered layers, fail-fast | `Test-PersonaConfiguration` | `LayerOrdering.Tests.ps1` |
| VR-002 | Sixteen semantic conditions | `Test-PersonaConfigurationSemantic` | `Semantic.Tests.ps1` — one test per code |
| VR-003 | Seven safety conditions | `Test-PersonaConfigurationSafety` | `Safety.Tests.ps1`, `TraceAcknowledgement.Tests.ps1` |
| VR-004 | Finding shape | `New-PersonaValidationFinding` | `Semantic.Tests.ps1`, `Safety.Tests.ps1`, `RecordContracts.Tests.ps1` |
| VR-005 | Warnings block only on request | Editor exit-code mapping | `ExitCodes.Tests.ps1` (editor) |
## Non-functional requirements
| ID | Requirement | Implementation | Test |
| --- | --- | --- | --- |
| NFR-001 | PowerShell 7 | `#Requires -Version 7.2`; manifest floor | Runs on 7.6.5 |
| NFR-002 | Caching, per-user and total duration | `New-PersonaDataCache`; stopwatch in `Resolve-UserPersona`; `RunComplete.durationMs` | `AuditCompleteness.Tests.ps1`**no target set** |
| NFR-003 | Pagination, bounded retry, backoff | `Invoke-PersonaGraphRequest` | `RetryPolicy.Tests.ps1`, `Pagination.Tests.ps1` |
| NFR-004 | Comment-based help on public functions | Every function in `src/` | Manual review |
| NFR-005 | Run ID, UPN, Object ID, config hash | `New-PersonaAuditContext` | `AuditCompleteness.Tests.ps1` |
| NFR-006 | Least privilege, single attribute, no secrets | Six OTD-003 controls | `WriteBody.Tests.ps1`, `WriteBodyRejection.Tests.ps1`, sanitization gate |
| NFR-007 | Engine and validation run without the platform | Purity; layer dot-sourcing | Purity gate; whole offline suite |
| NFR-008 | No Windows PowerShell-only dependencies | `Microsoft.Graph.Authentication` only | **Unverified in Automation (V-5b)** |
## Success criteria
| ID | Criterion | Test | Status |
| --- | --- | --- | --- |
| SC-001 | Exactly one outcome per user | `OutcomeExclusivity.Tests.ps1` | Passing |
| SC-002 | Second run proposes zero changes | `Idempotence.Tests.ps1` | Passing |
| SC-003 | Determinism across shuffled input | `Determinism.Tests.ps1` | Passing |
| SC-004 | Zero writes under `-WhatIf` | `WhatIfZeroWrites.Tests.ps1` | Passing |
| SC-005 | Single-attribute request body | `WriteBody.Tests.ps1` | Passing |
| SC-006 | Audit completeness | `AuditCompleteness.Tests.ps1` | Passing |
| SC-007 | Reconciliation, and its failure path | `Reconciliation.Tests.ps1` | Passing |
| SC-008 | Everything runs offline | Whole offline suite; `validate.yml` gate 7 | Passing |
| SC-009 | Every VR-002 and VR-003 condition detected | `Semantic.Tests.ps1`, `Safety.Tests.ps1` | Passing |
| SC-010 | Non-interactive never prompts or hangs | `NonInteractive.Tests.ps1` | Passing |
| SC-011 | Every exit code reachable | `ExitCodes.Tests.ps1` (both) | Passing |
| SC-012 | Per-user output is immediate | `Write-UserPersonaResult` emits in `process` | Structural, not timed |
| SC-013 | No tenant data committed | `Test-Sanitization.ps1` | Passing |
## Gaps, stated plainly
| Item | Why it is not covered | What would close it |
| --- | --- | --- |
| NFR-002 performance | No target exists until representative tenant testing | A timed run against a real population |
| NFR-004 help coverage | Reviewed by eye, not asserted | A test parsing every exported function for a help block |
| NFR-008 Automation compatibility | No Automation account available | V-5b (T116) |
| SC-012 timing | Asserted structurally, not measured | A timed harness — low value against the cost |
| V-1, V-2, V-3 | Require a tenant | Stage A2 and A3 runs |
| V-4 | Requires a person | Written security sign-off |
| Phase 13 (T115T121) | Requires an Automation account | Stage B |
## How to keep this honest
When a requirement's implementation moves, this table moves with it. When a test is deleted, the row
it backed becomes a gap and belongs in the gaps table, not silently in the main one. A matrix that is
allowed to drift is worse than none, because it converts "we do not know" into "we checked".
@@ -0,0 +1,57 @@
# V-5a — `Test-Json -SchemaFile` failure behaviour (local PowerShell)
**Status**: CLOSED
**Date**: 2026-08-20
**Environment**: PowerShell 7.6.5, Windows 11, local workstation (Stage A1 — offline, no tenant)
**Task**: T061
**Pinned by**: [tests/Configuration/TestJsonBehaviour.Tests.ps1](../../../tests/Configuration/TestJsonBehaviour.Tests.ps1)
## Question
OTD-005 selected `Test-Json -SchemaFile` for layer 2 validation. The documented risk was that
`Test-Json` reports schema failure inconsistently across PowerShell versions — returning `$false`,
writing a non-terminating error, or throwing. Layer 2 cannot be written until the actual behaviour
on the target build is observed rather than assumed.
## Observed behaviour
| Scenario | Return value | Error stream | Terminating? |
| --- | --- | --- | --- |
| Valid document | `$true` | empty | no |
| Type mismatch (`"a": 123` against `"type": "string"`) | `$false` | 1 error: `The JSON is not valid with the schema: Value is "integer" but should be "string" at '/a'` | no |
| Missing required property | `$false` | 1 error: `The JSON is not valid with the schema: Required properties ["a"] are not present at ''` | no |
| **Schema file itself unparseable** | **`$true`** | 1 error: `Cannot parse the JSON schema.` | no |
Exception type on the error record is `System.Exception` in every failing case — there is no
distinct exception type to branch on, so the wrapper must branch on the message text or, better, on
error presence alone.
## Findings that shape the implementation
1. **Non-terminating, not throwing.** With the default `$ErrorActionPreference = 'Continue'` the
cmdlet writes to the error stream and execution continues, returning `$false`. It does not throw.
`-ErrorAction SilentlyContinue -ErrorVariable` is therefore sufficient to capture failures, as
the editor contract requires.
2. **An unparseable schema returns `$true`.** This is the load-bearing observation. A wrapper that
trusted the return value alone would report a configuration as schema-valid when the schema never
ran. Layer 2 MUST treat "error variable is non-empty" as failure regardless of the return value,
and MUST distinguish the `Cannot parse the JSON schema.` message so it can surface exit code 4
(schema file not found or itself invalid) rather than exit code 1 (configuration invalid).
3. **One error per violating location, but not exhaustive.** Two independent property violations
yield two error records, each with its own JSON pointer. Deeper or nested subschema failures may
still be reported as a single error at the outermost failing location. The wrapper therefore
emits one finding per collected error rather than assuming a single one, and the author may still
need more than one validation pass to see everything. That residual limitation is documented
rather than worked around — full violation reporting would require replacing `Test-Json` with a
third-party validator, which OTD-005 rejected.
## Consequences recorded elsewhere
- Layer 2 implementation: [src/Configuration/Test-PersonaConfiguration.ps1](../../../src/Configuration/Test-PersonaConfiguration.ps1)
- Regression pin: `tests/Configuration/TestJsonBehaviour.Tests.ps1` fails if a future PowerShell
build changes any row of the table above.
- **V-5b remains open**: this observation is for PowerShell 7.6.5 only. The Azure Automation runtime
version is unverified, and finding 2 in particular is version-sensitive. Re-run this probe there
before Stage B (T116).
@@ -0,0 +1,76 @@
# Sanitization scan result (SC-013)
**Status**: PASS
**Date**: 2026-08-20
**Task**: T113
**Scanner**: [tests/Test-Sanitization.ps1](../../../tests/Test-Sanitization.ps1)
**Files scanned**: 156
## What is scanned
`git ls-files --cached --others --exclude-standard` — tracked files **and** untracked files that are
not gitignored.
The original scanner walked `git ls-files` alone, which covered only tracked files. That made the
gate useless where it matters most: a leaked identifier in a file that has not been committed yet is
precisely the one worth catching, and scanning only what is already in history means the scan passes
right up until the commit that makes it too late. At the time this was found, the scan was covering
34 of the repository's 156 files and none of the implementation written in this phase.
`--exclude-standard` keeps gitignored build output and local scratch files out, so the scan covers
exactly what a commit would add.
## Patterns
| Pattern | Exemptions |
| --- | --- |
| GUIDs | Placeholder-shaped GUIDs (`00000000-0000-0000-0000-0000000000a0`); the module manifest's own `GUID =` identity line |
| Email addresses and UPNs | RFC 2606 / RFC 6761 reserved domains: `example.com/net/org`, `.invalid`, `.test`, `.localhost` |
| `onmicrosoft.com` domains | none |
| JWT and bearer-token shapes | none |
| Assigned secret, password, or key literals | none |
| PEM private key blocks | none |
Two exemptions were added during this scan, both narrow and both for things that cannot be replaced
with a placeholder:
**Reserved domains.** `alex.employee@example.invalid` is guaranteed by RFC to be unresolvable.
Rejecting reserved domains would push fixtures toward addresses that merely *look* fake, which is
worse — the difference between "obviously synthetic" and "probably nobody's" is the entire reason the
reserved list exists.
**The module manifest GUID.** A PowerShell module manifest must carry a genuine unique GUID as its
identity; it is what distinguishes this module from another of the same name. It identifies the
module, not a tenant. The exemption is **line-level** (`^\s*GUID\s*=`), not file-level: exempting the
whole manifest would let a real identifier land anywhere in it.
## Verification of the scanner itself
A negative control was run: a scratch file containing an email address on a real-world commercial
domain and a randomly generated real-shaped GUID was added to the working tree **without** committing
it. The scan failed with two findings and named both, by file and line. The file was then removed and
the scan returned to PASS.
The offending values are described here rather than quoted, because quoting them would make this
record itself a finding — which the scan promptly demonstrated when an earlier draft did exactly
that. That is the control working.
Without a negative control, a scanner that had silently stopped matching would report the same green
result as one that is working.
## Result
```
Sanitization scan passed: no tenant data, credentials, or real identifiers found.
```
## Standing obligations
This is a point-in-time result, not a property of the repository. The scan is **gate 1** of
[pipelines/validate.yml](../../../pipelines/validate.yml) and runs before every other gate on every
pull request — deliberately first, because a leaked identifier is a problem whether or not the code
compiles, and every later gate prints file contents into build logs.
Runtime audit records legitimately contain real UPNs and Object IDs, which are approved for logs. No
such value may ever be committed. When attaching evidence to a verification record (V-1, V-2, V-3),
redact identifiers to placeholders first.