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

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

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

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

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

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

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

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

Defects found by running the code, not by reading it

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-20 21:48:19 -04:00
parent c59c85dd55
commit cdc6bb33d3
124 changed files with 16638 additions and 199 deletions
+138
View File
@@ -0,0 +1,138 @@
# Architecture
How Persona Engine is put together, and why the boundaries sit where they do.
## The one structural rule
The rule engine is pure. It takes a normalized record and a rule set, and returns a decision. It has
no knowledge of Microsoft Graph, no authentication, no console, no filesystem, and no clock.
Everything else follows from that. It is constitution Principle IV, it is enforced by
[`tests/Test-EnginePurity.ps1`](../tests/Test-EnginePurity.ps1) on every build, and it is the reason
354 tests can run on a laptop with no tenant, no credentials, and no network.
The purity check is a tokenizer pass, not a text search: it parses each file under `src/RuleEngine/`
with the PowerShell AST parser and inspects only the code tokens. An earlier text-matching version
flagged a comment that merely *mentioned* a persistence function, which is the kind of false positive
that gets a check disabled.
## Layers
Loaded in this order by [`PersonaEngine.psm1`](../PersonaEngine.psm1). Order matters only for
readability — PowerShell resolves function names at call time — but the dependency direction is real
and one-way.
| Layer | Responsibility | May depend on |
| --- | --- | --- |
| `Normalization` | Convert raw directory objects into `UserRecord` and `MembershipRecord` | nothing |
| `Configuration` | Load, validate, and resolve configuration | Normalization |
| `RuleEngine` | Evaluate conditions, groups, rules; produce a decision | Normalization only |
| `Authentication` | Acquire a Graph connection | nothing in this module |
| `DataProviders` | Retrieve users, membership, roles; retry policy | Normalization |
| `Persistence` | Compare values; build and issue the write | DataProviders |
| `Presentation` | Per-user output, summaries, reconciliation | nothing |
| `Engine` | The run loop that composes all of the above | everything |
| `Audit` | Build and emit structured records | Presentation |
The arrow never points into `RuleEngine`. Nothing in `RuleEngine` may reference anything from
`Authentication`, `DataProviders`, `Persistence`, or `Presentation`.
## The normalization boundary
`ConvertTo-PersonaUserRecord` and `ConvertTo-PersonaMembershipRecord` are the only places where a raw
Graph shape becomes an engine shape. Downstream of them, nothing knows Graph exists.
Two consequences worth stating plainly:
**Fixtures are real inputs.** A synthetic `UserRecord` built in a test is indistinguishable to the
engine from one built from a live tenant response. That is what makes the offline suite evidence
rather than a rehearsal.
**Filtering rules live in one place.** `memberOf` returns directory objects of mixed type.
Administrative units arriving on that endpoint are discarded during normalization, not by each
caller. An administrative unit ID treated as a group ID would never match — which reads as "not a
member", a false non-match, the exact outcome FR-013 exists to prevent.
## The MembershipRecord shape
Three independently-retrieved facets — direct groups, transitive groups, directory roles — each with
its own retrieval flag and failure reason.
This started as a single record with one `Mode` field, and running the code against the example
configuration is what proved it wrong: RE-007 makes membership mode a **per-condition** choice, so a
rule set may legitimately ask for transitive membership in one rule and direct in another. A record
carrying only one mode cannot answer both, and seven of nine fixtures came back as `EvaluationError`.
The engine was right; the contract was wrong.
Independent facets also contain failure. If the transitive lookup times out but the direct lookup
succeeded, only conditions needing transitive data become `Unknown`. One collapsed flag would turn
one slow endpoint into a tenant-wide outage.
Every `*Retrieved` flag defaults to `$false`. An unset flag means *unknown*, never *not a member*, so
a forgotten flag degrades to `EvaluationError` rather than silently misclassifying a privileged
account.
## Tri-state evaluation
Conditions return `'True'`, `'False'`, or `'Unknown'` — not a boolean. `Unknown` propagates through
condition groups by the table in [data-model.md](../specs/001-persona-engine/data-model.md), and an
`Unknown` reaching a rule's root becomes `EvaluationError` for that account.
An `Unknown` at priority 30 stops evaluation even though a lower-priority rule might have matched.
Continuing would risk assigning a persona from priority 900 when the account may in truth have
matched at 30 — a privilege downgrade drawn from data nobody could read. Preserving the stored value
is the only safe answer.
## The write gate has one origin
Mode is derived from `$PSCmdlet.ShouldProcess()` and nothing else. There is no `-Preview` switch, no
configuration key that suppresses writes, and no reading of `$WhatIfPreference`. Two sources of truth
for a write gate is the defect class Principle III exists to prevent: the day they disagree, one of
them is wrong and the directory finds out first.
`Invoke-PersonaEngine.ps1` owns the `ShouldProcess` call and passes the result down to
`Invoke-PersonaEngineRun` as a scriptblock. The run loop never learns what `-WhatIf` is, so it cannot
disagree with it — and it defaults to a gate that refuses, so a caller that forgets to supply one
previews rather than writes.
## Why the run loop is a module function
`Invoke-PersonaEngine.ps1` is a thin wrapper: parameter binding, module import, `ShouldProcess`,
exit code. The loop itself is `Invoke-PersonaEngineRun` in `src/Engine/`.
That split exists because SC-004 requires proof that a `-WhatIf` run issues zero writes across a full
population. A loop that only exists inside an entry script — one that imports a manifest requiring
the Graph SDK — cannot be exercised without a tenant, so the claim could not be tested. What ships
and what is tested are now the same code.
## Single emission point for audit
Every audit record passes through `Write-PersonaAuditRecord`. Adding a transport is a change to that
one function. If call sites wrote their own output, each new transport would mean auditing every call
site again, and the one that got missed would be silent.
Records go to the **Information** stream, not the success stream. Audit records on the success stream
would be indistinguishable from a function's return value — the run loop returns its outcome there,
and mixing the two turns one object into an array of several thousand.
## Failure containment
| Failure | Scope | Outcome |
| --- | --- | --- |
| Configuration invalid | Run | Exit 1, before any connection is attempted |
| Authentication fails | Run | Exit 2 |
| Enumeration truncated | Run | Exit 3 — a partial population is never processed |
| One membership lookup fails | One facet, one user | `EvaluationError`, stored value preserved |
| Too many `EvaluationError` | Run status only | Exit 4; no value was changed |
| Counters disagree | Run | Exit 5, `EngineDefect` record |
| One write fails | One user | `UpdateFailed`, run continues |
The dividing line: anything affecting the whole population ends the run; anything affecting one
account is contained and reported.
## Related
- [SecurityModel.md](SecurityModel.md) — the OTD-003 trade-off and its compensating controls
- [ConfigurationReference.md](ConfigurationReference.md) — every schema field and finding code
- [OperationsRunbook.md](OperationsRunbook.md) — kill switch and rollback
- [Logging.md](Logging.md) — record types and what they may contain