Implement Stage A: rule engine, validation, audit, and safety gates
Completes 109 of 121 tasks. Every remaining task needs a tenant connection
(T055, T056, T101-T103) or an Azure Automation account (T115-T121).
354 offline Pester tests PASS
Engine purity (Principle IV) PASS
Sanitization (SC-013) PASS (156 files)
Graph module loaded in tests none (SC-008 holds)
What landed
- Four-layer configuration validation with stable finding codes, covering
every VR-002 and VR-003 condition, plus a 23-fixture invalid-config corpus
- Run loop, audit records (NDJSON through a single sink), summaries,
reconciliation, and exit codes 0-6
- Persistence behind a single write-body builder whose result always has
exactly one key
- Invoke-PersonaEngine.ps1 and Edit-PersonaEngineConfig.ps1
- Six docs, two pipelines, traceability matrix, V-5a and sanitization records
Three deviations from tasks.md, each recorded in its status block
T033 is not in Resolve-UserPersona. evaluationErrorThreshold is run-level
state and the rule engine is pure; a counter there would break Principle IV.
It lives in New-PersonaRunCounter and is applied in the run loop.
A new src/Engine/ layer holds Invoke-PersonaEngineRun. The entry script
imports the manifest, which requires Microsoft.Graph.Authentication, so a
loop living only inside it could not run on a machine without the Graph SDK
and SC-004 could not be proven at all. The entry script is now a thin
wrapper and what ships is what is tested.
The invalid-config corpus is generated by a committed script, with the
generated fixtures committed too, so a reviewer sees the fixture in the diff.
Defects found by running the code, not by reading it
Group and role ID lists were double-wrapped: @(Get-PersonaGroupIdPage ...)
around a comma-returned array collapsed every membership list into one
bogus space-joined entry. That is a silent false non-match, exactly what
FR-013 exists to prevent.
A 403 whose status appears only in the exception message parsed as $null,
which the retry policy treats as a transport error - five requests per
account against a tenant already refusing. Status extraction now falls back
to the message text, bounded to 400-599.
The sanitization scan walked tracked files only, so it covered 34 of 156
files and none of this phase's code. It now scans untracked non-ignored
files too, and a negative control confirms it catches a planted leak.
Test-Json reports one error per violating location, not first-failure-only
as the V-5a draft claimed. Record and pin corrected.
Enforcement remains blocked on the V-4 security sign-off.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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}$"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -0,0 +1,202 @@
|
||||
# Implementation Plan: Persona Engine
|
||||
|
||||
**Branch**: `main` (feature directory `001-persona-engine`) | **Date**: 2026-08-20 | **Spec**: [spec.md](spec.md)
|
||||
|
||||
**Input**: Feature specification from `/specs/001-persona-engine/spec.md`
|
||||
|
||||
## Summary
|
||||
|
||||
Deterministic, configuration-driven persona classification for Microsoft Entra ID user objects. The
|
||||
engine enumerates in-scope users, evaluates each against an ordered JSON rule set, assigns exactly
|
||||
one persona, and updates a single approved directory attribute only when the calculated value
|
||||
differs from the stored value.
|
||||
|
||||
**Technical approach**: a PowerShell 7 module (`PersonaEngine`) whose rule engine is a pure function
|
||||
over normalized records, with Graph access, persistence, and presentation isolated behind adapters.
|
||||
Directory access uses `Invoke-MgGraphRequest` (direct REST over the `Microsoft.Graph.Authentication`
|
||||
module) so the write body is explicitly constructed and test-assertable. The persona value is stored
|
||||
in a **directory (schema) extension** on the user object, consumed downstream by dynamic membership
|
||||
groups. Configuration is validated with the built-in `Test-Json -SchemaFile` against a draft-07
|
||||
schema. See [research.md](research.md) for the decisions and their rationale.
|
||||
|
||||
## Delivery Staging
|
||||
|
||||
**Constraint (2026-08-20)**: no Azure Automation account is available. All development and testing
|
||||
proceeds on a local PowerShell 7 workstation using **user accounts and delegated authentication**.
|
||||
|
||||
This changes sequencing, not architecture. The adapter boundaries that make the engine testable
|
||||
offline (Principle IV) are the same boundaries that make the Automation runtime a late, additive
|
||||
step — so the deferral costs nothing structurally.
|
||||
|
||||
| Stage | Environment | Auth | Status |
|
||||
| --- | --- | --- | --- |
|
||||
| **A1** — offline | Local PS7, synthetic fixtures | None | Available now. Covers the rule engine, all four validation layers, and the safety suites. No tenant, no network. |
|
||||
| **A2** — connected read-only | Local PS7, tenant | Delegated (`Connect-MgGraph -Scopes`) | Available now. Covers enumeration, membership, roles, normalization, presentation, reconciliation, and `-WhatIf`. |
|
||||
| **A3** — connected write | Local PS7, **test accounts only** | Delegated | Gated on V-4. Test accounts only — the baseline's read-only-during-early-development assumption still stands for the general population. |
|
||||
| **B** — Automation | Azure Automation PS7 | Managed identity | **Deferred.** Additive: a second authentication adapter, a runbook wrapper, and a schedule. |
|
||||
|
||||
**Consequences, stated plainly:**
|
||||
|
||||
1. **v1 cannot be declared complete while Stage B is deferred.** The Definition of Done requires an
|
||||
Azure Automation PowerShell 7 run to pass. Deferring it does not violate the constitution — it
|
||||
defers *completion*. The correct milestone to claim in the meantime is "Stage A complete", not
|
||||
"v1 done". Do not quietly redefine done.
|
||||
2. **`Connect-PersonaGraphManagedIdentity` will ship unexercised.** `Connect-MgGraph -Identity`
|
||||
cannot run on a workstation. The mitigation is to keep the authentication adapter's surface
|
||||
minimal — one function, returning the same handle shape as the interactive path, with no
|
||||
engine-visible difference — so that the untested code is a few lines rather than a subsystem.
|
||||
3. **Delegated authorization behaves differently from application permissions.** Effective access is
|
||||
the intersection of the requested scope and the signed-in user's directory roles. This makes V-3
|
||||
*more* meaningful when run as an ordinary user account, and meaningless when run as a Global
|
||||
Administrator. See research.md V-3.
|
||||
4. **Automation-specific risk stays open**: runtime PowerShell version, module availability, and
|
||||
sandbox behaviour are unverified until Stage B. The one-module dependency decision (OTD-004) is
|
||||
what keeps that risk small.
|
||||
|
||||
## Technical Context
|
||||
|
||||
**Language/Version**: PowerShell 7.4 locally. The Automation runtime version is unverified and
|
||||
remains so until Stage B (verification item V-5b in research.md). Avoid any construct newer than
|
||||
PS 7.2 so the eventual Automation runtime is not a constraint discovered late.
|
||||
|
||||
**Primary Dependencies**: `Microsoft.Graph.Authentication` (token acquisition and
|
||||
`Invoke-MgGraphRequest`) is the only runtime dependency. `Pester` 5.x and `PSScriptAnalyzer` are
|
||||
development/CI-only. No full Microsoft Graph SDK dependency — see OTD-004. Module availability in
|
||||
the Automation sandbox is unverified until Stage B.
|
||||
|
||||
**Storage**: JSON configuration file on disk; no database. Persona values live in the directory
|
||||
itself. Audit output is newline-delimited JSON to a file plus the Automation output stream.
|
||||
|
||||
**Testing**: Pester 5.x. Unit and rule-engine suites run fully offline against synthetic fixtures
|
||||
(SC-008); integration suites require a read-only tenant identity, satisfied in Stage A2 by a
|
||||
delegated connection; safety suites assert zero writes under `-WhatIf` (SC-004) and single-attribute
|
||||
write bodies (SC-005). The safety suites mock the write adapter, so they are fully available now and
|
||||
are **not** gated on Stage A3 or B — the zero-write guarantee is proven against the adapter contract,
|
||||
not against a tenant.
|
||||
|
||||
**Target Platform**: PowerShell 7 on a local workstation (Stages A1–A3). The Azure Automation
|
||||
PowerShell 7 runtime remains the eventual production target but is out of the current stage.
|
||||
|
||||
**Project Type**: PowerShell module plus two CLI entry-point scripts.
|
||||
|
||||
**Performance Goals**: None fixed. NFR-002 explicitly defers a hard target until representative
|
||||
tenant testing. The plan requires per-user and total duration to be recorded from the first
|
||||
connected run so a baseline exists before any target is set.
|
||||
|
||||
**Constraints**: Rule engine must be free of Graph, authentication, Automation, and console
|
||||
dependencies (Principle IV). `-WhatIf` must issue zero writes (Principle III). Write payloads carry
|
||||
exactly one attribute (Principle III). All artifacts sanitized to placeholders (Principle V,
|
||||
SC-013).
|
||||
|
||||
**Scale/Scope**: In-scope population size is tenant-specific and unknown at planning time. Full
|
||||
enumeration with pagination is the v1 processing model (OTD-008); delta processing is deferred. The
|
||||
read-only pilot establishes the population size and run duration baseline.
|
||||
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-checked after Phase 1 design.*
|
||||
|
||||
Evaluated against [constitution.md](../../.specify/memory/constitution.md) v1.0.0.
|
||||
|
||||
| Gate | Principle | Pre-research | Post-design | Notes |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Deterministic, single-persona result | I (NON-NEGOTIABLE) | PASS | PASS | Ordered priority evaluation, first-match stop, no clock/random/unordered inputs in the engine. Rejecting `extensionAttributeN` (research OTD-001) removes a population-dependent failure mode that would have broken determinism across a hybrid population. |
|
||||
| Configuration-driven rules | II | PASS | PASS | No persona, priority, group ID, role ID, or attribute name in source. Four-layer validation ordering preserved in the config contract. |
|
||||
| Fail-safe, idempotent persistence | III (NON-NEGOTIABLE) | PASS | PASS | `EvaluationError` preserves stored value; `SupportsShouldProcess` on both write paths; changed-values-only comparison; single-attribute body construction isolated in one function. |
|
||||
| Pure rule engine, offline-tested first | IV | PASS | PASS | Rule engine depends only on normalized records. Build order enforced in the task sequencing below; persistence adapter is last. |
|
||||
| Explainable, sanitized observability | V | PASS | PASS | Run ID, UPN, Account Object ID, and matched rule ID on every user event; condition-value tracing gated behind `-Debug`; placeholders only in all artifacts. |
|
||||
|
||||
**Security and least-privilege constraints**: PASS with a mandatory condition. Research OTD-003
|
||||
establishes that Microsoft Graph application permissions **cannot** be scoped to an individual user
|
||||
attribute for the selected mechanism. The constitution anticipates exactly this outcome and makes
|
||||
the compensating controls mandatory rather than optional; they are carried into the design as
|
||||
testable requirements (see the persistence contract). This is a documented and approved-by-design
|
||||
condition, not a constitution violation. Security approval of the compensating controls is a gate
|
||||
before enforcement, per the Development Workflow section.
|
||||
|
||||
**Automation deferral (Stage B)**: PASS. Every principle is satisfiable on a local workstation —
|
||||
determinism, configuration-driven rules, fail-safe persistence, engine purity, and observability are
|
||||
all properties of the code, not of the hosting environment. Two constitution items are *deferred, not
|
||||
waived*: the Definition of Done's Azure Automation PowerShell 7 run, and the release-pipeline stages
|
||||
that deploy to it. Both are recorded in the Delivery Staging table and gate the v1 completion claim.
|
||||
|
||||
**Result**: no unjustified violations. Complexity Tracking is empty.
|
||||
|
||||
## Project Structure
|
||||
|
||||
### Documentation (this feature)
|
||||
|
||||
```text
|
||||
specs/001-persona-engine/
|
||||
├── plan.md # This file
|
||||
├── research.md # Phase 0 output — OTD-001..010 decisions
|
||||
├── data-model.md # Phase 1 output — entity contracts
|
||||
├── quickstart.md # Phase 1 output — validation scenarios
|
||||
├── contracts/ # Phase 1 output
|
||||
│ ├── persona-engine.schema.json # Configuration JSON Schema (draft-07)
|
||||
│ ├── cli-invoke-persona-engine.md # Engine CLI contract
|
||||
│ ├── cli-edit-persona-engine-config.md # Editor CLI contract
|
||||
│ ├── graph-data-provider.md # Directory read/write contract
|
||||
│ └── audit-record.md # Structured log record contracts
|
||||
└── tasks.md # Phase 2 output (/speckit-tasks — NOT created here)
|
||||
```
|
||||
|
||||
### Source Code (repository root)
|
||||
|
||||
```text
|
||||
PersonaEngine.psd1 # Module manifest
|
||||
PersonaEngine.psm1 # Module loader
|
||||
Invoke-PersonaEngine.ps1 # Engine entry point (CmdletBinding, SupportsShouldProcess)
|
||||
Edit-PersonaEngineConfig.ps1 # Configuration validation / editor entry point
|
||||
|
||||
config/
|
||||
├── persona-engine.example.json # Placeholder-only example
|
||||
└── persona-engine.schema.json # Shipped schema (from contracts/)
|
||||
|
||||
src/
|
||||
├── Configuration/ # Import-PersonaConfiguration, Test-PersonaConfiguration, Resolve-TargetAttribute
|
||||
├── Authentication/ # Connect-PersonaGraphInteractive, Connect-PersonaGraphManagedIdentity
|
||||
├── DataProviders/ # Get-PersonaUsers, Get-PersonaGroupMembership, Get-PersonaDirectoryRoles
|
||||
├── Normalization/ # ConvertTo-PersonaUserRecord, ConvertTo-PersonaMembershipRecord
|
||||
├── RuleEngine/ # Test-PersonaCondition, Test-PersonaConditionGroup, Test-PersonaRule,
|
||||
│ # Resolve-UserPersona <-- no Graph/auth/console dependency
|
||||
├── Persistence/ # Compare-PersonaValue, New-PersonaWriteBody, Set-UserPersonaAttribute
|
||||
├── Presentation/ # Write-UserPersonaResult, Write-PersonaSummary
|
||||
└── Audit/ # New-PersonaAuditRecord, Export-PersonaRunReport
|
||||
|
||||
tests/
|
||||
├── Unit/ # Per-function offline tests
|
||||
├── RuleEngine/ # Rule evaluation matrix against synthetic fixtures
|
||||
├── Configuration/ # Schema, semantic (VR-002), and safety (VR-003) validation
|
||||
├── Integration/ # Read-only tenant tests
|
||||
├── Safety/ # SC-004 zero-write, SC-005 single-attribute-body assertions
|
||||
└── TestData/ # Obviously fictional synthetic users, memberships, configs
|
||||
|
||||
docs/ # Architecture, BusinessRules, ConfigurationReference, Logging,
|
||||
# SecurityModel, OperationsRunbook
|
||||
pipelines/ # validate.yml, test.yml, release.yml
|
||||
```
|
||||
|
||||
**Structure Decision**: single PowerShell module with two CLI entry points, matching the layout
|
||||
already published in [README.md](../../README.md). The directory split is the enforcement mechanism
|
||||
for Principle IV — `src/RuleEngine/` may import nothing from `src/Authentication/`,
|
||||
`src/DataProviders/`, `src/Persistence/`, or `src/Presentation/`, and a CI check asserts this.
|
||||
|
||||
### Build order (Principle IV, non-negotiable sequencing)
|
||||
|
||||
1. Normalized record contracts and synthetic fixtures.
|
||||
2. Pure rule engine + offline Pester suite (no tenant connectivity).
|
||||
3. Configuration import, four-layer validation, and non-interactive pipeline mode.
|
||||
4. `Edit-PersonaEngineConfig.ps1` interactive editor and synthetic rule testing.
|
||||
5. Graph authentication and **read** adapters; normalization wiring.
|
||||
6. Presentation, summaries, reconciliation, and structured audit output.
|
||||
7. Persistence adapter **last**, with `ShouldProcess` and the zero-write/single-attribute suites.
|
||||
|
||||
Steps 1–4 are Stage A1 (offline). Steps 5–6 are Stage A2 (delegated read-only). Step 7 is built and
|
||||
fully unit-tested in Stage A1/A2 against a mocked adapter, and only *exercised against the tenant* in
|
||||
Stage A3, behind V-4. Adding the managed-identity adapter and runbook wrapper is Stage B and touches
|
||||
nothing in steps 1–7 — that is the test of whether the boundaries were drawn correctly.
|
||||
|
||||
## Complexity Tracking
|
||||
|
||||
> No Constitution Check violations. This section is intentionally empty.
|
||||
@@ -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 1–3 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 1–4 and 6 are all
|
||||
available now.
|
||||
|
||||
| Scenario | Stage | Requirement |
|
||||
| --- | --- | --- |
|
||||
| 1–3 (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 1–5 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 |
|
||||
@@ -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`** (extensionAttribute1–15) | **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 A1–A3 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)
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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]**: US1–US9, 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, T101–T103) or an Azure Automation account (Phase 13,
|
||||
T115–T121). 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 0–6 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 (T062–T065). If pipeline gating is needed sooner than the interactive
|
||||
editor, build T062–T065 and T071–T073 first and defer T067–T070.
|
||||
|
||||
### 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 0–4 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 0–4 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 (T101–T103) until**: V-4 security sign-off is recorded,
|
||||
`-WhatIf` impact evidence from Scenario 4 is reviewed, and Phases 1–10 pass. Tasks T095–T100 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 0–6 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 1–12 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.
|
||||
US4–US9 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 | T016–T020 (all tests, separate files) |
|
||||
| 4 | T027, T028, T029 |
|
||||
| 5 | T034–T038 (tests); then T039, T040 |
|
||||
| 6 | T057–T060 |
|
||||
| 7 | T071, T072 |
|
||||
| 8 | T077, T078 |
|
||||
| 9 | T083, T084, T085 |
|
||||
| 10 | T091, T092 |
|
||||
| 11 | T095, T096, T097 |
|
||||
| 12 | T104–T110 |
|
||||
|
||||
After Phase 5, three tracks can run concurrently: validation/editor (Phases 6–7),
|
||||
observability (Phases 8–10), and the offline half of enforcement (T095–T100).
|
||||
|
||||
## Implementation strategy
|
||||
|
||||
**MVP = Phases 1–5** (T001–T056). 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 6–7**. Configuration safety and CI gating — the prerequisite for letting anyone
|
||||
other than the author edit rules.
|
||||
|
||||
**Increment 3 = Phases 8–10**. 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 | — | T001–T008 | 8 |
|
||||
| 2 Foundational | — | T009–T015 | 7 |
|
||||
| 3 | US2 (P1) | T016–T026 | 11 |
|
||||
| 4 | US3 (P1) | T027–T033 | 7 |
|
||||
| 5 | US1 (P1) 🎯 | T034–T056 | 23 |
|
||||
| 6 | US5 (P2) | T057–T070 | 14 |
|
||||
| 7 | US6 (P2) | T071–T076 | 6 |
|
||||
| 8 | US4 (P2) | T077–T082 | 6 |
|
||||
| 9 | US7 (P2) | T083–T090 | 8 |
|
||||
| 10 | US9 (P3) | T091–T094 | 4 |
|
||||
| 11 | US8 (P3) 🔒 | T095–T103 | 9 |
|
||||
| 12 Polish | — | T104–T114 | 11 |
|
||||
| 13 Stage B | — | T115–T121 | 7 (deferred) |
|
||||
| **Total** | | | **121** |
|
||||
@@ -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 (T115–T121) | 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.
|
||||
Reference in New Issue
Block a user