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}$"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user