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,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
|
||||
Reference in New Issue
Block a user