Compare commits

..

12 Commits

Author SHA1 Message Date
dave 80ef1303ea speckit 2026-08-25 00:27:48 -04:00
dave d1b0ac992e Add CompanyName and Department to the results CSV
Add-PersonaRunResult now optionally accepts the normalized UserRecord and
pulls CompanyName/Department from it for the results.csv row, via
TryGetValue rather than the Properties dictionary indexer so a record built
without those keys doesn't throw. Kept off the PersonaDecisionResult/audit
contract on purpose - this is CSV-only, not a widening of what gets logged.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-25 00:25:36 -04:00
dave 5f125c34f2 Add elapsed run time, per-account results CSV, and a default config path
Summaries now show elapsed wall-clock time since the run started, and every
summary (interim and final) overwrites a results.csv (Object ID, UPN,
persona/status) next to the audit log, so an operator has a plain export
without parsing NDJSON. ConfigPath also now defaults to
./config/persona-engine.json instead of requiring -ConfigPath every run.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 22:10:24 -04:00
dave 8c8fd47f74 test 2026-08-24 11:31:52 -04:00
dave 8c2dc842e6 Pin audit sink writes against ambient WhatIfPreference
Add-Content/New-Item in Write-PersonaAuditRecord honour ShouldProcess, so
an ambient $WhatIfPreference left set in the caller's session (e.g. from
dot-sourcing a prior -WhatIf run) silently turned the audit write into a
no-op, even though the sink itself never opts into ShouldProcess. The
audit log is supposed to be unconditional under -WhatIf, so both calls
now pin -WhatIf:$false -Confirm:$false.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-24 10:16:37 -04:00
dave a54ff3c8f2 updated docs, removed testing files, fixed logging 2026-08-24 10:01:21 -04:00
dave 01d08e635c config editor 2026-08-21 01:18:19 -04:00
dave c30ef6ec24 push 2026-08-21 00:50:56 -04:00
dave aeedb7170a Removed extension attribute requirement for testing 2026-08-21 00:31:47 -04:00
dave cdc6bb33d3 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>
2026-08-20 21:48:19 -04:00
dave c59c85dd55 updated readme 2026-08-20 16:53:23 -04:00
dave db6ad1f3e8 add constitution 2026-08-20 16:48:44 -04:00
79 changed files with 13291 additions and 232 deletions
+262
View File
@@ -0,0 +1,262 @@
---
name: "speckit-analyze"
description: "Perform a non-destructive cross-artifact consistency and quality analysis across spec.md, plan.md, and tasks.md after task generation."
argument-hint: "Optional focus areas for analysis"
compatibility: "Requires spec-kit project structure with .specify/ directory"
metadata:
author: "github-spec-kit"
source: "templates/commands/analyze.md"
user-invocable: true
disable-model-invocation: false
---
## User Input
```text
$ARGUMENTS
```
You **MUST** consider the user input before proceeding (if not empty).
## Pre-Execution Checks
**Check for extension hooks (before analysis)**:
- Check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.before_analyze` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit``/speckit-git-commit`.
- For each executable hook, output the following based on its `optional` flag:
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Pre-Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```
## Extension Hooks
**Automatic Pre-Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
Wait for the result of the hook command before proceeding to the Goal.
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
## Goal
Identify inconsistencies, duplications, ambiguities, and underspecified items across the three core artifacts (`spec.md`, `plan.md`, `tasks.md`) before implementation. This command MUST run only after `/speckit-tasks` has successfully produced a complete `tasks.md`.
## Operating Constraints
**STRICTLY READ-ONLY**: Do **not** modify any files. Output a structured analysis report. Offer an optional remediation plan (user must explicitly approve before any follow-up editing commands would be invoked manually).
**Constitution Authority**: The project constitution (`.specify/memory/constitution.md`) is **non-negotiable** within this analysis scope. Constitution conflicts are automatically CRITICAL and require adjustment of the spec, plan, or tasks—not dilution, reinterpretation, or silent ignoring of the principle. If a principle itself needs to change, that must occur in a separate, explicit constitution update outside `/speckit-analyze`.
## Execution Steps
### 1. Initialize Analysis Context
Run `.specify/scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks` once from repo root and parse JSON for FEATURE_DIR and AVAILABLE_DOCS. Derive absolute paths:
- SPEC = FEATURE_DIR/spec.md
- PLAN = FEATURE_DIR/plan.md
- TASKS = FEATURE_DIR/tasks.md
Abort with an error message if any required file is missing (instruct the user to run missing prerequisite command).
For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
### 2. Load Artifacts (Progressive Disclosure)
Load only the minimal necessary context from each artifact:
**From spec.md:**
- Overview/Context
- Functional Requirements
- Success Criteria (measurable outcomes — e.g., performance, security, availability, user success, business impact)
- User Stories
- Edge Cases (if present)
**From plan.md:**
- Architecture/stack choices
- Data Model references
- Phases
- Technical constraints
**From tasks.md:**
- Task IDs
- Descriptions
- Phase grouping
- Parallel markers [P]
- Referenced file paths
**From constitution:**
- Load `.specify/memory/constitution.md` for principle validation
### 3. Build Semantic Models
Create internal representations (do not include raw artifacts in output):
- **Requirements inventory**: For each Functional Requirement (FR-###) and Success Criterion (SC-###), record a stable key. Use the explicit FR-/SC- identifier as the primary key when present, and optionally also derive an imperative-phrase slug for readability (e.g., "User can upload file" → `user-can-upload-file`). Include only Success Criteria items that require buildable work (e.g., load-testing infrastructure, security audit tooling), and exclude post-launch outcome metrics and business KPIs (e.g., "Reduce support tickets by 50%").
- **User story/action inventory**: Discrete user actions with acceptance criteria
- **Task coverage mapping**: Map each task to one or more requirements or stories (inference by keyword / explicit reference patterns like IDs or key phrases)
- **Constitution rule set**: Extract principle names and MUST/SHOULD normative statements
### 4. Detection Passes (Token-Efficient Analysis)
Focus on high-signal findings. Limit to 50 findings total; aggregate remainder in overflow summary.
#### A. Duplication Detection
- Identify near-duplicate requirements
- Mark lower-quality phrasing for consolidation
#### B. Ambiguity Detection
- Flag vague adjectives (fast, scalable, secure, intuitive, robust) lacking measurable criteria
- Flag unresolved placeholders (TODO, TKTK, ???, `<placeholder>`, etc.)
#### C. Underspecification
- Requirements with verbs but missing object or measurable outcome
- User stories missing acceptance criteria alignment
- Tasks referencing files or components not defined in spec/plan
#### D. Constitution Alignment
- Any requirement or plan element conflicting with a MUST principle
- Missing mandated sections or quality gates from constitution
#### E. Coverage Gaps
- Requirements with zero associated tasks
- Tasks with no mapped requirement/story
- Success Criteria requiring buildable work (performance, security, availability) not reflected in tasks
#### F. Inconsistency
- Terminology drift (same concept named differently across files)
- Data entities referenced in plan but absent in spec (or vice versa)
- Task ordering contradictions (e.g., integration tasks before foundational setup tasks without dependency note)
- Conflicting requirements (e.g., one requires Next.js while other specifies Vue)
### 5. Severity Assignment
Use this heuristic to prioritize findings:
- **CRITICAL**: Violates constitution MUST, missing core spec artifact, or requirement with zero coverage that blocks baseline functionality
- **HIGH**: Duplicate or conflicting requirement, ambiguous security/performance attribute, untestable acceptance criterion
- **MEDIUM**: Terminology drift, missing non-functional task coverage, underspecified edge case
- **LOW**: Style/wording improvements, minor redundancy not affecting execution order
### 6. Produce Compact Analysis Report
Output a Markdown report (no file writes) with the following structure:
## Specification Analysis Report
| ID | Category | Severity | Location(s) | Summary | Recommendation |
|----|----------|----------|-------------|---------|----------------|
| A1 | Duplication | HIGH | spec.md:L120-134 | Two similar requirements ... | Merge phrasing; keep clearer version |
(Add one row per finding; generate stable IDs prefixed by category initial.)
**Coverage Summary Table:**
| Requirement Key | Has Task? | Task IDs | Notes |
|-----------------|-----------|----------|-------|
**Constitution Alignment Issues:** (if any)
**Unmapped Tasks:** (if any)
**Metrics:**
- Total Requirements
- Total Tasks
- Coverage % (requirements with >=1 task)
- Ambiguity Count
- Duplication Count
- Critical Issues Count
### 7. Provide Next Actions
At end of report, output a concise Next Actions block:
- If CRITICAL issues exist: Recommend resolving before `/speckit-implement`
- If only LOW/MEDIUM: User may proceed, but provide improvement suggestions
- Provide explicit command suggestions: e.g., "Run /speckit-specify with refinement", "Run /speckit-plan to adjust architecture", "Manually edit tasks.md to add coverage for 'performance-metrics'"
### 8. Offer Remediation
Ask the user: "Would you like me to suggest concrete remediation edits for the top N issues?" (Do NOT apply them automatically.)
### 9. Check for extension hooks
After reporting, check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.after_analyze` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
- For each executable hook, output the following based on its `optional` flag:
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```
## Extension Hooks
**Automatic Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
## Operating Principles
### Context Efficiency
- **Minimal high-signal tokens**: Focus on actionable findings, not exhaustive documentation
- **Progressive disclosure**: Load artifacts incrementally; don't dump all content into analysis
- **Token-efficient output**: Limit findings table to 50 rows; summarize overflow
- **Deterministic results**: Rerunning without changes should produce consistent IDs and counts
### Analysis Guidelines
- **NEVER modify files** (this is read-only analysis)
- **NEVER hallucinate missing sections** (if absent, report them accurately)
- **Prioritize constitution violations** (these are always CRITICAL)
- **Use examples over exhaustive rules** (cite specific instances, not generic patterns)
- **Report zero issues gracefully** (emit success report with coverage statistics)
## Context
$ARGUMENTS
+386
View File
@@ -0,0 +1,386 @@
---
name: "speckit-checklist"
description: "Generate a custom checklist for the current feature based on user requirements."
argument-hint: "Domain or focus area for the checklist"
compatibility: "Requires spec-kit project structure with .specify/ directory"
metadata:
author: "github-spec-kit"
source: "templates/commands/checklist.md"
user-invocable: true
disable-model-invocation: false
---
## Checklist Purpose: "Unit Tests for English"
**CRITICAL CONCEPT**: Checklists are **UNIT TESTS FOR REQUIREMENTS WRITING** - they validate the quality, clarity, and completeness of requirements in a given domain.
**NOT for verification/testing**:
- ❌ NOT "Verify the button clicks correctly"
- ❌ NOT "Test error handling works"
- ❌ NOT "Confirm the API returns 200"
- ❌ NOT checking if code/implementation matches the spec
**FOR requirements quality validation**:
- ✅ "Are visual hierarchy requirements defined for all card types?" (completeness)
- ✅ "Is 'prominent display' quantified with specific sizing/positioning?" (clarity)
- ✅ "Are hover state requirements consistent across all interactive elements?" (consistency)
- ✅ "Are accessibility requirements defined for keyboard navigation?" (coverage)
- ✅ "Does the spec define what happens when logo image fails to load?" (edge cases)
**Metaphor**: If your spec is code written in English, the checklist is its unit test suite. You're testing whether the requirements are well-written, complete, unambiguous, and ready for implementation - NOT whether the implementation works.
**Ownership and checkbox lifecycle**:
- Custom checklists generated by this command are reviewer-owned requirements-quality review artifacts.
- `[x]` means the reviewer determined the requirements-quality criterion is satisfied.
- `[x]` does NOT mean implementation work is complete.
- This command generates or appends checklist items; it MUST NOT mark generated items `[x]`.
- An agent may assist with evaluating items only when explicitly asked by the reviewer.
- `checklists/requirements.md` is a separate built-in spec-quality checklist maintained by `/speckit-specify` and `/speckit-clarify`; do not treat that exception as applying to custom checklists generated here.
## User Input
```text
$ARGUMENTS
```
You **MUST** consider the user input before proceeding (if not empty).
## Pre-Execution Checks
**Check for extension hooks (before checklist generation)**:
- Check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.before_checklist` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit``/speckit-git-commit`.
- For each executable hook, output the following based on its `optional` flag:
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Pre-Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```
## Extension Hooks
**Automatic Pre-Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
Wait for the result of the hook command before proceeding to the Execution Steps.
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
## Execution Steps
1. **Setup**: Run `.specify/scripts/powershell/check-prerequisites.ps1 -Json -Template checklist-template` from repo root and parse JSON for FEATURE_DIR, AVAILABLE_DOCS list, and TEMPLATE_CONTENT.
- All file paths must be absolute.
- For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
2. **IF EXISTS**: Load `.specify/memory/constitution.md` for project principles and governance constraints.
3. **Clarify intent (dynamic)**: Derive up to THREE initial contextual clarifying questions (no pre-baked catalog). They MUST:
- Be generated from the user's phrasing + extracted signals from spec/plan/tasks
- Only ask about information that materially changes checklist content
- Be skipped individually if already unambiguous in `$ARGUMENTS`
- Prefer precision over breadth
Generation algorithm:
1. Extract signals: feature domain keywords (e.g., auth, latency, UX, API), risk indicators ("critical", "must", "compliance"), stakeholder hints ("QA", "review", "security team"), and explicit deliverables ("a11y", "rollback", "contracts").
2. Cluster signals into candidate focus areas (max 4) ranked by relevance.
3. Identify probable audience & timing (author, reviewer, QA, release) if not explicit.
4. Detect missing dimensions: scope breadth, depth/rigor, risk emphasis, exclusion boundaries, measurable acceptance criteria.
5. Formulate questions chosen from these archetypes:
- Scope refinement (e.g., "Should this include integration touchpoints with X and Y or stay limited to local module correctness?")
- Risk prioritization (e.g., "Which of these potential risk areas should receive mandatory gating checks?")
- Depth calibration (e.g., "Is this a lightweight pre-commit sanity list or a formal release gate?")
- Audience framing (e.g., "Will this be used by the author only or peers during PR review?")
- Boundary exclusion (e.g., "Should we explicitly exclude performance tuning items this round?")
- Scenario class gap (e.g., "No recovery flows detected—are rollback / partial failure paths in scope?")
Question formatting rules:
- If presenting options, generate a compact table with columns: Option | Candidate | Why It Matters
- Limit to AE options maximum; omit table if a free-form answer is clearer
- Never ask the user to restate what they already said
- Avoid speculative categories (no hallucination). If uncertain, ask explicitly: "Confirm whether X belongs in scope."
Defaults when interaction impossible:
- Depth: Standard
- Audience: Reviewer (PR) if code-related; Author otherwise
- Focus: Top 2 relevance clusters
Output the questions (label Q1/Q2/Q3). After answers: if ≥2 scenario classes (Alternate / Exception / Recovery / Non-Functional domain) remain unclear, you MAY ask up to TWO more targeted followups (Q4/Q5) with a one-line justification each (e.g., "Unresolved recovery path risk"). Do not exceed five total questions. Skip escalation if user explicitly declines more.
4. **Understand user request**: Combine `$ARGUMENTS` + clarifying answers:
- Derive checklist theme (e.g., security, review, deploy, ux)
- Consolidate explicit must-have items mentioned by user
- Map focus selections to category scaffolding
- Infer any missing context from spec/plan/tasks (do NOT hallucinate)
5. **Load feature context**: Read from FEATURE_DIR:
- spec.md: Feature requirements and scope
- plan.md (if exists): Technical details, dependencies
- tasks.md (if exists): Implementation tasks
**Context Loading Strategy**:
- Load only necessary portions relevant to active focus areas (avoid full-file dumping)
- Prefer summarizing long sections into concise scenario/requirement bullets
- Use progressive disclosure: add follow-on retrieval only if gaps detected
- If source docs are large, generate interim summary items instead of embedding raw text
6. **Generate checklist** - Use TEMPLATE_CONTENT as the structural template and create "Unit Tests for Requirements":
- Create `FEATURE_DIR/checklists/` directory if it doesn't exist
- Generate unique checklist filename:
- Use short, descriptive name based on domain (e.g., `ux.md`, `api.md`, `security.md`)
- Format: `[domain].md`
- File handling behavior:
- If file does NOT exist: Create new file and number items starting from CHK001
- If file exists: Append new items to existing file, continuing from the last CHK ID (e.g., if last item is CHK015, start new items at CHK016)
- Never delete or replace existing checklist content - always preserve and append
- Leave every newly generated item unchecked (`[ ]`); checkbox state belongs to the reviewer
**CORE PRINCIPLE - Test the Requirements, Not the Implementation**:
Every checklist item MUST evaluate the REQUIREMENTS THEMSELVES for:
- **Completeness**: Are all necessary requirements present?
- **Clarity**: Are requirements unambiguous and specific?
- **Consistency**: Do requirements align with each other?
- **Measurability**: Can requirements be objectively verified?
- **Coverage**: Are all scenarios/edge cases addressed?
**Category Structure** - Group items by requirement quality dimensions:
- **Requirement Completeness** (Are all necessary requirements documented?)
- **Requirement Clarity** (Are requirements specific and unambiguous?)
- **Requirement Consistency** (Do requirements align without conflicts?)
- **Acceptance Criteria Quality** (Are success criteria measurable?)
- **Scenario Coverage** (Are all flows/cases addressed?)
- **Edge Case Coverage** (Are boundary conditions defined?)
- **Non-Functional Requirements** (Performance, Security, Accessibility, etc. - are they specified?)
- **Dependencies & Assumptions** (Are they documented and validated?)
- **Ambiguities & Conflicts** (What needs clarification?)
**HOW TO WRITE CHECKLIST ITEMS - "Unit Tests for English"**:
❌ **WRONG** (Testing implementation):
- "Verify landing page displays 3 episode cards"
- "Test hover states work on desktop"
- "Confirm logo click navigates home"
✅ **CORRECT** (Testing requirements quality):
- "Are the exact number and layout of featured episodes specified?" [Completeness]
- "Is 'prominent display' quantified with specific sizing/positioning?" [Clarity]
- "Are hover state requirements consistent across all interactive elements?" [Consistency]
- "Are keyboard navigation requirements defined for all interactive UI?" [Coverage]
- "Is the fallback behavior specified when logo image fails to load?" [Edge Cases]
- "Are loading states defined for asynchronous episode data?" [Completeness]
- "Does the spec define visual hierarchy for competing UI elements?" [Clarity]
**ITEM STRUCTURE**:
Each item should follow this pattern:
- Question format asking about requirement quality
- Focus on what's WRITTEN (or not written) in the spec/plan
- Include quality dimension in brackets [Completeness/Clarity/Consistency/etc.]
- Reference spec section `[Spec §X.Y]` when checking existing requirements
- Use `[Gap]` marker when checking for missing requirements
**EXAMPLES BY QUALITY DIMENSION**:
Completeness:
- "Are error handling requirements defined for all API failure modes? [Gap]"
- "Are accessibility requirements specified for all interactive elements? [Completeness]"
- "Are mobile breakpoint requirements defined for responsive layouts? [Gap]"
Clarity:
- "Is 'fast loading' quantified with specific timing thresholds? [Clarity, Spec §NFR-2]"
- "Are 'related episodes' selection criteria explicitly defined? [Clarity, Spec §FR-5]"
- "Is 'prominent' defined with measurable visual properties? [Ambiguity, Spec §FR-4]"
Consistency:
- "Do navigation requirements align across all pages? [Consistency, Spec §FR-10]"
- "Are card component requirements consistent between landing and detail pages? [Consistency]"
Coverage:
- "Are requirements defined for zero-state scenarios (no episodes)? [Coverage, Edge Case]"
- "Are concurrent user interaction scenarios addressed? [Coverage, Gap]"
- "Are requirements specified for partial data loading failures? [Coverage, Exception Flow]"
Measurability:
- "Are visual hierarchy requirements measurable/testable? [Acceptance Criteria, Spec §FR-1]"
- "Can 'balanced visual weight' be objectively verified? [Measurability, Spec §FR-2]"
**Scenario Classification & Coverage** (Requirements Quality Focus):
- Check if requirements exist for: Primary, Alternate, Exception/Error, Recovery, Non-Functional scenarios
- For each scenario class, ask: "Are [scenario type] requirements complete, clear, and consistent?"
- If scenario class missing: "Are [scenario type] requirements intentionally excluded or missing? [Gap]"
- Include resilience/rollback when state mutation occurs: "Are rollback requirements defined for migration failures? [Gap]"
**Traceability Requirements**:
- MINIMUM: ≥80% of items MUST include at least one traceability reference
- Each item should reference: spec section `[Spec §X.Y]`, or use markers: `[Gap]`, `[Ambiguity]`, `[Conflict]`, `[Assumption]`
- If no ID system exists: "Is a requirement & acceptance criteria ID scheme established? [Traceability]"
**Surface & Resolve Issues** (Requirements Quality Problems):
Ask questions about the requirements themselves:
- Ambiguities: "Is the term 'fast' quantified with specific metrics? [Ambiguity, Spec §NFR-1]"
- Conflicts: "Do navigation requirements conflict between §FR-10 and §FR-10a? [Conflict]"
- Assumptions: "Is the assumption of 'always available podcast API' validated? [Assumption]"
- Dependencies: "Are external podcast API requirements documented? [Dependency, Gap]"
- Missing definitions: "Is 'visual hierarchy' defined with measurable criteria? [Gap]"
**Content Consolidation**:
- Soft cap: If raw candidate items > 40, prioritize by risk/impact
- Merge near-duplicates checking the same requirement aspect
- If >5 low-impact edge cases, create one item: "Are edge cases X, Y, Z addressed in requirements? [Coverage]"
**🚫 ABSOLUTELY PROHIBITED** - These make it an implementation test, not a requirements test:
- ❌ Any item starting with "Verify", "Test", "Confirm", "Check" + implementation behavior
- ❌ References to code execution, user actions, system behavior
- ❌ "Displays correctly", "works properly", "functions as expected"
- ❌ "Click", "navigate", "render", "load", "execute"
- ❌ Test cases, test plans, QA procedures
- ❌ Implementation details (frameworks, APIs, algorithms)
**✅ REQUIRED PATTERNS** - These test requirements quality:
- ✅ "Are [requirement type] defined/specified/documented for [scenario]?"
- ✅ "Is [vague term] quantified/clarified with specific criteria?"
- ✅ "Are requirements consistent between [section A] and [section B]?"
- ✅ "Can [requirement] be objectively measured/verified?"
- ✅ "Are [edge cases/scenarios] addressed in requirements?"
- ✅ "Does the spec define [missing aspect]?"
7. **Structure Reference**: Generate the checklist following the canonical template in `.specify/templates/checklist-template.md` for title, meta section, category headings, ownership note, notes section, and ID formatting. If template is unavailable, use: H1 title, purpose/created meta lines, an ownership note explaining that `[x]` means reviewer approval of requirements quality, `##` category sections containing `- [ ] CHK### <requirement item>` lines with globally incrementing IDs starting at CHK001, and notes that `/speckit-implement` reads checklist state but does not modify markers.
8. **Report**: Output full path to checklist file, item count, and summarize whether the run created a new file or appended to an existing one. Summarize:
- Focus areas selected
- Depth level
- Actor/timing
- Any explicit user-specified must-have items incorporated
**Important**: Each `/speckit-checklist` command invocation uses a short, descriptive checklist filename and either creates a new file or appends to an existing one. This allows:
- Multiple checklists of different types (e.g., `ux.md`, `test.md`, `security.md`)
- Simple, memorable filenames that indicate checklist purpose
- Easy identification and navigation in the `checklists/` folder
To avoid clutter, use descriptive types and clean up obsolete checklists when done.
## Example Checklist Types & Sample Items
**UX Requirements Quality:** `ux.md`
Sample items (testing the requirements, NOT the implementation):
- "Are visual hierarchy requirements defined with measurable criteria? [Clarity, Spec §FR-1]"
- "Is the number and positioning of UI elements explicitly specified? [Completeness, Spec §FR-1]"
- "Are interaction state requirements (hover, focus, active) consistently defined? [Consistency]"
- "Are accessibility requirements specified for all interactive elements? [Coverage, Gap]"
- "Is fallback behavior defined when images fail to load? [Edge Case, Gap]"
- "Can 'prominent display' be objectively measured? [Measurability, Spec §FR-4]"
**API Requirements Quality:** `api.md`
Sample items:
- "Are error response formats specified for all failure scenarios? [Completeness]"
- "Are rate limiting requirements quantified with specific thresholds? [Clarity]"
- "Are authentication requirements consistent across all endpoints? [Consistency]"
- "Are retry/timeout requirements defined for external dependencies? [Coverage, Gap]"
- "Is versioning strategy documented in requirements? [Gap]"
**Performance Requirements Quality:** `performance.md`
Sample items:
- "Are performance requirements quantified with specific metrics? [Clarity]"
- "Are performance targets defined for all critical user journeys? [Coverage]"
- "Are performance requirements under different load conditions specified? [Completeness]"
- "Can performance requirements be objectively measured? [Measurability]"
- "Are degradation requirements defined for high-load scenarios? [Edge Case, Gap]"
**Security Requirements Quality:** `security.md`
Sample items:
- "Are authentication requirements specified for all protected resources? [Coverage]"
- "Are data protection requirements defined for sensitive information? [Completeness]"
- "Is the threat model documented and requirements aligned to it? [Traceability]"
- "Are security requirements consistent with compliance obligations? [Consistency]"
- "Are security failure/breach response requirements defined? [Gap, Exception Flow]"
## Anti-Examples: What NOT To Do
**❌ WRONG - These test implementation, not requirements:**
```markdown
- [ ] CHK001 - Verify landing page displays 3 episode cards [Spec §FR-001]
- [ ] CHK002 - Test hover states work correctly on desktop [Spec §FR-003]
- [ ] CHK003 - Confirm logo click navigates to home page [Spec §FR-010]
- [ ] CHK004 - Check that related episodes section shows 3-5 items [Spec §FR-005]
```
**✅ CORRECT - These test requirements quality:**
```markdown
- [ ] CHK001 - Are the number and layout of featured episodes explicitly specified? [Completeness, Spec §FR-001]
- [ ] CHK002 - Are hover state requirements consistently defined for all interactive elements? [Consistency, Spec §FR-003]
- [ ] CHK003 - Are navigation requirements clear for all clickable brand elements? [Clarity, Spec §FR-010]
- [ ] CHK004 - Is the selection criteria for related episodes documented? [Gap, Spec §FR-005]
- [ ] CHK005 - Are loading state requirements defined for asynchronous episode data? [Gap]
- [ ] CHK006 - Can "visual hierarchy" requirements be objectively measured? [Measurability, Spec §FR-001]
```
**Key Differences:**
- Wrong: Tests if the system works correctly
- Correct: Tests if the requirements are written correctly
- Wrong: Verification of behavior
- Correct: Validation of requirement quality
- Wrong: "Does it do X?"
- Correct: "Is X clearly specified?"
## Post-Execution Checks
**Check for extension hooks (after checklist generation)**:
Check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.after_checklist` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
- For each executable hook, output the following based on its `optional` flag:
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```
## Extension Hooks
**Automatic Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
+294
View File
@@ -0,0 +1,294 @@
---
name: "speckit-clarify"
description: "Identify underspecified areas in the current feature spec by asking up to 5 highly targeted clarification questions and encoding answers back into the spec."
argument-hint: "Optional areas to clarify in the spec"
compatibility: "Requires spec-kit project structure with .specify/ directory"
metadata:
author: "github-spec-kit"
source: "templates/commands/clarify.md"
user-invocable: true
disable-model-invocation: false
---
## User Input
```text
$ARGUMENTS
```
You **MUST** consider the user input before proceeding (if not empty).
## Pre-Execution Checks
**Check for extension hooks (before clarification)**:
- Check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.before_clarify` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit``/speckit-git-commit`.
- For each executable hook, output the following based on its `optional` flag:
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Pre-Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```
## Extension Hooks
**Automatic Pre-Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
Wait for the result of the hook command before proceeding to the Outline.
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
## Outline
Goal: Detect and reduce ambiguity or missing decision points in the active feature specification and record the clarifications directly in the spec file.
Note: This clarification workflow is expected to run (and be completed) BEFORE invoking `/speckit-plan`. If the user explicitly states they are skipping clarification (e.g., exploratory spike), you may proceed, but must warn that downstream rework risk increases.
Execution steps:
1. Run `.specify/scripts/powershell/check-prerequisites.ps1 -Json -PathsOnly` from repo root **once** (combined `--json --paths-only` mode / `-Json -PathsOnly`). Parse minimal JSON payload fields:
- `FEATURE_DIR`
- `FEATURE_SPEC`
- (Optionally capture `IMPL_PLAN`, `TASKS` for future chained flows.)
- If JSON parsing fails, abort and instruct user to re-run `/speckit-specify` or verify feature branch environment.
- For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
2. **IF EXISTS**: Load `.specify/memory/constitution.md` for project principles and governance constraints.
3. Load the current spec file. Perform a structured ambiguity & coverage scan using this taxonomy. For each category, mark status: Clear / Partial / Missing. Produce an internal coverage map used for prioritization (do not output raw map unless no questions will be asked).
Functional Scope & Behavior:
- Core user goals & success criteria
- Explicit out-of-scope declarations
- User roles / personas differentiation
Domain & Data Model:
- Entities, attributes, relationships
- Identity & uniqueness rules
- Lifecycle/state transitions
- Data volume / scale assumptions
Interaction & UX Flow:
- Critical user journeys / sequences
- Error/empty/loading states
- Accessibility or localization notes
Non-Functional Quality Attributes:
- Performance (latency, throughput targets)
- Scalability (horizontal/vertical, limits)
- Reliability & availability (uptime, recovery expectations)
- Observability (logging, metrics, tracing signals)
- Security & privacy (authN/Z, data protection, threat assumptions)
- Compliance / regulatory constraints (if any)
Integration & External Dependencies:
- External services/APIs and failure modes
- Data import/export formats
- Protocol/versioning assumptions
Edge Cases & Failure Handling:
- Negative scenarios
- Rate limiting / throttling
- Conflict resolution (e.g., concurrent edits)
Constraints & Tradeoffs:
- Technical constraints (language, storage, hosting)
- Explicit tradeoffs or rejected alternatives
Terminology & Consistency:
- Canonical glossary terms
- Avoided synonyms / deprecated terms
Completion Signals:
- Acceptance criteria testability
- Measurable Definition of Done style indicators
Misc / Placeholders:
- TODO markers / unresolved decisions
- Ambiguous adjectives ("robust", "intuitive") lacking quantification
For each category with Partial or Missing status, add a candidate question opportunity unless:
- Clarification would not materially change implementation or validation strategy
- Information is better deferred to planning phase (note internally)
4. Generate (internally) a prioritized queue of candidate clarification questions (maximum 5). Do NOT output them all at once. Apply these constraints:
- Maximum of 5 total questions across the whole session.
- Each question must be answerable with EITHER:
- A short multiplechoice selection (25 distinct, mutually exclusive options), OR
- A one-word / shortphrase answer (explicitly constrain: "Answer in <=5 words").
- Only include questions whose answers materially impact architecture, data modeling, task decomposition, test design, UX behavior, operational readiness, or compliance validation.
- Ensure category coverage balance: attempt to cover the highest impact unresolved categories first; avoid asking two low-impact questions when a single high-impact area (e.g., security posture) is unresolved.
- Exclude questions already answered, trivial stylistic preferences, or plan-level execution details (unless blocking correctness).
- Favor clarifications that reduce downstream rework risk or prevent misaligned acceptance tests.
- If more than 5 categories remain unresolved, select the top 5 by (Impact * Uncertainty) heuristic.
5. Sequential questioning loop (interactive):
- Present EXACTLY ONE question at a time.
- **Question writing quality (applies to every question, MC or short-answer):**
- Lead with `**Question:**` followed by a full interrogative that ends with `?`. The question text before the `?` must make sense on its own.
- NEVER use a topic label, section heading, or requirement id as the question itself. For example, `Acceptance device/runtime matrix (FR-023)` is INVALID — it is a label, not a question.
- After the `?`, the only permitted suffix is an optional parenthesized requirement/question id. Exact format: `**Question:** <interrogative>?` or `**Question:** <interrogative>? (FR-023)`. Never put the id before the `?`, and never use the id (alone or with a topic label) as the whole prompt.
- Immediately after the question line, add one plain-language "Why it matters" sentence (the stake for acceptance or shipping) before the recommendation/options.
- Use everyday wording; introduce jargon only if defined in the same sentence. Self-check: a reader who does not know Spec Kit must be able to answer from the Question line alone. Terse is fine; cryptic labels are not.
- For multiplechoice questions:
- **Analyze all options** and determine the **most suitable option** based on:
- Best practices for the project type
- Common patterns in similar implementations
- Risk reduction (security, performance, maintainability)
- Alignment with any explicit project goals or constraints visible in the spec
- Present your **recommended option prominently** at the top with clear reasoning (1-2 sentences explaining why this is the best choice).
- Format as: `**Recommended:** Option [X] - <reasoning>`
- Then render all options as a Markdown table:
| Option | Description |
|--------|-------------|
| A | <Option A description> |
| B | <Option B description> |
| C | <Option C description> (add D/E as needed up to 5) |
| Short | Provide a different short answer (<=5 words) (Include only if free-form alternative is appropriate) |
- After the table, add: `You can reply with the option letter (e.g., "A"), accept the recommendation by saying "yes" or "recommended", or provide your own short answer.`
- For shortanswer style (no meaningful discrete options):
- Provide your **suggested answer** based on best practices and context.
- Format as: `**Suggested:** <your proposed answer> - <brief reasoning>`
- Then output: `Format: Short answer (<=5 words). You can accept the suggestion by saying "yes" or "suggested", or provide your own answer.`
- After the user answers:
- If the user replies with "yes", "recommended", or "suggested", use your previously stated recommendation/suggestion as the answer.
- Otherwise, validate the answer maps to one option or fits the <=5 word constraint.
- If ambiguous, ask for a quick disambiguation (count still belongs to same question; do not advance).
- Once satisfactory, record it in working memory (do not yet write to disk) and move to the next queued question.
- Stop asking further questions when:
- All critical ambiguities resolved early (remaining queued items become unnecessary), OR
- User signals completion ("done", "good", "no more"), OR
- You reach 5 asked questions.
- Never reveal future queued questions in advance.
- If no valid questions exist at start, immediately report no critical ambiguities.
6. Integration after EACH accepted answer (incremental update approach):
- Maintain in-memory representation of the spec (loaded once at start) plus the raw file contents.
- For the first integrated answer in this session:
- Ensure a `## Clarifications` section exists (create it just after the highest-level contextual/overview section per the spec template if missing).
- Under it, create (if not present) a `### Session YYYY-MM-DD` subheading for today.
- Append a bullet line immediately after acceptance: `- Q: <question> → A: <final answer>`.
- Then immediately apply the clarification to the most appropriate section(s):
- Functional ambiguity → Update or add a bullet in Functional Requirements.
- User interaction / actor distinction → Update User Stories or Actors subsection (if present) with clarified role, constraint, or scenario.
- Data shape / entities → Update Data Model (add fields, types, relationships) preserving ordering; note added constraints succinctly.
- Non-functional constraint → Add/modify measurable criteria in Success Criteria > Measurable Outcomes (convert vague adjective to metric or explicit target).
- Edge case / negative flow → Add a new bullet under Edge Cases / Error Handling (or create such subsection if template provides placeholder for it).
- Terminology conflict → Normalize term across spec; retain original only if necessary by adding `(formerly referred to as "X")` once.
- If the clarification invalidates an earlier ambiguous statement, replace that statement instead of duplicating; leave no obsolete contradictory text.
- Save the spec file AFTER each integration to minimize risk of context loss (atomic overwrite).
- Preserve formatting: do not reorder unrelated sections; keep heading hierarchy intact.
- Keep each inserted clarification minimal and testable (avoid narrative drift).
7. Validation (performed after EACH write plus final pass):
- Clarifications session contains exactly one bullet per accepted answer (no duplicates).
- Total asked (accepted) questions ≤ 5.
- Updated sections contain no lingering vague placeholders the new answer was meant to resolve.
- No contradictory earlier statement remains (scan for now-invalid alternative choices removed).
- Markdown structure valid; only allowed new headings: `## Clarifications`, `### Session YYYY-MM-DD`.
- Terminology consistency: same canonical term used across all updated sections.
8. Write the updated spec back to `FEATURE_SPEC`.
9. **Re-validate Spec Quality Checklist** (if it exists):
- Check if `FEATURE_DIR/checklists/requirements.md` exists.
- If it does NOT exist, skip this step silently.
- If it exists:
1. Read the checklist file.
2. Identify all GitHub task-list checkbox lines — lines matching `- [ ]`, `- [x]`, or `- [X]` (case-insensitive, tolerant of leading whitespace for nested items) outside of code fences. Ignore all other content (headings, notes, non-checkbox bullets, metadata).
3. For each checkbox line, record its current marker state (checked or unchecked) and item text into a before-snapshot list.
4. Re-evaluate each checkbox item against the **updated** spec (the version just saved in step 7).
5. For each checkbox item, update only if the checked/unchecked state actually changes:
- If the item now passes and was unchecked: change `[ ]` to `[x]`.
- If the item now fails and was checked: change `[x]`/`[X]` to `[ ]`.
- If the state is unchanged: leave the marker as-is (preserve existing case to avoid cosmetic diffs).
6. Save the updated checklist file. **Only toggle the `[ ]`/`[x]` marker portion of checkbox lines whose state changed.** All other file content — headings, metadata, notes, line ordering, whitespace — must remain unchanged to avoid noisy diffs.
7. Compare the before-snapshot with the current state to compute three lists for the Completion Report:
- **Newly passing**: items that changed from unchecked to checked.
- **Regressions**: items that changed from checked to unchecked.
- **Still unchecked**: items that remain unchecked.
8. Record the before/after pass counts as checked/total checkbox items (e.g., "12/16 → 15/16 items passing").
Behavior rules:
- If no meaningful ambiguities found (or all potential questions would be low-impact), respond: "No critical ambiguities detected worth formal clarification." and suggest proceeding.
- If spec file missing, instruct user to run `/speckit-specify` first (do not create a new spec here).
- Never exceed 5 total asked questions (clarification retries for a single question do not count as new questions).
- Avoid speculative tech stack questions unless the absence blocks functional clarity.
- Respect user early termination signals ("stop", "done", "proceed").
- If no questions asked due to full coverage, output a compact coverage summary (all categories Clear) then suggest advancing.
- If quota reached with unresolved high-impact categories remaining, explicitly flag them under Deferred with rationale.
Context for prioritization: $ARGUMENTS
## Mandatory Post-Execution Hooks
**You MUST complete this section before reporting completion to the user.**
Check if `.specify/extensions.yml` exists in the project root.
- If it does not exist, or no hooks are registered under `hooks.after_clarify`, skip to the Completion Report.
- If it exists, read it and look for entries under the `hooks.after_clarify` key.
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue to the Completion Report.
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
- For each executable hook, output the following based on its `optional` flag:
- **Mandatory hook** (`optional: false`) — **You MUST emit `EXECUTE_COMMAND:` for each mandatory hook**:
```
## Extension Hooks
**Automatic Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
## Completion Report
Report completion (after questioning loop ends or early termination):
- Number of questions asked & answered.
- Path to updated spec.
- Sections touched (list names).
- Spec quality checklist status (if `FEATURE_DIR/checklists/requirements.md` was re-validated): show before/after pass counts (e.g., "Spec Quality Checklist: 12/16 → 15/16 items passing") and list any items that changed state — both newly checked (unchecked → checked) and any regressions (checked → unchecked). If any items remain unchecked, list them as areas needing attention.
- Coverage summary table listing each taxonomy category with Status: Resolved (was Partial/Missing and addressed), Deferred (exceeds question quota or better suited for planning), Clear (already sufficient), Outstanding (still Partial/Missing but low impact).
- If any Outstanding or Deferred remain, recommend whether to proceed to `/speckit-plan` or run `/speckit-clarify` again later post-plan.
- Suggested next command.
## Done When
- [ ] Spec ambiguities identified and clarifications integrated into spec file
- [ ] Spec quality checklist re-validated against updated spec (if `FEATURE_DIR/checklists/requirements.md` exists)
- [ ] Extension hooks dispatched or skipped according to the rules in Mandatory Post-Execution Hooks above
- [ ] Completion reported to user with questions answered, sections touched, checklist status, and coverage summary
@@ -0,0 +1,180 @@
---
name: "speckit-constitution"
description: "Create or update the project constitution from interactive or provided principle inputs."
argument-hint: "Principles or values for the project constitution"
compatibility: "Requires spec-kit project structure with .specify/ directory"
metadata:
author: "github-spec-kit"
source: "templates/commands/constitution.md"
user-invocable: true
disable-model-invocation: false
---
## User Input
```text
$ARGUMENTS
```
You **MUST** consider the user input before proceeding (if not empty).
## Scope Guard
This command's own work is limited to updating the project constitution itself. Dependent templates
and commands read the constitution at runtime and are not modified here.
- Classify every part of the user input as either constitution content or a separate,
non-governance intent.
- If the input includes feature implementation, code generation, refactoring, building, or
deployment requests, you **MUST NOT** execute them. Extract them as deferred intents instead.
- You **MUST NOT** create, modify, or delete application source files, feature routes,
components, tests, deployment files, or other artifacts unrelated to the constitution
workflow.
- If it is unclear whether an instruction is constitution content, ask for clarification before
making changes.
- After completing the constitution update, include a `Next Actions` section for each deferred
intent. List the original intent and suggest the appropriate follow-up Spec Kit command, such
as `/speckit-specify`, without invoking it.
- If there are no non-governance intents, omit the `Next Actions` section.
## Pre-Execution Checks
**Check for extension hooks (before constitution update)**:
- Check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.before_constitution` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit``/speckit-git-commit`.
- For each executable hook, output the following based on its `optional` flag:
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Pre-Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```
## Extension Hooks
**Automatic Pre-Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
Wait for the result of the hook command before proceeding to the Outline.
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
## Outline
You are updating the project constitution at `.specify/memory/constitution.md`. The active
constitution scaffold is resolved at command time from `constitution-template` through the Spec Kit
preset/template resolution stack.
Follow this execution flow:
1. Run `.specify/scripts/powershell/resolve-template.ps1 constitution-template -Json` from the repository root and parse `TEMPLATE_CONTENT` as the active template.
- The shared resolver applies project overrides, composing preset layers, and extension layers
before the core template fallback. It MUST succeed before continuing.
- If it fails, stop and report the resolution error; do not continue with only one contributing
template layer.
- If `.specify/memory/constitution.md` exists, load it as the source of current project-specific
values and amendments. Preserve information that is still applicable when applying the newly
resolved scaffold.
- If it does not exist, use the resolved template as the initial document.
- Do not write back to any versioned template layer.
- Identify every placeholder token of the form `[ALL_CAPS_IDENTIFIER]`.
**IMPORTANT**: The user might require less or more principles than the ones used in the template. If a number is specified, respect that - follow the general template. You will update the doc accordingly.
2. Collect/derive values for placeholders:
- If user input (conversation) supplies a value, use it.
- Otherwise infer from existing repo context (README, docs, prior constitution versions if embedded).
- For governance dates: `RATIFICATION_DATE` is the original adoption date (if unknown ask or mark TODO), `LAST_AMENDED_DATE` is today if changes are made, otherwise keep previous.
- `CONSTITUTION_VERSION` must increment according to semantic versioning rules:
- MAJOR: Backward incompatible governance/principle removals or redefinitions.
- MINOR: New principle/section added or materially expanded guidance.
- PATCH: Clarifications, wording, typo fixes, non-semantic refinements.
- If version bump type ambiguous, propose reasoning before finalizing.
3. Draft the updated constitution content using the resolved template as the required structure:
- Replace every placeholder with concrete text (no bracketed tokens left except intentionally retained template slots that the project has chosen not to define yet—explicitly justify any left).
- Preserve heading hierarchy and comments can be removed once replaced unless they still add clarifying guidance.
- Ensure each Principle section: succinct name line, paragraph (or bullet list) capturing nonnegotiable rules, explicit rationale if not obvious.
- Ensure Governance section lists amendment procedure, versioning policy, and compliance review expectations.
4. Produce a Sync Impact Report (prepend as an HTML comment at top of the constitution file after update):
- Version change: old → new
- List of modified principles (old title → new title if renamed)
- Added sections
- Removed sections
- Follow-up TODOs if any placeholders intentionally deferred.
5. Validation before final output:
- No remaining unexplained bracket tokens.
- Version line matches report.
- Dates ISO format YYYY-MM-DD.
- Principles are declarative, testable, and free of vague language ("should" → replace with MUST/SHOULD rationale where appropriate).
6. Write the completed constitution back to `.specify/memory/constitution.md` (overwrite).
7. Output a final summary to the user with:
- New version and bump rationale.
- Any TODO placeholders or deferred items requiring manual follow-up.
- Suggested commit message (e.g., `docs: amend constitution to vX.Y.Z (principle additions + governance update)`).
- A `Next Actions` section for any deferred non-governance intents.
Formatting & Style Requirements:
- Use Markdown headings exactly as in the template (do not demote/promote levels).
- Wrap long rationale lines to keep readability (<100 chars ideally) but do not hard enforce with awkward breaks.
- Keep a single blank line between sections.
- Avoid trailing whitespace.
If the user supplies partial updates (e.g., only one principle revision), still perform validation and version decision steps.
If critical info missing (e.g., ratification date truly unknown), insert `TODO(<FIELD_NAME>): explanation` and include in the Sync Impact Report under deferred items.
Write only `.specify/memory/constitution.md`; do not create or modify template source files.
## Post-Execution Checks
**Check for extension hooks (after constitution update)**:
Check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.after_constitution` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
- For each executable hook, output the following based on its `optional` flag:
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```
## Extension Hooks
**Automatic Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
+279
View File
@@ -0,0 +1,279 @@
---
name: "speckit-converge"
description: "Assess the current codebase against the feature's spec, plan, and tasks, then append any remaining unbuilt work as new tasks to tasks.md so implement can complete it."
compatibility: "Requires spec-kit project structure with .specify/ directory"
metadata:
author: "github-spec-kit"
source: "templates/commands/converge.md"
user-invocable: true
disable-model-invocation: false
---
## User Input
```text
$ARGUMENTS
```
You **MUST** consider the user input before proceeding (if not empty).
## Pre-Execution Checks
**Check for extension hooks (before convergence)**:
- Check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.before_converge` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit``/speckit-git-commit`.
- For each executable hook, output the following based on its `optional` flag:
- **Optional hook** (`optional: true`):
```text
## Extension Hooks
**Optional Pre-Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```text
## Extension Hooks
**Automatic Pre-Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
Wait for the result of the hook command before proceeding to the Goal.
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
## Goal
Close the gap between what a feature's specification, plan, and tasks call for and what the
codebase currently implements. Read `spec.md`, `plan.md`, and `tasks.md` as the **sole
source of intent** (with the constitution as governing constraints), assess the current
state of the code, determine which requirements, acceptance criteria, plan decisions, and
existing tasks are unmet, incomplete, or only partially satisfied, and **append each piece
of remaining work as a new, traceable task** at the bottom of `tasks.md` so that
`/speckit-implement` can complete it. This command MUST run only after
`/speckit-implement` has run on the current `tasks.md`, and after `/speckit-tasks` has produced a complete `tasks.md`.
This is **not** a diff tool and does **not** track changes. It assesses the present state
of the code relative to the feature's artifacts — no git, no branch comparison, no history.
## Operating Constraints
**APPEND-ONLY, NEVER REWRITE**: The command's **only** write is appending a new
`## Phase N: Convergence` section to `tasks.md`. It MUST NOT:
- modify `spec.md` or `plan.md` in any way;
- rewrite, renumber, reorder, or delete any existing task (including tasks from a prior
Convergence phase);
- modify, create, or delete any application code — completing the appended tasks is the
job of `/speckit-implement`.
When the codebase already satisfies everything, the command MUST leave `tasks.md`
**byte-for-byte unchanged** (no empty Convergence header) and report a clean result.
**Constitution Authority**: The project constitution (`.specify/memory/constitution.md`) is
**non-negotiable**. Code that violates a MUST principle is the highest-severity finding and
produces a corresponding remediation task. If the constitution is an unfilled template,
skip constitution checks gracefully rather than failing.
## Execution Steps
### 1. Initialize Convergence Context
Run `.specify/scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks` once from repo root and parse JSON for FEATURE_DIR and AVAILABLE_DOCS. Derive absolute paths:
- SPEC = FEATURE_DIR/spec.md
- PLAN = FEATURE_DIR/plan.md
- TASKS = FEATURE_DIR/tasks.md
- CONSTITUTION = `.specify/memory/constitution.md` (if present)
If `spec.md`, `plan.md`, or `tasks.md` is missing, STOP with a clear, actionable message naming the
prerequisite command to run (`/speckit-specify` for a missing spec, `/speckit-plan` for a missing plan,
`/speckit-tasks` for missing tasks). Do not produce partial output.
For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
### 2. Load Artifacts (Progressive Disclosure)
Load only the minimal necessary context from each artifact:
**From spec.md:**
- Functional Requirements (FR-###)
- Success Criteria (SC-###) — include only items requiring buildable work; exclude
post-launch outcome metrics and business KPIs
- User Stories and their Acceptance Scenarios
- Edge Cases (if present)
**From plan.md:**
- Architecture/stack choices and technical decisions
- Data Model references
- Phases and named touch-points (files/components the plan says will be created or edited)
- Technical constraints
**From tasks.md:**
- Task IDs (to compute the next ID and next phase number)
- Descriptions, phase grouping, and referenced file paths
**From constitution (if not an unfilled template):**
- Principle names and MUST/SHOULD normative statements
### 3. Build the Intent Inventory
Create an internal model (do not echo raw artifacts):
- **Requirements inventory**: one stable key per FR-### / SC-### / user-story acceptance
scenario (e.g. `US1/AC2`), plus the plan decisions and constitution principles that
impose buildable obligations.
- **Code-scope map**: from the file paths named in `plan.md` and `tasks.md`, plus a keyword
search for the concepts each requirement describes, derive the set of source files and
components in scope for assessment. Bound the assessment to these — do **not** infer
scope beyond what the artifacts define.
### 4. Assess the Codebase and Classify Findings
For each item in the intent inventory, inspect the current code in scope and produce a
`Finding` only where there is a gap. Classify every finding by **gap type**:
- **`missing`**: the required work is absent from the code entirely.
- **`partial`**: the work exists but does not yet fully satisfy the requirement /
acceptance criterion / plan decision.
- **`contradicts`**: the code does something that conflicts with stated intent or a
constitution MUST principle.
- **`unrequested`**: the code contains work not called for by the spec, plan, or tasks
(surfaced for awareness — converge does **not** delete code, it only appends a task to
review/justify or remove it).
Each `Finding` records: a stable id, the `source-ref` it traces to, the `gap-type`, a
severity, and a short human-readable description with the evidence (the file/area observed).
**Edge cases:**
- **Little or no code yet**: treat the entire specified scope as `missing` remaining work
rather than failing.
- **Nothing remains**: produce zero findings and follow the converged branch in Step 7.
### 5. Assign Severity
- **CRITICAL**: violates a constitution MUST principle, or a `missing`/`contradicts` gap
that blocks baseline functionality of a P1 user story.
- **HIGH**: a `missing` or `partial` gap on a core functional requirement or acceptance
criterion.
- **MEDIUM**: a `partial` gap on a secondary requirement, or an `unrequested` addition with
unclear justification.
- **LOW**: minor partial gaps, polish, or low-risk `unrequested` additions.
### 6. Present the In-Session Findings Summary
Before appending anything, output a compact, severity-graded summary (no file writes yet):
## Convergence Findings
| ID | Gap Type | Severity | Source | Evidence | Remaining Work |
|----|----------|----------|--------|----------|----------------|
| F1 | missing | HIGH | FR-008 | Example: no append-only guard detected in path/to/module.py when writing tasks.md | Add append-only enforcement |
**Summary metrics:**
- Requirements / acceptance criteria checked
- Plan decisions checked
- Constitution principles checked (or "skipped — template")
- Findings by gap type (missing / partial / contradicts / unrequested)
- Findings by severity
### 7. Append Convergence Tasks (or report converged)
**If there are one or more actionable findings** (`tasks_appended` outcome):
Append to the **end** of `tasks.md`, per the append contract:
1. Scan all existing task IDs; let `M` be the maximum. Determine the next phase number `N`
(highest existing phase + 1).
2. Write a single new section header `## Phase N: Convergence`.
3. Emit one checklist item per actionable finding, ordered CRITICAL/HIGH first, assigning
zero-padded IDs `T{M+1:03d}, T{M+2:03d}, …`:
```markdown
- [ ] T042 <imperative description> per <source-ref> (<gap-type>)
```
`<source-ref>` traces the task to its origin: e.g. `FR-003`, `SC-002`,
`US1/AC2`, `plan: storage decision`, `Constitution II`.
`<gap-type>` is one of `missing`, `partial`, `contradicts`, `unrequested`.
Constitution-violation tasks MUST be emitted first and described as
`CRITICAL`.
4. Never reuse or renumber existing IDs. If a prior Convergence phase exists, add a new,
separately-numbered one below it — do not touch the old one.
**If there are no actionable findings** (`converged` outcome):
- Do **not** modify `tasks.md` at all — no empty phase header.
- Report: **"✅ Converged — the implementation satisfies the spec, plan, and tasks."**
- Include the summary counts of what was checked.
### 8. Provide Next Actions (Handoff)
- On `tasks_appended`: state how many tasks were appended under which phase, and recommend
running `/speckit-implement` to complete them; note that a follow-up converge
run will find fewer or no remaining items.
- On `converged`: recommend proceeding to review / opening a PR. No further implement pass
is needed for this feature's specified scope.
### 9. Check for extension hooks
After producing the result, check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.after_converge` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- Report the convergence outcome (`converged` or `tasks_appended`) in-session before listing
any hooks, so users can decide whether to run optional follow-up commands.
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
- For each executable hook, output the following based on its `optional` flag:
- **Optional hook** (`optional: true`):
```text
## Extension Hooks
**Optional Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```text
## Extension Hooks
**Automatic Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
+229
View File
@@ -0,0 +1,229 @@
---
name: "speckit-implement"
description: "Execute the implementation plan by processing and executing all tasks defined in tasks.md"
argument-hint: "Optional implementation guidance or task filter"
compatibility: "Requires spec-kit project structure with .specify/ directory"
metadata:
author: "github-spec-kit"
source: "templates/commands/implement.md"
user-invocable: true
disable-model-invocation: false
---
## User Input
```text
$ARGUMENTS
```
You **MUST** consider the user input before proceeding (if not empty).
## Pre-Execution Checks
**Check for extension hooks (before implementation)**:
- Check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.before_implement` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit``/speckit-git-commit`.
- For each executable hook, output the following based on its `optional` flag:
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Pre-Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```
## Extension Hooks
**Automatic Pre-Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
Wait for the result of the hook command before proceeding to the Outline.
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
## Outline
1. Run `.specify/scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
2. **Check checklists status** (if FEATURE_DIR/checklists/ exists):
- Treat checklist markers as a read-only gate: scan checkbox state, report status, and ask before proceeding when needed; do NOT modify checklist files or markers
- `checklists/requirements.md` is the built-in spec-quality checklist maintained by `/speckit-specify` and `/speckit-clarify`; custom checklists generated by `/speckit-checklist` are reviewer-owned requirements-quality review artifacts
- For custom checklists, `[x]` means the reviewer determined the requirements-quality criterion is satisfied; it does NOT mean implementation work is complete
- Scan all checklist files in the checklists/ directory
- For each checklist, count:
- Total items: All lines matching `- [ ]` or `- [X]` or `- [x]`
- Checked items: Lines matching `- [X]` or `- [x]`
- Unchecked items: Lines matching `- [ ]`
- Create a status table:
```text
| Checklist | Total | Checked | Unchecked | Status |
|-----------|-------|---------|-----------|--------|
| ux.md | 12 | 12 | 0 | ✓ PASS |
| test.md | 8 | 5 | 3 | ✗ FAIL |
| security.md | 6 | 6 | 0 | ✓ PASS |
```
- Calculate overall status:
- **PASS**: All checklists have 0 unchecked items
- **FAIL**: One or more checklists have unchecked items
- **If any checklist has unchecked items**:
- Display the table with unchecked item counts
- **STOP** and ask: "Some checklists have unchecked items. Do you want to proceed with implementation anyway? (yes/no)"
- Wait for user response before continuing
- If user says "no" or "wait" or "stop", halt execution
- If user says "yes" or "proceed" or "continue", proceed to step 3
- **If all checklists are checked**:
- Display the table showing all checklists passed
- Automatically proceed to step 3
3. Load and analyze the implementation context:
- **REQUIRED**: Read tasks.md for the complete task list and execution plan
- **REQUIRED**: Read plan.md for tech stack, architecture, and file structure
- **IF EXISTS**: Read data-model.md for entities and relationships
- **IF EXISTS**: Read contracts/ for API specifications and test requirements
- **IF EXISTS**: Read research.md for technical decisions and constraints
- **IF EXISTS**: Read .specify/memory/constitution.md for governance constraints
- **IF EXISTS**: Read quickstart.md for integration scenarios
4. **Project Setup Verification**:
- **REQUIRED**: Create/verify ignore files based on actual project setup:
**Detection & Creation Logic**:
- Check if the following command succeeds to determine if the repository is a git repo (create/verify .gitignore if so):
```sh
git rev-parse --git-dir 2>/dev/null
```
- Check if Dockerfile* exists or Docker in plan.md → create/verify .dockerignore
- Check if .eslintrc* exists → create/verify .eslintignore
- Check if eslint.config.* exists → ensure the config's `ignores` entries cover required patterns
- Check if .prettierrc* exists → create/verify .prettierignore
- Check if .npmrc or package.json exists → create/verify .npmignore (if publishing)
- Check if terraform files (*.tf) exist → create/verify .terraformignore
- Check if .helmignore needed (helm charts present) → create/verify .helmignore
**If ignore file already exists**: Verify it contains essential patterns, append missing critical patterns only
**If ignore file missing**: Create with full pattern set for detected technology
**Common Patterns by Technology** (from plan.md tech stack):
- **Node.js/JavaScript/TypeScript**: `node_modules/`, `dist/`, `build/`, `*.log`, `.env*`
- **Python**: `__pycache__/`, `*.pyc`, `.venv/`, `venv/`, `dist/`, `*.egg-info/`
- **Java**: `target/`, `*.class`, `*.jar`, `.gradle/`, `build/`
- **C#/.NET**: `bin/`, `obj/`, `*.user`, `*.suo`, `packages/`
- **Go**: `*.exe`, `*.test`, `vendor/`, `*.out`
- **Ruby**: `.bundle/`, `log/`, `tmp/`, `*.gem`, `vendor/bundle/`
- **PHP**: `vendor/`, `*.log`, `*.cache`, `*.env`
- **Rust**: `target/`, `debug/`, `release/`, `*.rs.bk`, `*.rlib`, `*.prof*`, `.idea/`, `*.log`, `.env*`
- **Kotlin**: `build/`, `out/`, `.gradle/`, `.idea/`, `*.class`, `*.jar`, `*.iml`, `*.log`, `.env*`
- **C++**: `build/`, `bin/`, `obj/`, `out/`, `*.o`, `*.so`, `*.a`, `*.exe`, `*.dll`, `.idea/`, `*.log`, `.env*`
- **C**: `build/`, `bin/`, `obj/`, `out/`, `*.o`, `*.a`, `*.so`, `*.exe`, `*.dll`, `autom4te.cache/`, `config.status`, `config.log`, `.idea/`, `*.log`, `.env*`
- **Swift**: `.build/`, `DerivedData/`, `*.swiftpm/`, `Packages/`
- **R**: `.Rproj.user/`, `.Rhistory`, `.RData`, `.Ruserdata`, `*.Rproj`, `packrat/`, `renv/`
- **Universal**: `.DS_Store`, `Thumbs.db`, `*.tmp`, `*.swp`, `.vscode/`, `.idea/`
**Tool-Specific Patterns**:
- **Docker**: `node_modules/`, `.git/`, `Dockerfile*`, `.dockerignore`, `*.log*`, `.env*`, `coverage/`
- **ESLint**: `node_modules/`, `dist/`, `build/`, `coverage/`, `*.min.js`
- **Prettier**: `node_modules/`, `dist/`, `build/`, `coverage/`, `package-lock.json`, `yarn.lock`, `pnpm-lock.yaml`
- **Terraform**: `.terraform/`, `*.tfstate*`, `*.tfvars`, `.terraform.lock.hcl`
- **Kubernetes/k8s**: `*.secret.yaml`, `secrets/`, `.kube/`, `kubeconfig*`, `*.key`, `*.crt`
5. Parse tasks.md structure and extract:
- **Task phases**: Setup, Tests, Core, Integration, Polish
- **Task dependencies**: Sequential vs parallel execution rules
- **Task details**: ID, description, file paths, parallel markers [P]
- **Execution flow**: Order and dependency requirements
6. Execute implementation following the task plan:
- **Phase-by-phase execution**: Complete each phase before moving to the next
- **Respect dependencies**: Run sequential tasks in order, parallel tasks [P] can run together
- **Follow TDD approach**: Execute test tasks before their corresponding implementation tasks
- **File-based coordination**: Tasks affecting the same files must run sequentially
- **Validation checkpoints**: Verify each phase completion before proceeding
7. Implementation execution rules:
- **Setup first**: Initialize project structure, dependencies, configuration
- **Tests before code**: If you need to write tests for contracts, entities, and integration scenarios
- **Core development**: Implement models, services, CLI commands, endpoints
- **Integration work**: Database connections, middleware, logging, external services
- **Polish and validation**: Unit tests, performance optimization, documentation
8. Progress tracking and error handling:
- Report progress after each completed task
- Halt execution if any non-parallel task fails
- For parallel tasks [P], continue with successful tasks, report failed ones
- Provide clear error messages with context for debugging
- Suggest next steps if implementation cannot proceed
- **IMPORTANT** For completed tasks, make sure to mark the task off as [X] in the tasks file.
9. Completion validation:
- Verify all required tasks are completed
- Check that implemented features match the original specification
- Validate that tests pass and coverage meets requirements
- Confirm the implementation follows the technical plan
Note: This command assumes a complete task breakdown exists in tasks.md. If tasks are incomplete or missing, suggest running `/speckit-tasks` first to regenerate the task list.
## Mandatory Post-Execution Hooks
**You MUST complete this section before reporting completion to the user.**
Check if `.specify/extensions.yml` exists in the project root.
- If it does not exist, or no hooks are registered under `hooks.after_implement`, skip to the Completion Report.
- If it exists, read it and look for entries under the `hooks.after_implement` key.
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue to the Completion Report.
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
- For each executable hook, output the following based on its `optional` flag:
- **Mandatory hook** (`optional: false`) — **You MUST emit `EXECUTE_COMMAND:` for each mandatory hook**:
```
## Extension Hooks
**Automatic Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
## Completion Report
Report final status with summary of completed work.
## Done When
- [ ] All tasks in tasks.md completed and marked `[X]`
- [ ] Implementation validated against specification, plan, and test coverage
- [ ] Extension hooks dispatched or skipped according to the rules in Mandatory Post-Execution Hooks above
- [ ] Completion reported to user with summary of completed work
+169
View File
@@ -0,0 +1,169 @@
---
name: "speckit-plan"
description: "Execute the implementation planning workflow using the plan template to generate design artifacts."
argument-hint: "Optional guidance for the planning phase"
compatibility: "Requires spec-kit project structure with .specify/ directory"
metadata:
author: "github-spec-kit"
source: "templates/commands/plan.md"
user-invocable: true
disable-model-invocation: false
---
## User Input
```text
$ARGUMENTS
```
You **MUST** consider the user input before proceeding (if not empty).
## Pre-Execution Checks
**Check for extension hooks (before planning)**:
- Check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.before_plan` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit``/speckit-git-commit`.
- For each executable hook, output the following based on its `optional` flag:
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Pre-Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```
## Extension Hooks
**Automatic Pre-Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
Wait for the result of the hook command before proceeding to the Outline.
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
## Outline
1. **Setup**: Run `.specify/scripts/powershell/setup-plan.ps1 -Json` from repo root and parse JSON for FEATURE_SPEC, IMPL_PLAN, SPECS_DIR, BRANCH. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
2. **Load context**: Read FEATURE_SPEC and `.specify/memory/constitution.md`. Load IMPL_PLAN template (already copied).
3. **Execute plan workflow**: Follow the structure in IMPL_PLAN template to:
- Fill Technical Context (mark unknowns as "NEEDS CLARIFICATION")
- Fill Constitution Check section from constitution
- Evaluate gates (ERROR if violations unjustified)
- Phase 0: Generate research.md (resolve all NEEDS CLARIFICATION)
- Phase 1: Generate data-model.md, contracts/, quickstart.md
- Re-evaluate Constitution Check post-design
## Mandatory Post-Execution Hooks
**You MUST complete this section before reporting completion to the user.**
Check if `.specify/extensions.yml` exists in the project root.
- If it does not exist, or no hooks are registered under `hooks.after_plan`, skip to the Completion Report.
- If it exists, read it and look for entries under the `hooks.after_plan` key.
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue to the Completion Report.
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
- For each executable hook, output the following based on its `optional` flag:
- **Mandatory hook** (`optional: false`) — **You MUST emit `EXECUTE_COMMAND:` for each mandatory hook**:
```
## Extension Hooks
**Automatic Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
## Completion Report
Command ends after Phase 1 design. Report branch, IMPL_PLAN path, and generated artifacts.
## Phases
### Phase 0: Outline & Research
1. **Extract unknowns from Technical Context** above:
- For each NEEDS CLARIFICATION → research task
- For each dependency → best practices task
- For each integration → patterns task
2. **Generate and dispatch research agents**:
```text
For each unknown in Technical Context:
Task: "Research {unknown} for {feature context}"
For each technology choice:
Task: "Find best practices for {tech} in {domain}"
```
3. **Consolidate findings** in `research.md` using format:
- Decision: [what was chosen]
- Rationale: [why chosen]
- Alternatives considered: [what else evaluated]
**Output**: research.md with all NEEDS CLARIFICATION resolved
### Phase 1: Design & Contracts
**Prerequisites:** `research.md` complete
1. **Extract entities from feature spec** → `data-model.md`:
- Entity name, fields, relationships
- Validation rules from requirements
- State transitions if applicable
2. **Define interface contracts** (if project has external interfaces) → `/contracts/`:
- Identify what interfaces the project exposes to users or other systems
- Document the contract format appropriate for the project type
- Examples: public APIs for libraries, command schemas for CLI tools, endpoints for web services, grammars for parsers, UI contracts for applications
- Skip if project is purely internal (build scripts, one-off tools, etc.)
3. **Create quickstart validation guide** → `quickstart.md`:
- Document runnable validation scenarios that prove the feature works end-to-end
- Include prerequisites, setup commands, test/run commands, and expected outcomes
- Use links or references to contracts and data model details instead of duplicating them
- Do not include full implementation code, model/service/controller bodies, migrations, or complete test suites
- Keep this artifact as a validation/run guide; implementation details belong in `tasks.md` and the implementation phase
**Output**: data-model.md, /contracts/*, quickstart.md
## Key rules
- Use absolute paths for filesystem operations; use project-relative paths for references in documentation
- ERROR on gate failures or unresolved clarifications
## Done When
- [ ] Plan workflow executed and design artifacts generated
- [ ] Extension hooks dispatched or skipped according to the rules in Mandatory Post-Execution Hooks above
- [ ] Completion reported to user with branch, plan path, and generated artifacts
+348
View File
@@ -0,0 +1,348 @@
---
name: "speckit-specify"
description: "Create or update the feature specification from a natural language feature description."
argument-hint: "Describe the feature you want to specify"
compatibility: "Requires spec-kit project structure with .specify/ directory"
metadata:
author: "github-spec-kit"
source: "templates/commands/specify.md"
user-invocable: true
disable-model-invocation: false
---
## User Input
```text
$ARGUMENTS
```
You **MUST** consider the user input before proceeding (if not empty).
## Pre-Execution Checks
**Check for extension hooks (before specification)**:
- Check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.before_specify` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit``/speckit-git-commit`.
- For each executable hook, output the following based on its `optional` flag:
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Pre-Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```
## Extension Hooks
**Automatic Pre-Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
Wait for the result of the hook command before proceeding to the Outline.
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
## Outline
The text the user typed after `/speckit-specify` in the triggering message **is** the feature description. Assume you always have it available in this conversation even if `$ARGUMENTS` appears literally below. Do not ask the user to repeat it unless they provided an empty command.
Given that feature description, do this:
1. **Generate a concise short name** (2-4 words) for the feature:
- Analyze the feature description and extract the most meaningful keywords
- Create a 2-4 word short name that captures the essence of the feature
- Use action-noun format when possible (e.g., "add-user-auth", "fix-payment-bug")
- Preserve technical terms and acronyms (OAuth2, API, JWT, etc.)
- Keep it concise but descriptive enough to understand the feature at a glance
- Examples:
- "I want to add user authentication" → "user-auth"
- "Implement OAuth2 integration for the API" → "oauth2-api-integration"
- "Create a dashboard for analytics" → "analytics-dashboard"
- "Fix payment processing timeout bug" → "fix-payment-timeout"
2. **Branch creation** (optional, via hook):
If a `before_specify` hook ran successfully in the Pre-Execution Checks above, it will have created/switched to a git branch and output JSON containing `BRANCH_NAME` and `FEATURE_NUM`. Note these values for reference, but the branch name does **not** dictate the spec directory name.
If the user explicitly provided `GIT_BRANCH_NAME`, pass it through to the hook so the branch script uses the exact value as the branch name (bypassing all prefix/suffix generation).
3. **Create the spec feature directory**:
Specs live under the default `specs/` directory unless the user explicitly provides `SPECIFY_FEATURE_DIRECTORY`.
**Resolution order for `SPECIFY_FEATURE_DIRECTORY`**:
1. If the user explicitly provided `SPECIFY_FEATURE_DIRECTORY` (e.g., via environment variable, argument, or configuration), use it as-is
2. Otherwise, auto-generate it under `specs/`:
- Check `.specify/init-options.json` for `feature_numbering` (preferred) or `branch_numbering` (deprecated, migration only — will be removed in a future release)
- If `"timestamp"`: prefix is `YYYYMMDD-HHMMSS` (current timestamp)
- If `"sequential"` or absent: prefix is `NNN` (next available 3-digit number after scanning existing directories in `specs/`)
- Construct the directory name: `<prefix>-<short-name>` (e.g., `003-user-auth` or `20260319-143022-user-auth`)
- Set `SPECIFY_FEATURE_DIRECTORY` to `specs/<directory-name>`
- If `branch_numbering` was used (and `feature_numbering` was absent), emit a one-line warning: "⚠️ `branch_numbering` in init-options.json is deprecated. Rename to `feature_numbering`."
**Create the directory and spec file**:
- `mkdir -p SPECIFY_FEATURE_DIRECTORY`
- Resolve the active `spec-template` through the Spec Kit preset/template resolution stack (equivalent to `specify preset resolve spec-template`)
- Copy the resolved `spec-template` file to `SPECIFY_FEATURE_DIRECTORY/spec.md` as the starting point
- Set `SPEC_FILE` to `SPECIFY_FEATURE_DIRECTORY/spec.md`
- Persist the resolved path to `.specify/feature.json`:
```json
{
"feature_directory": "<resolved feature dir>"
}
```
Write the actual resolved directory path value (for example, `specs/003-user-auth`), not the literal string `SPECIFY_FEATURE_DIRECTORY`.
This allows downstream commands (`/speckit-plan`, `/speckit-tasks`, etc.) to locate the feature directory without relying on git branch name conventions.
**IMPORTANT**:
- You must only create one feature per `/speckit-specify` invocation
- The spec directory name and the git branch name are independent — they may be the same but that is the user's choice
- The spec directory and file are always created by this command, never by the hook
4. Load the resolved active `spec-template` file to understand required sections.
5. **IF EXISTS**: Load `.specify/memory/constitution.md` for project principles and governance constraints.
6. Follow this execution flow:
1. Parse user description from arguments
If empty: ERROR "No feature description provided"
2. Extract key concepts from description
Identify: actors, actions, data, constraints
3. For unclear aspects:
- Make informed guesses based on context and industry standards
- Only mark with [NEEDS CLARIFICATION: specific question] if:
- The choice significantly impacts feature scope or user experience
- Multiple reasonable interpretations exist with different implications
- No reasonable default exists
- **LIMIT: Maximum 3 [NEEDS CLARIFICATION] markers total**
- Prioritize clarifications by impact: scope > security/privacy > user experience > technical details
4. Fill User Scenarios & Testing section
If no clear user flow: ERROR "Cannot determine user scenarios"
5. Generate Functional Requirements
Each requirement must be testable
Use reasonable defaults for unspecified details (document assumptions in Assumptions section)
6. Define Success Criteria
Create measurable, technology-agnostic outcomes
Include both quantitative metrics (time, performance, volume) and qualitative measures (user satisfaction, task completion)
Each criterion must be verifiable without implementation details
7. Identify Key Entities (if data involved)
8. Return: SUCCESS (spec ready for planning)
7. Write the specification to SPEC_FILE using the template structure, replacing placeholders with concrete details derived from the feature description (arguments) while preserving section order and headings.
8. **Specification Quality Validation**: After writing the initial spec, validate it against quality criteria:
a. **Create Spec Quality Checklist**: Generate a checklist file at `SPECIFY_FEATURE_DIRECTORY/checklists/requirements.md` using the checklist template structure with these validation items:
```markdown
# Specification Quality Checklist: [FEATURE NAME]
**Purpose**: Validate specification completeness and quality before proceeding to planning
**Created**: [DATE]
**Feature**: [Link to spec.md]
## Content Quality
- [ ] No implementation details (languages, frameworks, APIs)
- [ ] Focused on user value and business needs
- [ ] Written for non-technical stakeholders
- [ ] All mandatory sections completed
## Requirement Completeness
- [ ] No [NEEDS CLARIFICATION] markers remain
- [ ] Requirements are testable and unambiguous
- [ ] Success criteria are measurable
- [ ] Success criteria are technology-agnostic (no implementation details)
- [ ] All acceptance scenarios are defined
- [ ] Edge cases are identified
- [ ] Scope is clearly bounded
- [ ] Dependencies and assumptions identified
## Feature Readiness
- [ ] All functional requirements have clear acceptance criteria
- [ ] User scenarios cover primary flows
- [ ] Feature meets measurable outcomes defined in Success Criteria
- [ ] No implementation details leak into specification
## Notes
- Items marked incomplete require spec updates before `/speckit-clarify` or `/speckit-plan`
```
b. **Run Validation Check**: Review the spec against each checklist item:
- For each item, determine if it passes or fails
- Document specific issues found (quote relevant spec sections)
c. **Handle Validation Results**:
- **If all items pass**: Mark checklist complete and proceed to the Mandatory Post-Execution Hooks section
- **If items fail (excluding [NEEDS CLARIFICATION])**:
1. List the failing items and specific issues
2. Update the spec to address each issue
3. Re-run validation until all items pass (max 3 iterations)
4. If still failing after 3 iterations, document remaining issues in checklist notes and warn user
- **If [NEEDS CLARIFICATION] markers remain**:
1. Extract all [NEEDS CLARIFICATION: ...] markers from the spec
2. **LIMIT CHECK**: If more than 3 markers exist, keep only the 3 most critical (by scope/security/UX impact) and make informed guesses for the rest
3. For each clarification needed (max 3), present options to user in this format:
```markdown
## Question [N]: [Topic]
**Context**: [Quote relevant spec section]
**What we need to know**: [Specific question from NEEDS CLARIFICATION marker]
**Suggested Answers**:
| Option | Answer | Implications |
|--------|--------|--------------|
| A | [First suggested answer] | [What this means for the feature] |
| B | [Second suggested answer] | [What this means for the feature] |
| C | [Third suggested answer] | [What this means for the feature] |
| Custom | Provide your own answer | [Explain how to provide custom input] |
**Your choice**: _[Wait for user response]_
```
4. **CRITICAL - Table Formatting**: Ensure markdown tables are properly formatted:
- Use consistent spacing with pipes aligned
- Each cell should have spaces around content: `| Content |` not `|Content|`
- Header separator must have at least 3 dashes: `|--------|`
- Test that the table renders correctly in markdown preview
5. Number questions sequentially (Q1, Q2, Q3 - max 3 total)
6. Present all questions together before waiting for responses
7. Wait for user to respond with their choices for all questions (e.g., "Q1: A, Q2: Custom - [details], Q3: B")
8. Update the spec by replacing each [NEEDS CLARIFICATION] marker with the user's selected or provided answer
9. Re-run validation after all clarifications are resolved
d. **Update Checklist**: After each validation iteration, update the checklist file with current pass/fail status
## Mandatory Post-Execution Hooks
**You MUST complete this section before reporting completion to the user.**
Check if `.specify/extensions.yml` exists in the project root.
- If it does not exist, or no hooks are registered under `hooks.after_specify`, skip to the Completion Report.
- If it exists, read it and look for entries under the `hooks.after_specify` key.
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue to the Completion Report.
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
- For each executable hook, output the following based on its `optional` flag:
- **Mandatory hook** (`optional: false`) — **You MUST emit `EXECUTE_COMMAND:` for each mandatory hook**:
```
## Extension Hooks
**Automatic Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
## Completion Report
Report completion to the user with:
- `SPECIFY_FEATURE_DIRECTORY` — the feature directory path
- `SPEC_FILE` — the spec file path
- Checklist results summary
- Readiness for the next phase (`/speckit-clarify` or `/speckit-plan`)
**NOTE:** Branch creation is handled by the `before_specify` hook (git extension). Spec directory and file creation are always handled by this core command.
## Quick Guidelines
- Focus on **WHAT** users need and **WHY**.
- Avoid HOW to implement (no tech stack, APIs, code structure).
- Written for business stakeholders, not developers.
- DO NOT create any checklists that are embedded in the spec. That will be a separate command.
### Section Requirements
- **Mandatory sections**: Must be completed for every feature
- **Optional sections**: Include only when relevant to the feature
- When a section doesn't apply, remove it entirely (don't leave as "N/A")
### For AI Generation
When creating this spec from a user prompt:
1. **Make informed guesses**: Use context, industry standards, and common patterns to fill gaps
2. **Document assumptions**: Record reasonable defaults in the Assumptions section
3. **Limit clarifications**: Maximum 3 [NEEDS CLARIFICATION] markers - use only for critical decisions that:
- Significantly impact feature scope or user experience
- Have multiple reasonable interpretations with different implications
- Lack any reasonable default
4. **Prioritize clarifications**: scope > security/privacy > user experience > technical details
5. **Think like a tester**: Every vague requirement should fail the "testable and unambiguous" checklist item
6. **Common areas needing clarification** (only if no reasonable default exists):
- Feature scope and boundaries (include/exclude specific use cases)
- User types and permissions (if multiple conflicting interpretations possible)
- Security/compliance requirements (when legally/financially significant)
**Examples of reasonable defaults** (don't ask about these):
- Data retention: Industry-standard practices for the domain
- Performance targets: Standard web/mobile app expectations unless specified
- Error handling: User-friendly messages with appropriate fallbacks
- Authentication method: Standard session-based or OAuth2 for web apps
- Integration patterns: Use project-appropriate patterns (REST/GraphQL for web services, function calls for libraries, CLI args for tools, etc.)
### Success Criteria Guidelines
Success criteria must be:
1. **Measurable**: Include specific metrics (time, percentage, count, rate)
2. **Technology-agnostic**: No mention of frameworks, languages, databases, or tools
3. **User-focused**: Describe outcomes from user/business perspective, not system internals
4. **Verifiable**: Can be tested/validated without knowing implementation details
**Good examples**:
- "Users can complete checkout in under 3 minutes"
- "System supports 10,000 concurrent users"
- "95% of searches return results in under 1 second"
- "Task completion rate improves by 40%"
**Bad examples** (implementation-focused):
- "API response time is under 200ms" (too technical, use "Users see results instantly")
- "Database can handle 1000 TPS" (implementation detail, use user-facing metric)
- "React components render efficiently" (framework-specific)
- "Redis cache hit rate above 80%" (technology-specific)
## Done When
- [ ] Specification written to `SPEC_FILE` and validated against quality checklist
- [ ] Extension hooks dispatched or skipped according to the rules in Mandatory Post-Execution Hooks above
- [ ] Completion reported to user with feature directory, spec file path, and checklist results
+217
View File
@@ -0,0 +1,217 @@
---
name: "speckit-tasks"
description: "Generate an actionable, dependency-ordered tasks.md for the feature based on available design artifacts."
argument-hint: "Optional task generation constraints"
compatibility: "Requires spec-kit project structure with .specify/ directory"
metadata:
author: "github-spec-kit"
source: "templates/commands/tasks.md"
user-invocable: true
disable-model-invocation: false
---
## User Input
```text
$ARGUMENTS
```
You **MUST** consider the user input before proceeding (if not empty).
## Pre-Execution Checks
**Check for extension hooks (before tasks generation)**:
- Check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.before_tasks` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit``/speckit-git-commit`.
- For each executable hook, output the following based on its `optional` flag:
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Pre-Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```
## Extension Hooks
**Automatic Pre-Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
Wait for the result of the hook command before proceeding to the Outline.
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
## Outline
1. **Setup**: Run `.specify/scripts/powershell/setup-tasks.ps1 -Json` from repo root and parse FEATURE_DIR, TASKS_TEMPLATE_CONTENT, TASKS_TEMPLATE, and AVAILABLE_DOCS list. `FEATURE_DIR` and `TASKS_TEMPLATE` must be absolute paths when provided. `AVAILABLE_DOCS` is a list of document names/relative paths available under `FEATURE_DIR` (for example `research.md` or `contracts/`). For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
2. **Load design documents**: Read from FEATURE_DIR:
- **Required**: plan.md (tech stack, libraries, structure), spec.md (user stories with priorities)
- **Optional**: data-model.md (entities), contracts/ (interface contracts), research.md (decisions), quickstart.md (test scenarios)
- **IF EXISTS**: Load `.specify/memory/constitution.md` for project principles and governance constraints
- Note: Not all projects have all documents. Generate tasks based on what's available.
3. **Execute task generation workflow**:
- Load plan.md and extract tech stack, libraries, project structure
- Load spec.md and extract user stories with their priorities (P1, P2, P3, etc.)
- If data-model.md exists: Extract entities and map to user stories
- If contracts/ exists: Map interface contracts to user stories
- If research.md exists: Extract decisions for setup tasks
- Generate tasks organized by user story (see Task Generation Rules below)
- Generate dependency graph showing user story completion order
- Create parallel execution examples per user story
- Validate task completeness (each user story has all needed tasks, independently testable)
4. **Generate tasks.md**: Use TASKS_TEMPLATE_CONTENT (from the JSON output above) as the structure. For compatibility with older setup scripts that omit TASKS_TEMPLATE_CONTENT, read TASKS_TEMPLATE instead. Fill with:
- Correct feature name from plan.md
- Phase 1: Setup tasks (project initialization)
- Phase 2: Foundational tasks (blocking prerequisites for all user stories)
- Phase 3+: One phase per user story (in priority order from spec.md)
- Each phase includes: story goal, independent test criteria, tests (if requested), implementation tasks
- Final Phase: Polish & cross-cutting concerns
- All tasks must follow the strict checklist format (see Task Generation Rules below)
- Clear file paths for each task
- Dependencies section showing story completion order
- Parallel execution examples per story
- Implementation strategy section (MVP first, incremental delivery)
## Mandatory Post-Execution Hooks
**You MUST complete this section before reporting completion to the user.**
Check if `.specify/extensions.yml` exists in the project root.
- If it does not exist, or no hooks are registered under `hooks.after_tasks`, skip to the Completion Report.
- If it exists, read it and look for entries under the `hooks.after_tasks` key.
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue to the Completion Report.
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
- For each executable hook, output the following based on its `optional` flag:
- **Mandatory hook** (`optional: false`) — **You MUST emit `EXECUTE_COMMAND:` for each mandatory hook**:
```
## Extension Hooks
**Automatic Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
## Completion Report
Output path to generated tasks.md and summary:
- Total task count
- Task count per user story
- Parallel opportunities identified
- Independent test criteria for each story
- Suggested MVP scope (typically just User Story 1)
- Format validation: Confirm ALL tasks follow the checklist format (checkbox, ID, labels, file paths)
Context for task generation: $ARGUMENTS
The tasks.md should be immediately executable - each task must be specific enough that an LLM can complete it without additional context.
## Task Generation Rules
**CRITICAL**: Tasks MUST be organized by user story to enable independent implementation and testing.
**Tests are OPTIONAL**: Only generate test tasks if explicitly requested in the feature specification or if user requests TDD approach.
### Checklist Format (REQUIRED)
Every task MUST strictly follow this format:
```text
- [ ] [TaskID] [P?] [Story?] Description with file path
```
**Format Components**:
1. **Checkbox**: ALWAYS start with `- [ ]` (markdown checkbox)
2. **Task ID**: Sequential number (T001, T002, T003...) in execution order
3. **[P] marker**: Include ONLY if task is parallelizable (different files, no dependencies on incomplete tasks)
4. **[Story] label**: REQUIRED for user story phase tasks only
- Format: [US1], [US2], [US3], etc. (maps to user stories from spec.md)
- Setup phase: NO story label
- Foundational phase: NO story label
- User Story phases: MUST have story label
- Polish phase: NO story label
5. **Description**: Clear action with exact file path
**Examples**:
- ✅ CORRECT: `- [ ] T001 Create project structure per implementation plan`
- ✅ CORRECT: `- [ ] T005 [P] Implement authentication middleware in src/middleware/auth.py`
- ✅ CORRECT: `- [ ] T012 [P] [US1] Create User model in src/models/user.py`
- ✅ CORRECT: `- [ ] T014 [US1] Implement UserService in src/services/user_service.py`
- ❌ WRONG: `- [ ] Create User model` (missing ID and Story label)
- ❌ WRONG: `T001 [US1] Create model` (missing checkbox)
- ❌ WRONG: `- [ ] [US1] Create User model` (missing Task ID)
- ❌ WRONG: `- [ ] T001 [US1] Create model` (missing file path)
### Task Organization
1. **From User Stories (spec.md)** - PRIMARY ORGANIZATION:
- Each user story (P1, P2, P3...) gets its own phase
- Map all related components to their story:
- Models needed for that story
- Services needed for that story
- Interfaces/UI needed for that story
- If tests requested: Tests specific to that story
- Mark story dependencies (most stories should be independent)
2. **From Contracts**:
- Map each interface contract → to the user story it serves
- If tests requested: Each interface contract → contract test task [P] before implementation in that story's phase
3. **From Data Model**:
- Map each entity to the user story(ies) that need it
- If entity serves multiple stories: Put in earliest story or Setup phase
- Relationships → service layer tasks in appropriate story phase
4. **From Setup/Infrastructure**:
- Shared infrastructure → Setup phase (Phase 1)
- Foundational/blocking tasks → Foundational phase (Phase 2)
- Story-specific setup → within that story's phase
### Phase Structure
- **Phase 1**: Setup (project initialization)
- **Phase 2**: Foundational (blocking prerequisites - MUST complete before user stories)
- **Phase 3+**: User Stories in priority order (P1, P2, P3...)
- Within each story: Tests (if requested) → Models → Services → Endpoints → Integration
- Each phase should be a complete, independently testable increment
- **Final Phase**: Polish & Cross-Cutting Concerns
## Done When
- [ ] tasks.md generated with all phases, task IDs, and file paths
- [ ] Extension hooks dispatched or skipped according to the rules in Mandatory Post-Execution Hooks above
- [ ] Completion reported to user with task count, story breakdown, and MVP scope
@@ -0,0 +1,112 @@
---
name: "speckit-taskstoissues"
description: "Convert existing tasks into actionable, dependency-ordered GitHub issues for the feature based on available design artifacts."
argument-hint: "Optional filter or label for GitHub issues"
compatibility: "Requires spec-kit project structure with .specify/ directory"
metadata:
author: "github-spec-kit"
source: "templates/commands/taskstoissues.md"
user-invocable: true
disable-model-invocation: false
---
## User Input
```text
$ARGUMENTS
```
You **MUST** consider the user input before proceeding (if not empty).
## Pre-Execution Checks
**Check for extension hooks (before tasks-to-issues conversion)**:
- Check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.before_taskstoissues` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit``/speckit-git-commit`.
- For each executable hook, output the following based on its `optional` flag:
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Pre-Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```
## Extension Hooks
**Automatic Pre-Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
Wait for the result of the hook command before proceeding to the Outline.
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
## Outline
1. Run `.specify/scripts/powershell/check-prerequisites.ps1 -Json -RequireTasks -IncludeTasks` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
1. **IF EXISTS**: Load `.specify/memory/constitution.md` for project principles and governance constraints.
1. From the executed script, extract the path to **tasks**.
1. Get the Git remote by running:
```bash
git config --get remote.origin.url
```
> [!CAUTION]
> ONLY PROCEED TO NEXT STEPS IF THE REMOTE IS A GITHUB URL
1. **Fetch existing issues for deduplication**: Before creating anything, build the set of task IDs you are about to process from `tasks.md` (each is a `T` followed by **at least** three digits, e.g. `T001` — `/speckit-converge` assigns new IDs with `T{M+1:03d}`, which is a floor rather than a cap, so once a file has more than 999 tasks the IDs are four digits or longer). Then use the GitHub MCP server's `list_issues` tool to look for issues that already cover those IDs. Do not pass a `state` value, since omitting it makes the tool return both open and closed issues. Request `perPage: 100` to keep the number of calls down, and since the tool uses cursor-based pagination, request pages with the `after` parameter (using the `endCursor` from the previous response). For each issue title, match it against the task ID pattern `\bT\d{3,}\b` (the `{3,}` accepts four-digit and longer IDs — with `\d{3}` a title containing `T1000` would not match at all, because the trailing `\b` cannot fall between two digits, so that task would be silently neither deduplicated nor created; word boundaries still stop a token like `ST001` from matching, and force the whole digit run to be consumed so `T100` can never match inside `T1000`; this also recognises titles written as `T001 ...`, `T001: ...` or `[T001] ...`) and, when it matches one of your task IDs, mark that ID as already having an issue. Stop paginating as soon as every task ID has been matched, or when there are no more pages, so you do not keep fetching the whole repository's issue history once all task IDs are accounted for. This bounds the number of calls on repos with large issue histories and still prevents duplicates when the command is re-run after `tasks.md` is regenerated or the skill is re-invoked.
1. For each task in the list, use the GitHub MCP server to create a new issue in the repository that is representative of the Git remote. Task lines in `tasks.md` start with a markdown checkbox, so first strip the leading `- [ ]` (and any `[P]` / `[US#]` markers) to recover the task ID and its description. Create the issue with a single canonical title of the form `T001: <description>`, with the ID written once followed by the task description (for example, the line `- [ ] T001 Create project structure` becomes the title `T001: Create project structure`).
- **Skip** any task whose ID is already present in the set of existing issues from the previous step, and report it (for example, `T001 already has an issue, skipping`).
- Only create issues for tasks that do not yet have a matching issue.
> [!CAUTION]
> UNDER NO CIRCUMSTANCES EVER CREATE ISSUES IN REPOSITORIES THAT DO NOT MATCH THE REMOTE URL
## Post-Execution Checks
**Check for extension hooks (after tasks-to-issues conversion)**:
Check if `.specify/extensions.yml` exists in the project root.
- If it exists, read it and look for entries under the `hooks.after_taskstoissues` key
- If the YAML cannot be parsed or is invalid, skip hook checking silently and continue normally
- Filter out hooks where `enabled` is explicitly `false`. Treat hooks without an `enabled` field as enabled by default.
- For each remaining hook, do **not** attempt to interpret or evaluate hook `condition` expressions:
- If the hook has no `condition` field, or it is null/empty, treat the hook as executable
- If the hook defines a non-empty `condition`, skip the hook and leave condition evaluation to the HookExecutor implementation
- When constructing command invocations from hook command names, replace dots (`.`) with hyphens (`-`). For example, `speckit.git.commit` → `/speckit-git-commit`.
- For each executable hook, output the following based on its `optional` flag:
- **Optional hook** (`optional: true`):
```
## Extension Hooks
**Optional Hook**: {extension}
Command: `/{command}`
Description: {description}
Prompt: {prompt}
To execute: `/{command}`
```
- **Mandatory hook** (`optional: false`):
```
## Extension Hooks
**Automatic Hook**: {extension}
Executing: `/{command}`
EXECUTE_COMMAND: {command}
```
After emitting the block above you MUST actually invoke the hook and wait for it to finish before continuing. Run it the same way you would run the command yourself in this agent/session (the invocation may differ from the literal `{command}` id shown above, e.g. a skills-mode agent runs it as `/skill:speckit-...` or `$speckit-...`). Emitting the block alone does not run the hook.
- If no hooks are registered or `.specify/extensions.yml` does not exist, skip silently
+160 -35
View File
@@ -1,50 +1,175 @@
# [PROJECT_NAME] Constitution <!--
<!-- Example: Spec Constitution, TaskFlow Constitution, etc. --> Sync Impact Report
==================
Version change: (unversioned template) → 1.0.0
Bump rationale: Initial ratification. All placeholder tokens replaced with concrete,
project-specific governance derived from README.md and the approved
Phase 0 baseline (Persona-Engine-Developer-Handoff.txt).
Modified principles:
[PRINCIPLE_1_NAME] → I. Deterministic, Single-Persona Classification (NON-NEGOTIABLE)
[PRINCIPLE_2_NAME] → II. Configuration-Driven Rules
[PRINCIPLE_3_NAME] → III. Fail-Safe, Idempotent Persistence (NON-NEGOTIABLE)
[PRINCIPLE_4_NAME] → IV. Pure Rule Engine, Tested Offline First
[PRINCIPLE_5_NAME] → V. Explainable, Sanitized Observability
Added sections:
[SECTION_2_NAME] → Security and Least-Privilege Constraints
[SECTION_3_NAME] → Development Workflow and Quality Gates
Governance rules populated (amendment procedure, versioning policy, compliance review)
Removed sections: none
Follow-up TODOs: none — no placeholders deferred.
-->
# Persona Engine Constitution
## Core Principles ## Core Principles
### [PRINCIPLE_1_NAME] ### I. Deterministic, Single-Persona Classification (NON-NEGOTIABLE)
<!-- Example: I. Library-First -->
[PRINCIPLE_1_DESCRIPTION]
<!-- Example: Every feature starts as a standalone library; Libraries must be self-contained, independently testable, documented; Clear purpose required - no organizational-only libraries -->
### [PRINCIPLE_2_NAME] Identical input records and identical configuration MUST always produce an identical persona
<!-- Example: II. CLI Interface --> decision. Every evaluated account MUST receive exactly one persona — never zero, never
[PRINCIPLE_2_DESCRIPTION] several. Rules MUST be evaluated in ascending numeric priority order and evaluation MUST stop
<!-- Example: Every library exposes functionality via CLI; Text in/out protocol: stdin/args → stdout, errors → stderr; Support JSON + human-readable formats --> at the first match. When all enabled rules evaluate successfully and none match, the result
MUST be `Unclassified`; when evaluation cannot complete reliably, the result MUST be
`EvaluationError`. Non-deterministic inputs — wall-clock time, random values, unordered
collection enumeration, or environment state — MUST NOT influence a decision.
### [PRINCIPLE_3_NAME] **Rationale**: Persona values drive access and Conditional Access decisions. A classification
<!-- Example: III. Test-First (NON-NEGOTIABLE) --> that varies between runs is unreviewable and cannot be safely enforced.
[PRINCIPLE_3_DESCRIPTION]
<!-- Example: TDD mandatory: Tests written → User approved → Tests fail → Then implement; Red-Green-Refactor cycle strictly enforced -->
### [PRINCIPLE_4_NAME] ### II. Configuration-Driven Rules
<!-- Example: IV. Integration Testing -->
[PRINCIPLE_4_DESCRIPTION]
<!-- Example: Focus areas requiring integration tests: New library contract tests, Contract changes, Inter-service communication, Shared schemas -->
### [PRINCIPLE_5_NAME] Business classification logic MUST live in JSON configuration, never in PowerShell source. The
<!-- Example: V. Observability, VI. Versioning & Breaking Changes, VII. Simplicity --> engine implements condition operators and composition (`all` / `any` nesting within the
[PRINCIPLE_5_DESCRIPTION] configured depth limit); it MUST NOT hard-code personas, priorities, group identifiers, role
<!-- Example: Text I/O ensures debuggability; Structured logging required; Or: MAJOR.MINOR.BUILD format; Or: Start simple, YAGNI principles --> identifiers, or tenant-specific attribute names. The candidate persona catalogue is business
data, not engine behaviour. JSON is the only supported configuration format for v1.
Configuration MUST be validated in four layers — JSON syntax, JSON Schema, semantic, then
safety — before it is used to evaluate any account.
## [SECTION_2_NAME] **Rationale**: Rule changes are business changes. Requiring a code change to reclassify accounts
<!-- Example: Additional Constraints, Security Requirements, Performance Standards, etc. --> couples routine policy updates to the release pipeline and invites unreviewed edits.
[SECTION_2_CONTENT] ### III. Fail-Safe, Idempotent Persistence (NON-NEGOTIABLE)
<!-- Example: Technology stack requirements, compliance standards, deployment policies, etc. -->
## [SECTION_3_NAME] Re-running the engine MUST change nothing unless a calculated value actually differs from the
<!-- Example: Development Workflow, Review Process, Quality Gates, etc. --> stored value. A missing or failed required data source MUST NOT be interpreted as a non-match:
the account MUST be recorded as `EvaluationError`, its existing persona MUST be preserved, no
write MUST be attempted for it, and the run MUST continue. Write-capable entry points MUST use
`CmdletBinding` with `SupportsShouldProcess`, and `-WhatIf` MUST produce zero directory writes.
`-Debug` and `-Verbose` MUST NOT be treated as read-only controls. A write payload MUST contain
only the single approved target attribute.
[SECTION_3_CONTENT] **Rationale**: The engine writes to a live directory. Silent misclassification of a privileged
<!-- Example: Code review requirements, testing gates, deployment approval process, etc. --> account is a security incident, so unavailable data must degrade to no change.
### IV. Pure Rule Engine, Tested Offline First
The rule engine MUST NOT depend on Microsoft Graph, authentication, Azure Automation, or console
rendering. Graph acquisition MUST be normalized into plain identity records before evaluation,
and persistence MUST sit behind an adapter the engine does not call directly. The rule engine
MUST be implemented and passing Pester tests against synthetic data before any Graph integration
is written, and the persistence adapter MUST be implemented last. Offline unit tests MUST run to
completion with no tenant connectivity and no credentials.
**Rationale**: Correctness of classification is provable only in isolation. A rule engine
reachable only through a live tenant cannot be exhaustively tested before it is trusted.
### V. Explainable, Sanitized Observability
Every persona result MUST identify the matched rule ID (or `Unclassified` / `EvaluationError`),
the run correlation ID, the UPN, and the Account Object ID. Each user result MUST be emitted
immediately as it is produced, with interim and final summaries, and reconciliation MUST confirm
that reported counts match accounts processed. Structured, audit-friendly logging is required
alongside console output. UPN and Account Object ID are approved for logs. Access tokens,
authorization headers, secrets, and full Graph responses MUST NEVER be logged. Detailed
condition-level values are diagnostic-only and MUST be gated behind `-Debug`.
**Rationale**: An unexplainable decision cannot be reviewed, appealed, or audited, and the
`-WhatIf` impact evidence required before enforcement depends on complete per-user output.
## Security and Least-Privilege Constraints
- **Authentication**: Azure Automation MUST use a managed identity. Local development MUST use an
approved interactive or read-only application identity. Client secrets MUST NEVER be committed
to source control.
- **Least privilege**: The execution identity MUST be granted only what the enabled rules require
— in-scope user properties, configured group membership, configured role data, and the existing
persona value.
- **Attribute-scoped writes**: Whether Entra can enforce write authorization at the individual
attribute level MUST be verified, never assumed (OTD-003). Until verified, the compensating
controls are mandatory: the persistence module accepts only the approved target attribute; that
attribute MUST appear in `approvedWritableAttributes`; validation rejects all others; a
dedicated function builds a request body containing only that attribute; tests inspect the
request body; and code owners gate persistence changes.
- **Sanitization**: Every artifact in this repository — docs, examples, tests, configuration
samples, specs — MUST be free of organization names, real domains, tenant or subscription IDs,
automation account names, real UPNs or Object IDs, real group or role identifiers,
environment-specific attribute names, log destination details, and any secret, token,
certificate, or credential. Only approved placeholders such as `<ORGANIZATION-NAME>`,
`<TENANT-ID>`, `<ACCOUNT-OBJECT-ID>`, `<GROUP-OBJECT-ID>`, and
`<APPROVED-PERSONA-ATTRIBUTE-NAME>` may be used. Test data MUST be obviously fictional and MUST
NEVER reproduce real employee records. Tenant-specific configuration belongs in a protected
repository or configuration store, not here.
- **Scope**: v1 covers Entra user objects only. Service principals, managed identities, workload
identities, and agentic identities are out of scope, and the architecture MUST NOT assume they
share user-object properties.
- **Kill switch and rollback**: A documented kill switch (disable the Automation schedule, run
with `-WhatIf`, revoke production write permission, or disable write deployment stages) and a
documented rollback procedure MUST exist before enforcement is enabled.
- **Runtime**: PowerShell 7 is the target runtime for both local execution and Azure Automation.
## Development Workflow and Quality Gates
- **Spec-driven delivery**: Nothing is implemented before it is specified, planned, and decomposed
into tasks (Specify → Plan → Tasks → Implement). Any unresolved question MUST be captured as an
explicit assumption, risk, or architecture decision — it MUST NEVER be silently implemented.
- **Open technical decisions**: OTD-001 through OTD-005 MUST be closed in `research.md` or an ADR
before any persistence implementation begins.
- **Build order**: Pure rule engine with offline Pester tests → configuration validation and
non-interactive pipeline mode → Graph read adapters → console and structured logging →
persistence adapter last, with tests proving zero writes under `-WhatIf`.
- **Traceability**: Requirements carry FR/NFR identifiers and MUST be traceable from spec through
tasks to tests.
- **Validation pipeline** (MUST pass before merge): repository hygiene and sanitization checks,
PowerShell static analysis, JSON Schema validation, semantic and safety configuration
validation, Pester unit tests, Pester safety tests, test result publication, and artifact
packaging.
- **Release pipeline**: validate the approved branch or tag, repeat validation and tests, package,
deploy to Azure Automation, import modules, publish the runbook with the schedule **disabled**,
execute `-WhatIf` validation, pass an approval gate, then enable enforcement.
- **Mandatory review**: Changes to the target attribute, `approvedWritableAttributes`, rule
priority, rule enablement, rule conditions, persona outputs, authentication permissions,
persistence functions, logging destination, or `WhatIf` / `ShouldProcess` behaviour MUST be
reviewed by a code owner. Delivery uses feature branches, pull requests, and a protected release
branch.
- **Definition of done**: A version is complete only when its `-WhatIf` impact evidence has been
reviewed and security review has confirmed permissions and compensating controls.
## Governance ## Governance
<!-- Example: Constitution supersedes all other practices; Amendments require documentation, approval, migration plan -->
[GOVERNANCE_RULES] This constitution supersedes all other development practices for this repository. Where a plan,
<!-- Example: All PRs/reviews must verify compliance; Complexity must be justified; Use [GUIDANCE_FILE] for runtime development guidance --> task list, or review comment conflicts with it, this document wins.
**Version**: [CONSTITUTION_VERSION] | **Ratified**: [RATIFICATION_DATE] | **Last Amended**: [LAST_AMENDED_DATE] **Amendment procedure**: Amendments MUST be proposed as a change to this file in a pull request,
<!-- Example: Version: 2.1.1 | Ratified: 2025-06-13 | Last Amended: 2025-07-16 --> MUST state the rationale and the version bump type, and MUST be approved by a code owner. An
amendment that invalidates existing artifacts MUST include a migration note naming the specs,
plans, tasks, or code that require updating.
**Versioning policy**: This constitution uses semantic versioning.
- **MAJOR** — a principle or governance rule is removed or redefined in a backward-incompatible way.
- **MINOR** — a new principle or section is added, or existing guidance is materially expanded.
- **PATCH** — clarifications, wording, and typo fixes with no change in obligation.
**Compliance review**: Every pull request MUST verify compliance with these principles, and
reviewers MUST reject changes that violate a NON-NEGOTIABLE principle regardless of urgency.
Complexity that departs from these principles MUST be justified in writing in the plan's
complexity tracking, or the simpler compliant approach MUST be taken instead. `README.md` and the
feature's `spec.md`, `plan.md`, and `tasks.md` provide runtime development guidance and MUST be
kept consistent with this constitution.
**Version**: 1.0.0 | **Ratified**: 2026-08-20 | **Last Amended**: 2026-08-20
+826
View File
@@ -0,0 +1,826 @@
#Requires -Version 7.2
<#
.SYNOPSIS
Validates, tests, and interactively edits a Persona Engine configuration.
.DESCRIPTION
Two jobs in one tool, deliberately: the thing that checks a configuration and the
thing that edits it must agree about what "valid" means, and the surest way to
guarantee that is to make them the same code path.
-ValidateOnly report findings and exit. Never enters the editor.
-NonInteractive pipeline mode. Never prompts, never hangs (SC-010).
(neither) interactive editor, re-validating before every save.
-NonInteractive is not a convenience flag. A build agent runs with stdin closed;
a tool that prompts there does not fail, it hangs until the job times out, and
the pipeline reports an infrastructure problem instead of a bad configuration.
Every prompting call in this script sits behind a check for it.
Rule testing against synthetic users (-TestDataPath) runs the real rule engine
with no tenant connectivity (FR-025, SC-008). The engine is pure, so the answer
it gives offline is the answer it gives in production.
.PARAMETER ConfigPath
Configuration to validate or edit.
.PARAMETER ValidateOnly
Validate and report; do not enter the editor.
.PARAMETER NonInteractive
Pipeline mode: no prompts, exit code only.
.PARAMETER SchemaPath
Override the shipped schema.
.PARAMETER OutputPath
Save-As target. Leaves the input file untouched.
.PARAMETER TreatWarningsAsErrors
Escalate Warning findings to blocking (VR-005).
.PARAMETER TestDataPath
Synthetic sample users for offline rule testing (FR-025).
.PARAMETER PreviousConfigPath
Currently deployed configuration, enabling the VR-003 drift checks.
.PARAMETER EnforcementEnabled
Validate as though this configuration will drive an enforcing run, which raises
the severity of several safety findings.
.EXAMPLE
./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -ValidateOnly -NonInteractive
The pipeline invocation. Returns 0 when the configuration is clean.
.EXAMPLE
./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -TestDataPath ./tests/TestData
Validate, then evaluate the rules against synthetic users with no tenant.
.NOTES
Exit codes
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
#>
[CmdletBinding(SupportsShouldProcess = $true)]
param(
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[string] $ConfigPath,
[switch] $ValidateOnly,
[switch] $NonInteractive,
[string] $SchemaPath,
[string] $OutputPath,
[switch] $TreatWarningsAsErrors,
[string] $TestDataPath,
[string] $PreviousConfigPath,
[switch] $EnforcementEnabled
)
$ErrorActionPreference = 'Stop'
# The .psm1 directly, not the manifest. The manifest declares
# Microsoft.Graph.Authentication as a required module, and this tool never touches
# Graph - SC-008 requires full validation and synthetic rule testing to complete with
# no network access. Importing the manifest would make a build agent that only
# validates configuration install a Graph SDK it will never call.
Import-Module (Join-Path $PSScriptRoot 'PersonaEngine.psm1') -Force -ErrorAction Stop
$EXIT_OK = 0
$EXIT_FINDINGS = 1
$EXIT_WARNINGS_AS_ERRORS = 2
$EXIT_CONFIG_UNREADABLE = 3
$EXIT_SCHEMA_UNUSABLE = 4
function Invoke-ConfigurationValidation {
<#
.SYNOPSIS
Runs all four layers and maps the result onto an exit code.
.DESCRIPTION
One function so the interactive save path and the pipeline path cannot
disagree. The exit-code mapping lives here rather than at the call sites for
the same reason: an exit code is the pipeline's only view of what happened.
#>
param([string] $Path)
$params = @{ Path = $Path; EnforcementEnabled = $EnforcementEnabled }
if ($SchemaPath) { $params['SchemaPath'] = $SchemaPath }
if ($PreviousConfigPath) { $params['PreviousConfigPath'] = $PreviousConfigPath }
$result = Test-PersonaConfiguration @params
$code = $EXIT_OK
# Order matters. An unreadable file and an unusable schema are environment
# faults, not authoring faults, and a pipeline needs to tell them apart from a
# genuinely invalid configuration - otherwise a missing schema file is reported
# to the author as "your rules are wrong".
if (@($result.Findings | Where-Object Code -In @('PE-SYN-001', 'PE-SYN-002')).Count -gt 0) {
$code = $EXIT_CONFIG_UNREADABLE
}
elseif ($result.SchemaUnusable -or @($result.Findings | Where-Object Code -EQ 'PE-SCH-002').Count -gt 0) {
$code = $EXIT_SCHEMA_UNUSABLE
}
elseif (-not $result.IsValid) {
$code = $EXIT_FINDINGS
}
elseif ($TreatWarningsAsErrors -and $result.WarningCount -gt 0) {
# VR-005: warnings block only here. The finding severity is unchanged - the
# configuration is not retroactively more broken because a switch was passed;
# what changed is the caller's tolerance for it.
$code = $EXIT_WARNINGS_AS_ERRORS
}
[pscustomobject]@{ Result = $result; ExitCode = $code }
}
function Invoke-SyntheticRuleTest {
<#
.SYNOPSIS
Evaluates the configuration's rules against synthetic users (FR-025).
.DESCRIPTION
No tenant, no credentials, no network. Reads the fixture files, builds
normalized records, and runs the real engine over them.
Fixtures declare their membership facets explicitly, including failed ones.
A fixture with an unretrieved facet is the only way to see EvaluationError
behaviour before it happens against a live directory.
#>
param(
[Parameter(Mandatory)] [object] $Configuration,
[Parameter(Mandatory)] [string] $DataPath
)
$usersFile = (Test-Path -LiteralPath $DataPath -PathType Container) `
? (Join-Path $DataPath 'Users/users.json') `
: $DataPath
if (-not (Test-Path -LiteralPath $usersFile -PathType Leaf)) {
Write-Host "No synthetic user fixtures found at '$usersFile'." -ForegroundColor Yellow
return
}
$fixtures = (Get-Content -LiteralPath $usersFile -Raw | ConvertFrom-Json -Depth 32).users
$membershipFile = Join-Path (Split-Path (Split-Path $usersFile -Parent) -Parent) 'Memberships/memberships.json'
$membershipByFixture = @{}
if (Test-Path -LiteralPath $membershipFile -PathType Leaf) {
foreach ($entry in (Get-Content -LiteralPath $membershipFile -Raw | ConvertFrom-Json -Depth 32).memberships) {
$params = @{
DirectGroupObjectIds = @($entry.directGroupObjectIds)
TransitiveGroupObjectIds = @($entry.transitiveGroupObjectIds)
DirectoryRoleIds = @($entry.directoryRoleIds)
}
foreach ($facet in @('allRetrieved', 'directRetrieved', 'transitiveRetrieved', 'rolesRetrieved')) {
if ($entry.PSObject.Properties[$facet] -and $entry.$facet) {
$params[(Get-Culture).TextInfo.ToTitleCase($facet)] = $true
}
}
foreach ($facet in @('directFailureReason', 'transitiveFailureReason', 'rolesFailureReason')) {
if ($entry.PSObject.Properties[$facet] -and $entry.$facet) {
$params[(Get-Culture).TextInfo.ToTitleCase($facet)] = [string]$entry.$facet
}
}
$membershipByFixture[[string]$entry.fixtureId] = New-PersonaMembershipRecord @params
}
}
Write-Host ''
Write-Host 'Synthetic rule test - no tenant connectivity (FR-025, SC-008)' -ForegroundColor Cyan
Write-Host ('{0,-24} {1,-45} {2,-20} {3}' -f 'Fixture', 'UPN', 'Outcome', 'Persona / rule') -ForegroundColor DarkGray
foreach ($fixture in $fixtures) {
$properties = @{}
if ($fixture.properties) {
foreach ($p in $fixture.properties.PSObject.Properties) { $properties[$p.Name] = $p.Value }
}
$record = New-PersonaUserRecord `
-AccountObjectId ([string]$fixture.accountObjectId) `
-UserPrincipalName ([string]$fixture.userPrincipalName) `
-DisplayName ([string]$fixture.displayName) `
-UserType ([string]$fixture.userType) `
-AccountEnabled ([bool]$fixture.accountEnabled) `
-Properties $properties `
-StoredPersona ([string]$fixture.storedPersona) `
-Membership $membershipByFixture[[string]$fixture.fixtureId]
$result = Resolve-UserPersona -UserRecord $record -Rules $Configuration.Rules `
-MaxDepth $Configuration.MaxConditionDepth `
-DefaultMembershipMode $Configuration.DefaultMembershipMode
$colour = switch ($result.Outcome) {
'Matched' { 'Green' }
'Unclassified' { 'Gray' }
'EvaluationError' { 'Yellow' }
}
$detail = $result.Outcome -eq 'Matched' `
? "$($result.CalculatedPersona) [$($result.MatchedRuleId)]" `
: [string]$result.EvaluationErrorReason
Write-Host ('{0,-24} {1,-45} {2,-20} {3}' -f
$fixture.fixtureId, $result.UserPrincipalName, $result.Outcome, $detail) -ForegroundColor $colour
}
Write-Host ''
}
function Save-PersonaConfiguration {
<#
.SYNOPSIS
Saves an edited configuration, backing up first (FR-026).
.DESCRIPTION
The save order is the contract, and it is deliberate:
1. Re-validate in full.
2. Refuse on any Error finding.
3. Back up the existing file, or write to -OutputPath instead.
4. Only then replace.
Backing up before replacing rather than after is what makes step 4 safe to
interrupt. A crash between backup and write leaves the original and a copy;
a crash the other way round leaves neither.
#>
param(
[Parameter(Mandatory)] [object] $Document,
[Parameter(Mandatory)] [string] $Path,
[string] $SaveAs
)
$json = $Document | ConvertTo-Json -Depth 32
$temp = [System.IO.Path]::GetTempFileName()
try {
Set-Content -LiteralPath $temp -Value $json -Encoding utf8NoBOM
$check = Invoke-ConfigurationValidation -Path $temp
if (-not $check.Result.IsValid) {
Write-Host 'Save blocked: the edited configuration has Error findings.' -ForegroundColor Red
Write-PersonaValidationFinding -Findings $check.Result.Findings
return $false
}
}
finally {
Remove-Item -LiteralPath $temp -Force -ErrorAction SilentlyContinue
}
$destination = $SaveAs ? $SaveAs : $Path
if ((Test-Path -LiteralPath $destination -PathType Leaf) -and -not $SaveAs) {
$backup = '{0}.{1}.bak' -f $destination, [DateTime]::UtcNow.ToString('yyyyMMddTHHmmssZ')
if (-not $PSCmdlet.ShouldProcess($destination, "Back up to '$backup' and replace")) {
Write-Host 'Save cancelled.' -ForegroundColor Yellow
return $false
}
Copy-Item -LiteralPath $destination -Destination $backup -Force
Write-Host "Backup written: $backup" -ForegroundColor DarkGray
}
elseif (-not $PSCmdlet.ShouldProcess($destination, 'Write configuration')) {
Write-Host 'Save cancelled.' -ForegroundColor Yellow
return $false
}
Set-Content -LiteralPath $destination -Value $json -Encoding utf8NoBOM
Write-Host "Saved: $destination" -ForegroundColor Green
$true
}
function Test-PersonaCandidateEdit {
<#
.SYNOPSIS
Applies a structural edit to a cloned document and re-validates it (FR-030).
.DESCRIPTION
Deep-clones Document via a JSON round trip (nested objects are reference
types, so mutating Document directly would leave a half-applied edit in
place if validation then rejected it), runs Apply against the clone, and
validates the clone in full - the identical check the `[V]` command and
`Save-PersonaConfiguration` use, so add/edit/delete can never be held to a
looser standard than a hand edit.
The document entering the interactive editor is already Error-free, so a
clone that fails here failed because of Apply, not because of pre-existing
state.
.PARAMETER Document
The current in-memory configuration document.
.PARAMETER Apply
A scriptblock taking the clone as its first parameter, followed by whatever
ApplyArgs supplies, and mutating the clone in place. Values are passed as
explicit arguments rather than closed over from the caller's scope: `&`
invocation runs a scriptblock in a new child scope of wherever it is invoked
(here, inside this function) rather than of the scope where the scriptblock
literal was written, so a bare `{ $doc.rules = ... $id ... }` would see $id as
unbound. Explicit parameters sidestep that entirely.
.PARAMETER ApplyArgs
Positional arguments passed to Apply after the clone.
.OUTPUTS
pscustomobject with Applied (bool), Document (the clone if Applied, otherwise
the original Document, unchanged), and Result (the validation result).
#>
param(
[Parameter(Mandatory)] [object] $Document,
[Parameter(Mandatory)] [scriptblock] $Apply,
[object[]] $ApplyArgs = @()
)
$clone = $Document | ConvertTo-Json -Depth 32 | ConvertFrom-Json -Depth 32
& $Apply $clone @ApplyArgs
$temp = [System.IO.Path]::GetTempFileName()
try {
Set-Content -LiteralPath $temp -Value ($clone | ConvertTo-Json -Depth 32) -Encoding utf8NoBOM
$check = Invoke-ConfigurationValidation -Path $temp
}
finally {
Remove-Item -LiteralPath $temp -Force -ErrorAction SilentlyContinue
}
[pscustomobject]@{
Applied = $check.Result.IsValid
Document = $check.Result.IsValid ? $clone : $Document
Result = $check.Result
}
}
function ConvertTo-PersonaConditionPath {
<#
.SYNOPSIS
Parses an operator-typed path like "1.0" into the int[] Get-PersonaConditionNode expects.
.DESCRIPTION
Blank input, or the literal word "root", addresses the rule's match group
itself.
#>
param([string] $Text)
$trimmed = ([string]$Text).Trim()
if ([string]::IsNullOrEmpty($trimmed) -or $trimmed -eq 'root') { return , @() }
$indices = foreach ($part in ($trimmed -split '[.\s]+' | Where-Object { $_ -ne '' })) {
$value = 0
if (-not [int]::TryParse($part, [ref] $value)) {
throw "'$part' is not a valid path segment - use dot-separated indices, for example '1.0'."
}
$value
}
, @($indices)
}
function Read-PersonaConditionLeafFields {
<#
.SYNOPSIS
Prompts for one leaf condition's fields, in the shape Set-PersonaConditionLeaf expects.
#>
Write-Host ' Condition type: [P]roperty [M]embership [R]ole' -ForegroundColor DarkGray
$typeChoice = (Read-Host ' Type').Trim().ToUpperInvariant()
$type = switch ($typeChoice) { 'P' { 'property' } 'M' { 'membership' } 'R' { 'role' } default { $null } }
if (-not $type) { throw "Unrecognized condition type '$typeChoice'." }
Write-Host ' Operators: equals notEquals contains notContains startsWith endsWith matchesRegex in notIn isNull isNotNull memberOf notMemberOf' -ForegroundColor DarkGray
$operator = (Read-Host ' Operator').Trim()
$fields = @{ type = $type; operator = $operator }
switch ($type) {
'property' { $fields['property'] = (Read-Host ' Property name').Trim() }
'membership' {
$ids = (Read-Host ' Group Object IDs (comma-separated)')
$fields['groupObjectIds'] = @($ids -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' })
$mode = (Read-Host ' Membership mode (direct/transitive, blank = configuration default)').Trim()
if ($mode) { $fields['membershipMode'] = $mode }
}
'role' {
$ids = (Read-Host ' Role IDs (comma-separated)')
$fields['roleIds'] = @($ids -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' })
}
}
if ($operator -in @('in', 'notIn')) {
$values = (Read-Host ' Values (comma-separated)')
$fields['values'] = @($values -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne '' })
}
elseif ($operator -notin @('isNull', 'isNotNull', 'memberOf', 'notMemberOf')) {
$fields['value'] = Read-Host ' Comparison value'
}
$fields
}
function Read-PersonaConditionNode {
<#
.SYNOPSIS
Interactively builds one condition or nested group (FR-027, FR-028).
.DESCRIPTION
Recurses for a group's children. Nesting depth is not limited here - see
Test-PersonaCandidateEdit and Add-PersonaConditionNode's doc comment for why
depth is enforced by the real validator instead of a second, local count.
#>
param([int] $Depth = 1)
$indent = ' ' * $Depth
Write-Host ("{0}[C]ondition or [G]roup?" -f $indent) -ForegroundColor DarkGray
$kind = (Read-Host "$indent Kind").Trim().ToUpperInvariant()
if ($kind -eq 'G') {
$operator = (Read-Host "$indent Group operator (all/any)").Trim().ToLowerInvariant()
if ($operator -notin @('all', 'any')) { throw "Group operator must be 'all' or 'any'." }
$children = [System.Collections.Generic.List[object]]::new()
do {
$children.Add((Read-PersonaConditionNode -Depth ($Depth + 1)))
$more = (Read-Host "$indent Add another condition to this group? (y/N)").Trim()
} while ($more.ToLowerInvariant() -eq 'y')
return [pscustomobject]@{ operator = $operator; conditions = $children.ToArray() }
}
[pscustomobject](Read-PersonaConditionLeafFields)
}
function Read-PersonaRuleFields {
<#
.SYNOPSIS
Prompts for a new rule's top-level fields (RE-001), excluding its match tree.
#>
$id = (Read-Host 'Rule id').Trim()
$name = (Read-Host 'Rule name').Trim()
$description = (Read-Host 'Description').Trim()
$priority = 0
if (-not [int]::TryParse((Read-Host 'Priority (integer)'), [ref] $priority)) {
throw 'Priority must be an integer.'
}
$persona = (Read-Host 'Persona').Trim()
$enabled = (Read-Host 'Enabled? (Y/n)').Trim().ToLowerInvariant() -ne 'n'
[pscustomobject]@{
id = $id; name = $name; description = $description
enabled = $enabled; priority = $priority; persona = $persona
}
}
function Invoke-InteractiveEditor {
<#
.SYNOPSIS
The interactive loop (FR-023).
.DESCRIPTION
Toggles rules, adjusts priorities, adds/edits/deletes whole rules including
their condition trees (FR-027 - FR-030), re-validates, tests against
fixtures, and saves.
Every structural edit (add/edit/delete) goes through Test-PersonaCandidateEdit,
which applies it to a clone and re-runs full validation before it is allowed
to reach $Document. The live document entering this loop is already
Error-free - the main script exits before this function is ever called
otherwise - so a clone that fails validation failed because of the edit just
applied, and is discarded rather than committed. This is what makes
structural editing safe to add at all: the editor can never produce a worse
document than a careful hand edit would, because it runs the same four
validation layers a hand edit is checked against at save time.
#>
param(
[Parameter(Mandatory)] [object] $Document,
[Parameter(Mandatory)] [string] $Path
)
$dirty = $false
while ($true) {
Write-Host ''
Write-Host 'Persona Engine configuration editor' -ForegroundColor Cyan
Write-Host (" file {0}{1}" -f $Path, ($dirty ? ' [modified]' : ''))
Write-Host (" version {0}" -f $Document.configVersion)
Write-Host (" rules {0} ({1} enabled)" -f @($Document.rules).Count, @($Document.rules | Where-Object { $_.enabled }).Count)
Write-Host ''
Write-Host ' [L] list rules [T] toggle a rule [P] change a priority'
Write-Host ' [A] add a rule [E] edit a rule [D] delete a rule'
Write-Host ' [V] re-validate [R] run rule test [S] save'
Write-Host ' [Q] quit'
Write-Host ''
$choice = (Read-Host 'Choice').Trim().ToUpperInvariant()
switch ($choice) {
'L' {
Write-Host ''
foreach ($rule in ($Document.rules | Sort-Object { [int]$_.priority })) {
Write-Host (' {0,-6} {1,-28} {2,-30} {3}' -f
$rule.priority, $rule.id, $rule.persona,
($rule.enabled ? 'enabled' : 'DISABLED')) -ForegroundColor ($rule.enabled ? 'Gray' : 'DarkGray')
}
}
'T' {
$id = (Read-Host 'Rule ID to toggle').Trim()
$rule = $Document.rules | Where-Object { [string]$_.id -eq $id } | Select-Object -First 1
if (-not $rule) { Write-Host "No rule with ID '$id'." -ForegroundColor Yellow; break }
$rule.enabled = -not $rule.enabled
$dirty = $true
Write-Host ("Rule {0} is now {1}." -f $id, ($rule.enabled ? 'enabled' : 'disabled')) -ForegroundColor Green
}
'P' {
$id = (Read-Host 'Rule ID').Trim()
$rule = $Document.rules | Where-Object { [string]$_.id -eq $id } | Select-Object -First 1
if (-not $rule) { Write-Host "No rule with ID '$id'." -ForegroundColor Yellow; break }
$value = 0
if (-not [int]::TryParse((Read-Host 'New priority'), [ref] $value)) {
Write-Host 'Priority must be an integer.' -ForegroundColor Yellow
break
}
$rule.priority = $value
$dirty = $true
Write-Host "Rule $id priority is now $value. Re-validate before saving - a reorder changes which rule wins." -ForegroundColor Green
}
'A' {
# FR-027. The candidate rule is built entirely from prompts before
# Test-PersonaCandidateEdit ever sees it, so a collision or a
# validation failure is reported once, against the whole rule, rather
# than mid-build against a rule that only half exists yet.
try {
$fields = Read-PersonaRuleFields
Write-Host 'Build the condition tree for this rule (the root group):' -ForegroundColor Cyan
$rootOperator = (Read-Host 'Root operator (all/any)').Trim().ToLowerInvariant()
if ($rootOperator -notin @('all', 'any')) { throw "Root operator must be 'all' or 'any'." }
$rootChildren = [System.Collections.Generic.List[object]]::new()
do {
$rootChildren.Add((Read-PersonaConditionNode -Depth 1))
$more = (Read-Host 'Add another top-level condition? (y/N)').Trim()
} while ($more.ToLowerInvariant() -eq 'y')
$newRule = [pscustomobject]@{
id = $fields.id
name = $fields.name
description = $fields.description
enabled = $fields.enabled
priority = $fields.priority
persona = $fields.persona
match = [pscustomobject]@{ operator = $rootOperator; conditions = $rootChildren.ToArray() }
}
$attempt = Test-PersonaCandidateEdit -Document $Document -ApplyArgs @($newRule) -Apply {
param($doc, $rule)
$doc.rules = Add-PersonaConfigRule -Rules $doc.rules -Rule $rule
}
if ($attempt.Applied) {
$Document = $attempt.Document
$dirty = $true
Write-Host "Rule $($newRule.id) added." -ForegroundColor Green
}
else {
Write-Host 'Add rejected: the resulting configuration would be invalid.' -ForegroundColor Red
Write-PersonaValidationFinding -Findings $attempt.Result.Findings
}
}
catch {
Write-Host "Add cancelled: $($_.Exception.Message)" -ForegroundColor Yellow
}
}
'E' {
# FR-028. One structural operation per pass through this case - the
# operator repeats [E] to make further changes, which keeps each
# Test-PersonaCandidateEdit call scoped to one edit and one finding
# set, rather than a batch where a rejection leaves it unclear which
# of several changes caused it.
try {
$id = (Read-Host 'Rule id to edit').Trim()
$rule = $Document.rules | Where-Object { [string]$_.id -eq $id } | Select-Object -First 1
if (-not $rule) { Write-Host "No rule with ID '$id'." -ForegroundColor Yellow; break }
Write-Host ("Editing rule {0} ({1})." -f $rule.id, $rule.name) -ForegroundColor Cyan
Write-Host ' [F] change a top-level field [C] add a condition/group'
Write-Host ' [E] edit a leaf condition [X] remove a condition/group'
Write-Host ' [B] back to the main menu, no change'
$subChoice = (Read-Host ' Edit action').Trim().ToUpperInvariant()
$attempt = switch ($subChoice) {
'F' {
$field = (Read-Host ' Field (name/description/priority/persona/enabled)').Trim().ToLowerInvariant()
$value = Read-Host ' New value'
Test-PersonaCandidateEdit -Document $Document -ApplyArgs @($id, $field, $value) -Apply {
param($doc, $ruleId, $fieldName, $newValue)
$target = $doc.rules | Where-Object { [string]$_.id -eq $ruleId } | Select-Object -First 1
switch ($fieldName) {
'priority' { $target.priority = [int]$newValue }
'enabled' { $target.enabled = $newValue.Trim().ToLowerInvariant() -in @('y', 'yes', 'true', '1') }
'name' { $target.name = $newValue }
'description' { $target.description = $newValue }
'persona' { $target.persona = $newValue }
default { throw "Unrecognized field '$fieldName'." }
}
}
}
'C' {
$parentPath = ConvertTo-PersonaConditionPath -Text (Read-Host ' Parent path (blank = root, e.g. "1.0")')
$newNode = Read-PersonaConditionNode -Depth ($parentPath.Count + 1)
# ApplyArgs is built with .Add() rather than @(...), because an
# int[] placed inside a `@()` array literal is unrolled into the
# outer array (the same gotcha Get-CapturedAuditRecord's comma
# idiom guards against elsewhere) - @($id, $parentPath, $newNode)
# would silently flatten a two-segment path into two extra
# positional arguments instead of passing it as one array.
$args = [System.Collections.Generic.List[object]]::new()
$args.Add($id); $args.Add($parentPath); $args.Add($newNode)
Test-PersonaCandidateEdit -Document $Document -ApplyArgs $args.ToArray() -Apply {
param($doc, $ruleId, $parentPathArg, $node)
$target = $doc.rules | Where-Object { [string]$_.id -eq $ruleId } | Select-Object -First 1
Add-PersonaConditionNode -Group $target.match -ParentPath $parentPathArg -Node $node | Out-Null
}
}
'E' {
$path = ConvertTo-PersonaConditionPath -Text (Read-Host ' Leaf condition path (e.g. "1.0")')
if ($path.Count -eq 0) { throw 'The root is a group, not a leaf condition - navigate to a leaf path.' }
$fields = Read-PersonaConditionLeafFields
$args = [System.Collections.Generic.List[object]]::new()
$args.Add($id); $args.Add($path); $args.Add($fields)
Test-PersonaCandidateEdit -Document $Document -ApplyArgs $args.ToArray() -Apply {
param($doc, $ruleId, $pathArg, $fieldsArg)
$target = $doc.rules | Where-Object { [string]$_.id -eq $ruleId } | Select-Object -First 1
$leaf = Get-PersonaConditionNode -Group $target.match -Path $pathArg
Set-PersonaConditionLeaf -Node $leaf -Fields $fieldsArg | Out-Null
}
}
'X' {
$path = ConvertTo-PersonaConditionPath -Text (Read-Host ' Condition/group path to remove (e.g. "1.0")')
$args = [System.Collections.Generic.List[object]]::new()
$args.Add($id); $args.Add($path)
Test-PersonaCandidateEdit -Document $Document -ApplyArgs $args.ToArray() -Apply {
param($doc, $ruleId, $pathArg)
$target = $doc.rules | Where-Object { [string]$_.id -eq $ruleId } | Select-Object -First 1
Remove-PersonaConditionNode -Group $target.match -Path $pathArg | Out-Null
}
}
'B' { $null }
default { Write-Host 'Unrecognized edit action.' -ForegroundColor Yellow; $null }
}
if ($null -ne $attempt) {
if ($attempt.Applied) {
$Document = $attempt.Document
$dirty = $true
Write-Host "Rule $id updated." -ForegroundColor Green
}
else {
Write-Host 'Edit rejected: the resulting configuration would be invalid.' -ForegroundColor Red
Write-PersonaValidationFinding -Findings $attempt.Result.Findings
}
}
}
catch {
Write-Host "Edit cancelled: $($_.Exception.Message)" -ForegroundColor Yellow
}
}
'D' {
# FR-029. Confirmation names id, name, and priority before anything is
# touched, matching the acceptance criterion literally rather than a
# generic "are you sure?".
try {
$id = (Read-Host 'Rule id to delete').Trim()
$rule = $Document.rules | Where-Object { [string]$_.id -eq $id } | Select-Object -First 1
if (-not $rule) { Write-Host "No rule with ID '$id'." -ForegroundColor Yellow; break }
Write-Host ("About to delete rule {0} '{1}' priority {2}." -f $rule.id, $rule.name, $rule.priority) -ForegroundColor Yellow
$confirm = (Read-Host 'Delete this rule? (y/N)').Trim().ToLowerInvariant()
if ($confirm -ne 'y') {
Write-Host 'Delete cancelled.' -ForegroundColor Yellow
break
}
$attempt = Test-PersonaCandidateEdit -Document $Document -ApplyArgs @($id) -Apply {
param($doc, $ruleId)
$doc.rules = Remove-PersonaConfigRule -Rules $doc.rules -RuleId $ruleId
}
if ($attempt.Applied) {
$Document = $attempt.Document
$dirty = $true
Write-Host "Rule $id deleted." -ForegroundColor Green
}
else {
Write-Host 'Delete rejected: the resulting configuration would be invalid.' -ForegroundColor Red
Write-PersonaValidationFinding -Findings $attempt.Result.Findings
}
}
catch {
Write-Host "Delete cancelled: $($_.Exception.Message)" -ForegroundColor Yellow
}
}
'V' {
$temp = [System.IO.Path]::GetTempFileName()
try {
Set-Content -LiteralPath $temp -Value ($Document | ConvertTo-Json -Depth 32) -Encoding utf8NoBOM
$check = Invoke-ConfigurationValidation -Path $temp
Write-PersonaValidationFinding -Findings $check.Result.Findings
}
finally { Remove-Item -LiteralPath $temp -Force -ErrorAction SilentlyContinue }
}
'R' {
$dataPath = $TestDataPath ? $TestDataPath : (Join-Path $PSScriptRoot 'tests/TestData')
$temp = [System.IO.Path]::GetTempFileName()
try {
Set-Content -LiteralPath $temp -Value ($Document | ConvertTo-Json -Depth 32) -Encoding utf8NoBOM
Invoke-SyntheticRuleTest -Configuration (Import-PersonaConfiguration -Path $temp) -DataPath $dataPath
}
finally { Remove-Item -LiteralPath $temp -Force -ErrorAction SilentlyContinue }
}
'S' {
if (Save-PersonaConfiguration -Document $Document -Path $Path -SaveAs $OutputPath) { $dirty = $false }
}
'Q' {
if ($dirty) {
$confirm = (Read-Host 'Unsaved changes will be lost. Quit anyway? (y/N)').Trim()
if ($confirm -ne 'y') { break }
}
return
}
default { Write-Host 'Unrecognized choice.' -ForegroundColor Yellow }
}
}
}
# ============================================================ main
$validation = Invoke-ConfigurationValidation -Path $ConfigPath
Write-PersonaValidationFinding -Findings $validation.Result.Findings
if ($validation.ExitCode -ne $EXIT_OK) {
Write-Host ("Validation failed with exit code {0}." -f $validation.ExitCode) -ForegroundColor Red
exit $validation.ExitCode
}
if ($TestDataPath) {
Invoke-SyntheticRuleTest -Configuration (Import-PersonaConfiguration -Path $ConfigPath) -DataPath $TestDataPath
}
# -ValidateOnly and -NonInteractive both short-circuit before any prompting call.
# The check is here, once, at the single point where the editor could be entered.
if ($ValidateOnly -or $NonInteractive) {
Write-Host 'Configuration is valid.' -ForegroundColor Green
exit $EXIT_OK
}
Invoke-InteractiveEditor -Document $validation.Result.Document -Path $ConfigPath
exit $EXIT_OK
+271
View File
@@ -0,0 +1,271 @@
#Requires -Version 7.2
<#
.SYNOPSIS
Classifies Entra ID user accounts against a configuration-driven rule set.
.DESCRIPTION
The engine entry point: validate, connect, enumerate, evaluate, report, and -
only when explicitly confirmed - persist.
SAFETY MODEL
Mode is derived from $PSCmdlet.ShouldProcess() and nothing else. There is no
-Preview switch and no configuration key that suppresses writes. Two sources of
truth for a write gate is precisely the defect class constitution Principle III
exists to prevent: the day they disagree, one of them is wrong and the directory
finds out first.
-WhatIf is the approved no-write control. Under it, reads happen, rules evaluate,
values are compared, console output and summaries appear, and audit records are
written exactly as they would be in enforcement. The single difference is that no
write request is ever constructed (FR-017, SC-004).
-Debug enables condition tracing. It does NOT imply read-only. A -Debug run
without -WhatIf writes, and a test asserts that it does - because an operator who
believed otherwise would reach for -Debug as a safety measure.
.PARAMETER ConfigPath
Path to the JSON configuration. Validated through all four layers before any
connection is attempted (FR-002). Defaults to ./config/persona-engine.json,
resolved against the current directory, when omitted.
.PARAMETER UserObjectId
Evaluate a single user instead of enumerating the tenant. The recommended first
run against any new configuration.
.PARAMETER OutputPath
Overrides logging.path for this run. When neither this nor logging.path is set,
audit records are written to '<current-directory>/logs/persona-engine-audit.ndjson'.
.PARAMETER CorrelationId
Run identifier. Generated when absent. Appears on every audit record (NFR-005).
.PARAMETER SchemaPath
Schema override for validation layer 2.
.PARAMETER PreviousConfigPath
Currently deployed configuration, enabling the VR-003 drift checks.
.EXAMPLE
./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json -WhatIf
The standard preview run. Reports what would change; writes nothing.
.EXAMPLE
./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json -UserObjectId <ACCOUNT-OBJECT-ID> -WhatIf -Verbose
Single-user preview, the recommended first run against a new configuration.
.OUTPUTS
Audit records on the success stream when logging.destination includes 'stream'.
The process exit code carries the run status; see the exit code table in
specs/001-persona-engine/contracts/cli-invoke-persona-engine.md.
.NOTES
Exit codes
0 successful run
1 configuration validation failure
2 authentication or authorization failure
3 user enumeration failure
4 fatal required data-provider failure, or evaluationErrorThreshold exceeded
5 reconciliation failure
6 unexpected fatal engine error
#>
[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
param(
[ValidateNotNullOrEmpty()]
[string] $ConfigPath = (Join-Path (Get-Location).Path 'config' 'persona-engine.json'),
[guid] $UserObjectId,
[string] $OutputPath,
[guid] $CorrelationId,
[string] $SchemaPath,
[string] $PreviousConfigPath
)
$ErrorActionPreference = 'Stop'
$engineVersion = '0.1.0'
$startedUtc = [DateTime]::UtcNow
$runId = $CorrelationId -and $CorrelationId -ne [guid]::Empty ? $CorrelationId.ToString() : [guid]::NewGuid().ToString()
Import-Module (Join-Path $PSScriptRoot 'PersonaEngine.psd1') -Force -ErrorAction Stop
# Exit codes are named rather than sprinkled as literals so the contract table and
# the code cannot drift apart.
$EXIT_OK = 0
$EXIT_CONFIG = 1
$EXIT_AUTH = 2
$EXIT_ENUMERATION = 3
$EXIT_DATA = 4
$EXIT_RECONCILIATION = 5
$EXIT_UNEXPECTED = 6
$exitCode = $EXIT_OK
$counters = $null
$context = $null
$sinkState = New-PersonaAuditSinkState
try {
# ======================================================== 1. Validate (FR-002)
Write-Verbose "Validating configuration: $ConfigPath"
$validationParams = @{ Path = $ConfigPath }
if ($SchemaPath) { $validationParams['SchemaPath'] = $SchemaPath }
if ($PreviousConfigPath) { $validationParams['PreviousConfigPath'] = $PreviousConfigPath }
$validation = Test-PersonaConfiguration @validationParams
if (-not $validation.IsValid) {
Write-PersonaValidationFinding -Findings $validation.Findings
Write-Host "Configuration validation failed with $($validation.ErrorCount) error(s). No connection was attempted." -ForegroundColor Red
exit $EXIT_CONFIG
}
if ($validation.WarningCount -gt 0) {
Write-PersonaValidationFinding -Findings @($validation.Findings | Where-Object Severity -NE 'Information')
}
$config = Import-PersonaConfiguration -Path $ConfigPath
$target = Resolve-TargetAttribute -Configuration $config
# ======================================================== 2. Mode (FR-016, FR-017)
# The single write gate. Called once, before anything is read, so the mode is
# known when the RunStart record is written and cannot change mid-run.
#
# Under -WhatIf this returns $false without prompting. Without -WhatIf, the High
# confirm impact means the operator is asked to confirm the run; answering
# "Yes to All" also satisfies the per-user gate below without further prompts.
$runConfirmed = $PSCmdlet.ShouldProcess(
"Entra ID directory - $($config.SourcePath)",
"Classify users and write '$target' where the calculated persona differs")
$mode = $runConfirmed ? 'Enforce' : 'Preview'
$context = New-PersonaAuditContext -RunId $runId -EngineVersion $engineVersion -Configuration $config -Mode $mode
$destination = $config.Logging.destination ? [string]$config.Logging.destination : 'stream'
# No -OutputPath and no logging.path means "just log somewhere sane" rather than
# "log nowhere" - an operator running a plain -WhatIf shouldn't have to configure
# a file path just to see what the engine would have done.
$configuredPath = [string]$config.Logging.path
$defaultLogPath = Join-Path (Get-Location).Path 'logs' 'persona-engine-audit.ndjson'
$logPath = $OutputPath ? $OutputPath : ($configuredPath ? $configuredPath : $defaultLogPath)
$auditParams = @{ Destination = $destination; Path = $logPath; State = $sinkState }
# Lives next to the audit log rather than under its own config key for path -
# one directory to point an operator at, not two. The file name alone is
# configurable because "results.csv" may collide with something else already
# written there.
$resultsFileName = $config.Logging.resultsFileName ? [string]$config.Logging.resultsFileName : 'results.csv'
$resultsPath = Join-Path (Split-Path -Parent $logPath) $resultsFileName
# Tracing is enabled by -Debug or by configuration, and requires acknowledgement
# in the configuration either way (VR-003, enforced in validation layer 4).
$traceRequested = $PSBoundParameters.ContainsKey('Debug') -or
($config.Logging.traceConditionValues -and [bool]$config.Logging.traceConditionValues)
$traceAcknowledged = $config.Logging.PSObject.Properties['acknowledgeConditionTracing'] -and
[bool]$config.Logging.acknowledgeConditionTracing
$tracing = [bool]($traceRequested -and $traceAcknowledged)
if ($traceRequested -and -not $traceAcknowledged) {
Write-Warning 'Condition tracing was requested but logging.acknowledgeConditionTracing is not set. Tracing is disabled for this run (VR-003).'
}
Write-Host ''
Write-Host "Persona Engine $engineVersion run $runId mode $mode" -ForegroundColor Cyan
Write-Host "Configuration $($config.ConfigVersion) hash $($config.ConfigurationHash.Substring(0, 16))..." -ForegroundColor DarkGray
if ($mode -eq 'Preview') {
Write-Host 'PREVIEW - no write request will be constructed or sent.' -ForegroundColor Yellow
}
Write-Host ''
New-PersonaAuditRecord -Context $context -RecordType 'RunStart' -Properties @{
configPath = $config.SourcePath
targetAttribute = $target
ruleCount = @($config.Rules).Count
enabledRules = @($config.Rules | Where-Object { $_.enabled }).Count
singleUser = [bool]($UserObjectId -and $UserObjectId -ne [guid]::Empty)
tracing = $tracing
} | Write-PersonaAuditRecord @auditParams
# ======================================================== 3. Connect (FR-003)
$facets = Get-PersonaRequiredFacets -Rules $config.Rules -DefaultMembershipMode $config.DefaultMembershipMode
try {
$null = Connect-PersonaGraphInteractive `
-IncludeGroups:([bool]($facets.Direct -or $facets.Transitive)) `
-IncludeRoles:([bool]$facets.Roles) `
-IncludeWrite:$runConfirmed
}
catch {
Write-Host "Authentication failed: $($_.Exception.Message)" -ForegroundColor Red
$exitCode = $EXIT_AUTH
throw
}
# ======================================================== 4. Run
# The per-user gate, passed down rather than re-derived. $PSCmdlet.ShouldProcess
# remains the single origin of the write decision; the run loop never learns what
# -WhatIf is and so cannot disagree with it.
$gate = { param($Target, $Description) $PSCmdlet.ShouldProcess($Target, $Description) }.GetNewClosure()
$runParams = @{
Configuration = $config
TargetAttribute = $target
Context = $context
AuditParameters = $auditParams
IsEnforcing = $runConfirmed
ShouldProcessGate = $gate
Tracing = $tracing
StartedUtc = $startedUtc
ResultsPath = $resultsPath
}
if ($UserObjectId -and $UserObjectId -ne [guid]::Empty) { $runParams['UserObjectId'] = $UserObjectId.ToString() }
$outcome = Invoke-PersonaEngineRun @runParams
$counters = $outcome.Counters
if ($outcome.ExitCode -ne $EXIT_OK) { $exitCode = $outcome.ExitCode }
if ($outcome.FailureReason) {
Write-Host "Run failed: $($outcome.FailureReason)" -ForegroundColor Red
}
if ($exitCode -eq $EXIT_RECONCILIATION) {
Write-Host 'Reconciliation failed. This is an engine defect, not a data condition (FR-021).' -ForegroundColor Red
}
elseif ($exitCode -eq $EXIT_DATA) {
Write-Host ("EvaluationError count {0} exceeds the configured threshold of {1}. Reporting the run as failed." -f
$counters.EvaluationError, $config.EvaluationErrorThreshold) -ForegroundColor Red
}
}
catch {
if ($exitCode -eq $EXIT_OK) { $exitCode = $EXIT_UNEXPECTED }
Write-Host "Run terminated: $($_.Exception.Message)" -ForegroundColor Red
Write-Verbose $_.ScriptStackTrace
}
finally {
# RunComplete is written even on a fatal error. A run that died at user 400 of
# 5000 leaves a record saying exactly that, which is what distinguishes "stopped
# early" from "never started" - two very different incidents that otherwise
# produce identical evidence.
if ($null -ne $context -and $null -ne $counters) {
Export-PersonaRunReport -Context $context -Counters $counters -StartedUtc $startedUtc -ExitCode $exitCode |
Write-PersonaAuditRecord @auditParams
}
}
exit $exitCode
+34
View File
@@ -0,0 +1,34 @@
@{
Severity = @('Error', 'Warning')
IncludeRules = @(
# Constitution Principle III: every state-changing function must support
# ShouldProcess, so -WhatIf reaches all of them.
'PSUseShouldProcessForStateChangingFunctions'
'PSShouldProcess'
# Principle V: no credential material in source.
'PSAvoidUsingPlainTextForPassword'
'PSAvoidUsingConvertToSecureStringWithPlainText'
'PSUsePSCredentialType'
# NFR-004 maintainability.
'PSUseApprovedVerbs'
'PSUseSingularNouns'
'PSAvoidUsingCmdletAliases'
'PSUseDeclaredVarsMoreThanAssignments'
'PSAvoidUsingPositionalParameters'
# NFR-008 portability: no Windows PowerShell-only constructs.
'PSUseCompatibleSyntax'
)
Rules = @{
PSUseCompatibleSyntax = @{
Enable = $true
# 7.2 is the floor because the Automation runtime version is unverified
# until Stage B (research.md V-5b).
TargetVersions = @('7.2')
}
}
}
+76
View File
@@ -0,0 +1,76 @@
@{
RootModule = 'PersonaEngine.psm1'
ModuleVersion = '0.1.0'
GUID = 'b3f1c2d4-5e6a-47b8-9c0d-1e2f3a4b5c6d'
Author = 'Persona Engine maintainers'
Description = 'Deterministic, configuration-driven identity classification for Microsoft Entra ID user objects.'
# PS 7.2 floor rather than 7.4: the Azure Automation runtime version is
# unverified until Stage B (research.md V-5b). Do not raise without evidence.
PowerShellVersion = '7.2'
# OTD-004: authentication and Invoke-MgGraphRequest only. No resource-specific
# Graph SDK modules — keeping the Automation import surface to one module.
RequiredModules = @('Microsoft.Graph.Authentication')
FunctionsToExport = @(
# Configuration
'Import-PersonaConfiguration'
'Test-PersonaConfiguration'
'Test-PersonaConfigurationSemantic'
'Test-PersonaConfigurationSafety'
'Write-PersonaValidationFinding'
'Resolve-TargetAttribute'
'Get-PersonaRequiredFacets'
'New-PersonaValidationFinding'
# Normalization
'New-PersonaUserRecord'
'New-PersonaMembershipRecord'
'ConvertTo-PersonaUserRecord'
'ConvertTo-PersonaMembershipRecord'
# Rule engine
'Test-PersonaCondition'
'Test-PersonaConditionGroup'
'Test-PersonaRule'
'Resolve-UserPersona'
# Authentication and data providers
'Connect-PersonaGraphInteractive'
'Invoke-PersonaGraphRequest'
'Get-PersonaUsers'
'Get-PersonaRequiredProperties'
'Get-PersonaGroupMembership'
'Get-PersonaDirectoryRoles'
'New-PersonaDataCache'
'Get-PersonaCachedMembership'
# Persistence
'Compare-PersonaValue'
'New-PersonaWriteBody'
'Set-UserPersonaAttribute'
# Presentation
'Write-UserPersonaResult'
'Write-PersonaSummary'
'New-PersonaRunCounter'
'Add-PersonaRunResult'
'Test-PersonaReconciliation'
'Get-PersonaReconciliationDetail'
'Export-PersonaResultsCsv'
# Audit
'New-PersonaAuditContext'
'New-PersonaAuditRecord'
'Write-PersonaAuditRecord'
'New-PersonaAuditSinkState'
'Export-PersonaRunReport'
# Run loop
'Invoke-PersonaEngineRun'
)
CmdletsToExport = @()
VariablesToExport = @()
AliasesToExport = @()
PrivateData = @{
PSData = @{
Tags = @('Entra', 'Identity', 'Classification')
ProjectUri = ''
}
}
}
+37
View File
@@ -0,0 +1,37 @@
#Requires -Version 7.2
<#
Module loader.
Dot-sources every function file under src/. Load order is layer-by-layer so a
file may rely on functions from a layer loaded before it.
Note for tests: offline suites (rule engine, normalization, configuration)
deliberately dot-source individual layer folders rather than importing this
module, because importing the manifest pulls in Microsoft.Graph.Authentication.
Keeping the pure layers loadable without that module is the practical proof of
constitution Principle IV.
#>
$ErrorActionPreference = 'Stop'
$layerOrder = @(
'Normalization'
'Configuration'
'RuleEngine'
'Authentication'
'DataProviders'
'Persistence'
'Presentation'
'Engine'
'Audit'
)
foreach ($layer in $layerOrder) {
$layerPath = Join-Path $PSScriptRoot "src/$layer"
if (-not (Test-Path $layerPath)) { continue }
foreach ($file in Get-ChildItem -Path $layerPath -Filter '*.ps1' -File | Sort-Object Name) {
. $file.FullName
}
}
+403 -185
View File
@@ -4,10 +4,274 @@ A modular, configuration-driven **PowerShell 7** identity-classification service
The engine enumerates Entra user accounts, evaluates each one against an ordered, JSON-defined rule set, deterministically assigns **exactly one persona**, and updates a single approved persona attribute — and only when the calculated value differs from the current value. The engine enumerates Entra user accounts, evaluates each one against an ordered, JSON-defined rule set, deterministically assigns **exactly one persona**, and updates a single approved persona attribute — and only when the calculated value differs from the current value.
> **Project status: Phase 0 — approved requirements baseline.** > **Project status: Stage A implementation complete — ready for tenant validation.**
> No implementation code exists yet. The authoritative baseline is >
> [`Persona-Engine-Developer-Handoff.txt`](Persona-Engine-Developer-Handoff.txt). > 109 of 121 tasks are done. Every remaining task needs something a developer workstation does not
> The next step is Phase 1: convert that baseline into `specs/001-persona-engine/spec.md`. > have: a tenant connection (T055, T056, T101T103) or an Azure Automation account (T115T121).
>
> **Nothing has ever been run against a real directory.** The next step is [Stage A2](#stage-a2--tenant-preview-read-only)
> — a delegated, read-only `-WhatIf` run. Follow the [checklist](#tenant-validation-checklist) in order.
---
## Quick start
```bash
pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.example.json -ValidateOnly -NonInteractive
```
That validates the configuration through all four layers. No tenant, no credentials, no network. It
is the fastest way to see what the engine does. Pass `-TestDataPath <dir>` with a directory of
synthetic user/membership fixtures to also run the real rule engine against them and see what
personas it would assign.
---
## Deployment
### Prerequisites
| Requirement | Notes |
| --- | --- |
| PowerShell 7.2 or later | Developed on 7.6.5. `pwsh -v` to check. |
| `Microsoft.Graph.Authentication` | **Runtime only.** Not needed for the config editor's offline validation. |
| PSScriptAnalyzer | Only if you want to run the lint checks locally. |
| An Entra app registration | For the persona extension property and delegated scopes. |
```powershell
Install-Module Microsoft.Graph.Authentication -Scope CurrentUser
Install-Module PSScriptAnalyzer -Scope CurrentUser
```
> Only `Microsoft.Graph.Authentication` is a runtime dependency (OTD-004). The engine calls Graph
> through `Invoke-MgGraphRequest` rather than resource-specific SDK modules, which keeps the import
> surface to one module and makes request bodies explicit values that tests can assert on — that is
> what makes the single-attribute guarantee provable.
### Step 1 — Get the code onto the target machine
```bash
git clone <REPO-URL> persona-engine
cd persona-engine
```
### Step 2 — Prove the machine can run it, before touching a tenant
```bash
pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.example.json -ValidateOnly -NonInteractive
```
Exit code `0` expected. This needs no credentials and no network. If it does not pass, stop —
nothing downstream is trustworthy.
### Step 3 — Register the persona extension property
The persona is stored in a **directory (schema) extension property** on an app registration (OTD-001),
addressable in dynamic group rules as `user.extension_<appId>_<name>`.
```powershell
Connect-MgGraph -Scopes 'Application.ReadWrite.All'
$app = Get-MgApplication -Filter "displayName eq '<APP-REGISTRATION-NAME>'"
New-MgApplicationExtensionProperty -ApplicationId $app.Id -BodyParameter @{
name = '<APPROVED-PERSONA-ATTRIBUTE-NAME>'
dataType = 'String'
targetObjects = @('User')
}
```
Record the returned `name` — it is the full `extension_<32-hex-app-id>_<name>` string, and it is what
goes into `engine.targetAttribute`.
> `extensionAttribute1..15` were **rejected**: they cannot be written from the cloud on objects that
> are, or ever were, synchronized from on-premises, or on Exchange-originated objects. Custom
> security attributes were **rejected**: they are not exposed to the dynamic group engine, which
> defeats the purpose.
### Step 4 — Grant delegated scopes
Read-only, and enough for every preview run:
```powershell
Connect-MgGraph -Scopes 'User.Read.All','GroupMember.Read.All','RoleManagement.Read.Directory'
```
The engine requests only what the enabled rules need — a configuration with no role conditions never
asks for `RoleManagement.Read.Directory`.
`User.ReadWrite.All` is added only for Stage A3, and only after V-4 sign-off.
> **Never sign in with a standing privileged account.** A Global Administrator run invalidates V-3
> as evidence and removes every practical limit on what a defect could reach.
### Step 5 — Build the configuration
```bash
cp ./config/persona-engine.example.json ./config/persona-engine.json
```
Then replace every placeholder:
| Placeholder | Replace with |
| --- | --- |
| `extension_<EXTENSION-APP-ID>_<APPROVED-PERSONA-ATTRIBUTE-NAME>` | The full extension property name from step 3 |
| `<TEAM-NAME>`, `<CHANGE-REFERENCE>` | Ownership metadata |
| `<TIER0-ROLE-TEMPLATE-ID>` | Role **template** IDs (stable across tenants) |
| `00000000-0000-...` group IDs | Real group Object IDs |
| `<ORGANIZATION-NAME>` | Your company name as it appears in `companyName` |
`logging.path` was removed from the copy — it's optional and defaults to
`<current-directory>/logs/persona-engine-audit.ndjson`. Set it (or pass `-OutputPath`) only if you
want the audit log somewhere else.
> **`config/persona-engine.json` must never be committed.** It contains real group Object IDs and
> your tenant's attribute name. Keep it in a protected configuration store, and confirm `.gitignore`
> covers it. The sanitization gate scans untracked files too, so it will catch this — but do not rely
> on that as your only control.
### Step 6 — Validate, before connecting to anything
```bash
pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -ValidateOnly -NonInteractive
```
Exit code `0` required. Codes: `1` findings · `2` warnings with `-TreatWarningsAsErrors` ·
`3` file unreadable · `4` schema unusable.
### Step 7 — Preview
```bash
pwsh ./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json -UserObjectId <ACCOUNT-OBJECT-ID> -WhatIf -Verbose
```
One account first. Then the tenant:
```bash
pwsh ./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json -WhatIf
```
`-WhatIf` is the **only** approved no-write control. `-Debug` does not imply read-only.
### Step 8 — Enforcement 🔒
**Blocked on the V-4 security sign-off** (T101). Do not run without `-WhatIf` against anything other
than purpose-created test accounts until that is recorded. See [docs/SecurityModel.md](docs/SecurityModel.md).
---
## Tenant validation checklist
Work through these in order. Each stage assumes the previous one passed. **Do not skip ahead** — the
whole point of the staging is that a failure is cheap at stage A1 and expensive at stage A3.
### Stage A1 — offline (no tenant, no credentials, no network)
Everything here runs on any machine with PowerShell 7.
- [ ] **Module manifest loads**
`pwsh -NoProfile -Command "Test-ModuleManifest ./PersonaEngine.psd1"`
Fails without `Microsoft.Graph.Authentication` installed. Expected on a bare machine.
- [ ] **Lint**
`pwsh -NoProfile -Command "Invoke-ScriptAnalyzer -Path . -Recurse -Settings ./PSScriptAnalyzerSettings.psd1"`
- [ ] **Example configuration passes all four layers**
`pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.example.json -ValidateOnly -NonInteractive`
- [ ] **Your real configuration passes all four layers**
`pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -ValidateOnly -NonInteractive`
- [ ] **Synthetic rule test produces the personas you expect**
`pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -TestDataPath <fixtures-dir> -ValidateOnly -NonInteractive`
Point `-TestDataPath` at a directory of synthetic user/membership fixtures. If any of them
simulate a failed membership lookup, that account must show `EvaluationError`, not a persona.
If it shows a persona instead, stop — FR-013 is broken.
- [ ] **Drift check against the deployed configuration** (once one exists)
`pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -PreviousConfigPath ./deployed/persona-engine.json -ValidateOnly -NonInteractive`
### Stage A2 — tenant preview, read-only
Requires delegated read scopes. **Sign in as a non-privileged account.**
- [ ] **Connect with read-only scopes and confirm no write scope was granted**
`Connect-MgGraph -Scopes 'User.Read.All','GroupMember.Read.All','RoleManagement.Read.Directory'`
then `(Get-MgContext).Scopes`
- [ ] **Single-user preview**
`pwsh ./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json -UserObjectId <ACCOUNT-OBJECT-ID> -WhatIf -Verbose`
Expect: one result line, `WouldUpdate` or `Unchanged`, exit `0`.
- [ ] **Single-user preview against each origin type — V-1 read half (T056)**
A cloud-only account, a currently-synced account, and a formerly-synced account. Record in
`specs/001-persona-engine/verification/V-1.md`. This is the check that proves the OTD-001
attribute choice actually works where `extensionAttributeN` would not.
- [ ] **Full tenant preview**
`pwsh ./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json -WhatIf`
Expect exit `0` and reconciliation `PASS` at every summary.
- [ ] **Record the V-3 evidence (T055)**
Confirm the run completed as a **non-privileged** account. A Global Administrator run
invalidates this item. Record in `specs/001-persona-engine/verification/V-3.md`.
- [ ] **Review the impact before going further**
From the final summary: how many `WouldUpdate`? Which rules fired, and which fired zero times?
Is the `EvaluationError` count near zero? A high count means the rule set is asking for data
the tenant will not reliably give it.
- [ ] **Idempotence against the real tenant**
Run the full preview twice. The counters must be identical.
- [ ] **Performance baseline (NFR-002)**
Record `durationMs` from the `RunComplete` record and the account count. No target exists yet;
this run is how one gets set.
### Stage A3 — delegated write, test accounts only 🔒
**Gated on V-4** — the written security sign-off on the six OTD-003 compensating controls
(T101, `specs/001-persona-engine/verification/V-4.md`).
- [ ] **V-4 sign-off recorded** — nothing below may start before this
- [ ] **Purpose-created test accounts exist**, one per origin type, and no other account is in scope
- [ ] **Write scope added**: `User.ReadWrite.All`, still as a non-privileged account
- [ ] **Preview the test accounts first**, one at a time, with `-WhatIf`
- [ ] **Enforce one test account**
`pwsh ./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json -UserObjectId <TEST-ACCOUNT-OBJECT-ID>`
Confirm the confirmation prompt appears — `ConfirmImpact` is `High`.
- [ ] **Verify the write landed and touched nothing else**
Re-read the account and diff every property against a copy taken beforehand. Only the persona
attribute may have changed.
- [ ] **Confirm `previousValue` is on the audit record** — without it, rollback is impossible retroactively
- [ ] **Second run proposes zero changes** (SC-002, against a real directory this time)
- [ ] **V-1 write half (T102)** — one write per origin type; append to `V-1.md`
- [ ] **V-2 (T103)** — build a dynamic group on `user.extension_<appId>_<name>`, assign a Conditional
Access policy to it in **report-only** mode, and confirm it applies. This is what proves the
persona is actually useful rather than merely stored.
### Stage B — Azure Automation ⛔
Deferred; no Automation account available. T115T121. Run **T116 first** (`Test-Json` behaviour in
the Automation runtime) — it is the cheapest item most likely to surprise, and V-5a already showed
this cmdlet behaves in a way nobody would guess.
### If something fails
| Symptom | Look at |
| --- | --- |
| Exit `1` | The findings printed above it. No connection was attempted. |
| Exit `2` | Scopes, consent, whether the account can sign in. |
| Exit `3` | Graph availability. **No accounts were processed** — a partial population is never used. |
| Exit `4` | Group/role endpoint health. Nothing was changed. |
| Exit `5` | **An engine defect.** File an issue with the `EngineDefect` record. |
| Every account `Skipped` | Target attribute blank or unapproved. |
| Every account `EvaluationError` | A required data source is disabled or unreachable. |
| Every account `Unclassified` | Every rule disabled, or no rule matches. The summary distinguishes these. |
[docs/OperationsRunbook.md](docs/OperationsRunbook.md) has the full table, the kill switch, and the
rollback procedure.
--- ---
@@ -19,16 +283,15 @@ This project follows the **GitHub Spec Kit** workflow. Nothing is implemented be
Specify -> Plan -> Tasks -> Implement Specify -> Plan -> Tasks -> Implement
``` ```
| Stage | Artifact | Phase | State | | Stage | Artifact | State |
| --- | --- | --- | --- | | --- | --- | --- |
| Baseline | `Persona-Engine-Developer-Handoff.txt` | 0 | Approved | | Baseline | `Persona-Engine-Developer-Handoff.txt` | Approved |
| Specify | `specs/001-persona-engine/spec.md` | 1 | Not started | | Constitution | `.specify/memory/constitution.md` | Ratified v1.0.0 |
| Plan | `specs/001-persona-engine/plan.md`, `research.md` | 2 | Not started | | Specify | `specs/001-persona-engine/spec.md` | Complete |
| Contracts | `data-model.md`, `contracts/`, `persona-engine.schema.json` | 3 | Not started | | Plan | `plan.md`, `research.md` | Complete — OTD-001…007, 010 resolved |
| Tasks | `specs/001-persona-engine/tasks.md` | 4 | Not started | | Contracts | `data-model.md`, `contracts/`, `persona-engine.schema.json` | Complete |
| Implement | `src/`, `tests/`, `pipelines/` | 512 | Not started | | Tasks | `specs/001-persona-engine/tasks.md` | Complete — 121 tasks |
| Implement | `src/`, `docs/` | **109 / 121** — remainder needs a tenant or Automation |
Any item that is unresolved must be captured as an explicit **assumption, risk, or architecture decision**. It must never be silently implemented.
--- ---
@@ -45,232 +308,187 @@ Any item that is unresolved must be captured as an explicit **assumption, risk,
| **Explainable** | Every result identifies the matched rule, run ID, UPN, and Account Object ID. | | **Explainable** | Every result identifies the matched rule, run ID, UPN, and Account Object ID. |
| **Modular** | The pure rule engine has no dependency on Graph, Azure Automation, or the console. | | **Modular** | The pure rule engine has no dependency on Graph, Azure Automation, or the console. |
--- ### The one that matters most
## Scope (version 1) **Unknown is not false.** If a membership lookup fails, the condition is `Unknown`, not `False` — so a
`notMemberOf` condition does not become satisfied when the lookup fails.
**In scope** Without that, a transient Graph outage would make every privileged account look like a non-member of
its Tier 0 group, and one run would quietly demote the entire administrative population.
- Microsoft Entra **user objects only**
- Ordered, first-match business rules defined in **JSON** (the only supported configuration format)
- Property, group-membership, and role-based conditions with nested `All` / `Any` composition
- Native PowerShell `-WhatIf` as the approved no-write control
- Structured, audit-friendly logging plus immediate per-user output and periodic summaries
- Local PowerShell 7 execution and Azure Automation PowerShell 7 runbook execution
**Out of scope for v1** — the architecture must not assume these share user-object properties:
- Service principals, managed identities, workload identities, agentic identities
### Candidate persona catalogue
These are candidate business classifications, **not** hard-coded engine behaviour:
`Guest` · `BreakGlass-Admin` · `Tier0-Admin` · `Tier1-Admin` · `Tier2-Admin` · `Restricted-User` · `Test-Account` · `Service-Account` · `Shared-Functional-Account` · `Meeting-Room-Device` · `Employee` · `Contractor` · `Student`
Two values are processing results rather than rules:
- **`Unclassified`** — evaluation succeeded, but no rule matched.
- **`EvaluationError`** — evaluation could not complete; the existing persona is preserved.
--- ---
## Components ## Components
### 1. `Invoke-PersonaEngine.ps1` ### `Invoke-PersonaEngine.ps1`
Retrieval, evaluation, reporting, and controlled persistence.
| Parameter | Notes | | Parameter | Notes |
| --- | --- | | --- | --- |
| `-ConfigPath <string>` | Required | | `-ConfigPath <string>` | Defaults to `./config/persona-engine.json`. Validated through all four layers before any connection. |
| `-WhatIf` | Native risk-mitigation parameter; the approved no-write control | | `-WhatIf` | **The approved no-write control.** |
| `-Verbose` / `-Debug` | Native common parameters; `-Debug` must **not** mean read-only | | `-UserObjectId <GUID>` | Single-user execution. |
| `-UserObjectId <GUID>` | Optional single-user test execution | | `-OutputPath <string>` | Overrides `logging.path`. Both default to `<current-directory>/logs/persona-engine-audit.ndjson`. |
| `-OutputPath <string>` | Optional override, if permitted | | `-CorrelationId <GUID>` | Run identifier; generated when absent. |
| `-CorrelationId <GUID>` | Optional supplied run identifier | | `-SchemaPath <string>` | Schema override. |
| `-PreviousConfigPath <string>` | Enables the VR-003 drift checks. |
| `-Verbose` / `-Debug` | Common parameters. **Neither implies read-only.** |
The script uses `CmdletBinding` with `SupportsShouldProcess`. **Exit codes**: `0` success · `1` config invalid · `2` auth · `3` enumeration · `4` data / threshold ·
`5` reconciliation · `6` unexpected.
```powershell ### `Edit-PersonaEngineConfig.ps1`
# Read-only evaluation
./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json -WhatIf
# Read-only with operational detail
./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json -WhatIf -Verbose
# Single-user validation
./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json -UserObjectId <ACCOUNT-OBJECT-ID> -WhatIf
# Production, changed-values-only processing
./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json
```
**Exit codes**
| Code | Meaning |
| --- | --- |
| `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 |
A per-user `EvaluationError` does not necessarily terminate the run, but the final status must report the number of affected accounts and may apply a configurable warning/failure threshold.
### 2. `Edit-PersonaEngineConfig.ps1`
Configuration validation, interactive editing, synthetic rule testing, and pipeline enforcement.
| Parameter | Notes | | Parameter | Notes |
| --- | --- | | --- | --- |
| `-ConfigPath <string>` | Required | | `-ConfigPath <string>` | Required. |
| `-ValidateOnly` | Validate without entering the editor | | `-ValidateOnly` | Validate; never enter the editor. |
| `-NonInteractive` | Pipeline mode; returns codes instead of prompting | | `-NonInteractive` | Pipeline mode. Never prompts, never hangs. |
| `-SchemaPath <string>` | Optional schema override | | `-SchemaPath` / `-OutputPath` | Schema override; Save-As target. |
| `-OutputPath <string>` | Optional Save-As target | | `-TreatWarningsAsErrors` | Escalate warnings (VR-005). |
| `-TreatWarningsAsErrors` | Escalate warnings | | `-TestDataPath <string>` | Synthetic rule testing, no tenant. |
| `-TestDataPath <string>` | Optional synthetic sample input | | `-PreviousConfigPath` / `-EnforcementEnabled` | Drift checks; raise safety severities. |
```powershell **Exit codes**: `0` valid · `1` errors · `2` warnings escalated · `3` file unreadable · `4` schema unusable.
# Validate only
./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -ValidateOnly
# Pipeline validation Validation runs in four layers: **JSON syntax → JSON Schema → semantic → safety**, stopping at the
./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -ValidateOnly -NonInteractive first that produces errors.
# Interactive editor
./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json
```
Validation runs in four layers: **JSON syntax → JSON Schema → semantic → safety**.
--- ---
## Architecture ## Architecture
``` ```
Invoke-PersonaEngine.ps1 Invoke-PersonaEngine.ps1 thin wrapper: parameters, ShouldProcess, exit code
| └── src/Engine/ Invoke-PersonaEngineRun (the run loop, testable offline)
+-- Configuration Import-PersonaConfiguration, Test-PersonaConfiguration, Resolve-TargetAttribute ├── Configuration Import, Test (4 layers), Resolve-TargetAttribute
+-- Authentication Connect-PersonaGraphInteractive, Connect-PersonaGraphManagedIdentity ├── Authentication Connect-PersonaGraphInteractive
+-- Data Providers Get-PersonaUsers, Get-PersonaGroupMembership, Get-PersonaDirectoryRoles ├── DataProviders Get-PersonaUsers, GroupMembership, DirectoryRoles, retry, cache
+-- Normalization ConvertTo-PersonaUserRecord, ConvertTo-PersonaMembershipRecord ├── Normalization ConvertTo-PersonaUserRecord, ConvertTo-PersonaMembershipRecord
+-- Rule Engine Test-PersonaCondition, Test-PersonaConditionGroup, Test-PersonaRule, Resolve-UserPersona ├── RuleEngine Test-PersonaCondition/ConditionGroup/Rule, Resolve-UserPersona
+-- Persistence Compare-PersonaValue, Set-UserPersonaAttribute ├── Persistence Compare-PersonaValue, New-PersonaWriteBody, Set-UserPersonaAttribute
+-- Presentation Write-UserPersonaResult, Write-PersonaSummary ├── Presentation Write-UserPersonaResult, Write-PersonaSummary, reconciliation
+-- Audit New-PersonaAuditRecord, Export-PersonaRunReport └── Audit New-PersonaAuditRecord, Write-PersonaAuditRecord, Export-PersonaRunReport
``` ```
**Critical flow** The **rule engine is pure** — no Graph, no auth, no console, no filesystem, no clock. That purity is
what lets it be evaluated offline against synthetic fixtures with no tenant connection.
```
Graph acquisition -> normalized identity record -> pure rule engine
-> persona decision result -> comparison -> optional persistence adapter
-> console and structured audit output
```
The **pure rule engine must not depend** on Graph authentication, Azure Automation, or console rendering. It has to be testable offline with synthetic data.
--- ---
## Planned repository structure ## Repository layout
``` ```
PersonaEngine/ Invoke-PersonaEngine.ps1 Edit-PersonaEngineConfig.ps1
|-- README.md PersonaEngine.psd1 PersonaEngine.psm1
|-- Invoke-PersonaEngine.ps1
|-- Edit-PersonaEngineConfig.ps1 config/ persona-engine.example.json, persona-engine.schema.json
|-- PersonaEngine.psd1 src/ Configuration/ Authentication/ DataProviders/ Normalization/
|-- PersonaEngine.psm1 RuleEngine/ Persistence/ Presentation/ Engine/ Audit/
| docs/ Architecture.md SecurityModel.md ConfigurationReference.md
|-- config/ persona-engine.example.json, persona-engine.schema.json RuleAuthoringGuide.md OperationsRunbook.md BusinessRules.md Logging.md
|-- src/ Authentication/ Configuration/ DataProviders/ Normalization/ specs/001-persona-engine/
| RuleEngine/ Persistence/ Presentation/ Audit/ spec.md plan.md tasks.md research.md data-model.md
|-- tests/ Unit/ Integration/ Configuration/ Safety/ TestData/ quickstart.md traceability.md contracts/ verification/
|-- docs/ Architecture.md BusinessRules.md ConfigurationReference.md
| Logging.md SecurityModel.md OperationsRunbook.md
|-- pipelines/ validate.yml test.yml release.yml
|-- specs/
|-- 001-persona-engine/
|-- spec.md plan.md tasks.md research.md data-model.md quickstart.md
|-- contracts/ checklists/
``` ```
--- ---
## Security model ## Security model
- **Authentication** — Azure Automation uses a **managed identity**. Local development uses an approved interactive or read-only application identity. **No client secret in source control.** The short version; the full one is [docs/SecurityModel.md](docs/SecurityModel.md).
- **Least privilege** — the execution identity gets only what the enabled rules require: in-scope user properties, configured group membership, configured role data, and the existing persona value.
- **Write permissions** — the production identity is granted the minimum permission needed to update the configured target attribute. Whether Entra can enforce write scope at the **individual attribute level must be verified, never assumed** (see OTD-003). **Graph application permissions have no per-property write scope** (OTD-003). An identity that can
- **Compensating controls**, if the Graph permission proves broader than the single attribute: the persistence module accepts only the approved target attribute; that attribute must appear in `approvedWritableAttributes`; validation rejects all others; a dedicated function builds a request body containing only that attribute; unit and integration tests inspect the request body; code owners and branch policies gate persistence changes; directory audit logs are monitored for unexpected property writes. write the persona attribute can write any writable user property. The directory will not stop a
- **Data handling** — UPN and Account Object ID are approved for logs. Never log access tokens, authorization headers, secrets, or full Graph responses. Detailed condition values are diagnostic-only, behind `-Debug`. malformed request on our behalf, so six compensating controls hold that line, and each is tested:
- **Kill switch** — disable the Automation schedule, run with `-WhatIf`, revoke production write permission, or disable write deployment stages.
1. `New-PersonaWriteBody` throws for any attribute other than the configured target.
2. The target must appear in `approvedWritableAttributes` — checked at validation **and** at write time.
3. Validation rejects any approved attribute that is not a directory extension property (`PE-SAF-002`).
4. One function builds the request body, and it returns a hashtable whose `Count` is exactly 1.
5. Tests inspect every body issued during a full enforcing run.
6. Code owners and branch policies gate changes to `src/Persistence/`.
**V-4 — written security sign-off on these controls — gates all enforcement.**
Never logged: tokens, `Authorization` headers, secrets, certificates, raw Graph responses. The
guarantee is structural: the record builder accepts only named, typed values, so there is nothing for
a secret to ride in on.
--- ---
## Sanitization requirements ## Sanitization (SC-013)
**Every** artifact in this repository — docs, examples, tests, configuration samples — must be free of organization names, real domains, tenant or subscription IDs, automation account names, real UPNs or Object IDs, real group/role identifiers, environment-specific attribute names, Log Analytics details, and any secret, token, certificate, or credential. No organization name, real domain, tenant or subscription ID, real UPN or Object ID, real group or
role identifier, environment-specific attribute name, or secret may appear in any file this
repository would commit.
Use placeholders only: Reserved domains (`example.com`, `.invalid`, `.test`) and the module manifest's own identity GUID are
exempt; nothing else is. See [docs/SecurityModel.md](docs/SecurityModel.md) for the full policy.
`<ORGANIZATION-NAME>` · `<PRIMARY-DOMAIN>` · `<TENANT-ID>` · `<ACCOUNT-OBJECT-ID>` · `<GROUP-OBJECT-ID>` · `<APPROVED-PERSONA-ATTRIBUTE-NAME>` · `<AUTOMATION-ACCOUNT-NAME>` · `<LOG-OUTPUT-PATH>` Placeholders: `<ORGANIZATION-NAME>` · `<PRIMARY-DOMAIN>` · `<TENANT-ID>` · `<ACCOUNT-OBJECT-ID>` ·
`<GROUP-OBJECT-ID>` · `<APPROVED-PERSONA-ATTRIBUTE-NAME>` · `<AUTOMATION-ACCOUNT-NAME>`
Synthetic test data must be obviously fictional and must never reproduce real employee records. Tenant-specific configuration belongs in a protected repository or configuration store, not here.
--- ---
## Open technical decisions ## Open decisions and verification
Research items to be resolved in `research.md` or an ADR. **OTD-001 through OTD-005 must be closed before persistence implementation.** | ID | Status |
| ID | Decision |
| --- | --- | | --- | --- |
| OTD-001 | Select the exact Entra persona attribute mechanism — data type, Graph read/update method, discoverability, Conditional Access compatibility | | OTD-001 persona attribute | **Resolved** — directory extension property |
| OTD-002 | Confirm exact least-privilege Microsoft Graph permissions | | OTD-002 least-privilege permissions | **Resolved** |
| OTD-003 | Confirm whether write authorization can be restricted to the individual target attribute | | OTD-003 per-attribute write scope | **Resolved: not possible.** Six compensating controls; V-4 outstanding |
| OTD-004 | Select the Graph access approach — SDK cmdlets, direct REST, or a controlled combination | | OTD-004 Graph access approach | **Resolved**`Invoke-MgGraphRequest` |
| OTD-005 | Select a JSON Schema validation approach compatible with PS7 locally and in Azure Automation | | OTD-005 schema validation | **Resolved locally**`Test-Json`; V-5b open for Automation |
| OTD-006 | Select structured-log destination and transport | | OTD-006 log transport | **Resolved** — NDJSON via a single sink |
| OTD-007 | Define retry policy — retryable status codes, max attempts, backoff, jitter, logging | | OTD-007 retry policy | **Resolved** |
| OTD-008 | Define full versus incremental processing roadmap | | OTD-008 incremental processing | Open — full enumeration only in v1 |
| OTD-009 | Define production schedule and concurrency lock | | OTD-009 concurrency lock | Open — deferred with Stage B |
| OTD-010 | Define rollback implementation | | OTD-010 rollback | **Data captured**; tool out of scope for v1 |
| Verification | Status |
| --- | --- |
| V-1 read / write | Open — needs a tenant / test accounts |
| V-2 dynamic group + CA | Open |
| V-3 non-privileged preview | Open |
| **V-4 security sign-off** | **Open — gates enforcement** |
| V-5a `Test-Json` behaviour | **Closed** — [V-5a.md](specs/001-persona-engine/verification/V-5a.md) |
| V-5b `Test-Json` in Automation | Deferred |
Full requirement-to-test mapping, including the gaps: [traceability.md](specs/001-persona-engine/traceability.md).
--- ---
## Getting started ## Documentation
Implementation has not begun. The current work item is Phase 1. | Document | For |
| --- | --- |
1. Initialize the Spec Kit project structure. | [Architecture.md](docs/Architecture.md) | Boundaries, and why the rule engine is pure |
2. Convert the handoff baseline into `specs/001-persona-engine/spec.md`. | [SecurityModel.md](docs/SecurityModel.md) | OTD-003, the six controls, V-4 |
3. Build a requirements traceability list using FR/NFR identifiers. | [ConfigurationReference.md](docs/ConfigurationReference.md) | Every field and every finding code |
4. Close OTD-001 through OTD-005 before any persistence work. | [RuleAuthoringGuide.md](docs/RuleAuthoringGuide.md) | User manual: every condition type and operator, worked examples, the interactive editor |
5. Create `persona-engine.schema.json` and a placeholder-only `persona-engine.example.json`. | [BusinessRules.md](docs/BusinessRules.md) | Writing and changing rules — the judgement calls |
6. Define normalized PowerShell object contracts. | [OperationsRunbook.md](docs/OperationsRunbook.md) | Kill switch, rollback, incidents |
7. **Build the pure rule engine first**, with offline Pester tests, before any Graph integration. | [Logging.md](docs/Logging.md) | Record types and querying |
8. Implement configuration validation and non-interactive pipeline mode.
9. Implement Graph read adapters, then console and structured logging.
10. Implement the persistence adapter **last**, with `ShouldProcess` and tests proving zero writes under `-WhatIf`.
Requirements: **PowerShell 7**. Offline unit testing must not require tenant connectivity.
--- ---
## Delivery ## Scope (version 1)
- Hosted in **Azure DevOps Git** — feature branches, pull requests, protected release branch, code owners on persistence, security configuration, and production rules. **In scope** — Entra **user objects only**; ordered first-match rules in JSON; property,
- **Validation pipeline** — repository hygiene checks, PowerShell static analysis, JSON Schema validation, semantic/safety configuration validation, Pester unit tests, Pester safety tests, test result publication, artifact packaging. group-membership, and role conditions with nested `all`/`any`; `-WhatIf` as the no-write control;
- **Release pipeline** — validate approved branch/tag, repeat validation and tests, package, deploy to Azure Automation, import modules, publish runbook, **keep the schedule disabled**, execute `-WhatIf` validation, approval gate, then enable enforcement. structured audit logging; local PowerShell 7 and (deferred) Azure Automation.
- Changes to the target attribute, `approvedWritableAttributes`, rule priority, rule enablement, rule conditions, persona outputs, authentication permissions, persistence functions, logging destination, or `WhatIf`/`ShouldProcess` behaviour **all require review**.
--- **Out of scope** — service principals, managed identities, workload identities, agentic identities;
non-JSON configuration; delta processing; condition-level case-sensitivity; PIM-eligible role
assignments; a rollback tool.
## Definition of done (v1) ### Candidate persona catalogue
Version 1 is complete when both PowerShell scripts are implemented; the JSON Schema exists and is documented; validation covers syntax, schema, semantic, and safety layers; ordered first-match evaluation and nested `All`/`Any` work within the configured depth; the initial property and membership operators are tested; null behaviour matches the approved decision; required group-lookup failures produce `EvaluationError` and preserve the existing persona; `Unclassified` users are reported distinctly; each user result displays immediately; interim and final summaries work — including interval `0` — and reconciliation checks pass; logs include UPN and Account Object ID; **`-WhatIf` produces zero Graph writes**; only changed valid values are written in enforcement mode; the write payload contains only the approved target attribute; local offline Pester, read-only production-tenant, Azure Automation PowerShell 7, and Azure DevOps pipeline runs all pass; security review confirms permissions and compensating controls; operational documentation, kill switch, and rollback procedure are complete; and `-WhatIf` impact evidence is reviewed before enforcement is enabled. `Guest` · `BreakGlass-Admin` · `Tier0-Admin` · `Tier1-Admin` · `Tier2-Admin` · `Restricted-User` ·
`Test-Account` · `Service-Account` · `Shared-Functional-Account` · `Meeting-Room-Device` · `Employee` ·
`Contractor` · `Student`
Two values are processing results, not rule outcomes:
- **`Unclassified`** — evaluation succeeded, no rule matched.
- **`EvaluationError`** — evaluation could not complete; the existing persona is preserved.
+230
View File
@@ -0,0 +1,230 @@
{
"configVersion": "1.0.0",
"metadata": {
"owner": "<TEAM-NAME>",
"changeReference": "<CHANGE-REFERENCE>",
"description": "Placeholder-only example. Every identifier below is fictional. Real group Object IDs, attribute names, and domains belong in a protected configuration store, never in this repository."
},
"engine": {
"targetAttribute": "extension_<EXTENSION-APP-ID>_<APPROVED-PERSONA-ATTRIBUTE-NAME>",
"approvedWritableAttributes": [
"extension_<EXTENSION-APP-ID>_<APPROVED-PERSONA-ATTRIBUTE-NAME>"
],
"maxConditionDepth": 5,
"summaryInterval": 25,
"defaultMembershipMode": "direct",
"evaluationErrorThreshold": 50
},
"dataSources": {
"groups": {
"enabled": true
},
"roles": {
"enabled": true,
"includeEligible": false
}
},
"logging": {
"destination": "both",
"resultsFileName": "results.csv",
"traceConditionValues": false
},
"personas": [
"Guest",
"BreakGlass-Admin",
"Tier0-Admin",
"Tier1-Admin",
"Tier2-Admin",
"Restricted-User",
"Test-Account",
"Service-Account",
"Shared-Functional-Account",
"Meeting-Room-Device",
"Employee",
"Contractor",
"Student"
],
"rules": [
{
"id": "RULE-0010-BREAKGLASS",
"name": "Emergency access accounts",
"description": "Emergency access accounts identified by immutable Object ID (RE-009). Evaluated first so no later rule can reclassify them.",
"enabled": true,
"priority": 10,
"persona": "BreakGlass-Admin",
"owner": "<TEAM-NAME>",
"match": {
"operator": "any",
"conditions": [
{
"type": "property",
"property": "AccountObjectId",
"operator": "in",
"values": [
"00000000-0000-0000-0000-000000000001",
"00000000-0000-0000-0000-000000000002"
]
}
]
}
},
{
"id": "RULE-0020-GUEST",
"name": "Guest accounts",
"description": "Any account whose directory user type is Guest.",
"enabled": true,
"priority": 20,
"persona": "Guest",
"match": {
"operator": "all",
"conditions": [
{
"type": "property",
"property": "UserType",
"operator": "equals",
"value": "Guest"
}
]
}
},
{
"id": "RULE-0030-TIER0",
"name": "Tier 0 administrators",
"description": "Members of the Tier 0 administrative group, or holders of a Tier 0 directory role.",
"enabled": true,
"priority": 30,
"persona": "Tier0-Admin",
"match": {
"operator": "any",
"conditions": [
{
"type": "membership",
"operator": "memberOf",
"membershipMode": "transitive",
"groupObjectIds": [
"00000000-0000-0000-0000-0000000000a0"
]
},
{
"type": "role",
"operator": "memberOf",
"roleIds": [
"<TIER0-ROLE-TEMPLATE-ID>"
]
}
]
}
},
{
"id": "RULE-0040-SERVICE",
"name": "Service accounts",
"description": "Non-human accounts identified by naming convention and the service account group. Both must hold, so a naming-convention collision alone cannot classify a person as a service account.",
"enabled": true,
"priority": 40,
"persona": "Service-Account",
"match": {
"operator": "all",
"conditions": [
{
"type": "property",
"property": "UserPrincipalName",
"operator": "startsWith",
"value": "svc-"
},
{
"type": "membership",
"operator": "memberOf",
"groupObjectIds": [
"00000000-0000-0000-0000-0000000000b0"
]
}
]
}
},
{
"id": "RULE-0050-TEST",
"name": "Test accounts",
"description": "Accounts in the test account group, or matching the test naming convention while disabled.",
"enabled": true,
"priority": 50,
"persona": "Test-Account",
"match": {
"operator": "any",
"conditions": [
{
"type": "membership",
"operator": "memberOf",
"groupObjectIds": [
"00000000-0000-0000-0000-0000000000c0"
]
},
{
"operator": "all",
"conditions": [
{
"type": "property",
"property": "UserPrincipalName",
"operator": "startsWith",
"value": "test-"
},
{
"type": "property",
"property": "AccountEnabled",
"operator": "equals",
"value": "False"
}
]
}
]
}
},
{
"id": "RULE-0060-CONTRACTOR",
"name": "Contractors",
"description": "Accounts whose company name marks them as external, excluding those already classified by an earlier rule.",
"enabled": true,
"priority": 60,
"persona": "Contractor",
"match": {
"operator": "all",
"conditions": [
{
"type": "property",
"property": "CompanyName",
"operator": "isNotNull"
},
{
"type": "property",
"property": "CompanyName",
"operator": "notEquals",
"value": "<ORGANIZATION-NAME>"
}
]
}
},
{
"id": "RULE-0900-EMPLOYEE",
"name": "Employees",
"description": "Default classification for enabled member accounts with a department. Lowest priority so every more specific rule wins first.",
"enabled": true,
"priority": 900,
"persona": "Employee",
"match": {
"operator": "all",
"conditions": [
{
"type": "property",
"property": "UserType",
"operator": "equals",
"value": "Member"
},
{
"type": "property",
"property": "Department",
"operator": "isNotNull"
}
]
}
}
]
}
+284
View File
@@ -0,0 +1,284 @@
{
"$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": "NDJSON output file for 'file'/'both' destinations. Defaults to <current-directory>/logs/persona-engine-audit.ndjson when unset."
},
"resultsFileName": {
"type": "string",
"minLength": 1,
"default": "results.csv",
"description": "Per-account results CSV (AccountObjectId, UserPrincipalName, persona/status), written alongside the audit log and overwritten on every summary."
},
"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}$"
}
}
}
+132
View File
@@ -0,0 +1,132 @@
# 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, and it is the reason the rule
engine can be evaluated on a laptop with no tenant, no credentials, and no network.
## 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
+139
View File
@@ -0,0 +1,139 @@
# Business rules
How to write, order, and change the rules that decide what an account is.
Field-by-field syntax is in [ConfigurationReference.md](ConfigurationReference.md). Every condition
type and operator, with worked examples, is in [RuleAuthoringGuide.md](RuleAuthoringGuide.md). This
document is about judgement.
## The model
Rules are evaluated in ascending `priority`. The first rule that returns `True` wins, and evaluation
stops. An account matching no enabled rule is `Unclassified`. An account whose evaluation hits data
that could not be retrieved is `EvaluationError`, and its stored value is preserved.
Three consequences worth internalising before writing a rule:
**Order is meaning.** A rule at priority 900 only ever sees accounts that failed every rule above it.
Changing a priority silently reclassifies every account matched by more than one rule, which is why
`PE-SAF-005` blocks a reorder without a `configVersion` change.
**A rule cannot express "and not the previous ones".** It does not need to. First-match already
excludes them. Adding explicit exclusions duplicates the ordering in two places, and the day they
disagree the ordering wins silently.
**Determinism is absolute.** The same account and the same configuration always produce the same
persona. Nothing time-dependent, random, or order-dependent may enter a decision — `effectiveDate` is
metadata for exactly this reason, and the engine's per-user timing uses a monotonic stopwatch rather
than the wall clock so no clock value can reach a decision.
## Priority bands
A convention, not enforced, but it makes the intent of a rule set legible at a glance:
| Band | Purpose | Examples |
| --- | --- | --- |
| 199 | Accounts that must never be reclassified by anything | Emergency access, Tier 0 |
| 100199 | Directory facts that are definitional | Guest, external |
| 200499 | Non-human accounts | Service, shared functional, room devices |
| 500799 | Population subsets | Contractor, student, restricted |
| 800999 | Defaults | Employee |
Leave gaps. Renumbering to insert a rule is a reorder, and a reorder is a `PE-SAF-005` finding.
## Writing a rule that holds up
**Identify special accounts by Object ID, never by name.** Display names and UPNs change; Object IDs
do not. RE-009 exists because a break-glass account renamed during an incident must not silently stop
being a break-glass account.
**Require two independent signals for a consequential classification.** The example configuration's
service-account rule requires both a naming convention *and* group membership, so a person whose UPN
happens to start with `svc-` is not classified as a service account.
**Prefer group membership to string matching for anything privileged.** A group is administered,
auditable, and has an owner. A naming convention is a habit.
**Give every rule a description that says why it exists**, not what it does — the conditions already
say what it does. A rule nobody can explain cannot be safely changed, which is why `description` is
required.
## Membership mode
`direct` asks whether the account is a member of the named group itself. `transitive` asks whether it
is a member through any chain of nesting.
Mode is a per-condition choice (RE-007). The three facets — direct, transitive, roles — are retrieved
independently, so mixing modes in one rule set is fully supported. It costs one extra request per
account for each additional facet.
Use `transitive` when the group is a role-holding group that other groups nest into — which is most
Tier 0 groups. Use `direct` when membership is explicitly managed and nesting would be a mistake.
**Do not pin `dataSources.groups.membershipMode` unless you mean to restrict.** Absent means "any mode
is acceptable". Pinning it turns every per-condition override into a `PE-SEM-014` warning, which
trains people to ignore warnings.
## Unknown is not false
If a membership lookup fails, the condition is `Unknown`, not `False`. A `notMemberOf` condition
therefore does **not** become satisfied when the lookup fails.
This is the single most important behaviour in the engine. Without it, a transient Graph outage would
make every privileged account look like a non-member of its Tier 0 group, and a single run would
quietly demote the entire administrative population. `UnknownNotFalse.Tests.ps1` exists solely to
prevent that regression.
The cost is that a failed lookup produces `EvaluationError` rather than a classification. That is the
correct trade: preserving a possibly-stale value is recoverable, and writing a confidently wrong one
is not.
## Nesting
`all` and `any` groups nest to `maxConditionDepth` (default 5, ceiling 10). Beyond the limit the
engine returns `Unknown`, which becomes `EvaluationError` for every account the rule reaches — so a
too-deep rule fails safe rather than silently.
A rule needing more than three levels is usually two rules with different priorities. Depth is
expensive to read and the ordering you would express with nesting is already available for free.
## Changing a rule set
1. Edit the configuration.
2. Validate against the deployed copy so the drift checks actually run:
```bash
pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -PreviousConfigPath ./deployed/persona-engine.json -ValidateOnly -NonInteractive
```
3. Run the rules against synthetic fixtures.
4. Preview against one account, then against the tenant with `-WhatIf`.
5. Compare the summary's per-rule match counts against the previous run. A rule whose count moved
sharply is either the change you made or a change you did not intend.
6. Raise `configVersion`.
Step 5 is the one people skip. The summary lists every rule including zero-match ones precisely so
that a rule which *stopped* firing is visible, and a rule that stopped firing is the usual signature
of an accidental reorder.
## Disabling versus deleting
Disable rather than delete. A disabled rule still appears in every summary with a zero count, so the
audit trail keeps reporting on it and an operator can see it was deliberately turned off. A deleted
rule is indistinguishable from one that never existed, which is why `PE-SAF-005` flags a deletion
without a version change.
## Testing a rule set without a tenant
```bash
pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -TestDataPath <fixtures-dir> -ValidateOnly -NonInteractive
```
This runs the real engine against a directory of synthetic user/membership fixtures you provide.
Build fixtures for the cases your rule set actually cares about: accounts with null and absent
properties, mixed casing, a guest, a disabled account, and — most usefully — an account whose
membership lookup fails, so `EvaluationError` behaviour is visible before it happens against a real
directory.
A fixture that reproduces a real edge case, sanitized, is worth more than any amount of reasoning
about what the engine will probably do.
+222
View File
@@ -0,0 +1,222 @@
# Configuration reference
Every field in `persona-engine.json`, and every finding code the validator can produce.
The authoritative schema is [`config/persona-engine.schema.json`](../config/persona-engine.schema.json)
(JSON Schema draft-07). A working example is
[`config/persona-engine.example.json`](../config/persona-engine.example.json), which is validated
against its own schema before every commit — if the example the documentation points at could not
pass, every reader's first run would fail.
For a task-oriented walkthrough of every condition type and operator with worked JSON examples, see
[RuleAuthoringGuide.md](RuleAuthoringGuide.md). This document is the terse reference; that one
teaches by example.
## Top level
| Field | Required | Notes |
| --- | --- | --- |
| `configVersion` | yes | Semantic version, `major.minor.patch`. A downgrade is a safety finding. |
| `metadata` | no | `owner`, `changeReference`, `description`. Free-form; not read by the engine. |
| `engine` | yes | Engine behaviour. |
| `dataSources` | yes | Which directory data may be retrieved. |
| `logging` | no | Audit output. |
| `personas` | yes | The declared persona catalogue. |
| `rules` | yes | Ordered business rules. |
## `engine`
| Field | Required | Default | Notes |
| --- | --- | --- | --- |
| `targetAttribute` | yes | — | The single attribute the engine may write. Must be a directory extension property and must appear in `approvedWritableAttributes`. |
| `approvedWritableAttributes` | yes | — | The allow-list. Comparison is **ordinal** — extension property names are case-sensitive in Graph. |
| `maxConditionDepth` | no | `5` | RE-004. Minimum 1, hard ceiling 10. |
| `summaryInterval` | no | `25` | Interim summary frequency. `0` suppresses interim summaries; a final summary always appears. |
| `defaultMembershipMode` | no | `direct` | Mode for membership conditions that do not specify one. |
| `evaluationErrorThreshold` | no | unset | Count of `EvaluationError` results above which the run reports exit code 4. Unset means report, do not fail. |
Setting `evaluationErrorThreshold` to `0` makes a single transient lookup failure fail the run. That
is occasionally what you want; it is rarely what you meant.
## `dataSources`
| Field | Required | Notes |
| --- | --- | --- |
| `groups.enabled` | yes | When false, no membership facet is retrieved. Rules needing it become `EvaluationError`. |
| `groups.membershipMode` | no | Pins a mode globally. **Leave it out unless you mean to restrict** — absent means "any mode is acceptable", and RE-007 makes mode a per-condition choice. Pinning it makes every per-condition override a `PE-SEM-014` warning. |
| `roles.enabled` | yes | Directory role assignments. |
| `roles.includeEligible` | no | PIM-eligible assignments. **Out of scope for v1**; no provider is implemented. |
The engine retrieves only the facets enabled rules actually reference. A configuration with no role
conditions never calls the role endpoint, so a tenant where role reads are unavailable can still run
property-only rules.
## `logging`
| Field | Required | Default | Notes |
| --- | --- | --- | --- |
| `destination` | no | `both` | `file`, `stream`, `both`, or `none`. `stream` writes records to the PowerShell Information stream. |
| `path` | no | `<current-directory>/logs/persona-engine-audit.ndjson` | NDJSON output file. One record per line. |
| `resultsFileName` | no | `results.csv` | Per-account results CSV, written next to `path`'s directory. Lists `AccountObjectId`, `UserPrincipalName`, `PersonaStatus`, `CompanyName`, `Department` for every account processed so far. Overwritten on every summary, interim and final. |
| `traceConditionValues` | no | `false` | Writes evaluated attribute values into audit records. |
| `acknowledgeConditionTracing` | no | `false` | **Required whenever `traceConditionValues` is true** (VR-003). |
## `personas`
The declared catalogue. A rule assigning a persona absent from this list is a `PE-SEM-010` error — the
catalogue is what stops a typo from writing a new persona value into the directory.
`Unclassified` and `EvaluationError` are processing results and may never be declared or assigned.
## `rules`
| Field | Required | Notes |
| --- | --- | --- |
| `id` | yes | Unique. Appears in every audit record; this is how a decision is traced to its rule. |
| `name` | yes | Human-readable. |
| `description` | yes | Why the rule exists. Required, because a rule nobody can explain cannot be safely changed. |
| `enabled` | yes | Disabled rules are excluded from evaluation but still appear in summaries with zero matches. |
| `priority` | yes | Unique integer. **Lower evaluates first.** |
| `persona` | yes | Must appear in `personas`. |
| `match` | yes | The root condition group. |
| `tags`, `owner`, `changeReference`, `effectiveDate`, `notes`, `testCases` | no | Metadata. `effectiveDate` is **not** evaluated — a date-dependent decision would break determinism. |
Priorities must be unique among enabled rules. The engine breaks ties by rule ID so results stay
deterministic, but the resulting order is an accident rather than a decision, so `PE-SEM-002` blocks it.
## Condition groups and conditions
A group has `operator` (`all` or `any`) and a `conditions` array. Each entry is either another group
or a condition.
| Field | Applies to | Notes |
| --- | --- | --- |
| `type` | all | `property`, `membership`, or `role`. |
| `property` | `property` | One of the supported names below, or an extension property. |
| `operator` | all | See the operator table. |
| `value` | most | Single comparison value. |
| `values` | `in`, `notIn` | Comparison set. |
| `groupObjectIds` | `membership` | Group Object IDs. Names are mutable; IDs are not (RE-009). |
| `roleIds` | `role` | Role **template** IDs, which are stable across tenants. |
| `membershipMode` | `membership` | `direct` or `transitive`, per condition. |
| `caseSensitive` | — | Reserved. Not implemented in v1; the schema accepts the key so a later version needs no breaking change. |
### Supported properties
`AccountObjectId` · `UserPrincipalName` · `DisplayName` · `UserType` · `AccountEnabled` ·
`CompanyName` · `JobTitle` · `Department`
Plus any directory extension property named `extension_<32-hex-app-id>_<name>`. Anything else is
`PE-SEM-015`: unsupported properties are never retrieved, so the condition would compare against a
permanently absent value and quietly never match.
### Operators (RE-005)
| Operator | Applies to | Notes |
| --- | --- | --- |
| `equals`, `notEquals` | property | Case-insensitive (RE-006). |
| `contains`, `notContains` | property | Case-insensitive substring. |
| `startsWith`, `endsWith` | property | Case-insensitive. |
| `matchesRegex` | property | Pattern compiled at validation time. An invalid pattern is `PE-SEM-016`, not a runtime failure. |
| `in`, `notIn` | property | Requires `values`. |
| `isNull`, `isNotNull` | property | Tests presence. **Must not carry a value** — it would be silently ignored (`PE-SEM-009`). |
| `memberOf`, `notMemberOf` | membership, role | Requires `groupObjectIds` or `roleIds`. |
Null and absent properties are treated as empty for ordinary comparisons and never cause an
evaluation failure (FR-012). Intentional null matching uses `isNull` / `isNotNull`.
## Tri-state evaluation
Conditions return `True`, `False`, or `Unknown`. `Unknown` means required data could not be
retrieved, and it propagates:
| Group | Contains | Result |
| --- | --- | --- |
| `all` | any `False` | `False` |
| `all` | only `True` plus at least one `Unknown` | `Unknown` |
| `any` | any `True` | `True` |
| `any` | only `False` plus at least one `Unknown` | `Unknown` |
An `Unknown` at a rule's root makes the account `EvaluationError`: the stored persona is preserved
and no write is attempted (FR-013, FR-014).
## Validation layers
Run in order, stopping at the first that produces `Error` findings. Running semantic checks over a
structurally invalid document yields noise, not signal.
| Layer | Mechanism | Codes |
| --- | --- | --- |
| 1 Syntax | `ConvertFrom-Json` | `PE-SYN-nnn` |
| 2 Schema | `Test-Json -SchemaFile` | `PE-SCH-nnn` |
| 3 Semantic | PowerShell checks | `PE-SEM-nnn` |
| 4 Safety | PowerShell checks | `PE-SAF-nnn` |
Codes are stable. Pipelines and runbooks match on them, so a code is never reused for a different
condition and never renumbered.
### Syntax — `PE-SYN`
| Code | Condition |
| --- | --- |
| `PE-SYN-001` | Configuration file not found, or is not a file |
| `PE-SYN-002` | File exists but could not be read |
| `PE-SYN-003` | File is not valid JSON |
### Schema — `PE-SCH`
| Code | Condition |
| --- | --- |
| `PE-SCH-001` | Document violates the schema |
| `PE-SCH-002` | Schema file not found |
| `PE-SCH-003` | Schema file exists but is not valid JSON Schema |
`PE-SCH-003` exists because of V-5a: `Test-Json` returns `$true` when the schema itself cannot be
parsed. A wrapper trusting the return value would report every configuration as schema-valid against
a schema that never ran.
### Semantic — `PE-SEM` (VR-002)
| Code | Condition | Severity |
| --- | --- | --- |
| `PE-SEM-001` | Duplicate rule ID | Error |
| `PE-SEM-002` | Duplicate priority among enabled rules | Error |
| `PE-SEM-003` | No rules, or no enabled rules | Error |
| `PE-SEM-004` | Blank target attribute | Error |
| `PE-SEM-005` | Target attribute absent from the approved list | Error |
| `PE-SEM-006` | Rule references a disabled data source | Error |
| `PE-SEM-007` | `memberOf` / `notMemberOf` with no group or role IDs | Error |
| `PE-SEM-008` | `in` / `notIn` with no `values` | Error |
| `PE-SEM-009` | `isNull` / `isNotNull` carrying a comparison value | Error |
| `PE-SEM-010` | Persona not in the declared catalogue | Error |
| `PE-SEM-011` | `Unclassified` used as a rule persona | Error |
| `PE-SEM-012` | Nesting deeper than `maxConditionDepth` | Error |
| `PE-SEM-013` | `maxConditionDepth` outside 110 | Error |
| `PE-SEM-014` | Condition mode differs from an explicitly pinned global mode | Warning |
| `PE-SEM-015` | Unsupported property name | Error |
| `PE-SEM-016` | Invalid regular expression | Error |
Several of these are also enforced by the schema. The overlap is deliberate: layer 2 can be bypassed
with `-SchemaPath`, and V-5a showed an unparseable schema passes silently. Anything that can
misclassify a privileged account is checked twice.
### Safety — `PE-SAF` (VR-003)
| Code | Condition | Severity |
| --- | --- | --- |
| `PE-SAF-001` | Blank target attribute | Error enforcing, Warning in preview |
| `PE-SAF-002` | Approved list contains a non-extension attribute | Error |
| `PE-SAF-003` | Enabled rules need data the data sources do not provide | Error |
| `PE-SAF-004` | `configVersion` lower than the deployed version | Error enforcing, Warning in preview |
| `PE-SAF-005` | Rules removed or reordered with no version change | Error enforcing, Warning in preview |
| `PE-SAF-006` | Tracing enabled without acknowledgement | Error |
| `PE-SAF-007` | Save would overwrite an existing configuration with no backup | Error |
`PE-SAF-004` and `PE-SAF-005` need `-PreviousConfigPath`. Without it they are **skipped**, and an
`Information` finding says so — silence would be read as approval.
## Escalation (VR-005)
`Error` blocks execution and saving. `Warning` blocks only under `-TreatWarningsAsErrors`.
`Information` never blocks. Passing `-TreatWarningsAsErrors` does not change a finding's severity;
it changes the caller's tolerance for it.
+149
View File
@@ -0,0 +1,149 @@
# Logging
Structured audit output: what is emitted, where it goes, and what may never appear in it.
The serialized contract is [audit-record.md](../specs/001-persona-engine/contracts/audit-record.md).
This document covers the operational side.
## Format and transport
Newline-delimited JSON (OTD-006). One record per line, UTF-8 without BOM, appended.
Every record passes through a single sink, `Write-PersonaAuditRecord`. Adding a transport — an
approved logging platform, an event hub, a different file layout — 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.
| `logging.destination` | Behaviour |
| --- | --- |
| `file` | Appends NDJSON to `logging.path` |
| `stream` | Emits the record object on the PowerShell **Information** stream |
| `both` | Both |
| `none` | Nothing |
`logging.path` is optional. When it (and `-OutputPath`) are unset, `file`/`both` write to
`<current-directory>/logs/persona-engine-audit.ndjson`, created on first write.
`stream` uses the Information stream rather than the success stream deliberately. 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. Capture
them with `-InformationVariable`, or redirect with `6>`.
### Sink failure never ends a run
A full disk or a locked file is an operational problem with the sink, not a reason to abandon a
classification run mid-population and leave the directory half-reconciled. The failure surfaces as a
warning, **once** per run, and processing continues.
Once, not once per user: a run over five thousand accounts with a locked log file should warn once,
or the warning that matters is buried in the noise it generates.
## Results CSV
Alongside the NDJSON audit log, every summary — interim and final — (re)writes a plain CSV listing
every account processed so far: `AccountObjectId`, `UserPrincipalName`, `PersonaStatus` (the assigned
persona for `Matched` accounts, otherwise `Unclassified` or `EvaluationError`), `CompanyName`, and
`Department` (as retrieved from the directory, blank when absent). It is overwritten in full each
time, not appended, so it always reflects the whole run to that point rather than only the accounts
since the last summary.
It is written next to the audit log — same directory as `logging.path` — under `logging.resultsFileName`
(default `results.csv`). Export failure never ends a run, for the same reason a sink failure doesn't:
a locked file or full disk is an operational problem, not a reason to abandon a classification run
mid-population.
## Record types
| Type | When | Carries |
| --- | --- | --- |
| `RunStart` | Once, after mode is determined | Config path, target attribute, rule counts, whether tracing is on |
| `UserEvent` | Once per processed account | The full decision |
| `Summary` | Every `summaryInterval` accounts, and once at the end | Counters, per-rule match counts, reconciliation result |
| `RunComplete` | Once, in a `finally` block | Final counters, timing, exit code |
| `EngineDefect` | Reconciliation failure, or threshold breach | What went wrong and by how much |
`RunComplete` is written even on a fatal error. A run that died at account 400 of 5,000 leaves a
record saying exactly that — which is what lets an operator tell "the engine stopped early" from "the
engine never started", two very different incidents that produce identical evidence if the record is
written only on success.
## The common envelope
Every record, every type:
`timestamp` · `recordType` · `runId` · `engineVersion` · `configVersion` · `configurationHash` · `mode`
These appear **first** in each record, so a truncated line still identifies the run that produced it.
`runId` comes from `-CorrelationId` or is generated, and is constant for the run (NFR-005).
`configurationHash` is the SHA-256 of the configuration file bytes — two files differing only in
whitespace are different configurations for audit purposes, and the hash must be reproducible from
the artifact on disk.
## `UserEvent`
100% carry `runId`, `userPrincipalName`, and `accountObjectId` (SC-006). 100% of `Matched` records
carry `matchedRuleId`. `evaluationErrorReason` is non-null exactly when `outcome` is
`EvaluationError`.
`previousValue` is present **only** on `Updated` records, captured at write time. On any other action
there is nothing that was replaced, and a populated `previousValue` would imply otherwise to a
rollback tool reading these records later. Without it, OTD-010 rollback is impossible retroactively —
no future run can reconstruct what a value used to be.
## What may never appear
Access tokens, `Authorization` headers, client secrets, certificates, credentials, and full Graph
responses.
The guarantee is structural rather than filtered. `New-PersonaAuditRecord` accepts only named, typed
values from the decision result and the counters — there is no pass-through of an arbitrary object,
so there is nothing for a secret to ride in on, even if a caller attaches a token to the decision
result.
## Approved for logs
User principal name, account object ID, matched rule ID, stored and calculated persona values, run
ID, configuration version and hash, per-rule match counts, timing.
Runtime records naturally contain real UPNs and Object IDs. **No such value may ever be committed to
this repository** (SC-013) — the sanitization scan enforces that on every build.
## Condition tracing
`conditionTrace` is added to a `UserEvent` only when **both** gates are open: the decision result was
built with tracing, and the record was asked to include it. It carries the per-rule result
(`True` / `False` / `Unknown`) and priority.
Tracing widens what the log contains beyond the approved set, so it requires
`logging.acknowledgeConditionTracing` in the same configuration (`PE-SAF-006`). Tracing never changes
a decision — if it could, a debug run would stop being evidence about the real one.
## Querying
```powershell
# Everything from one run
Get-Content <LOG-OUTPUT-PATH> | ForEach-Object { $_ | ConvertFrom-Json } |
Where-Object runId -eq '<RUN-ID>'
# Accounts a run changed, with what it replaced
Get-Content <LOG-OUTPUT-PATH> | ForEach-Object { $_ | ConvertFrom-Json } |
Where-Object { $_.recordType -eq 'UserEvent' -and $_.action -eq 'Updated' } |
Select-Object userPrincipalName, previousValue, calculatedPersona, matchedRuleId
# Accounts that could not be evaluated, and why
Get-Content <LOG-OUTPUT-PATH> | ForEach-Object { $_ | ConvertFrom-Json } |
Where-Object outcome -eq 'EvaluationError' |
Select-Object userPrincipalName, evaluationErrorReason
# Which rule set produced a given decision
Get-Content <LOG-OUTPUT-PATH> | ForEach-Object { $_ | ConvertFrom-Json } |
Where-Object recordType -eq 'RunStart' |
Select-Object runId, configVersion, configurationHash, mode
```
## Retention
Not set by this engine. Records contain UPNs and Object IDs, so retention is governed by the
organization's identity-data policy rather than by anything in this repository. Decide it before the
first enforcing run, not after.
+172
View File
@@ -0,0 +1,172 @@
# Operations runbook
What to do when the engine is running, and what to do when it should not be.
## Before any run
1. Validate the configuration. It costs seconds and needs no tenant.
```bash
pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -ValidateOnly -NonInteractive
```
2. Run the rules against synthetic fixtures. This shows what the rule set *does* before it sees a
real account.
```bash
pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -TestDataPath <fixtures-dir> -ValidateOnly -NonInteractive
```
3. Preview a single user before previewing the tenant.
```bash
pwsh ./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json -UserObjectId <ACCOUNT-OBJECT-ID> -WhatIf -Verbose
```
Never skip step 3. A rule set that behaves correctly against fixtures can still request a property
your tenant does not populate, and finding that out on one account is cheaper than on fifty thousand.
## Reading a run
Per-user lines appear immediately, one per account, colour-coded by action:
| Action | Meaning |
| --- | --- |
| `Unchanged` | Calculated value already matches the stored value. Nothing to do. |
| `WouldUpdate` | A change is proposed. Preview mode, or the per-user gate refused. |
| `Updated` | The attribute was written. |
| `UpdateFailed` | The write was attempted and rejected. Stored value is untouched. |
| `Skipped` | No write is possible: `EvaluationError`, or a blank or unapproved target. |
`Skipped` on every account almost always means the target attribute is blank or unapproved — check
the header line and `PE-SAF-001`.
A summary appears every `summaryInterval` accounts and once at the end, listing **every** rule
including disabled and zero-match ones. A rule that never fired and a rule that is not in the
configuration look identical if zero-match rules are omitted, and that distinction is usually what
you are looking for. Its header shows elapsed wall-clock time since the run started.
Each summary also (re)writes `logging.resultsFileName` (default `results.csv`, next to the audit log)
with one row per account processed so far — Object ID, UPN, assigned persona/status, company name,
and department — for an operator who wants the current population breakdown without parsing NDJSON.
## Exit codes
| Code | Meaning | First thing to check |
| --- | --- | --- |
| 0 | Success | — |
| 1 | Configuration validation failed | The findings printed above it; no connection was attempted |
| 2 | Authentication or authorization failed | Scopes, consent, and whether the account can sign in |
| 3 | User enumeration failed | Graph availability. **No accounts were processed** — a partial population is never used |
| 4 | `evaluationErrorThreshold` exceeded | Group or role endpoint health. Nothing was changed |
| 5 | Reconciliation failed | **An engine defect.** Open an issue with the `EngineDefect` record |
| 6 | Unexpected fatal error | The message and `-Verbose` stack trace |
Exit 5 is never a data condition. Outcomes are assigned by the engine, exactly one per account, so if
`Processed` does not equal `Matched + Unclassified + EvaluationError` the engine lost a user or
double-counted one. Report it rather than re-running.
Exit 4 means the population was classified from data that could not be trusted. Stored values were
preserved, so nothing is damaged — but do not draw conclusions from the run.
## Kill switch
In increasing order of severity. Pick the lowest one that addresses the problem.
### 1. Stop writing — immediate, no deployment
Add `-WhatIf` to the invocation. Reads, evaluation, output, and audit records continue unchanged;
zero write requests are constructed.
### 2. Stop classifying — one configuration change
Set `enabled: false` on every rule and deploy.
> Every account becomes `Unclassified`. **In an enforcing run that proposes clearing every stored
> persona.** Combine with `-WhatIf`, or use option 1 instead, unless clearing is what you want.
### 3. Stop running — Stage B only
Disable the Automation schedule. Nothing is in flight; the next run simply does not start.
### 4. Remove the capability — the one that holds if the code is the problem
Revoke `User.ReadWrite.All` from the execution identity. The engine keeps running and every write
becomes `UpdateFailed`, which is loud, logged, and harmless.
### 5. Remove the path — for a suspected defect in the write path
Remove the write deployment stage from the release pipeline so no build can restore write capability
by accident.
Options 1 through 3 rely on the engine behaving correctly. Options 4 and 5 do not, which is why they
exist.
## Rollback (OTD-010)
Every `Updated` audit record carries `previousValue`, captured **before** the PATCH. That is what
makes rollback possible; reading the value back afterwards would return the new one.
To roll back a run:
1. Find the run's records by `runId`.
2. Select records where `recordType` is `UserEvent` and `action` is `Updated`.
3. For each, write `previousValue` back to `accountObjectId`.
```powershell
# Reads the NDJSON audit file and lists what a rollback would restore.
# Review this output before writing anything back.
Get-Content <LOG-OUTPUT-PATH> |
ForEach-Object { $_ | ConvertFrom-Json } |
Where-Object { $_.runId -eq '<RUN-ID>' -and $_.recordType -eq 'UserEvent' -and $_.action -eq 'Updated' } |
Select-Object accountObjectId, userPrincipalName, previousValue, calculatedPersona
```
> A rollback is itself a directory write and is subject to the same V-4 gate and the same test-account
> restriction as any enforcement run. A rollback tool is **not implemented in v1** — the data needed
> to build one is captured, deliberately, because it cannot be reconstructed retroactively.
If a rollback is genuinely intended at the configuration level, publish it as a **new higher
version** rather than reusing the old number. Two different rule sets sharing one `configVersion`
makes the audit trail unable to tell them apart (`PE-SAF-004`).
## Common situations
**Every account is `EvaluationError`.** A required data source is disabled or unreachable. Check
`dataSources.groups.enabled` and `dataSources.roles.enabled` against what the rules need — `PE-SAF-003`
catches this at validation time, so a run reaching this state usually means validation was bypassed.
**Every account is `Unclassified`.** Either every rule is disabled, or no rule matches. The summary
table distinguishes these: disabled rules are dimmed, zero-match enabled rules show `0`.
**The run is slow.** Check the cache hit ratio via `-Verbose`. Membership lookups dominate: one
account needing both direct and transitive facets plus roles is three requests. Narrowing rules to a
single membership mode roughly halves that.
**A write failed with 403.** Non-retryable by design — retrying would hide a configuration or
authorization defect behind a timeout. Check that the identity holds `User.ReadWrite.All` and that
the target attribute exists on the application registration.
**Audit file sink warnings.** The sink warns once per run and processing continues. A locked or full
log file is an operational problem with the sink, not a reason to abandon a run mid-population and
leave the directory half-reconciled.
## Concurrency
Two concurrent enforcing runs against the same tenant would race on the same attributes. Until the
OTD-009 run-start concurrency check is implemented (T120, Stage B), **the schedule is the lock**: do
not start a manual enforcing run while a scheduled one may be in flight.
Preview runs are read-only and safe to run concurrently.
## What to attach to a bug report
- The exit code
- The `RunComplete` record for the run — it carries the counters, timing, and exit code even when the
run died early
- Any `EngineDefect` record
- The `configVersion` and `configurationHash` from any record
- The rule set, sanitized
Do **not** attach raw audit records containing real UPNs or Object IDs to anything that leaves the
organization. They are approved for internal logs, not for public issue trackers.
+513
View File
@@ -0,0 +1,513 @@
# Rule authoring guide
A hands-on manual for writing the `rules` array in `persona-engine.json` — every condition type,
every operator, worked examples, and the interactive editor.
This document teaches by example. [ConfigurationReference.md](ConfigurationReference.md) is the
terse field-by-field reference and the finding-code list; [BusinessRules.md](BusinessRules.md) is
about judgement — ordering, priority bands, when to trust a naming convention. Read this one first if
you have never written a rule before.
---
## Anatomy of a rule
Every rule has the same shape: identity fields, a target persona, and a condition tree called
`match`.
```json
{
"id": "RULE-0040-SERVICE",
"name": "Service accounts",
"description": "Non-human accounts identified by naming convention and group membership.",
"enabled": true,
"priority": 40,
"persona": "Service-Account",
"match": {
"operator": "all",
"conditions": [
{ "type": "property", "property": "UserPrincipalName", "operator": "startsWith", "value": "svc-" },
{ "type": "membership", "operator": "memberOf", "groupObjectIds": ["00000000-0000-0000-0000-0000000000b0"] }
]
}
}
```
- `id`, `name`, `description`, `enabled`, `priority`, `persona`, `match` are all required.
- `priority` decides evaluation order — lower runs first — and the first rule whose `match`
evaluates `True` wins. See [BusinessRules.md](BusinessRules.md#the-model) for why ordering, not
exclusion logic, is how you keep one rule from stepping on another.
- `match` is always a **condition group** (an `all`/`any` node), never a bare condition — even a
rule with a single test needs a one-item `conditions` array inside a group.
- Optional metadata — `tags`, `owner`, `changeReference`, `effectiveDate`, `notes`, `testCases` — is
never evaluated. `effectiveDate` in particular is a label, not a schedule (Principle I:
determinism).
---
## Condition types
Every leaf condition has a `type`. It decides which other fields are required and where the engine
looks for the answer.
| `type` | Answers | Required fields | Data source |
| --- | --- | --- | --- |
| `property` | What value does this account have? | `property`, `operator`, plus `value`/`values` depending on operator | The normalized user record — directory properties and extension properties |
| `membership` | Is this account in one of these groups? | `operator` (`memberOf`/`notMemberOf`), `groupObjectIds` | Group membership, direct or transitive |
| `role` | Does this account hold one of these directory roles? | `operator` (`memberOf`/`notMemberOf`), `roleIds` | Directory role assignments |
`property` is the default if `type` is omitted, but write it explicitly — a rule set is read far
more often than it is written, and an implicit type makes every reader re-derive it.
### `property`
```json
{ "type": "property", "property": "Department", "operator": "equals", "value": "Finance" }
```
`property` names one of the [supported intrinsic properties](#supported-properties) or an extension
property. The engine reads it from the normalized record, never from a raw Graph response.
### `membership`
```json
{
"type": "membership",
"operator": "memberOf",
"membershipMode": "transitive",
"groupObjectIds": ["00000000-0000-0000-0000-0000000000a0"]
}
```
Always identify groups **by Object ID**, never by display name — names are mutable, IDs are not
(RE-009). `membershipMode` is optional per condition; see [Direct vs. transitive](#direct-vs-transitive-membership)
below.
### `role`
```json
{
"type": "role",
"operator": "memberOf",
"roleIds": ["<TIER0-ROLE-TEMPLATE-ID>"]
}
```
`roleIds` takes directory role **template** IDs — the ID that is stable across tenants, not the
tenant-specific role assignment ID. There is no membership mode for roles; a role is held or it is
not.
---
## Operators
Every operator in one place, with what it needs and how it compares.
### String comparisons (`property` only)
All string comparisons are **case-insensitive** (RE-006) and compare against the property's value
coerced to a string. A missing or `null` property compares as an empty string (FR-012) — it never
throws and never produces `Unknown`.
| Operator | Meaning | Example |
| --- | --- | --- |
| `equals` | Exact match | `{ "type": "property", "property": "UserType", "operator": "equals", "value": "Guest" }` |
| `notEquals` | Exact non-match | `{ "type": "property", "property": "CompanyName", "operator": "notEquals", "value": "Contoso" }` |
| `contains` | Substring present | `{ "type": "property", "property": "JobTitle", "operator": "contains", "value": "intern" }` |
| `notContains` | Substring absent | `{ "type": "property", "property": "DisplayName", "operator": "notContains", "value": "test" }` |
| `startsWith` | Prefix match | `{ "type": "property", "property": "UserPrincipalName", "operator": "startsWith", "value": "svc-" }` |
| `endsWith` | Suffix match | `{ "type": "property", "property": "UserPrincipalName", "operator": "endsWith", "value": "@vendor.example.com" }` |
### Pattern match (`property` only)
| Operator | Meaning | Example |
| --- | --- | --- |
| `matchesRegex` | .NET regex, matched case-insensitively | `{ "type": "property", "property": "UserPrincipalName", "operator": "matchesRegex", "value": "^svc-[a-z0-9]+-\\d{3}@" }` |
The pattern is compiled at **validation time**, not evaluation time — an invalid pattern is a
`PE-SEM-016` finding that blocks saving, never a surprise mid-run. Prefer `startsWith` /
`contains` when they say what you mean; reach for `matchesRegex` only when the naming convention
genuinely needs a pattern (fixed-width suffixes, alternation, anchoring).
### Set membership (`property` only)
| Operator | Meaning | Example |
| --- | --- | --- |
| `in` | Value equals one of a list | `{ "type": "property", "property": "AccountObjectId", "operator": "in", "values": ["00000000-0000-0000-0000-000000000001", "00000000-0000-0000-0000-000000000002"] }` |
| `notIn` | Value equals none of a list | `{ "type": "property", "property": "Department", "operator": "notIn", "values": ["Finance", "Legal"] }` |
`in`/`notIn` require `values` (plural, an array) rather than `value`. Each candidate is compared
case-insensitively, same as `equals`. Prefer `in` over a chain of `any`-grouped `equals` conditions
— it says "one list" instead of making a reader count `equals` clauses to notice they are mutually
exclusive alternatives.
### Null tests (`property` only)
| Operator | Meaning | Example |
| --- | --- | --- |
| `isNull` | Property is absent, `null`, or empty string | `{ "type": "property", "property": "CompanyName", "operator": "isNull" }` |
| `isNotNull` | Property has a non-empty value | `{ "type": "property", "property": "Department", "operator": "isNotNull" }` |
`isNull`/`isNotNull` must **not** carry `value` or `values` — the schema and `PE-SEM-009` both reject
it, because a value sitting on a presence check is either a typo or a misunderstanding, and either
way it would silently be ignored if allowed through.
Both `$null` and `""` count as null. A directory clears an attribute to an empty string as often as
it leaves it entirely unset, and a rule author asking "is this unset" means both.
### Membership and role (`membership` / `role` only)
| Operator | Meaning | Example |
| --- | --- | --- |
| `memberOf` | Account is in at least one listed group/role | `{ "type": "membership", "operator": "memberOf", "groupObjectIds": ["00000000-…"] }` |
| `notMemberOf` | Account is in none of the listed groups/roles | `{ "type": "membership", "operator": "notMemberOf", "groupObjectIds": ["00000000-…"] }` |
**Read [Unknown is not false](BusinessRules.md#unknown-is-not-false) before writing `notMemberOf`.**
If the membership lookup fails, the condition evaluates `Unknown`, not `True` — a `notMemberOf`
never becomes satisfied just because the engine could not check. This is deliberate and is the
single most important safety behaviour in the engine.
### Reserved
`caseSensitive` is accepted by the schema on any condition but is **not implemented in v1** — every
comparison is case-insensitive regardless of what you set it to. It exists so a future version that
adds case sensitivity does not need a breaking schema change. Do not set it expecting an effect
today.
---
## Supported properties
| Property | Type as compared |
| --- | --- |
| `AccountObjectId` | String (GUID) |
| `UserPrincipalName` | String |
| `DisplayName` | String |
| `UserType` | String — typically `Member` or `Guest` |
| `AccountEnabled` | Boolean, compared as the string `"True"` or `"False"` |
| `CompanyName` | String |
| `JobTitle` | String |
| `Department` | String |
Plus any directory **extension property**, addressed by its full name:
`extension_<32-hex-app-id>_<name>`.
```json
{ "type": "property", "property": "extension_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4_costCenter", "operator": "equals", "value": "1042" }
```
Anything not on this list, and not shaped like an extension property, is a `PE-SEM-015` validation
error rather than a silent non-match — an unsupported property is never retrieved, so the condition
would otherwise compare against a value that is permanently absent and quietly never fire.
`AccountEnabled` is a boolean at the source but is compared as text, so match it with `equals` and
the literal string `"True"` or `"False"` — not `isNull`/`isNotNull`, which test for absence and would
never fire on a boolean the directory always populates.
---
## Direct vs. transitive membership
```json
{ "type": "membership", "operator": "memberOf", "membershipMode": "direct", "groupObjectIds": ["…"] }
{ "type": "membership", "operator": "memberOf", "membershipMode": "transitive", "groupObjectIds": ["…"] }
```
- `direct` — the account is a member of the named group itself.
- `transitive` — the account is a member through any chain of nested groups.
`membershipMode` is per-condition (RE-007); mixing both in one rule set — or one rule — is fully
supported. Omit it to fall back to `engine.defaultMembershipMode` (itself defaulting to `direct`).
Use `transitive` for role-holding groups that other groups nest into, which describes most Tier 0
groups. Use `direct` when membership is explicitly and individually managed, and nesting into the
group would be a mistake you want the engine to ignore.
Each facet — direct groups, transitive groups, directory roles — is retrieved independently and
fails independently. A transitive lookup timing out does not affect a `direct` condition in the same
rule; it becomes `Unknown` only for the conditions that actually needed it.
**Do not set `dataSources.groups.membershipMode` globally unless you specifically want to forbid the
other mode.** Leaving it unset means "any mode is acceptable, decide per condition"; setting it turns
every condition using the other mode into a `PE-SEM-014` warning.
---
## Combining conditions: `all` and `any`
Every condition tree, at every level, is a group:
```json
{ "operator": "all", "conditions": [ /* */ ] }
```
or
```json
{ "operator": "any", "conditions": [ /* */ ] }
```
`conditions` holds a mix of leaf conditions and nested groups — a nested group is just an entry in
the array that itself has `operator` and `conditions` instead of `type`.
### Simple: all conditions must hold
```json
{
"operator": "all",
"conditions": [
{ "type": "property", "property": "UserPrincipalName", "operator": "startsWith", "value": "svc-" },
{ "type": "membership", "operator": "memberOf", "groupObjectIds": ["00000000-0000-0000-0000-0000000000b0"] }
]
}
```
### Simple: any condition is enough
```json
{
"operator": "any",
"conditions": [
{ "type": "membership", "operator": "memberOf", "membershipMode": "transitive", "groupObjectIds": ["00000000-0000-0000-0000-0000000000a0"] },
{ "type": "role", "operator": "memberOf", "roleIds": ["<TIER0-ROLE-TEMPLATE-ID>"] }
]
}
```
### Nested: "in the test group, OR named like a test account while disabled"
```json
{
"operator": "any",
"conditions": [
{ "type": "membership", "operator": "memberOf", "groupObjectIds": ["00000000-0000-0000-0000-0000000000c0"] },
{
"operator": "all",
"conditions": [
{ "type": "property", "property": "UserPrincipalName", "operator": "startsWith", "value": "test-" },
{ "type": "property", "property": "AccountEnabled", "operator": "equals", "value": "False" }
]
}
]
}
```
Read the nested group as one unit: it is "named like a test account **and** disabled", offered as
one alternative alongside plain group membership. Nesting composes exactly the way parentheses do in
any boolean expression.
**Keep nesting to three levels or fewer.** The hard ceiling is `maxConditionDepth` (default 5,
configurable up to 10), and exceeding it fails the rule safe — every account it reaches becomes
`EvaluationError` rather than silently truncating the tree. But depth is also a readability cost
before it is ever a technical one: a rule that seems to need four or five levels is usually two
rules at different priorities instead. See [Nesting](BusinessRules.md#nesting) for why.
---
## How `Unknown` moves through a tree
Every condition and every group returns one of three results: `True`, `False`, or `Unknown`.
`Unknown` means the data needed to answer could not be retrieved — not "we don't know the value," but
"the lookup itself failed."
| Group | If it contains… | Result |
| --- | --- | --- |
| `all` | any `False` | `False`, regardless of any `Unknown` sibling |
| `all` | only `True`, plus at least one `Unknown` | `Unknown` |
| `any` | any `True` | `True`, regardless of any `Unknown` sibling |
| `any` | only `False`, plus at least one `Unknown` | `Unknown` |
A definite result always wins over an `Unknown` sibling — an `all` group with one `False` condition
cannot match no matter what else is unknown, so there is no reason to degrade that to an error. Only
when nothing definite decided the group does an `Unknown` anywhere in it propagate.
An `Unknown` at a rule's **root** `match` makes the account `EvaluationError`: nothing is written, and
the previously stored persona is preserved. This is why `notMemberOf` guarding a privileged
classification is safe even when a lookup occasionally fails — the account falls into
`EvaluationError`, never into a false demotion. Full mechanics: [ConfigurationReference.md](ConfigurationReference.md#tri-state-evaluation).
---
## Worked recipes
Complete, runnable condition trees for situations that come up constantly. Combine, don't
copy-paste blindly — read [BusinessRules.md](BusinessRules.md) for when each pattern is and isn't
appropriate.
**Identify a fixed set of accounts by Object ID** (break-glass, service owners — anything where a
name would be the wrong signal):
```json
{
"operator": "any",
"conditions": [
{ "type": "property", "property": "AccountObjectId", "operator": "in",
"values": ["00000000-0000-0000-0000-000000000001", "00000000-0000-0000-0000-000000000002"] }
]
}
```
**Two independent signals for a consequential classification** (naming convention alone is a habit,
not a control):
```json
{
"operator": "all",
"conditions": [
{ "type": "property", "property": "UserPrincipalName", "operator": "startsWith", "value": "svc-" },
{ "type": "membership", "operator": "memberOf", "groupObjectIds": ["00000000-0000-0000-0000-0000000000b0"] }
]
}
```
**Privileged group OR the equivalent directory role** (a group nested for one identity model, a role
assignment for another — either should count):
```json
{
"operator": "any",
"conditions": [
{ "type": "membership", "operator": "memberOf", "membershipMode": "transitive", "groupObjectIds": ["00000000-0000-0000-0000-0000000000a0"] },
{ "type": "role", "operator": "memberOf", "roleIds": ["<TIER0-ROLE-TEMPLATE-ID>"] }
]
}
```
**External by company name, but not your own organization** (`isNotNull` first, so a blank
`CompanyName` does not fall through as "external"):
```json
{
"operator": "all",
"conditions": [
{ "type": "property", "property": "CompanyName", "operator": "isNotNull" },
{ "type": "property", "property": "CompanyName", "operator": "notEquals", "value": "<ORGANIZATION-NAME>" }
]
}
```
**Internal by domain suffix instead of company name:**
```json
{
"operator": "all",
"conditions": [
{ "type": "property", "property": "UserPrincipalName", "operator": "endsWith", "value": "@<PRIMARY-DOMAIN>" }
]
}
```
**Exclude disabled accounts from an otherwise broad rule:**
```json
{
"operator": "all",
"conditions": [
{ "type": "property", "property": "Department", "operator": "equals", "value": "Finance" },
{ "type": "property", "property": "AccountEnabled", "operator": "equals", "value": "True" }
]
}
```
**Match against a custom directory extension attribute** (e.g. an HR-fed employment type):
```json
{
"operator": "all",
"conditions": [
{ "type": "property", "property": "extension_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4_employmentType",
"operator": "in", "values": ["Contractor", "Vendor"] }
]
}
```
**Default / catch-all rule** (lowest priority in the rule set, so every more specific rule wins
first):
```json
{
"operator": "all",
"conditions": [
{ "type": "property", "property": "UserType", "operator": "equals", "value": "Member" },
{ "type": "property", "property": "Department", "operator": "isNotNull" }
]
}
```
The shipped [`config/persona-engine.example.json`](../config/persona-engine.example.json) is a
complete rule set built from these same patterns end to end, in priority order — read it alongside
this guide.
---
## Building a rule with the interactive editor
Everything above can be hand-written as JSON, or built through
[`Edit-PersonaEngineConfig.ps1`](../Edit-PersonaEngineConfig.ps1)'s menu, which won't let you save a
structurally invalid tree.
```bash
pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json
```
From the main menu:
| Key | Action |
| --- | --- |
| `L` | List rules — ID, name, priority, enabled state |
| `A` | Add a rule — prompts for identity fields, then walks you through building `match` node by node |
| `E` | Edit a rule — change a top-level field, or add/edit/remove a condition or nested group |
| `T` | Toggle a rule enabled/disabled |
| `P` | Change a rule's priority |
| `D` | Delete a rule |
| `V` | Re-validate the in-memory document |
| `R` | Run the rule set against synthetic fixtures (`-TestDataPath`) |
| `S` | Save — blocked while any `Error` finding is outstanding |
| `Q` | Quit |
When building a condition, the editor prompts `[P]roperty [M]embership [R]ole` for the type, then
lists the operators valid for whatever you chose next — you cannot accidentally pair `memberOf` with
a `property` condition, because the editor only offers the combinations the schema allows.
For scripted use, add `-NonInteractive -ValidateOnly` to validate a file with no menu at all — see
[Changing a rule set](BusinessRules.md#changing-a-rule-set) for the full validate-preview-deploy
sequence.
---
## Validate what you wrote
```bash
pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -ValidateOnly -NonInteractive
```
Exit `0` means all four validation layers passed — syntax, schema, semantic, safety. A non-zero exit
prints the findings that blocked it; codes and their meaning are in
[ConfigurationReference.md](ConfigurationReference.md#validation-layers).
Then run the rule against fixtures before it ever sees a real account:
```bash
pwsh ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.json -TestDataPath <fixtures-dir> -ValidateOnly -NonInteractive
```
See [Testing a rule set without a tenant](BusinessRules.md#testing-a-rule-set-without-a-tenant) for
what a good fixture directory looks like.
---
## Cheat sheet
| Operator | `type` | Needs | Compares |
| --- | --- | --- | --- |
| `equals` / `notEquals` | property | `value` | Case-insensitive exact |
| `contains` / `notContains` | property | `value` | Case-insensitive substring |
| `startsWith` / `endsWith` | property | `value` | Case-insensitive prefix/suffix |
| `matchesRegex` | property | `value` (a pattern) | Case-insensitive regex, validated at save time |
| `in` / `notIn` | property | `values` (array) | Case-insensitive membership in a list |
| `isNull` / `isNotNull` | property | *(neither `value` nor `values`)* | Absent, `null`, or empty string |
| `memberOf` / `notMemberOf` | membership | `groupObjectIds`, optional `membershipMode` | Group membership, direct or transitive |
| `memberOf` / `notMemberOf` | role | `roleIds` | Directory role assignment |
Group operators: `all` (every child must hold) and `any` (at least one child must hold), nestable to
`maxConditionDepth`.
+168
View File
@@ -0,0 +1,168 @@
# Security model
What this engine is permitted to do, what it is not, and where the gap between those two is held
open by testing rather than by the platform.
## The central trade-off (OTD-003)
**Microsoft Graph application permissions have no per-property write scope.** An identity granted
`User.ReadWrite.All` can write *any* writable property on *any* user object. It cannot be narrowed to
one extension attribute.
This is not a limitation to be worked around. It is a fact about the platform, recorded here so that
nobody later assumes the directory is enforcing something it is not.
The consequence: **the only thing standing between this engine and every writable user property is
the code in this repository, and the tests that hold it to that.** Every control below exists because
the directory will not refuse a malformed request on our behalf.
## The six compensating controls
All six are mandatory. Each is testable, and each is tested.
| # | Control | Where it lives | Proof |
| --- | --- | --- | --- |
| 1 | The persistence layer accepts only the configured target attribute | `New-PersonaWriteBody` throws for any other name | `WriteBodyRejection.Tests.ps1` |
| 2 | The target must appear in `approvedWritableAttributes` | `Resolve-TargetAttribute` and `New-PersonaWriteBody`, checked twice | `WriteBodyRejection.Tests.ps1` |
| 3 | Validation rejects every other attribute | `PE-SAF-002`, layer 4 | `Safety.Tests.ps1` |
| 4 | One dedicated function builds the request body, and it is the only one | `New-PersonaWriteBody` returns a hashtable whose `Count` is exactly 1 | `WriteBody.Tests.ps1` |
| 5 | Tests inspect the captured request body | Every body issued during a full enforcing run is asserted to have one key | `WriteBody.Tests.ps1` |
| 6 | Code owners and branch policies gate persistence changes | Repository configuration, outside this codebase | Branch protection on `src/Persistence/` |
Control 4 is the load-bearing one. A single construction site makes SC-005 a property of one testable
function rather than a convention every future call site has to remember. `WriteBody.Tests.ps1`
includes a scan asserting that no other file under `src/` builds a PATCH body.
Control 2 is deliberately redundant. Validation runs once at startup against the file; the write
builder checks again on every write against the values actually in hand — so a configuration object
mutated mid-run still cannot widen the blast radius.
### Why comparison is ordinal here and case-insensitive elsewhere
Rule matching is case-insensitive (RE-006), because a rule author should not have to match directory
casing. Attribute approval is **ordinal and case-sensitive**, because extension property names are
case-sensitive in Graph: `extension_<id>_Persona` and `extension_<id>_persona` are two different
attributes, and approving one does not approve the other.
Change detection is also ordinal (FR-015). A stored `employee` against a calculated `Employee` is a
real difference worth correcting, not a formatting quirk.
## Permissions
### Stage A — local, delegated (current)
```powershell
Connect-MgGraph -Scopes 'User.Read.All','GroupMember.Read.All','RoleManagement.Read.Directory'
```
Read-only. Sufficient for every preview run and for closing V-1 (read) and V-3.
The engine requests only the scopes the enabled rules actually need: a configuration with no role
conditions never asks for `RoleManagement.Read.Directory`, and a configuration with no membership
conditions never asks for `GroupMember.Read.All`. Least privilege applies to data as well as to
permissions — properties nothing references are not even added to `$select`.
**Stage A3 (delegated write) adds `User.ReadWrite.All` and targets purpose-created test accounts
only.** Under delegated authentication the write runs *as the operator*, which makes the compensating
controls more important rather than less: the directory sees the operator's own permissions, not a
narrowed service identity.
> **Never sign in with a standing privileged account for a write run.** A Global Administrator
> session invalidates V-3 as evidence and removes every practical limit on what a defect could reach.
### Stage B — Azure Automation, application permissions (deferred)
`User.Read.All`, `GroupMember.Read.All`, `RoleManagement.Read.Directory`, and — for enforcement —
`User.ReadWrite.All`, granted to a **managed identity**. No client secret, ever, in source control or
in a runbook parameter.
Deferred, not waived: no Automation account is available. V-3b and V-5b remain open.
## The persona attribute (OTD-001)
A **directory (schema) extension property**, registered on an application registration and addressable
as `extension_<appId>_<name>`.
Two alternatives were rejected for concrete reasons:
- **`extensionAttribute1..15`** — unavailable for cloud writes on objects that are, or ever were,
synchronized from on-premises, and on Exchange-originated objects. A classification engine that
silently cannot write to a subset of the population is worse than one that cannot write at all.
- **Custom security attributes** — not exposed to the dynamic group membership engine, which defeats
the purpose: the persona exists so that Conditional Access can be targeted through dynamic groups.
`PE-SAF-002` rejects any approved attribute that is not shaped like a directory extension property.
Built-in attributes such as `department` or `jobTitle` are excluded **even when the operator holds
permission to write them** — they are authoritative in the sync source or in HR, and this engine does
not own them.
## Data handling
**Approved for logs**: user principal name, account object ID, matched rule ID, stored and calculated
persona values, run ID, configuration version and hash.
**Never logged, under any setting**: access tokens, `Authorization` headers, client secrets,
certificates, credentials, or full Graph responses.
The guarantee is structural rather than filtered. `New-PersonaAuditRecord` accepts only named, typed
values from the decision result and the counters — there is no pass-through of an arbitrary object,
so there is nothing for a secret to ride in on. `AuditRedaction.Tests.ps1` asserts this holds even
when a caller actively attaches a token to the decision result.
### Condition tracing
`logging.traceConditionValues` writes evaluated attribute values into audit records, widening what
the log contains beyond the approved set. It requires `logging.acknowledgeConditionTracing` in the
same configuration, or validation fails with `PE-SAF-006`.
The acknowledgement lives in the configuration rather than in a command-line switch on purpose: a
flag passed at a console is invisible to review, while a field in the configuration appears in the
diff of the change that enables tracing, next to the person who approved it.
## The no-write control
`-WhatIf` is the only approved no-write control.
`-Debug` does **not** imply read-only. A `-Debug` run without `-WhatIf` writes, and
`ShouldProcessGate.Tests.ps1` asserts that it does — because an operator who believed otherwise would
reach for `-Debug` as a safety measure and get an enforcing run. The same holds for `-Verbose`.
`ShouldProcessGate.Tests.ps1` also asserts that the entry script declares no `-Preview`, `-NoWrite`,
`-ReadOnly`, or `-DryRun` parameter, and never reads `$WhatIfPreference`.
## Verification gates
| Item | Status | Blocks |
| --- | --- | --- |
| V-1 read half | Open — needs a tenant | Confidence in the read path across origin types |
| V-1 write half | Open — needs test accounts | Enforcement |
| V-2 dynamic group + CA | Open | Declaring the persona useful |
| V-3 non-privileged `-WhatIf` run | Open — needs a tenant | Stage A2 sign-off |
| V-3b managed-identity scopes | Deferred | Stage B |
| **V-4 security sign-off on these controls** | **Open** | **All enforcement (T101)** |
| V-5a `Test-Json` behaviour | **Closed** — see [V-5a.md](../specs/001-persona-engine/verification/V-5a.md) | Layer 2 implementation |
| V-5b `Test-Json` in Automation | Deferred | Stage B |
**V-4 gates enforcement.** No write run against anything other than purpose-created test accounts
until it is recorded in `specs/001-persona-engine/verification/V-4.md`.
## Kill switch
In increasing order of severity — see [OperationsRunbook.md](OperationsRunbook.md) for the procedure:
1. Run with `-WhatIf`.
2. Set every rule to `enabled: false` and deploy.
3. Disable the Automation schedule (Stage B).
4. Revoke `User.ReadWrite.All` from the execution identity.
5. Remove the write deployment stage.
Steps 4 and 5 are the ones that hold if the code itself is the problem.
## Sanitization (SC-013)
No organization name, real domain, tenant or subscription ID, real UPN or Object ID, real group or
role identifier, environment-specific attribute name, or any secret may appear in any tracked file.
Placeholders only.
Runtime records naturally contain real UPNs and Object IDs — approved for logs — but no such value is
ever committed.
@@ -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,113 @@
# 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 |
## Interactive editor commands (FR-023, FR-027 FR-030)
The interactive loop (entered when neither `-ValidateOnly` nor `-NonInteractive` is set) supports:
| Command | Behaviour |
| --- | --- |
| List rules | Show every rule's priority, ID, persona, and enabled state. |
| Toggle a rule | Flip `enabled` on an existing rule. |
| Change a priority | Set a new numeric priority on an existing rule. |
| **Add a rule** | Prompt for every RE-001 field (`id`, `name`, `description`, `priority`, `persona`, `enabled`, and optional fields) and for the condition tree — nested `all`/`any` groups and, per leaf condition, the property/membership source, operator, and comparison value. Reject on the spot if the `id` or `priority` collides with an existing rule (FR-027). |
| **Edit a rule** | Select an existing rule by `id`; change any top-level field and/or the condition tree — add, edit, remove, or renest conditions and groups within `MaxConditionDepth` (FR-028). |
| **Delete a rule** | Select an existing rule by `id`; show its `id`, `name`, and `priority` and require explicit confirmation before removing it (FR-029). |
| Re-validate | Run all four validation layers against the in-memory document, including any unsaved add/edit/delete, and print findings without saving. |
| Run rule test | Evaluate the in-memory document (including unsaved structural edits) against `-TestDataPath` fixtures. |
| Save | Re-validate, then persist per the save contract below. |
| Quit | Warn if there are unsaved changes (including structural edits) before discarding them. |
All structural edits (add/edit/delete) are applied to the in-memory document only. They are never
written to `-ConfigPath` (or `-OutputPath`) until a `Save` re-validates the full document and that
validation passes — the same rule that governs field-level edits (FR-030). A depth violation
introduced by an add or edit is reported immediately using the same finding the runtime validator
would produce, rather than deferred to the next save or re-validate.
## 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,93 @@
# Contract: `Invoke-PersonaEngine.ps1`
The engine entry point. Retrieval, evaluation, reporting, and controlled persistence.
## Signature
```powershell
[CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')]
param(
[Parameter()][string] $ConfigPath = './config/persona-engine.json',
[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` | No | Path to the JSON configuration. Defaults to `./config/persona-engine.json`, resolved against the current directory, when omitted. 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, elapsed wall-clock time since the run started, and a reconciliation check.
- **At completion**: a final summary regardless of interval, including when the interval is `0`
(FR-020).
- **Every summary, interim and final**: `logging.resultsFileName` (default `results.csv`, written
next to the audit log) is overwritten with one row per account processed so far — Account Object
ID, UPN, the assigned persona or outcome status, company name, and department.
- **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,284 @@
{
"$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": "NDJSON output file for 'file'/'both' destinations. Defaults to <current-directory>/logs/persona-engine-audit.ndjson when unset."
},
"resultsFileName": {
"type": "string",
"minLength": 1,
"default": "results.csv",
"description": "Per-account results CSV (AccountObjectId, UserPrincipalName, persona/status), written alongside the audit log and overwritten on every summary."
},
"traceConditionValues": {
"type": "boolean",
"default": false,
"description": "Diagnostic only. Enabling this without explicit acknowledgement is a safety finding (VR-003)."
},
"acknowledgeConditionTracing": {
"type": "boolean",
"default": false,
"description": "Explicit acknowledgement that condition-value tracing writes evaluated attribute values into audit records. Required by VR-003 whenever traceConditionValues is true. Kept in the configuration rather than passed as a command-line flag so the acknowledgement is reviewable in the change that enables tracing."
}
}
},
"personas": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": {
"type": "string",
"minLength": 1,
"not": { "enum": ["EvaluationError"] }
},
"description": "Defined persona catalogue. EvaluationError is an execution result and must never be declared as a persona."
},
"rules": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/definitions/rule" }
}
},
"definitions": {
"rule": {
"type": "object",
"required": ["id", "name", "description", "enabled", "priority", "persona", "match"],
"additionalProperties": false,
"properties": {
"id": { "type": "string", "minLength": 1 },
"name": { "type": "string", "minLength": 1 },
"description": { "type": "string", "minLength": 1 },
"enabled": { "type": "boolean" },
"priority": { "type": "integer", "minimum": 0 },
"persona": {
"type": "string",
"minLength": 1,
"not": { "enum": ["Unclassified", "EvaluationError"] },
"description": "Unclassified is a processing result, not a rule outcome (VR-002)."
},
"match": { "$ref": "#/definitions/conditionGroup" },
"tags": { "type": "array", "items": { "type": "string" } },
"owner": { "type": "string" },
"changeReference": { "type": "string" },
"effectiveDate": {
"type": "string",
"format": "date",
"description": "Metadata only in v1. It must not gate evaluation — a date-dependent decision would break determinism (Principle I)."
},
"notes": { "type": "string" },
"testCases": {
"type": "array",
"items": {
"type": "object",
"required": ["name", "expectedMatch"],
"additionalProperties": true,
"properties": {
"name": { "type": "string" },
"expectedMatch": { "type": "boolean" },
"user": { "type": "object" }
}
}
}
}
},
"conditionGroup": {
"type": "object",
"required": ["operator", "conditions"],
"additionalProperties": false,
"properties": {
"operator": { "type": "string", "enum": ["all", "any"] },
"conditions": {
"type": "array",
"minItems": 1,
"items": {
"anyOf": [
{ "$ref": "#/definitions/conditionGroup" },
{ "$ref": "#/definitions/condition" }
]
}
}
}
},
"condition": {
"type": "object",
"required": ["type", "operator"],
"additionalProperties": false,
"properties": {
"type": { "type": "string", "enum": ["property", "membership", "role"] },
"property": { "type": "string", "minLength": 1 },
"operator": {
"type": "string",
"enum": [
"equals", "notEquals", "contains", "notContains",
"startsWith", "endsWith", "matchesRegex",
"in", "notIn", "isNull", "isNotNull",
"memberOf", "notMemberOf"
]
},
"value": { "type": "string" },
"values": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": { "type": "string" }
},
"groupObjectIds": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": { "$ref": "#/definitions/guid" }
},
"roleIds": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
"items": { "type": "string", "minLength": 1 }
},
"membershipMode": { "type": "string", "enum": ["direct", "transitive"] },
"caseSensitive": {
"type": "boolean",
"default": false,
"description": "Reserved. Condition-level case sensitivity is out of scope for v1; the schema accepts the key so a later version does not require a breaking change."
}
},
"allOf": [
{
"if": { "properties": { "type": { "const": "property" } }, "required": ["type"] },
"then": { "required": ["property"] }
},
{
"if": { "properties": { "type": { "const": "membership" } }, "required": ["type"] },
"then": { "required": ["groupObjectIds"] }
},
{
"if": { "properties": { "type": { "const": "role" } }, "required": ["type"] },
"then": { "required": ["roleIds"] }
},
{
"if": {
"properties": { "operator": { "enum": ["in", "notIn"] } },
"required": ["operator"]
},
"then": { "required": ["values"] }
},
{
"if": {
"properties": { "operator": { "enum": ["isNull", "isNotNull"] } },
"required": ["operator"]
},
"then": {
"allOf": [
{ "not": { "required": ["value"] } },
{ "not": { "required": ["values"] } }
]
}
}
]
},
"guid": {
"type": "string",
"pattern": "^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
}
}
}
+228
View File
@@ -0,0 +1,228 @@
# Data Model: Persona Engine
**Date**: 2026-08-20 | **Spec**: [spec.md](spec.md) | **Plan**: [plan.md](plan.md)
Normalized in-memory contracts. These are the objects the rule engine sees. Per Principle IV the
rule engine MUST NOT receive raw directory responses — normalization is the boundary.
All types are plain `PSCustomObject` shapes. Field types are PowerShell types.
---
## UserRecord
Produced by `ConvertTo-PersonaUserRecord`. Consumed by the rule engine, presentation, and audit.
| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `AccountObjectId` | `string` (GUID) | Yes | Immutable identity key. Approved for logs. |
| `UserPrincipalName` | `string` | Yes | Approved for logs. |
| `DisplayName` | `string` | No | Diagnostics only. |
| `UserType` | `string` | No | `Member` / `Guest`. |
| `AccountEnabled` | `bool` | Yes | Disabled accounts remain in scope (FR-011). |
| `Properties` | `hashtable` | Yes | Case-insensitive map of evaluable property name → value. Populated from FR-005 selection plus any property a rule references. Absent property returns `$null`. |
| `StoredPersona` | `string` | No | Current value of the target attribute; `$null` when unset. |
| `Membership` | `MembershipRecord` | Yes | Never `$null`; an unattempted lookup is represented by an empty record with `RetrievalSucceeded = $true` and `Mode = 'None'`. |
**Validation rules**
- `AccountObjectId` and `UserPrincipalName` MUST be non-empty; a record failing this is an
upstream defect and MUST raise, not silently skip.
- `Properties` lookups are case-insensitive (RE-006).
- A `$null` or absent value in `Properties` is treated as empty for ordinary string comparisons and
MUST NOT fail evaluation (FR-012).
---
## MembershipRecord
Produced by `ConvertTo-PersonaMembershipRecord`. This type carries the most safety-critical fields
in the model.
**Revised 2026-08-20 during implementation.** The original design held a single `Mode` field
(`Direct` / `Transitive` / `None`) alongside one `GroupObjectIds` set. That cannot satisfy RE-007,
which makes membership mode a **per-condition** choice: a rule set may legitimately ask for
transitive membership in one rule and direct membership in another, and a single-mode record can
only answer one of them — every user became an `EvaluationError` on the other. The defect was
caught by running the shipped example configuration, which mixes both modes, against the fixtures.
The record now holds three independently-retrieved facets.
| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `DirectGroupObjectIds` | `string[]` | Yes | May be empty. |
| `DirectRetrieved` | `bool` | Yes | **`$false` means "unknown", never "not a member".** |
| `DirectFailureReason` | `string` | No | Populated only when `DirectRetrieved` is `$false`. |
| `TransitiveGroupObjectIds` | `string[]` | Yes | May be empty. |
| `TransitiveRetrieved` | `bool` | Yes | Same semantics as `DirectRetrieved`. |
| `TransitiveFailureReason` | `string` | No | |
| `DirectoryRoleIds` | `string[]` | Yes | May be empty. |
| `RolesRetrieved` | `bool` | Yes | Same semantics. |
| `RolesFailureReason` | `string` | No | |
**Validation rules**
- Every `*Retrieved` flag defaults to `$false`. A condition MUST read the flag for the facet it
actually queries, and a `$false` MUST yield `Unknown`, propagating to `EvaluationError` (FR-013).
- Facets are independent: a failed transitive lookup MUST NOT make direct-membership conditions
unevaluable. Collapsing them would turn one slow endpoint into a tenant-wide outage.
- An empty identifier collection with its facet `Retrieved = $true` is a legitimate "member of
nothing" and evaluates normally.
- A facet MUST NOT be both retrieved and carry a failure reason; the constructor throws.
- Mode selection is exact. A condition asking for transitive membership MUST NOT be answered from
direct data (false negatives on nested groups) and vice versa (false positives).
---
## BusinessRule
Deserialized from configuration. Never constructed in source (Principle II).
| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `Id` | `string` | Yes | Unique across the rule set (VR-002). |
| `Name` | `string` | Yes | |
| `Description` | `string` | Yes | |
| `Enabled` | `bool` | Yes | Disabled rules are skipped and excluded from the enabled count. |
| `Priority` | `int` | Yes | Unique; lower evaluates first (RE-002). |
| `Persona` | `string` | Yes | MUST be a defined persona; MUST NOT be `Unclassified` or `EvaluationError` (VR-002). |
| `Match` | `ConditionGroup` | Yes | Root condition group. |
| `Tags` | `string[]` | No | |
| `Owner` | `string` | No | |
| `ChangeReference` | `string` | No | |
| `EffectiveDate` | `string` | No | Metadata only in v1 — MUST NOT gate evaluation, as a date-dependent decision would break Principle I. |
| `Notes` | `string` | No | |
| `TestCases` | `object[]` | No | Consumed by the editor's synthetic testing (FR-025). |
---
## ConditionGroup / Condition
Recursive structure bounded by the configured depth (RE-004: default 5, min 1, ceiling 10).
**ConditionGroup**
| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `Operator` | `string` | Yes | `all` or `any`. |
| `Conditions` | `(Condition\|ConditionGroup)[]` | Yes | MUST be non-empty. |
**Condition (leaf)**
| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `Type` | `string` | Yes | `property`, `membership`, or `role`. |
| `Property` | `string` | For `property` | MUST be a supported property name (VR-002). |
| `Operator` | `string` | Yes | One of RE-005. |
| `Value` | `string` | Conditional | Required for comparison operators; MUST be absent for `isNull` / `isNotNull` (VR-002). |
| `Values` | `string[]` | Conditional | Required for `in` / `notIn`. |
| `GroupObjectIds` | `string[]` | For `membership` | MUST be non-empty (VR-002). |
| `RoleIds` | `string[]` | For `role` | MUST be non-empty. |
| `MembershipMode` | `string` | No | `direct` or `transitive`; defaults to the engine setting (RE-007). |
**Evaluation result values**: every condition evaluates to `True`, `False`, or **`Unknown`**.
`Unknown` is what makes FR-013 expressible.
**Propagation rules** (these are the whole safety argument — implement exactly):
| Group | Contains `Unknown` | Result |
| --- | --- | --- |
| `all` | plus any `False` | `False` — a definite non-match wins; the unknown cannot rescue it |
| `all` | plus only `True` | `Unknown` |
| `any` | plus any `True` | `True` — a definite match wins |
| `any` | plus only `False` | `Unknown` |
An `Unknown` at the rule root yields `EvaluationError` for that user.
---
## PersonaDecisionResult
Produced by `Resolve-UserPersona`. The engine's authoritative per-user output.
| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `AccountObjectId` | `string` | Yes | |
| `UserPrincipalName` | `string` | Yes | |
| `Outcome` | `string` | Yes | `Matched`, `Unclassified`, or `EvaluationError` — exactly one (SC-001). |
| `MatchedRuleId` | `string` | When `Matched` | `$null` otherwise. |
| `CalculatedPersona` | `string` | Yes | The persona, `Unclassified`, or `$null` when `EvaluationError`. |
| `StoredPersona` | `string` | No | Copied from the `UserRecord`. |
| `Action` | `string` | Yes | `Unchanged`, `WouldUpdate`, `Updated`, `UpdateFailed`, or `Skipped`. |
| `EvaluationErrorReason` | `string` | When `EvaluationError` | |
| `RulesEvaluated` | `int` | Yes | Count until first match or exhaustion. |
| `DurationMs` | `int` | Yes | Per-user timing (NFR-002). |
| `ConditionTrace` | `object[]` | No | Populated only under `-Debug` (Principle V). |
**State transitions for `Action`**
```text
EvaluationError ─────────────────────────────► Skipped (FR-014, no write ever)
Calculated == Stored ────────────────────────► Unchanged
Calculated != Stored, preview mode ──────────► WouldUpdate (FR-017, no request issued)
Calculated != Stored, enforce, write ok ─────► Updated
Calculated != Stored, enforce, write fails ──► UpdateFailed
```
`Unclassified` follows the same comparison path as any other calculated value — it is a legitimate
value to write if the configuration approves it, and is reported distinctly either way.
---
## Configuration
The complete ordered decision process. Structure is normative in
[contracts/persona-engine.schema.json](contracts/persona-engine.schema.json).
| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `configVersion` | `string` | Yes | Downgrade is a safety violation (VR-003). |
| `engine.targetAttribute` | `string` | Yes | MUST appear in `approvedWritableAttributes`. |
| `engine.approvedWritableAttributes` | `string[]` | Yes | MUST be non-empty. |
| `engine.maxConditionDepth` | `int` | No | Default 5, min 1, max 10 (RE-004). |
| `engine.summaryInterval` | `int` | No | Default 25; `0` suppresses interim summaries (FR-020). |
| `engine.defaultMembershipMode` | `string` | No | `direct` or `transitive`. |
| `dataSources.groups.enabled` | `bool` | Yes | Enabled group rules with this `false` is a safety violation (VR-003). |
| `dataSources.roles.enabled` | `bool` | Yes | |
| `personas` | `string[]` | Yes | Defined persona catalogue. `EvaluationError` MUST NOT appear. |
| `logging.*` | `object` | No | Destination and path (OTD-006). |
| `rules` | `BusinessRule[]` | Yes | MUST contain at least one enabled rule (VR-002). |
**Derived at load**: `ConfigurationHash` (SHA-256 of the canonical file bytes) — recorded on every
run record (NFR-005).
---
## ValidationFinding
Produced by all four validation layers (VR-004).
| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `Severity` | `string` | Yes | `Error`, `Warning`, `Information`. |
| `Code` | `string` | Yes | Stable finding code, e.g. `PE-SEM-012`. |
| `Location` | `string` | Yes | JSON path or rule ID. |
| `Description` | `string` | Yes | |
| `SuggestedResolution` | `string` | Yes | |
| `Layer` | `string` | Yes | `Syntax`, `Schema`, `Semantic`, `Safety`. |
`Error` blocks execution and saving; `Warning` blocks only under `-TreatWarningsAsErrors` (VR-005).
---
## RunRecord
One per execution. See [contracts/audit-record.md](contracts/audit-record.md) for the serialized
form.
| Field | Type | Required | Notes |
| --- | --- | --- | --- |
| `RunId` | `string` (GUID) | Yes | Supplied via `-CorrelationId` or generated. |
| `StartedUtc` / `CompletedUtc` | `datetime` | Yes | |
| `Mode` | `string` | Yes | `Preview` or `Enforce`. |
| `EngineVersion` | `string` | Yes | |
| `ConfigVersion` / `ConfigurationHash` | `string` | Yes | |
| `Processed` / `Matched` / `Unclassified` / `EvaluationError` | `int` | Yes | Reconciliation: `Processed = Matched + Unclassified + EvaluationError` (FR-021). |
| `Unchanged` / `WouldUpdate` / `Updated` / `UpdateFailed` | `int` | Yes | |
| `ExitCode` | `int` | Yes | Per the CLI contract. |
A failed reconciliation MUST be logged as an engine defect, not merely reported.
+202
View File
@@ -0,0 +1,202 @@
# Implementation Plan: Persona Engine
**Branch**: `main` (feature directory `001-persona-engine`) | **Date**: 2026-08-20 | **Spec**: [spec.md](spec.md)
**Input**: Feature specification from `/specs/001-persona-engine/spec.md`
## Summary
Deterministic, configuration-driven persona classification for Microsoft Entra ID user objects. The
engine enumerates in-scope users, evaluates each against an ordered JSON rule set, assigns exactly
one persona, and updates a single approved directory attribute only when the calculated value
differs from the stored value.
**Technical approach**: a PowerShell 7 module (`PersonaEngine`) whose rule engine is a pure function
over normalized records, with Graph access, persistence, and presentation isolated behind adapters.
Directory access uses `Invoke-MgGraphRequest` (direct REST over the `Microsoft.Graph.Authentication`
module) so the write body is explicitly constructed and test-assertable. The persona value is stored
in a **directory (schema) extension** on the user object, consumed downstream by dynamic membership
groups. Configuration is validated with the built-in `Test-Json -SchemaFile` against a draft-07
schema. See [research.md](research.md) for the decisions and their rationale.
## Delivery Staging
**Constraint (2026-08-20)**: no Azure Automation account is available. All development and testing
proceeds on a local PowerShell 7 workstation using **user accounts and delegated authentication**.
This changes sequencing, not architecture. The adapter boundaries that make the engine testable
offline (Principle IV) are the same boundaries that make the Automation runtime a late, additive
step — so the deferral costs nothing structurally.
| Stage | Environment | Auth | Status |
| --- | --- | --- | --- |
| **A1** — offline | Local PS7, synthetic fixtures | None | Available now. Covers the rule engine, all four validation layers, and the safety suites. No tenant, no network. |
| **A2** — connected read-only | Local PS7, tenant | Delegated (`Connect-MgGraph -Scopes`) | Available now. Covers enumeration, membership, roles, normalization, presentation, reconciliation, and `-WhatIf`. |
| **A3** — connected write | Local PS7, **test accounts only** | Delegated | Gated on V-4. Test accounts only — the baseline's read-only-during-early-development assumption still stands for the general population. |
| **B** — Automation | Azure Automation PS7 | Managed identity | **Deferred.** Additive: a second authentication adapter, a runbook wrapper, and a schedule. |
**Consequences, stated plainly:**
1. **v1 cannot be declared complete while Stage B is deferred.** The Definition of Done requires an
Azure Automation PowerShell 7 run to pass. Deferring it does not violate the constitution — it
defers *completion*. The correct milestone to claim in the meantime is "Stage A complete", not
"v1 done". Do not quietly redefine done.
2. **`Connect-PersonaGraphManagedIdentity` will ship unexercised.** `Connect-MgGraph -Identity`
cannot run on a workstation. The mitigation is to keep the authentication adapter's surface
minimal — one function, returning the same handle shape as the interactive path, with no
engine-visible difference — so that the untested code is a few lines rather than a subsystem.
3. **Delegated authorization behaves differently from application permissions.** Effective access is
the intersection of the requested scope and the signed-in user's directory roles. This makes V-3
*more* meaningful when run as an ordinary user account, and meaningless when run as a Global
Administrator. See research.md V-3.
4. **Automation-specific risk stays open**: runtime PowerShell version, module availability, and
sandbox behaviour are unverified until Stage B. The one-module dependency decision (OTD-004) is
what keeps that risk small.
## Technical Context
**Language/Version**: PowerShell 7.4 locally. The Automation runtime version is unverified and
remains so until Stage B (verification item V-5b in research.md). Avoid any construct newer than
PS 7.2 so the eventual Automation runtime is not a constraint discovered late.
**Primary Dependencies**: `Microsoft.Graph.Authentication` (token acquisition and
`Invoke-MgGraphRequest`) is the only runtime dependency. `Pester` 5.x and `PSScriptAnalyzer` are
development/CI-only. No full Microsoft Graph SDK dependency — see OTD-004. Module availability in
the Automation sandbox is unverified until Stage B.
**Storage**: JSON configuration file on disk; no database. Persona values live in the directory
itself. Audit output is newline-delimited JSON to a file plus the Automation output stream.
**Testing**: Pester 5.x. Unit and rule-engine suites run fully offline against synthetic fixtures
(SC-008); integration suites require a read-only tenant identity, satisfied in Stage A2 by a
delegated connection; safety suites assert zero writes under `-WhatIf` (SC-004) and single-attribute
write bodies (SC-005). The safety suites mock the write adapter, so they are fully available now and
are **not** gated on Stage A3 or B — the zero-write guarantee is proven against the adapter contract,
not against a tenant.
**Target Platform**: PowerShell 7 on a local workstation (Stages A1A3). The Azure Automation
PowerShell 7 runtime remains the eventual production target but is out of the current stage.
**Project Type**: PowerShell module plus two CLI entry-point scripts.
**Performance Goals**: None fixed. NFR-002 explicitly defers a hard target until representative
tenant testing. The plan requires per-user and total duration to be recorded from the first
connected run so a baseline exists before any target is set.
**Constraints**: Rule engine must be free of Graph, authentication, Automation, and console
dependencies (Principle IV). `-WhatIf` must issue zero writes (Principle III). Write payloads carry
exactly one attribute (Principle III). All artifacts sanitized to placeholders (Principle V,
SC-013).
**Scale/Scope**: In-scope population size is tenant-specific and unknown at planning time. Full
enumeration with pagination is the v1 processing model (OTD-008); delta processing is deferred. The
read-only pilot establishes the population size and run duration baseline.
## Constitution Check
*GATE: Must pass before Phase 0 research. Re-checked after Phase 1 design.*
Evaluated against [constitution.md](../../.specify/memory/constitution.md) v1.0.0.
| Gate | Principle | Pre-research | Post-design | Notes |
| --- | --- | --- | --- | --- |
| Deterministic, single-persona result | I (NON-NEGOTIABLE) | PASS | PASS | Ordered priority evaluation, first-match stop, no clock/random/unordered inputs in the engine. Rejecting `extensionAttributeN` (research OTD-001) removes a population-dependent failure mode that would have broken determinism across a hybrid population. |
| Configuration-driven rules | II | PASS | PASS | No persona, priority, group ID, role ID, or attribute name in source. Four-layer validation ordering preserved in the config contract. |
| Fail-safe, idempotent persistence | III (NON-NEGOTIABLE) | PASS | PASS | `EvaluationError` preserves stored value; `SupportsShouldProcess` on both write paths; changed-values-only comparison; single-attribute body construction isolated in one function. |
| Pure rule engine, offline-tested first | IV | PASS | PASS | Rule engine depends only on normalized records. Build order enforced in the task sequencing below; persistence adapter is last. |
| Explainable, sanitized observability | V | PASS | PASS | Run ID, UPN, Account Object ID, and matched rule ID on every user event; condition-value tracing gated behind `-Debug`; placeholders only in all artifacts. |
**Security and least-privilege constraints**: PASS with a mandatory condition. Research OTD-003
establishes that Microsoft Graph application permissions **cannot** be scoped to an individual user
attribute for the selected mechanism. The constitution anticipates exactly this outcome and makes
the compensating controls mandatory rather than optional; they are carried into the design as
testable requirements (see the persistence contract). This is a documented and approved-by-design
condition, not a constitution violation. Security approval of the compensating controls is a gate
before enforcement, per the Development Workflow section.
**Automation deferral (Stage B)**: PASS. Every principle is satisfiable on a local workstation —
determinism, configuration-driven rules, fail-safe persistence, engine purity, and observability are
all properties of the code, not of the hosting environment. Two constitution items are *deferred, not
waived*: the Definition of Done's Azure Automation PowerShell 7 run, and the release-pipeline stages
that deploy to it. Both are recorded in the Delivery Staging table and gate the v1 completion claim.
**Result**: no unjustified violations. Complexity Tracking is empty.
## Project Structure
### Documentation (this feature)
```text
specs/001-persona-engine/
├── plan.md # This file
├── research.md # Phase 0 output — OTD-001..010 decisions
├── data-model.md # Phase 1 output — entity contracts
├── quickstart.md # Phase 1 output — validation scenarios
├── contracts/ # Phase 1 output
│ ├── persona-engine.schema.json # Configuration JSON Schema (draft-07)
│ ├── cli-invoke-persona-engine.md # Engine CLI contract
│ ├── cli-edit-persona-engine-config.md # Editor CLI contract
│ ├── graph-data-provider.md # Directory read/write contract
│ └── audit-record.md # Structured log record contracts
└── tasks.md # Phase 2 output (/speckit-tasks — NOT created here)
```
### Source Code (repository root)
```text
PersonaEngine.psd1 # Module manifest
PersonaEngine.psm1 # Module loader
Invoke-PersonaEngine.ps1 # Engine entry point (CmdletBinding, SupportsShouldProcess)
Edit-PersonaEngineConfig.ps1 # Configuration validation / editor entry point
config/
├── persona-engine.example.json # Placeholder-only example
└── persona-engine.schema.json # Shipped schema (from contracts/)
src/
├── Configuration/ # Import-PersonaConfiguration, Test-PersonaConfiguration, Resolve-TargetAttribute
├── Authentication/ # Connect-PersonaGraphInteractive, Connect-PersonaGraphManagedIdentity
├── DataProviders/ # Get-PersonaUsers, Get-PersonaGroupMembership, Get-PersonaDirectoryRoles
├── Normalization/ # ConvertTo-PersonaUserRecord, ConvertTo-PersonaMembershipRecord
├── RuleEngine/ # Test-PersonaCondition, Test-PersonaConditionGroup, Test-PersonaRule,
│ # Resolve-UserPersona <-- no Graph/auth/console dependency
├── Persistence/ # Compare-PersonaValue, New-PersonaWriteBody, Set-UserPersonaAttribute
├── Presentation/ # Write-UserPersonaResult, Write-PersonaSummary
└── Audit/ # New-PersonaAuditRecord, Export-PersonaRunReport
tests/
├── Unit/ # Per-function offline tests
├── RuleEngine/ # Rule evaluation matrix against synthetic fixtures
├── Configuration/ # Schema, semantic (VR-002), and safety (VR-003) validation
├── Integration/ # Read-only tenant tests
├── Safety/ # SC-004 zero-write, SC-005 single-attribute-body assertions
└── TestData/ # Obviously fictional synthetic users, memberships, configs
docs/ # Architecture, BusinessRules, ConfigurationReference, Logging,
# SecurityModel, OperationsRunbook
pipelines/ # validate.yml, test.yml, release.yml
```
**Structure Decision**: single PowerShell module with two CLI entry points, matching the layout
already published in [README.md](../../README.md). The directory split is the enforcement mechanism
for Principle IV — `src/RuleEngine/` may import nothing from `src/Authentication/`,
`src/DataProviders/`, `src/Persistence/`, or `src/Presentation/`, and a CI check asserts this.
### Build order (Principle IV, non-negotiable sequencing)
1. Normalized record contracts and synthetic fixtures.
2. Pure rule engine + offline Pester suite (no tenant connectivity).
3. Configuration import, four-layer validation, and non-interactive pipeline mode.
4. `Edit-PersonaEngineConfig.ps1` interactive editor and synthetic rule testing.
5. Graph authentication and **read** adapters; normalization wiring.
6. Presentation, summaries, reconciliation, and structured audit output.
7. Persistence adapter **last**, with `ShouldProcess` and the zero-write/single-attribute suites.
Steps 14 are Stage A1 (offline). Steps 56 are Stage A2 (delegated read-only). Step 7 is built and
fully unit-tested in Stage A1/A2 against a mocked adapter, and only *exercised against the tenant* in
Stage A3, behind V-4. Adding the managed-identity adapter and runbook wrapper is Stage B and touches
nothing in steps 17 — that is the test of whether the boundaries were drawn correctly.
## Complexity Tracking
> No Constitution Check violations. This section is intentionally empty.
+213
View File
@@ -0,0 +1,213 @@
# Quickstart: Persona Engine Validation
**Date**: 2026-08-20 | **Spec**: [spec.md](spec.md) | **Plan**: [plan.md](plan.md)
Runnable scenarios that prove the feature works. Ordered by the build sequence in
[plan.md](plan.md) — each stage is validatable before the next exists. Scenarios 13 require no
tenant, no credentials, and no network.
Structural details live in [data-model.md](data-model.md) and [contracts/](contracts/); this guide
does not repeat them.
## Prerequisites
**Current constraint**: no Azure Automation account. Everything runs locally on PowerShell 7 with
user accounts and delegated authentication. Scenario 5 is deferred; Scenarios 14 and 6 are all
available now.
| Scenario | Stage | Requirement |
| --- | --- | --- |
| 13 (offline) | A1 | PowerShell 7.4, Pester 5.x, PSScriptAnalyzer. No tenant, no network. |
| 4 (read-only) | A2 | An app registration with admin consent for the three delegated scopes, and a **non-privileged** user account to sign in with. Closes V-1 (read), V-3. |
| 5 (automation) | B | **Deferred** — no Automation account available. Closes V-3b, V-5b when it lands. |
| 6 (enforcement) | A3 | Delegated `User.ReadWrite.All`, **written security sign-off (V-4)**, reviewed `-WhatIf` evidence, and **purpose-created test accounts** as the write targets. |
### Local connection
```powershell
Connect-MgGraph -Scopes 'User.Read.All','GroupMember.Read.All','RoleManagement.Read.Directory'
```
Sign in as an ordinary user account, not a Global Administrator — see the caution in Scenario 4.
---
## Scenario 1 — Rule engine determinism, offline
Proves SC-001, SC-003, SC-008 and the `Unknown` propagation table in
[data-model.md](data-model.md).
```bash
pwsh -NoProfile -Command "Invoke-Pester ./tests/RuleEngine -Output Detailed"
```
**Expected**: all pass with no network access. Specifically:
- Every synthetic user yields exactly one outcome.
- Shuffling fixture order changes nothing.
- A rule matching at priority 10 wins over one matching at 20, and evaluation stops.
- A `MembershipRecord` with `RetrievalSucceeded = $false` yields `EvaluationError`, never a
non-match.
- Depth beyond `maxConditionDepth` is rejected rather than silently truncated.
**Disconnect the network and re-run.** Identical results, or SC-008 is not met.
---
## Scenario 2 — Configuration validation
Proves SC-009, SC-010 and the four-layer ordering.
```bash
pwsh -NoProfile -File ./Edit-PersonaEngineConfig.ps1 -ConfigPath ./config/persona-engine.example.json -ValidateOnly -NonInteractive
```
**Expected**: exit code `0`, no findings.
Then run the invalid-configuration corpus in `tests/TestData/InvalidConfigs/` — one file per VR-002
and VR-003 condition:
```bash
pwsh -NoProfile -Command "Invoke-Pester ./tests/Configuration -Output Detailed"
```
**Expected**: each file produces its documented finding code, severity, and location; blocking
findings return a non-zero exit code with **no prompt and no hang** (SC-010).
---
## Scenario 3 — Safety invariants
Proves SC-004 and SC-005 with the write adapter mocked. This suite is the reason the persistence
adapter is built last.
```bash
pwsh -NoProfile -Command "Invoke-Pester ./tests/Safety -Output Detailed"
```
**Expected**:
- Full synthetic population under `-WhatIf`: write adapter call count is exactly `0` — asserted, not
inspected (SC-004).
- Every captured request body has exactly one key, equal to `engine.targetAttribute` (SC-005).
- `New-PersonaWriteBody` throws for any other attribute name and for an attribute absent from
`approvedWritableAttributes`.
- A `-Debug` run **without** `-WhatIf` still reaches the write path — `-Debug` is not a safety
control.
---
## Scenario 4 — Read-only tenant preview (User Story 1)
The first connected run. Uses a read-only identity, so it is safe by construction rather than by
correct behaviour.
> **Sign in as a non-privileged account.** V-3 asks whether the three scopes are *sufficient*.
> A Global Administrator answers yes regardless — the scope narrows the token, but the account's
> directory roles still grant broad read access, so the run succeeds whether or not the permission
> set is correct. Running this as GA produces a green result that means nothing.
```bash
pwsh -NoProfile -File ./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json -WhatIf -Verbose
```
**Expected**:
- A result line appears for every in-scope user, visible before the next user is processed (SC-012).
- Differences report as `WouldUpdate` with stored value, calculated value, and matched rule ID.
- Interim summaries at the configured interval; a final summary always; reconciliation passes at
every summary (SC-007).
- Zero write requests — confirm independently in the Entra sign-in and audit logs, not only from
console output.
Single-user check first, before the full population:
```bash
pwsh -NoProfile -File ./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json -UserObjectId <ACCOUNT-OBJECT-ID> -WhatIf
```
**Also close V-1 here**: run the single-user check against a cloud-only user, a currently-synced
user, and a formerly-synced user. Confirm the persona extension **reads** on all three. The write
half of V-1 is closed in Scenario 6.
**Idempotence check** (SC-002): run twice unchanged. The second run reports the same counts and zero
additional proposed changes.
---
## Scenario 5 — Azure Automation PowerShell 7 *(deferred — Stage B)*
**Not runnable in the current stage.** No Automation account is available. Recorded here so it is
not lost, and so the Stage B entry cost stays visible.
Proves NFR-001, NFR-008 and closes V-3b and V-5b.
1. Import the module and publish the runbook with the schedule **disabled**.
2. Run the runbook with `-WhatIf` using the managed identity.
3. Record the runtime's exact PowerShell version.
4. Confirm `Test-Json -SchemaFile` behaves as observed locally in V-5a — its error-reporting
behaviour varies by version, and the layer-2 wrapper depends on it (OTD-005). **Run this first**;
it is the cheapest item with the highest chance of surprising you.
5. Confirm the three read scopes work as *application* permissions on the managed identity (V-3b).
**Expected**: the run completes with only `Microsoft.Graph.Authentication` imported, and output
matches the equivalent local `-WhatIf` run.
**Until this scenario passes, v1 is not complete.** The Definition of Done requires an Automation
PowerShell 7 run. Stage A completion is a real milestone and worth claiming — but it is not v1.
---
## Scenario 6 — Enforcement (User Story 8)
**Gated.** Do not run until all of these hold:
- [ ] V-4 security sign-off on the OTD-003 compensating controls, in writing
- [ ] `-WhatIf` impact evidence from Scenario 4 reviewed and approved
- [ ] Scenarios 15 passing
- [ ] Kill switch and rollback procedure documented
- [ ] OTD-001 OTD-005 closed
```bash
pwsh -NoProfile -File ./Invoke-PersonaEngine.ps1 -ConfigPath ./config/persona-engine.json
```
**Expected**:
- Only changed values are written; unchanged users produce no request (SC-002).
- Every write body contains exactly one attribute (SC-005).
- Every `Updated` audit record carries `previousValue` — without it, rollback is impossible
retroactively (OTD-010).
- `EvaluationError` users are skipped with their stored persona intact (FR-014).
**Closes V-1 (write half)** and **V-2**: confirm the dynamic membership group built on the persona
extension populates, and that a Conditional Access policy assigned to that group applies.
---
## Verification item coverage
| Item | Closed by | Available now? |
| --- | --- | --- |
| V-1 | Scenario 4 (read) + Scenario 6 (write) | Yes |
| V-2 | Scenario 6 | Yes |
| V-3 | Scenario 4, as a non-privileged account | Yes |
| V-3b | Scenario 5 | **No — Stage B** |
| V-4 | Out-of-band security review — **gate on Scenario 6** | Yes (a conversation, not a tenant) |
| V-4a | Investigation; no scenario | Yes |
| V-5a | Scenario 2, behaviour pinned in a unit test | Yes |
| V-5b | Scenario 5 | **No — Stage B** |
## Exit code check (SC-011)
Every documented exit code must be reachable. Cover them deliberately rather than incidentally:
| Code | How to trigger |
| --- | --- |
| `0` | Scenario 4 |
| `1` | Any invalid configuration from Scenario 2 |
| `2` | Run with an unauthorized or expired identity |
| `3` | Fault injection on enumeration |
| `4` | Fault injection on a required data provider |
| `5` | Fault injection on the counter path (reconciliation defect) |
| `6` | Fault injection on an unhandled engine path |
+322
View File
@@ -0,0 +1,322 @@
# Phase 0 Research: Persona Engine
**Date**: 2026-08-20 | **Spec**: [spec.md](spec.md) | **Plan**: [plan.md](plan.md)
Resolves the Clarification Register in [spec.md](spec.md). OTD-001 through OTD-005 are
**persistence-blocking** and are decided here. OTD-006 through OTD-010 receive provisional decisions
sufficient to plan implementation.
Every decision below that depends on a tenant-specific fact carries a **verification item (V-n)**.
Per the constitution, attribute-level write authorization "MUST be verified, never assumed" — the
decisions state what the product documentation says, and the V-items state what the team must prove
in its own tenant before enforcement is enabled.
---
## OTD-001 — Persona attribute mechanism
**Decision**: Store the persona in a **directory (schema) extension** single-valued string property
on the `user` resource, registered against a dedicated application registration in the tenant. The
property is referenced as `extension_<EXTENSION-APP-ID>_<APPROVED-PERSONA-ATTRIBUTE-NAME>`.
**Rationale**:
- **Writable via Graph for cloud-mastered users** with an ordinary `PATCH /users/{id}`, and readable
via `$select` on the extension property name.
- **Consumable by Conditional Access.** CA assigns policy by user and group, not by user attribute,
so the consumption path is: persona attribute → dynamic membership group rule → CA assignment.
Dynamic membership rules support custom extension properties in the form
`user.extension_<appId>_<propertyName>`, provided the property is **single-valued** and the
extension belongs to an application in the tenant. Both conditions hold here.
- **Not population-dependent.** Unlike `extensionAttributeN`, it does not fail on accounts with an
external origin (see rejected alternative A).
**Alternatives considered**:
| Alternative | Verdict | Reason |
| --- | --- | --- |
| **A. `onPremisesExtensionAttributes.extensionAttributeN`** (extensionAttribute115) | **Rejected** | Updates via Graph succeed only for objects that have always been mastered in Entra. Accounts that were ever synced from on-premises AD — or that originated in Exchange Online — fail with *"Unable to update the specified properties for objects that have originated within an external service."* In a hybrid or formerly-hybrid tenant this produces write failures determined by an account's history rather than by its rule match, which is a direct hazard to Principle I (deterministic) and Principle III (fail-safe). Remediation would require an Exchange Online PowerShell write path — a second persistence mechanism and a second permission surface. |
| **B. Directory (schema) extension** | **Selected** | See rationale above. |
| **C. Custom security attribute** | **Rejected as primary; retained as the security-first alternative** | This is the *only* mechanism offering genuine attribute-scoped authorization (see OTD-003), which makes it attractive. But custom security attributes are **not exposed to the dynamic group evaluation engine** and cannot be referenced in dynamic membership rules, so they cannot drive the CA consumption path that motivates the persona value. They are also not returned by default and require a separate permission and role. Choosing C trades the feature's primary downstream use for a stronger write boundary. |
**Consequences**: the compensating controls in OTD-003 become mandatory, because alternative B has
no attribute-scoped authorization.
**Verification items**:
- **V-1** — Register the extension application and property in a non-production tenant or an
isolated attribute name; confirm read via `$select` and write via `PATCH` for: a cloud-only user, a
currently-synced user, and a formerly-synced user. Directory extensions are not on-premises-mastered
properties, so all three are expected to succeed — but this must be proven, not assumed, because the
whole reason A was rejected is an origin-dependent write restriction.
- **V-2** — Confirm a dynamic membership group rule referencing the extension property evaluates and
populates as expected, and that a CA policy assigned to that group applies.
---
## OTD-002 — Least-privilege Microsoft Graph permissions
**Decision**: application (managed identity) permissions, granted only as each capability is enabled:
| Capability | Permission | Notes |
| --- | --- | --- |
| Read users and the persona extension | `User.Read.All` | Extension property returned via `$select`. |
| Read group membership (`memberOf` conditions) | `GroupMember.Read.All` | Sufficient for `/users/{id}/memberOf` and `/transitiveMemberOf`. Narrower than `Group.Read.All`. |
| Read directory role assignments | `RoleManagement.Read.Directory` | For role-based conditions. |
| Write the persona attribute (enforcement only) | `User.ReadWrite.All` | **Supersedes** `User.Read.All`; grant only to the enforcement identity, and only after security approval of the OTD-003 controls. |
Application permissions are the Stage B (Automation, managed identity) form. Stage A uses the same
four as **delegated** scopes on an interactive connection.
**Rationale**: each permission maps to exactly one enabled capability, so a tenant that disables
group or role conditions grants strictly less. `Directory.Read.All` is deliberately **rejected** — it
is materially broader than the three read permissions combined and would grant visibility well
outside the enumerated data sources.
**Local (Stage A) equivalent — delegated scopes.** With no Automation account, the same three
capabilities are requested as delegated scopes on an interactive connection:
```powershell
Connect-MgGraph -Scopes 'User.Read.All','GroupMember.Read.All','RoleManagement.Read.Directory'
```
These delegated scopes require one-time admin consent for the app registration used locally; after
that, an ordinary user account can hold them. **Effective access is the intersection of the granted
scope and the signed-in user's directory roles** — which is precisely why V-3 must be run as a
non-privileged account (see below).
**Alternatives considered**: `Directory.ReadWrite.All` (rejected — grossly over-broad);
`User.ManageIdentities.All` (not applicable); delegated-only operation (rejected — unattended
Automation requires application permissions; delegated remains the local development path per FR-003).
**Verification items**:
- **V-3** — During the read-only pilot, grant only the three read permissions and confirm every
enabled rule evaluates without a permission error. Any `EvaluationError` attributable to
authorization identifies a missing-but-required permission and must be resolved before enforcement.
**Stage A method**: connect with the three delegated scopes above while signed in as an **ordinary,
non-privileged user account**. Running this as a Global Administrator invalidates the test — the
scope narrows the token, but the account's directory roles still grant broad read access, so the
run would succeed regardless of whether the three permissions are actually sufficient. This item is
closeable now and does not need Automation.
- **V-3b** *(Stage B, deferred)* — Repeat as **application** permissions on the managed identity.
Delegated and application authorization are evaluated differently, so a passing V-3 is strong
evidence but not proof for the unattended path.
---
## OTD-003 — Can write authorization be restricted to the single target attribute?
**Decision**: **No — not for the mechanism selected in OTD-001.** Microsoft Graph application
permissions have no per-property scope: `User.ReadWrite.All` authorizes writes to every writable
property of every user in the tenant. There is no supported way to grant "write only
`extension_<app>_<persona>`".
Therefore the compensating controls are **mandatory and testable**, not advisory:
1. `Set-UserPersonaAttribute` accepts only the configured target attribute; any other name is a
terminating error.
2. The target attribute MUST appear in `approvedWritableAttributes`; validation rejects all others
(VR-002).
3. A single dedicated function, `New-PersonaWriteBody`, constructs the request body, and it emits a
hashtable containing exactly one key.
4. Unit and integration tests assert on the **request body**, not on observed behaviour (SC-005).
5. Code owners gate every change to persistence, `approvedWritableAttributes`, and the target
attribute.
6. Directory audit logs are monitored for property writes by the engine's service principal other
than the target attribute.
**The one mechanism that *would* satisfy attribute-level authorization**: custom security attributes
(OTD-001 alternative C). Their assignment is governed by attribute sets: a principal is granted
`Attribute Assignment Administrator` **scoped to a specific attribute set**, plus the separate
`CustomSecAttributeAssignment.ReadWrite.All` permission — and notably, Global Administrator does not
hold this access by default. That is a real, enforced boundary rather than a compensating control.
It was rejected only because custom security attributes cannot feed dynamic groups (OTD-001).
**Stage A makes these controls *more* important, not less.** Local delegated writes run as the
signed-in operator, whose directory roles are typically far broader than the eventual service
principal's. During Stage A3 the compensating controls are the **only** thing standing between the
engine and an unintended property write, because the authorization boundary is effectively "whatever
the operator can do." Two additional Stage A rules follow:
- Stage A3 writes target **purpose-created test accounts only**. The baseline's read-only-during-
early-development assumption continues to hold for the general population.
- Never sign in with a standing privileged account for a write run. Elevate for the session, and
expect the directory audit log to attribute the write to the operator rather than to a service
principal — which is exactly why Stage A3 evidence does not substitute for Stage B evidence.
**This trade-off requires explicit security sign-off.** The decision record for security review is:
*accept tenant-wide user-write permission plus six compensating controls, in exchange for a persona
value that Conditional Access can actually consume.*
**Verification items**:
- **V-4** — Confirm with the security owner, in writing, that the compensating-control set is
accepted in place of attribute-scoped authorization. This is a **gate before enforcement**, per the
constitution's Definition of Done.
- **V-4a** — Investigate whether an Administrative Unit-scoped role assignment can narrow the
enforcement identity's write scope to a subset of the user population. This narrows *which users*,
never *which attribute*, so it is a partial mitigation at best; do not present it as closing OTD-003.
---
## OTD-004 — Directory access approach
**Decision**: **Direct REST via `Invoke-MgGraphRequest`**, with `Microsoft.Graph.Authentication` as
the only runtime module. No resource-specific SDK modules (`Microsoft.Graph.Users`,
`Microsoft.Graph.Groups`, etc.).
**Rationale**:
- **Explicit request bodies.** The constitution requires the write payload to contain exactly one
attribute and requires tests to inspect that body. `Invoke-MgGraphRequest -Method PATCH -Body` makes
the body a first-class, assertable value. SDK cmdlets construct bodies internally from parameter
binding, which makes SC-005 far harder to prove.
- **Dynamic extension properties.** The persona property name is configuration-driven and unknown at
authoring time. Passing an arbitrary `extension_<appId>_<name>` key is natural in a hashtable body
and awkward through typed cmdlet parameters.
- **Automation footprint.** One small module to import instead of the SDK's large module set, which
reduces cold-start time, import failures, and version drift in the Automation PS7 environment
(NFR-008).
- Managed-identity and interactive token acquisition are still handled by `Connect-MgGraph`, so
nothing is reimplemented.
**Alternatives considered**: full Graph SDK cmdlets (rejected — heavy, opaque bodies, version drift);
raw `Invoke-RestMethod` with hand-rolled token acquisition (rejected — reimplements managed-identity
token handling and refresh for no benefit).
**Consequence**: pagination (`@odata.nextLink`), throttling, and error shaping are the engine's
responsibility. They are handled once, in the data-provider layer — see OTD-007 and
[contracts/graph-data-provider.md](contracts/graph-data-provider.md).
---
## OTD-005 — JSON Schema validation approach
**Decision**: the built-in **`Test-Json -SchemaFile`** cmdlet, with the schema authored to
**JSON Schema draft-07**.
**Rationale**: `Test-Json` ships with PowerShell 6.1+ and therefore needs no module import in either
the local or Automation PS7 environment — the strongest possible answer to NFR-008 and OTD-005's
"compatible locally and in automation" requirement. Its underlying validator is the Newtonsoft JSON
Schema implementation, whose reliable coverage is draft-04/06/07; **draft 2019-09 and 2020-12
constructs must not be used** in the schema.
**Implementation notes**:
- `Test-Json` signals failure by writing errors rather than simply returning `$false` in several
PowerShell versions. `Test-PersonaConfiguration` MUST wrap it with
`-ErrorAction SilentlyContinue -ErrorVariable` and translate the collected errors into
`Validation Finding` objects (VR-004), so that layer 2 produces structured findings like every other
layer.
- Schema validation is layer 2 of four. It cannot express the semantic rules in VR-002 (duplicate
priorities, depth limits, cross-field constraints), which is why layers 3 and 4 exist as PowerShell
checks. Do not attempt to push semantic rules into the schema.
**Alternatives considered**: bundling a third-party schema library (rejected — an extra Automation
dependency for capability the platform already provides); hand-written structural validation only
(rejected — VR-001 mandates a schema layer, and a schema is also the editor's contract).
**Verification items**:
- **V-5a** *(Stage A, closeable now)* — Execute `Test-Json -SchemaFile` against the draft-07 schema
on the local PowerShell 7.4 workstation. Record the exact behaviour on failure: whether it returns
`$false`, writes a non-terminating error, or throws. The layer-2 wrapper is built against **this
observed behaviour**, and the observation is pinned in a unit test so a runtime change is caught
rather than discovered.
- **V-5b** *(Stage B, deferred)* — Repeat inside the Azure Automation PowerShell 7 runtime and record
its exact PowerShell version. If the behaviour differs from V-5a, the wrapper handles both — do not
assume parity. This is the single highest-value item to run on day one of Stage B.
---
## Non-blocking decisions (OTD-006 OTD-010)
These do not block persistence. They are decided far enough to implement v1 without rework.
### OTD-006 — Structured log destination and transport
**Decision**: newline-delimited JSON (one audit record per line) written to a configurable file path,
plus the Automation output stream. Emission goes through a single `Write-PersonaAuditRecord` sink
function so a Log Analytics or Event Hub transport can be added later without touching call sites.
Log Analytics ingestion is **deferred**, not designed out.
**Rationale**: NDJSON is append-safe, streamable, trivially ingestible later, and needs no
dependency. The sink indirection is what keeps the deferral cheap.
### OTD-007 — Retry policy
**Decision**: bounded exponential backoff with full jitter in the data-provider layer.
- **Retryable**: HTTP 429, 500, 502, 503, 504, and transport-level timeouts.
- **Never retried**: 400, 401, 403, 404, 409 — these are configuration, authorization, or logic
defects and retrying masks them.
- **`Retry-After` honoured** whenever present; it overrides the computed backoff.
- **Max 5 attempts**, base delay 1s, exponential with full jitter, per-delay cap 60s.
- Every retry logs attempt number, status code, and delay. Exhausting retries on **required** data
yields `EvaluationError` for the affected user (FR-013) — never a silent non-match.
**Rationale**: satisfies NFR-003 while keeping Principle III intact: the failure mode of exhausted
retries is preserve-and-report, not assume-false.
### OTD-008 — Full versus incremental processing
**Decision**: v1 performs **full enumeration** with pagination. Delta query is deferred and out of
scope for v1 (already recorded in spec Out of Scope). Revisit only when pilot timings justify it.
### OTD-009 — Schedule and concurrency lock
**Decision**: hourly is the candidate cadence, deployment-configurable, and the schedule ships
**disabled** (per the release pipeline). Concurrency control for v1 is the Automation account's own
job behaviour plus a run-start check that fails fast if another job for the same runbook is running.
A durable distributed lock is deferred.
**Stage A status**: not applicable. With no Automation account there is no schedule and no
concurrency surface — runs are manual and serial by construction. Nothing here needs building until
Stage B, and nothing in Stages A1A3 depends on it.
**Rationale**: overlapping runs are idempotent by construction (Principle III) — the harm is wasted
Graph quota and confusing audit output, not incorrect data — so a lightweight check is proportionate
for v1.
### OTD-010 — Rollback
**Decision**: rollback is driven by the audit trail. Every write record carries the **previous
value**, the calculated value, the matched rule ID, and the run ID (NFR-005), which makes a run
reversible by replaying prior values from its audit output. A replay utility is deferred to v1.1; the
**record shape that makes it possible is v1** and is fixed in
[contracts/audit-record.md](contracts/audit-record.md).
**Rationale**: the cheap, decisive part is capturing the previous value at write time. Miss that in
v1 and rollback becomes impossible retroactively.
---
## Verification checklist
| ID | Item | Stage | Blocks |
| --- | --- | --- | --- |
| V-1 | Extension property read/write proven for cloud-only, synced, and formerly-synced users | A2 (read) / A3 (write) | Persistence implementation |
| V-2 | Dynamic group rule on the extension property populates; CA assignment applies | A3 | Downstream value of the feature |
| V-3 | Delegated pilot completes with only the three scopes, signed in as a **non-privileged** account | A2 | Enforcement |
| V-3b | Same, as application permissions on the managed identity | **B — deferred** | Unattended enforcement |
| V-4 | Written security sign-off on compensating controls in place of attribute-scoped write | Out-of-band | **Enforcement (constitution gate)** |
| V-4a | Administrative Unit scoping investigated as partial mitigation | Any | Nothing (informational) |
| V-5a | `Test-Json -SchemaFile` failure behaviour observed and pinned locally | A1 | Layer-2 wrapper implementation |
| V-5b | Same, confirmed in the Automation runtime, with PS version recorded | **B — deferred** | Configuration validation sign-off |
**Closeable in the current stage**: V-1 (read half), V-3, V-4a, V-5a — and V-4, which needs a
conversation rather than a tenant. **Deferred with Automation**: V-3b, V-5b.
## Sources
- [Manage rules for dynamic membership groups in Microsoft Entra ID](https://learn.microsoft.com/en-us/entra/identity/users/groups-dynamic-membership)
- [Creating dynamic groups using custom security attributes](https://learn.microsoft.com/en-us/answers/questions/5763638/creating-dynamic-groups-using-custom-security-attr)
- [Conditional Access: Users, Groups, Agents, and Workload Identities](https://learn.microsoft.com/en-us/entra/identity/conditional-access/concept-conditional-access-users-groups)
- [onPremisesExtensionAttributes resource type](https://learn.microsoft.com/en-us/graph/api/resources/onpremisesextensionattributes?view=graph-rest-1.0)
- [Update user — Microsoft Graph v1.0](https://learn.microsoft.com/en-us/graph/api/user-update?view=graph-rest-1.0)
- [Why is it not possible to update extension attributes of former hybrid users via Graph API?](https://learn.microsoft.com/en-us/answers/questions/1850101/why-is-it-not-possible-to-update-extension-attribu)
- [Add custom data to resources using extensions](https://learn.microsoft.com/en-us/graph/extensibility-overview)
- [What are custom security attributes in Microsoft Entra ID?](https://learn.microsoft.com/en-us/entra/fundamentals/custom-security-attributes-overview)
- [Manage access to custom security attributes in Microsoft Entra ID](https://learn.microsoft.com/en-us/entra/fundamentals/custom-security-attributes-manage)
- [Assign, update, list, or remove custom security attributes for a user](https://learn.microsoft.com/en-us/entra/identity/users/users-custom-security-attributes)
+25 -12
View File
@@ -84,7 +84,7 @@ An operator watching a run sees each user's result appear immediately, receives
### User Story 5 - Validate and edit configuration safely (Priority: P2) ### User Story 5 - Validate and edit configuration safely (Priority: P2)
A configuration owner validates a configuration file, edits rules interactively, tests rules against synthetic sample users, compares against another configuration, and saves only after validation passes — with a backup taken first. A configuration owner validates a configuration file, adds, edits, and deletes rules interactively — including each rule's condition tree — tests rules against synthetic sample users, compares against another configuration, and saves only after validation passes — with a backup taken first.
**Why this priority**: Makes the configuration-driven model usable and safe in practice, but the engine can be exercised with a hand-authored file first. **Why this priority**: Makes the configuration-driven model usable and safe in practice, but the engine can be exercised with a hand-authored file first.
@@ -96,6 +96,13 @@ A configuration owner validates a configuration file, edits rules interactively,
2. **Given** an edit session with unsaved valid changes, **When** the file is saved over an existing configuration, **Then** a timestamped backup or save-as output is produced first. 2. **Given** an edit session with unsaved valid changes, **When** the file is saved over an existing configuration, **Then** a timestamped backup or save-as output is produced first.
3. **Given** synthetic sample users are supplied, **When** rules are tested, **Then** the resulting persona for each sample is reported without any tenant connection. 3. **Given** synthetic sample users are supplied, **When** rules are tested, **Then** the resulting persona for each sample is reported without any tenant connection.
4. **Given** a valid configuration, **When** validation runs, **Then** no Error findings are produced and the file is accepted. 4. **Given** a valid configuration, **When** validation runs, **Then** no Error findings are produced and the file is accepted.
5. **Given** an interactive edit session, **When** the configuration owner adds a new rule, **Then** they are prompted for every required field (`id`, `name`, `description`, `priority`, `persona`, `enabled`) and for the rule's condition tree — including nested `all`/`any` groups and, for each leaf condition, the property or membership source, operator, and comparison value — and the new rule is appended to the in-memory document without being written to disk until save.
6. **Given** an interactive edit session, **When** the configuration owner attempts to add a rule using an `id` or `priority` already present in the document, **Then** the attempt is rejected before the rule is added, with a message naming the conflicting rule.
7. **Given** an interactive edit session, **When** the configuration owner selects an existing rule to edit, **Then** they can change any of its top-level fields and its condition tree — adding, editing, removing, or renesting conditions and `all`/`any` groups within the configured depth limit — and the change is held in memory, unsaved, until the session validates and saves.
8. **Given** an interactive edit session, **When** the configuration owner deletes a rule, **Then** they are shown the rule's `id`, `name`, and `priority` and asked to confirm before it is removed from the in-memory document.
9. **Given** a rule add, edit, or delete has been made in an edit session, **When** the session re-validates or saves, **Then** the same four validation layers (VR-001) run over the modified document exactly as they would over a hand-edited file, and any resulting Error finding blocks the save.
10. **Given** an edit session with unsaved add, edit, or delete changes, **When** the operator quits without saving, **Then** they are warned that unsaved changes will be lost and the file on disk is unchanged.
11. **Given** a rule add, edit, or delete would exceed the configured maximum condition nesting depth, **When** the change is applied, **Then** it is rejected at edit time with the same finding the runtime validator would produce, rather than deferred to the next save.
--- ---
@@ -219,6 +226,10 @@ Requirement identifiers are carried forward unchanged from the Phase 0 baseline
- **FR-024** — Non-interactive validation: the configuration tool MUST support non-interactive validation and return a non-zero exit code on failure. - **FR-024** — Non-interactive validation: the configuration tool MUST support non-interactive validation and return a non-zero exit code on failure.
- **FR-025** — Configuration test data: the editor MUST support testing rules against synthetic sample users without tenant connectivity. - **FR-025** — Configuration test data: the editor MUST support testing rules against synthetic sample users without tenant connectivity.
- **FR-026** — Backup before save: interactive edits MUST be validated before save and SHOULD create a timestamped backup or save-as output before replacing an existing configuration. - **FR-026** — Backup before save: interactive edits MUST be validated before save and SHOULD create a timestamped backup or save-as output before replacing an existing configuration.
- **FR-027** — Add a rule: the interactive editor MUST support adding a new rule, prompting for every field in RE-001 and for the rule's condition tree (nested `all`/`any` groups and leaf conditions), and MUST reject an `id` or `priority` that collides with an existing rule before the rule is added.
- **FR-028** — Edit a rule: the interactive editor MUST support editing any field of an existing rule, including full editing of its condition tree — adding, changing, removing, and renesting conditions and groups within the configured depth limit (RE-004).
- **FR-029** — Delete a rule: the interactive editor MUST support deleting an existing rule, and MUST require the operator to confirm against the rule's `id`, `name`, and `priority` before removal.
- **FR-030** — Uniform validation for structural edits: a rule added, edited, or deleted in the interactive editor MUST be subject to the same four validation layers (VR-001) as a hand-edited file, at re-validation and at save; edits are held only in memory until save succeeds (FR-026).
### Rule Engine Requirements ### Rule Engine Requirements
@@ -310,20 +321,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. 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 | | ID | Item | Status |
| --- | --- | --- | | --- | --- | --- |
| OTD-001 | Exact persona attribute mechanism — data type, read/update method, discoverability, Conditional Access compatibility | [NEEDS CLARIFICATION] Blocks persistence | | 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 | [NEEDS CLARIFICATION] Blocks persistence | | 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 | [NEEDS CLARIFICATION] Blocks persistence | | 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 | [NEEDS CLARIFICATION] Blocks persistence | | 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 | [NEEDS CLARIFICATION] Blocks persistence | | 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 | 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 | Open | | 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 | | 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 | | 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 | Open | | 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.
--- ---
+507
View File
@@ -0,0 +1,507 @@
---
description: "Task list for Persona Engine implementation"
---
# Tasks: Persona Engine
**Input**: Design documents from `/specs/001-persona-engine/`
**Prerequisites**: [plan.md](plan.md), [spec.md](spec.md), [research.md](research.md),
[data-model.md](data-model.md), [contracts/](contracts/), [quickstart.md](quickstart.md)
**Tests**: **Included and mandatory.** Not an optional TDD preference here — SC-004, SC-005, SC-008,
and SC-009 are written as test assertions, and constitution Principle IV requires the rule engine to
pass offline Pester tests before any Graph integration exists.
**Organization**: Grouped by user story. Story phases are ordered by the constitution's
**non-negotiable build order**, not strictly by priority — see the note below.
## Format: `[ID] [P?] [Story] Description`
- **[P]**: Can run in parallel (different files, no dependencies on incomplete tasks)
- **[Story]**: US1US9, mapping to the user stories in spec.md
- Every task names an exact file path
## Path Conventions
Single PowerShell module at repository root: `src/`, `tests/`, `config/`, `docs/`, `pipelines/`,
per the Project Structure section of [plan.md](plan.md).
## Implementation status — 2026-08-21
**117 of 129 tasks complete.** All twelve remaining tasks require something this workstation does not
have: a tenant connection (T055, T056, T101T103) or an Azure Automation account (Phase 13,
T115T121). Nothing offline-implementable is outstanding.
T122T129 (interactive add/edit/delete for rules, FR-027FR-030) landed after the initial Phase 6
build, which had shipped with toggle/priority editing only. 400 offline Pester tests now pass (up
from 354); see [traceability.md](traceability.md) for FR-027FR-030's implementation and test
mapping.
```
400 offline Pester tests PASS
Engine purity (Principle IV) PASS
Sanitization (SC-013) PASS
Graph module loaded during tests False (SC-008 holds)
```
Three deviations from the task list as written, each made while implementing and each for a reason
worth recording:
**T033 does not live in `Resolve-UserPersona`.** The task named that file, but `evaluationErrorThreshold`
is run-level state and the rule engine is pure — a counter there would break Principle IV. It is
implemented in `New-PersonaRunCounter` and applied in the run loop, which is what "and the run summary
path" in the task description points at.
**A new `src/Engine/` layer exists** holding `Invoke-PersonaEngineRun`, which was not in the planned
structure. `Invoke-PersonaEngine.ps1` imports the manifest, which requires `Microsoft.Graph.Authentication`;
a run loop living only inside that script could not be executed on a machine without the Graph SDK,
so SC-004 — zero writes under `-WhatIf` across a full population — could not be proven at all. The
entry script is now a thin wrapper and the loop is testable. What ships and what is tested are the
same code.
**T057's corpus is generated, not hand-written.** `tests/TestData/InvalidConfigs/New-InvalidConfigCorpus.ps1`
produces 23 fixtures, each a valid configuration with exactly one defect. The generated files are
committed so a reviewer sees the fixture in the diff rather than a script that produces it.
## Ordering note (read before starting)
Constitution Principle IV fixes the build order: pure rule engine first, persistence last. This
**overrides** strict priority ordering, so the P1 stories are sequenced US2 → US3 → US1 rather than
US1 first. US1 (preview) is the headline MVP story but cannot be built before the engine it previews.
Stage labels refer to the Delivery Staging table in [plan.md](plan.md): **A1** offline, **A2**
delegated read-only, **A3** delegated write to test accounts, **B** Azure Automation (deferred — no
Automation account available).
---
## Phase 1: Setup (Shared Infrastructure) — Stage A1
**Purpose**: Repository scaffolding and tooling. No behaviour.
- [X] T001 Create the directory tree (`src/{Configuration,Authentication,DataProviders,Normalization,RuleEngine,Persistence,Presentation,Audit}`, `tests/{Unit,RuleEngine,Configuration,Integration,Safety,TestData}`, `config/`, `docs/`, `pipelines/`) per the Project Structure section of specs/001-persona-engine/plan.md
- [X] T002 Create the module manifest PersonaEngine.psd1 with `PowerShellVersion = '7.2'` and `RequiredModules = @('Microsoft.Graph.Authentication')`
- [X] T003 Create the module loader PersonaEngine.psm1 that dot-sources every `src/**/*.ps1` and exports only public functions
- [X] T004 [P] Add PSScriptAnalyzer settings in PSScriptAnalyzerSettings.psd1 enabling PSUseShouldProcessForStateChangingFunctions and PSAvoidUsingPlainTextForPassword
- [X] T005 [P] Add Pester configuration in tests/PesterConfiguration.ps1 with separate tags for Offline, Integration, and Safety suites
- [X] T006 [P] Copy specs/001-persona-engine/contracts/persona-engine.schema.json to config/persona-engine.schema.json as the shipped schema
- [X] T007 [P] Create the placeholder-only sample configuration config/persona-engine.example.json using `<APPROVED-PERSONA-ATTRIBUTE-NAME>` and `<GROUP-OBJECT-ID>` tokens only
- [X] T008 [P] Write the sanitization scan script tests/Test-Sanitization.ps1 that fails on real-looking GUIDs, domains, UPNs, or secrets in any tracked file (SC-013)
---
## Phase 2: Foundational (Blocking Prerequisites) — Stage A1
**Purpose**: The normalized contracts and guardrails every story depends on.
**⚠️ CRITICAL**: No user story work begins until this phase is complete.
- [X] T009 Implement `New-PersonaUserRecord` in src/Normalization/New-PersonaUserRecord.ps1 producing the UserRecord shape in data-model.md, with case-insensitive `Properties` lookup and mandatory `AccountObjectId`/`UserPrincipalName`
- [X] T010 Implement `New-PersonaMembershipRecord` in src/Normalization/New-PersonaMembershipRecord.ps1 with three independently-retrieved facets (direct, transitive, roles), every `*Retrieved` flag defaulting to `$false` so an unset record is never mistaken for "not a member" (revised during implementation — see data-model.md)
- [X] T011 [P] Implement `New-PersonaValidationFinding` in src/Configuration/New-PersonaValidationFinding.ps1 emitting Severity, Code, Location, Description, SuggestedResolution, Layer (VR-004)
- [X] T012 [P] Create synthetic fixtures in tests/TestData/Users/ covering enabled, disabled, guest, null-property, and missing-property accounts — obviously fictional, placeholders only
- [X] T013 [P] Create synthetic membership fixtures in tests/TestData/Memberships/ including a partial-failure record (one facet failed) and a total-failure record
- [X] T014 [P] Write Pester contract tests for both record types in tests/Unit/RecordContracts.Tests.ps1
- [X] T015 Write the engine-purity CI check tests/Test-EnginePurity.ps1 asserting no file under src/RuleEngine/ references Graph, `Connect-`, `Invoke-MgGraphRequest`, `Write-Host`, or any function from src/Authentication|DataProviders|Persistence|Presentation (Principle IV)
**Checkpoint**: normalized contracts exist and purity is enforced by CI. Story work can begin.
---
## Phase 3: User Story 2 — Define classification rules without changing code (Priority: P1) 🎯 Engine core
**Goal**: Ordered, JSON-defined rules evaluate against normalized records with no code changes and no
tenant.
**Independent Test**: Author a multi-rule configuration, evaluate it against synthetic fixtures
offline with the network disconnected, and confirm the expected persona for each fixture.
### Tests for User Story 2
> Write these first and confirm they fail before implementing.
- [X] T016 [P] [US2] Operator matrix tests for all thirteen RE-005 operators in tests/RuleEngine/Operators.Tests.ps1, including case-insensitivity (RE-006) and null-as-empty (FR-012)
- [X] T017 [P] [US2] Ordering tests in tests/RuleEngine/Ordering.Tests.ps1 asserting ascending priority evaluation, first-match stop, and that a priority-10 match beats a priority-20 match
- [X] T018 [P] [US2] Composition tests in tests/RuleEngine/Composition.Tests.ps1 for nested `all`/`any` within the depth limit, and rejection beyond `maxConditionDepth` and the hard ceiling of 10 (RE-004)
- [X] T019 [P] [US2] Determinism tests in tests/RuleEngine/Determinism.Tests.ps1 asserting identical results across shuffled fixture order and repeated runs (SC-003)
- [X] T020 [P] [US2] Unclassified test in tests/RuleEngine/Unclassified.Tests.ps1 asserting that zero matches with all rules evaluating successfully yields `Unclassified` (FR-010)
### Implementation for User Story 2
- [X] T021 [US2] Implement `Import-PersonaConfiguration` in src/Configuration/Import-PersonaConfiguration.ps1 converting JSON to BusinessRule/ConditionGroup/Condition objects and computing the SHA-256 `ConfigurationHash`
- [X] T022 [US2] Implement `Test-PersonaCondition` in src/RuleEngine/Test-PersonaCondition.ps1 covering all RE-005 operators, with regex patterns validated before execution (RE-006)
- [X] T023 [US2] Implement `Test-PersonaConditionGroup` in src/RuleEngine/Test-PersonaConditionGroup.ps1 with `all`/`any` composition and depth enforcement
- [X] T024 [US2] Implement `Test-PersonaRule` in src/RuleEngine/Test-PersonaRule.ps1 evaluating a rule's root condition group
- [X] T025 [US2] Implement `Resolve-UserPersona` in src/RuleEngine/Resolve-UserPersona.ps1 with priority sort, first-match stop, `Unclassified` fallback, `RulesEvaluated`, and `DurationMs`
- [X] T026 [US2] Implement membership and role condition types in src/RuleEngine/Test-PersonaCondition.ps1 reading only from the MembershipRecord, honouring per-condition `direct`/`transitive` mode (RE-007)
**Checkpoint**: the rule engine classifies synthetic users offline. Disconnect the network and re-run
tests/RuleEngine — SC-008 holds or the phase is not done.
---
## Phase 4: User Story 3 — Preserve existing values when evaluation cannot be trusted (Priority: P1)
**Goal**: Unretrievable required data yields `EvaluationError`, preserves the stored persona, and
never becomes a silent non-match.
**Independent Test**: Inject a group-lookup failure for one synthetic user; that user receives
`EvaluationError`, no write is attempted, and the run continues.
### Tests for User Story 3
- [X] T027 [P] [US3] Tri-state propagation tests in tests/RuleEngine/UnknownPropagation.Tests.ps1 covering all four rows of the propagation table in data-model.md
- [X] T028 [P] [US3] Preservation tests in tests/RuleEngine/EvaluationError.Tests.ps1 asserting stored persona is retained, `Action` is `Skipped`, and processing continues to the next user (FR-014)
- [X] T029 [P] [US3] Regression test in tests/RuleEngine/UnknownNotFalse.Tests.ps1 asserting a failed membership lookup never satisfies `notMemberOf` — the specific misclassification hazard FR-013 exists to prevent
### Implementation for User Story 3
- [X] T030 [US3] Extend condition evaluation in src/RuleEngine/Test-PersonaCondition.ps1 to return `True`/`False`/`Unknown` instead of a boolean
- [X] T031 [US3] Implement the propagation rules in src/RuleEngine/Test-PersonaConditionGroup.ps1: `all` with any `False` is `False`; `all` with only `True` plus `Unknown` is `Unknown`; `any` with any `True` is `True`; `any` with only `False` plus `Unknown` is `Unknown`
- [X] T032 [US3] Map a root-level `Unknown` to the `EvaluationError` outcome with `EvaluationErrorReason` in src/RuleEngine/Resolve-UserPersona.ps1
- [X] T033 [US3] Add the `evaluationErrorThreshold` counter and final-status effect in src/RuleEngine/Resolve-UserPersona.ps1 and the run summary path
**Checkpoint**: unknown data degrades to preserve-and-report. Principle III is satisfied in the pure
engine, before any tenant exists.
---
## Phase 5: User Story 1 — Preview classification without changing the directory (Priority: P1) 🎯 MVP
**Goal**: Every in-scope user is retrieved, evaluated, and reported with stored value, calculated
value, and matched rule — with zero directory writes.
**Independent Test**: Run with `-WhatIf` against the tenant using a delegated read-only connection;
verify a result appears for every account and the write adapter receives zero calls.
**Stage**: A2. Requires an app registration with admin consent for the three delegated scopes.
### Tests for User Story 1
- [X] T034 [P] [US1] Zero-write safety test in tests/Safety/WhatIfZeroWrites.Tests.ps1 mocking the write adapter and asserting call count is exactly 0 across the full synthetic population (SC-004)
- [X] T035 [P] [US1] Mode-derivation test in tests/Safety/ShouldProcessGate.Tests.ps1 asserting `-Debug` without `-WhatIf` still reaches the write path — `-Debug` is not a safety control
- [X] T036 [P] [US1] Pagination test in tests/Unit/Pagination.Tests.ps1 asserting `@odata.nextLink` is followed to exhaustion and a truncated enumeration raises rather than returning a partial population
- [X] T037 [P] [US1] Idempotence test in tests/Safety/Idempotence.Tests.ps1 asserting a second consecutive run over unchanged input proposes zero changes (SC-002)
- [X] T038 [P] [US1] Exactly-one-outcome test in tests/Unit/OutcomeExclusivity.Tests.ps1 asserting every processed user lands in exactly one bucket (SC-001)
### Implementation for User Story 1
- [X] T039 [P] [US1] Implement `Connect-PersonaGraphInteractive` in src/Authentication/Connect-PersonaGraphInteractive.ps1 requesting only `User.Read.All`, `GroupMember.Read.All`, `RoleManagement.Read.Directory`, returning an opaque handle that never exposes a token
- [X] T040 [P] [US1] Implement the retry helper `Invoke-PersonaGraphRequest` in src/DataProviders/Invoke-PersonaGraphRequest.ps1 per the OTD-007 table: retry 429/500/502/503/504/timeout, never 400/401/403/404/409, honour `Retry-After`, max 5 attempts, exponential backoff with full jitter capped at 60s
- [X] T041 [US1] Implement `Get-PersonaUsers` in src/DataProviders/Get-PersonaUsers.ps1 with `$select` built from FR-005 plus rule-referenced properties plus the target attribute, `$top=999`, and full `@odata.nextLink` pagination
- [X] T042 [US1] Add single-user retrieval to src/DataProviders/Get-PersonaUsers.ps1 for the `-UserObjectId` path
- [X] T043 [US1] Implement `Get-PersonaGroupMembership` in src/DataProviders/Get-PersonaGroupMembership.ps1 selecting `memberOf` or `transitiveMemberOf` by mode, returning a MembershipRecord with `RetrievalSucceeded = $false` on failure rather than throwing or returning an empty list
- [X] T044 [US1] Implement `Get-PersonaDirectoryRoles` in src/DataProviders/Get-PersonaDirectoryRoles.ps1 via `/roleManagement/directory/roleAssignments`
- [X] T045 [US1] Implement the run-scoped group/role cache in src/DataProviders/PersonaDataCache.ps1, never persisted between runs
- [X] T046 [US1] Implement `ConvertTo-PersonaUserRecord` in src/Normalization/ConvertTo-PersonaUserRecord.ps1 mapping raw Graph responses to UserRecord, including the persona extension property
- [X] T047 [US1] Implement `ConvertTo-PersonaMembershipRecord` in src/Normalization/ConvertTo-PersonaMembershipRecord.ps1
- [X] T048 [US1] Implement `Compare-PersonaValue` in src/Persistence/Compare-PersonaValue.ps1 performing ordinal comparison of stored versus calculated (FR-015)
- [X] T049 [US1] Implement `Write-UserPersonaResult` in src/Presentation/Write-UserPersonaResult.ps1 emitting one line per user immediately with UPN, Account Object ID, outcome, matched rule ID, stored value, calculated value, and action (FR-018, SC-012)
- [X] T050 [US1] Create Invoke-PersonaEngine.ps1 with `CmdletBinding(SupportsShouldProcess, ConfirmImpact='High')` and the parameter set from contracts/cli-invoke-persona-engine.md
- [X] T051 [US1] Derive execution mode solely from `$PSCmdlet.ShouldProcess()` in Invoke-PersonaEngine.ps1 — no separate preview boolean, per the contract's prohibition on two sources of truth for the write gate
- [X] T052 [US1] Implement the `WouldUpdate` reporting path in Invoke-PersonaEngine.ps1 so preview reports intended changes without constructing a request (FR-017)
- [X] T053 [US1] Implement exit codes 06 in Invoke-PersonaEngine.ps1 per the contract table
- [X] T054 [US1] Record per-user and total duration in Invoke-PersonaEngine.ps1 to establish the NFR-002 baseline no target yet exists for
- [ ] T055 [US1] Run quickstart Scenario 4 as a **non-privileged** account and record results in specs/001-persona-engine/verification/V-3.md — a Global Administrator run invalidates this item
- [ ] T056 [US1] Close the V-1 read half against a cloud-only, a currently-synced, and a formerly-synced account; record in specs/001-persona-engine/verification/V-1.md
**Checkpoint**: 🎯 **MVP complete.** Full classification visibility against the live tenant with zero
write risk. Everything after this point adds observability, safety tooling, or enforcement.
---
## Phase 6: User Story 5 — Validate and edit configuration safely (Priority: P2)
**Goal**: Four-layer validation with structured findings, plus an interactive editor that cannot save
an invalid configuration.
**Independent Test**: Run the invalid-configuration corpus and confirm each file produces its
documented finding code, severity, and location.
### Tests for User Story 5
- [X] T057 [P] [US5] Build the invalid-configuration corpus in tests/TestData/InvalidConfigs/ — one file per VR-002 and VR-003 condition
- [X] T058 [P] [US5] Semantic validation tests in tests/Configuration/Semantic.Tests.ps1 asserting code, severity, and location for every VR-002 condition (SC-009)
- [X] T059 [P] [US5] Safety validation tests in tests/Configuration/Safety.Tests.ps1 asserting every VR-003 condition
- [X] T060 [P] [US5] Layer-ordering test in tests/Configuration/LayerOrdering.Tests.ps1 asserting a structurally invalid document stops before semantic checks run
### Implementation for User Story 5
- [X] T061 [US5] Close V-5a: observe and record whether `Test-Json -SchemaFile` returns `$false`, writes a non-terminating error, or throws on this PowerShell build; record in specs/001-persona-engine/verification/V-5a.md and pin the observation in tests/Configuration/TestJsonBehaviour.Tests.ps1
- [X] T062 [US5] Implement validation layer 1 (syntax) in src/Configuration/Test-PersonaConfiguration.ps1
- [X] T063 [US5] Implement validation layer 2 (schema) in src/Configuration/Test-PersonaConfiguration.ps1 wrapping `Test-Json -SchemaFile` with `-ErrorAction SilentlyContinue -ErrorVariable` and converting collected errors into ValidationFinding objects per the V-5a observation
- [X] T064 [US5] Implement validation layer 3 (semantic) in src/Configuration/Test-PersonaConfigurationSemantic.ps1 covering every VR-002 condition with stable `PE-SEM-nnn` codes
- [X] T065 [US5] Implement validation layer 4 (safety) in src/Configuration/Test-PersonaConfigurationSafety.ps1 covering every VR-003 condition with stable `PE-SAF-nnn` codes
- [X] T066 [US5] Implement `Resolve-TargetAttribute` in src/Configuration/Resolve-TargetAttribute.ps1 rejecting any attribute absent from `approvedWritableAttributes`
- [X] T067 [US5] Create Edit-PersonaEngineConfig.ps1 with the parameter set from contracts/cli-edit-persona-engine-config.md
- [X] T068 [US5] Implement the interactive editor loop in Edit-PersonaEngineConfig.ps1 with re-validation before save
- [X] T069 [US5] Implement the timestamped backup and Save-As path in Edit-PersonaEngineConfig.ps1 (FR-026)
- [X] T070 [US5] Implement synthetic rule testing via `-TestDataPath` in Edit-PersonaEngineConfig.ps1, reusing the rule engine with no tenant connectivity (FR-025)
### Rule CRUD in the interactive editor (US5 addition — FR-027FR-030)
Added after the initial Phase 6 build. The editor originally shipped deliberately scoped to
toggle/priority only (see the `Invoke-InteractiveEditor` comment header); spec.md now requires
add/edit/delete, including full condition-tree editing, so that scope is reversed here rather than
worked around.
#### Tests for rule CRUD
- [X] T122 [P] [US5] Add-rule tests in tests/Configuration/EditorAddRule.Tests.ps1 covering the full field/condition-tree prompt sequence and rejection of a duplicate `id` or `priority` before the rule is added (FR-027)
- [X] T123 [P] [US5] Edit-rule tests in tests/Configuration/EditorEditRule.Tests.ps1 covering top-level field changes and condition-tree add/edit/remove/renest operations within `MaxConditionDepth` (FR-028)
- [X] T124 [P] [US5] Delete-rule tests in tests/Configuration/EditorDeleteRule.Tests.ps1 covering the `id`/`name`/`priority` confirmation prompt and removal, including a declined confirmation leaving the rule in place (FR-029)
- [X] T125 [P] [US5] Structural-edit validation tests in tests/Configuration/EditorStructuralEdits.Tests.ps1 asserting add/edit/delete changes stay in memory until save, re-validate through all four layers on `[V]`/`[S]`, and that a depth-limit violation is reported immediately at edit time rather than deferred (FR-030)
#### Implementation for rule CRUD
- [X] T126 [US5] Implement rule addition (prompt for every RE-001 field plus the condition tree, reject a colliding `id`/`priority`) in Edit-PersonaEngineConfig.ps1 and wire the `[A]` command into the interactive loop (FR-027)
- [X] T127 [US5] Implement rule editing (top-level fields plus add/edit/remove/renest on the condition tree) in Edit-PersonaEngineConfig.ps1 and wire the `[E]` command into the interactive loop (FR-028)
- [X] T128 [US5] Implement rule deletion with an `id`/`name`/`priority` confirmation prompt in Edit-PersonaEngineConfig.ps1 and wire the `[D]` command into the interactive loop (FR-029)
- [X] T129 [US5] Route add/edit/delete through the existing `$dirty` tracking and `Invoke-ConfigurationValidation` re-validation path in Edit-PersonaEngineConfig.ps1, and surface a depth-limit finding at edit time using the same check the runtime validator uses (FR-030)
**Checkpoint**: invalid configurations cannot reach the engine or overwrite a good file, and rules can be added, edited, and deleted entirely from the editor.
---
## Phase 7: User Story 6 — Block invalid configuration in a pipeline (Priority: P2)
**Goal**: Non-interactive validation that returns exit codes and never prompts.
**Independent Test**: Run `-ValidateOnly -NonInteractive` against an invalid file with stdin closed;
confirm a non-zero exit code, no prompt, and no hang.
**Depends on**: US5's validator (T062T065). If pipeline gating is needed sooner than the interactive
editor, build T062T065 and T071T073 first and defer T067T070.
### Tests for User Story 6
- [X] T071 [P] [US6] Non-interactive test in tests/Configuration/NonInteractive.Tests.ps1 running with stdin closed and asserting no prompt and no hang (SC-010)
- [X] T072 [P] [US6] Exit-code test in tests/Configuration/ExitCodes.Tests.ps1 covering codes 04 from the editor contract
### Implementation for User Story 6
- [X] T073 [US6] Implement `-NonInteractive` and `-ValidateOnly` short-circuits in Edit-PersonaEngineConfig.ps1 that never call a prompting cmdlet
- [X] T074 [US6] Implement `-TreatWarningsAsErrors` escalation in Edit-PersonaEngineConfig.ps1 (VR-005)
- [X] T075 [US6] Implement the editor exit codes 04 in Edit-PersonaEngineConfig.ps1
- [X] T076 [US6] Add the validation stage to pipelines/validate.yml invoking sanitization, PSScriptAnalyzer, engine purity, schema validation, and the offline Pester suites
**Checkpoint**: CI blocks a bad configuration before it can reach a tenant.
---
## Phase 8: User Story 4 — Observe progress and reconcile results (Priority: P2)
**Goal**: Interim and final summaries with a reconciliation check that treats a mismatch as an engine
defect.
**Independent Test**: Run over a fixture population with `summaryInterval` set to 5, then to 0;
confirm interim summaries appear at the interval, are suppressed at 0, and a final summary appears in
both cases.
### Tests for User Story 4
- [X] T077 [P] [US4] Interval-semantics tests in tests/Unit/SummaryInterval.Tests.ps1 covering default 25, a custom interval, and the `0` case that still produces a final summary (FR-020)
- [X] T078 [P] [US4] Reconciliation tests in tests/Unit/Reconciliation.Tests.ps1 asserting `Processed = Matched + Unclassified + EvaluationError` at every summary and that a forced mismatch raises an engine defect (SC-007)
### Implementation for User Story 4
- [X] T079 [US4] Implement `Write-PersonaSummary` in src/Presentation/Write-PersonaSummary.ps1 rendering all business rules with match counts — including disabled and zero-match rules, so an absent rule is distinguishable from one that never fired
- [X] T080 [US4] Implement interval-triggered interim summaries in Invoke-PersonaEngine.ps1 (FR-019)
- [X] T081 [US4] Implement `Test-PersonaReconciliation` in src/Presentation/Test-PersonaReconciliation.ps1 and invoke it at every summary (FR-021)
- [X] T082 [US4] Emit an `EngineDefect` record and exit code 5 on reconciliation failure in Invoke-PersonaEngine.ps1
**Checkpoint**: operators can watch a long run and trust the counters.
---
## Phase 9: User Story 7 — Audit any classification decision (Priority: P2)
**Goal**: Structured, audit-friendly records that explain every decision and make rollback possible.
**Independent Test**: Run over fixtures and confirm 100% of user events carry run ID, UPN, and
Account Object ID, and 100% of matched results carry a rule ID.
### Tests for User Story 7
- [X] T083 [P] [US7] Completeness tests in tests/Unit/AuditCompleteness.Tests.ps1 asserting SC-006 across every record
- [X] T084 [P] [US7] Redaction tests in tests/Unit/AuditRedaction.Tests.ps1 asserting no token, `Authorization` header, secret, or raw Graph response can appear in any record
- [X] T085 [P] [US7] Schema tests in tests/Unit/AuditRecordShape.Tests.ps1 validating each record type against contracts/audit-record.md
### Implementation for User Story 7
- [X] T086 [US7] Implement `New-PersonaAuditRecord` in src/Audit/New-PersonaAuditRecord.ps1 building the common envelope plus each `recordType`
- [X] T087 [US7] Implement the single sink `Write-PersonaAuditRecord` in src/Audit/Write-PersonaAuditRecord.ps1 emitting NDJSON to file and/or stream per `logging.destination`, as the only emission point so a future transport needs no call-site changes
- [X] T088 [US7] Implement `Export-PersonaRunReport` in src/Audit/Export-PersonaRunReport.ps1 producing the `RunComplete` record with all counters and the exit code
- [X] T089 [US7] Wire the run ID from `-CorrelationId` or a generated GUID through every record in Invoke-PersonaEngine.ps1 (NFR-005)
- [X] T090 [US7] Record `configVersion` and `configurationHash` on every record in src/Audit/New-PersonaAuditRecord.ps1
**Checkpoint**: every decision is explainable after the fact.
---
## Phase 10: User Story 9 — Trace the values behind every rule decision (Priority: P3)
**Goal**: Condition-level diagnostic tracing, available only when explicitly enabled.
**Independent Test**: Run one user with `-Debug` and confirm a condition trace appears; run without
`-Debug` and confirm no condition values are emitted anywhere.
### Tests for User Story 9
- [X] T091 [P] [US9] Gating tests in tests/Unit/ConditionTrace.Tests.ps1 asserting `conditionTrace` is absent without `-Debug` and present with it
- [X] T092 [P] [US9] Acknowledgement test in tests/Configuration/TraceAcknowledgement.Tests.ps1 asserting `traceConditionValues` without explicit acknowledgement produces a VR-003 safety finding
### Implementation for User Story 9
- [X] T093 [US9] Populate `ConditionTrace` on the decision result in src/RuleEngine/Resolve-UserPersona.ps1, built only when tracing is active
- [X] T094 [US9] Add the `conditionTrace` array to user events in src/Audit/New-PersonaAuditRecord.ps1, gated on `-Debug` or `logging.traceConditionValues`
**Checkpoint**: rule authors can debug a decision without loosening default logging.
---
## Phase 11: User Story 8 — Enforce changes in production (Priority: P3) 🔒 Gated
**Goal**: Write the calculated persona, only when changed, only the approved attribute.
**Independent Test**: Against test accounts, confirm only changed values are written, every request
body has exactly one key, and every `Updated` record carries `previousValue`.
**⚠️ Do not begin the tenant-facing tasks (T101T103) until**: V-4 security sign-off is recorded,
`-WhatIf` impact evidence from Scenario 4 is reviewed, and Phases 110 pass. Tasks T095T100 are
offline and may proceed at any time.
### Tests for User Story 8
- [X] T095 [P] [US8] Single-attribute body test in tests/Safety/WriteBody.Tests.ps1 asserting every captured body has exactly one key equal to `engine.targetAttribute` (SC-005)
- [X] T096 [P] [US8] Rejection tests in tests/Safety/WriteBodyRejection.Tests.ps1 asserting `New-PersonaWriteBody` throws for any other attribute name and for an attribute absent from `approvedWritableAttributes`
- [X] T097 [P] [US8] Write-gate tests in tests/Safety/WriteGate.Tests.ps1 covering all four FR-016 conditions, including that an `EvaluationError` user is never written
### Implementation for User Story 8
- [X] T098 [US8] Implement `New-PersonaWriteBody` in src/Persistence/New-PersonaWriteBody.ps1 as the only function permitted to construct a write body, returning a hashtable whose `Count` is exactly 1
- [X] T099 [US8] Implement `Set-UserPersonaAttribute` in src/Persistence/Set-UserPersonaAttribute.ps1 issuing `PATCH /v1.0/users/{id}`, reachable only when `ShouldProcess` returned true
- [X] T100 [US8] Capture `previousValue` at write time into the audit record in src/Persistence/Set-UserPersonaAttribute.ps1 — missing this in v1 makes OTD-010 rollback impossible retroactively
- [ ] T101 [US8] Record the V-4 security sign-off in specs/001-persona-engine/verification/V-4.md before any enforcement run
- [ ] T102 [US8] Close the V-1 write half against purpose-created test accounts of each origin type; append to specs/001-persona-engine/verification/V-1.md
- [ ] T103 [US8] Close V-2 by building a dynamic membership group on the persona extension and confirming a CA policy assigned to it applies; record in specs/001-persona-engine/verification/V-2.md
**Checkpoint**: enforcement works against test accounts with every safety invariant proven.
---
## Phase 12: Polish & Cross-Cutting Concerns
- [X] T104 [P] Add comment-based help to every public function across src/ (NFR-004)
- [X] T105 [P] Write docs/Architecture.md describing the adapter boundaries and why the rule engine is pure
- [X] T106 [P] Write docs/ConfigurationReference.md documenting every schema field and finding code
- [X] T107 [P] Write docs/SecurityModel.md recording the OTD-003 trade-off, the six compensating controls, and the V-4 sign-off
- [X] T108 [P] Write docs/OperationsRunbook.md including the kill switch and the rollback procedure
- [X] T109 [P] Write docs/BusinessRules.md and docs/Logging.md
- [X] T110 [P] Add the test stage to pipelines/test.yml publishing Pester results
- [X] T111 Build the FR/NFR traceability matrix in specs/001-persona-engine/traceability.md mapping every requirement ID to its implementing task and test
- [X] T112 Verify every exit code 06 is reachable via fault injection in tests/Unit/ExitCodes.Tests.ps1 (SC-011)
- [X] T113 Run tests/Test-Sanitization.ps1 across all tracked files and record the result in specs/001-persona-engine/verification/sanitization.md (SC-013)
- [X] T114 Update specs/001-persona-engine/spec.md Clarification Register to mark OTD-001 through OTD-005 resolved, citing research.md
---
## Phase 13: Stage B — Azure Automation (DEFERRED)
**Blocked**: no Automation account is available. Listed so the remaining entry cost stays visible and
nothing is lost. Nothing in Phases 112 depends on these.
- [ ] T115 Implement `Connect-PersonaGraphManagedIdentity` in src/Authentication/Connect-PersonaGraphManagedIdentity.ps1 returning the same handle shape as the interactive path
- [ ] T116 Close V-5b: confirm `Test-Json -SchemaFile` behaviour in the Automation runtime and record its exact PowerShell version in specs/001-persona-engine/verification/V-5b.md — run this first, it is the cheapest item most likely to surprise
- [ ] T117 Close V-3b: confirm the three read scopes work as application permissions on the managed identity; record in specs/001-persona-engine/verification/V-3b.md
- [ ] T118 Create the runbook wrapper pipelines/runbook/Invoke-PersonaEngineRunbook.ps1
- [ ] T119 Build pipelines/release.yml deploying to Automation with the schedule shipped **disabled**, a `-WhatIf` validation stage, and an approval gate before enforcement
- [ ] T120 Implement the run-start concurrency check in Invoke-PersonaEngine.ps1 per OTD-009
- [ ] T121 Run quickstart Scenario 5 and record results in specs/001-persona-engine/verification/Scenario5.md — **this is what allows v1 to be declared complete**
---
## Dependencies
```text
Phase 1 (Setup)
└─> Phase 2 (Foundational) ← blocks everything
└─> Phase 3 US2 Rule engine ← blocks US3, US1
└─> Phase 4 US3 Fail-safe ← blocks US1
└─> Phase 5 US1 Preview 🎯 MVP
├─> Phase 6 US5 Validation + editor
│ └─> Phase 7 US6 Pipeline mode
├─> Phase 8 US4 Summaries
│ └─> Phase 9 US7 Audit
│ └─> Phase 10 US9 Tracing
└─> Phase 11 US8 Enforcement 🔒 (also gated on V-4)
└─> Phase 12 Polish
└─> Phase 13 Stage B (deferred)
```
**Story independence, honestly stated**: US2 and US3 are genuinely independent of everything except
the foundation. US1 depends on both — the constitution's build order makes that unavoidable, and
pretending otherwise would produce a task list that cannot be executed in the mandated sequence.
US4US9 are independent of each other and may proceed in any order once US1 lands, except that US9
reads the audit shape US7 defines.
## Parallel opportunities
| Phase | Parallel set |
| --- | --- |
| 1 | T004, T005, T006, T007, T008 |
| 2 | T011, T012, T013, T014 |
| 3 | T016T020 (all tests, separate files) |
| 4 | T027, T028, T029 |
| 5 | T034T038 (tests); then T039, T040 |
| 6 | T057T060; then T122T125 |
| 7 | T071, T072 |
| 8 | T077, T078 |
| 9 | T083, T084, T085 |
| 10 | T091, T092 |
| 11 | T095, T096, T097 |
| 12 | T104T110 |
After Phase 5, three tracks can run concurrently: validation/editor (Phases 67),
observability (Phases 810), and the offline half of enforcement (T095T100).
## Implementation strategy
**MVP = Phases 15** (T001T056). Delivers complete classification visibility against the live tenant
with zero write capability, closes V-1 (read) and V-3, and establishes the performance baseline. This
is a genuinely useful deliverable on its own: it answers "what would this classify my tenant as?"
without touching anything.
**Increment 2 = Phases 67**. Configuration safety and CI gating — the prerequisite for letting anyone
other than the author edit rules.
**Increment 3 = Phases 810**. Observability, reconciliation, and audit. Required before enforcement
is defensible, because the `-WhatIf` impact evidence the constitution demands is only as good as the
output that produces it.
**Increment 4 = Phase 11**, behind the V-4 gate, against test accounts only.
**Stage B (Phase 13)** converts the result into an unattended service. Until T121 passes, the correct
status to report is **"Stage A complete"**, not "v1 done" — the Definition of Done requires an
Automation PowerShell 7 run.
## Task summary
| Phase | Story | Tasks | Count |
| --- | --- | --- | --- |
| 1 Setup | — | T001T008 | 8 |
| 2 Foundational | — | T009T015 | 7 |
| 3 | US2 (P1) | T016T026 | 11 |
| 4 | US3 (P1) | T027T033 | 7 |
| 5 | US1 (P1) 🎯 | T034T056 | 23 |
| 6 | US5 (P2) | T057T070, T122T129 | 22 |
| 7 | US6 (P2) | T071T076 | 6 |
| 8 | US4 (P2) | T077T082 | 6 |
| 9 | US7 (P2) | T083T090 | 8 |
| 10 | US9 (P3) | T091T094 | 4 |
| 11 | US8 (P3) 🔒 | T095T103 | 9 |
| 12 Polish | — | T104T114 | 11 |
| 13 Stage B | — | T115T121 | 7 (deferred) |
| **Total** | | | **129** |
+119
View File
@@ -0,0 +1,119 @@
# 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-21**: 400 offline tests passing; engine purity and sanitization gates passing;
no tenant-dependent item verified. The increase from 354 is FR-027FR-030 (rule add/edit/delete in
the interactive editor), added to spec.md and implemented in the same change (T122T129).
## 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`) |
| FR-027 | Add a rule interactively | `Add-PersonaConfigRule`; `[A]` command in `Edit-PersonaEngineConfig.ps1` | `EditorAddRule.Tests.ps1`, `EditorStructuralEdits.Tests.ps1` |
| FR-028 | Edit a rule interactively, including its condition tree | `Get/Add/Remove-PersonaConditionNode`, `Set-PersonaConditionLeaf`; `[E]` command | `EditorEditRule.Tests.ps1`, `EditorStructuralEdits.Tests.ps1` |
| FR-029 | Delete a rule interactively, with confirmation | `Remove-PersonaConfigRule`; `[D]` command | `EditorDeleteRule.Tests.ps1`, `EditorStructuralEdits.Tests.ps1` |
| FR-030 | Structural edits validated like a hand-edited file | `Test-PersonaCandidateEdit` in `Edit-PersonaEngineConfig.ps1` | `EditorStructuralEdits.Tests.ps1` |
## Rule engine requirements
| ID | Requirement | Implementation | Test |
| --- | --- | --- | --- |
| RE-001 | Required rule fields | Schema `definitions/rule` | `LayerOrdering.Tests.ps1` |
| RE-002 | Unique priorities, lower first | `Resolve-UserPersona`; `PE-SEM-002` | `Ordering.Tests.ps1`, `Semantic.Tests.ps1` |
| RE-003 | `all` / `any` with nesting | `Test-PersonaConditionGroup` | `Composition.Tests.ps1` |
| RE-004 | Depth limit and hard ceiling | `Test-PersonaConditionGroup`; `PE-SEM-012`, `PE-SEM-013` | `Composition.Tests.ps1`, `Semantic.Tests.ps1` |
| RE-005 | Thirteen operators | `Test-PersonaCondition` | `Operators.Tests.ps1` |
| RE-006 | Case-insensitive; regex validated first | `Test-PersonaCondition`; `PE-SEM-016` | `Operators.Tests.ps1`, `Semantic.Tests.ps1` |
| RE-007 | Per-condition membership mode | Three-facet `MembershipRecord` | `RecordContracts.Tests.ps1`, `UnknownPropagation.Tests.ps1` |
| RE-008 | Combined identity sources | `Get-PersonaRequiredFacets` | `OutcomeExclusivity.Tests.ps1` |
| RE-009 | Special accounts by Object ID | Example configuration; no hard-coded path | Purity gate |
## Validation requirements
| ID | Requirement | Implementation | Test |
| --- | --- | --- | --- |
| VR-001 | Four ordered layers, fail-fast | `Test-PersonaConfiguration` | `LayerOrdering.Tests.ps1` |
| VR-002 | Sixteen semantic conditions | `Test-PersonaConfigurationSemantic` | `Semantic.Tests.ps1` — one test per code |
| VR-003 | Seven safety conditions | `Test-PersonaConfigurationSafety` | `Safety.Tests.ps1`, `TraceAcknowledgement.Tests.ps1` |
| VR-004 | Finding shape | `New-PersonaValidationFinding` | `Semantic.Tests.ps1`, `Safety.Tests.ps1`, `RecordContracts.Tests.ps1` |
| VR-005 | Warnings block only on request | Editor exit-code mapping | `ExitCodes.Tests.ps1` (editor) |
## Non-functional requirements
| ID | Requirement | Implementation | Test |
| --- | --- | --- | --- |
| NFR-001 | PowerShell 7 | `#Requires -Version 7.2`; manifest floor | Runs on 7.6.5 |
| NFR-002 | Caching, per-user and total duration | `New-PersonaDataCache`; stopwatch in `Resolve-UserPersona`; `RunComplete.durationMs` | `AuditCompleteness.Tests.ps1`**no target set** |
| NFR-003 | Pagination, bounded retry, backoff | `Invoke-PersonaGraphRequest` | `RetryPolicy.Tests.ps1`, `Pagination.Tests.ps1` |
| NFR-004 | Comment-based help on public functions | Every function in `src/` | Manual review |
| NFR-005 | Run ID, UPN, Object ID, config hash | `New-PersonaAuditContext` | `AuditCompleteness.Tests.ps1` |
| NFR-006 | Least privilege, single attribute, no secrets | Six OTD-003 controls | `WriteBody.Tests.ps1`, `WriteBodyRejection.Tests.ps1`, sanitization gate |
| NFR-007 | Engine and validation run without the platform | Purity; layer dot-sourcing | Purity gate; whole offline suite |
| NFR-008 | No Windows PowerShell-only dependencies | `Microsoft.Graph.Authentication` only | **Unverified in Automation (V-5b)** |
## Success criteria
| ID | Criterion | Test | Status |
| --- | --- | --- | --- |
| SC-001 | Exactly one outcome per user | `OutcomeExclusivity.Tests.ps1` | Passing |
| SC-002 | Second run proposes zero changes | `Idempotence.Tests.ps1` | Passing |
| SC-003 | Determinism across shuffled input | `Determinism.Tests.ps1` | Passing |
| SC-004 | Zero writes under `-WhatIf` | `WhatIfZeroWrites.Tests.ps1` | Passing |
| SC-005 | Single-attribute request body | `WriteBody.Tests.ps1` | Passing |
| SC-006 | Audit completeness | `AuditCompleteness.Tests.ps1` | Passing |
| SC-007 | Reconciliation, and its failure path | `Reconciliation.Tests.ps1` | Passing |
| SC-008 | Everything runs offline | Whole offline suite; `validate.yml` gate 7 | Passing |
| SC-009 | Every VR-002 and VR-003 condition detected | `Semantic.Tests.ps1`, `Safety.Tests.ps1` | Passing |
| SC-010 | Non-interactive never prompts or hangs | `NonInteractive.Tests.ps1` | Passing |
| SC-011 | Every exit code reachable | `ExitCodes.Tests.ps1` (both) | Passing |
| SC-012 | Per-user output is immediate | `Write-UserPersonaResult` emits in `process` | Structural, not timed |
| SC-013 | No tenant data committed | `Test-Sanitization.ps1` | Passing |
## Gaps, stated plainly
| Item | Why it is not covered | What would close it |
| --- | --- | --- |
| NFR-002 performance | No target exists until representative tenant testing | A timed run against a real population |
| NFR-004 help coverage | Reviewed by eye, not asserted | A test parsing every exported function for a help block |
| NFR-008 Automation compatibility | No Automation account available | V-5b (T116) |
| SC-012 timing | Asserted structurally, not measured | A timed harness — low value against the cost |
| V-1, V-2, V-3 | Require a tenant | Stage A2 and A3 runs |
| V-4 | Requires a person | Written security sign-off |
| Phase 13 (T115T121) | Requires an Automation account | Stage B |
## How to keep this honest
When a requirement's implementation moves, this table moves with it. When a test is deleted, the row
it backed becomes a gap and belongs in the gaps table, not silently in the main one. A matrix that is
allowed to drift is worse than none, because it converts "we do not know" into "we checked".
@@ -0,0 +1,57 @@
# V-5a — `Test-Json -SchemaFile` failure behaviour (local PowerShell)
**Status**: CLOSED
**Date**: 2026-08-20
**Environment**: PowerShell 7.6.5, Windows 11, local workstation (Stage A1 — offline, no tenant)
**Task**: T061
**Pinned by**: [tests/Configuration/TestJsonBehaviour.Tests.ps1](../../../tests/Configuration/TestJsonBehaviour.Tests.ps1)
## Question
OTD-005 selected `Test-Json -SchemaFile` for layer 2 validation. The documented risk was that
`Test-Json` reports schema failure inconsistently across PowerShell versions — returning `$false`,
writing a non-terminating error, or throwing. Layer 2 cannot be written until the actual behaviour
on the target build is observed rather than assumed.
## Observed behaviour
| Scenario | Return value | Error stream | Terminating? |
| --- | --- | --- | --- |
| Valid document | `$true` | empty | no |
| Type mismatch (`"a": 123` against `"type": "string"`) | `$false` | 1 error: `The JSON is not valid with the schema: Value is "integer" but should be "string" at '/a'` | no |
| Missing required property | `$false` | 1 error: `The JSON is not valid with the schema: Required properties ["a"] are not present at ''` | no |
| **Schema file itself unparseable** | **`$true`** | 1 error: `Cannot parse the JSON schema.` | no |
Exception type on the error record is `System.Exception` in every failing case — there is no
distinct exception type to branch on, so the wrapper must branch on the message text or, better, on
error presence alone.
## Findings that shape the implementation
1. **Non-terminating, not throwing.** With the default `$ErrorActionPreference = 'Continue'` the
cmdlet writes to the error stream and execution continues, returning `$false`. It does not throw.
`-ErrorAction SilentlyContinue -ErrorVariable` is therefore sufficient to capture failures, as
the editor contract requires.
2. **An unparseable schema returns `$true`.** This is the load-bearing observation. A wrapper that
trusted the return value alone would report a configuration as schema-valid when the schema never
ran. Layer 2 MUST treat "error variable is non-empty" as failure regardless of the return value,
and MUST distinguish the `Cannot parse the JSON schema.` message so it can surface exit code 4
(schema file not found or itself invalid) rather than exit code 1 (configuration invalid).
3. **One error per violating location, but not exhaustive.** Two independent property violations
yield two error records, each with its own JSON pointer. Deeper or nested subschema failures may
still be reported as a single error at the outermost failing location. The wrapper therefore
emits one finding per collected error rather than assuming a single one, and the author may still
need more than one validation pass to see everything. That residual limitation is documented
rather than worked around — full violation reporting would require replacing `Test-Json` with a
third-party validator, which OTD-005 rejected.
## Consequences recorded elsewhere
- Layer 2 implementation: [src/Configuration/Test-PersonaConfiguration.ps1](../../../src/Configuration/Test-PersonaConfiguration.ps1)
- Regression pin: `tests/Configuration/TestJsonBehaviour.Tests.ps1` fails if a future PowerShell
build changes any row of the table above.
- **V-5b remains open**: this observation is for PowerShell 7.6.5 only. The Azure Automation runtime
version is unverified, and finding 2 in particular is version-sensitive. Re-run this probe there
before Stage B (T116).
@@ -0,0 +1,76 @@
# Sanitization scan result (SC-013)
**Status**: PASS
**Date**: 2026-08-20
**Task**: T113
**Scanner**: [tests/Test-Sanitization.ps1](../../../tests/Test-Sanitization.ps1)
**Files scanned**: 156
## What is scanned
`git ls-files --cached --others --exclude-standard` — tracked files **and** untracked files that are
not gitignored.
The original scanner walked `git ls-files` alone, which covered only tracked files. That made the
gate useless where it matters most: a leaked identifier in a file that has not been committed yet is
precisely the one worth catching, and scanning only what is already in history means the scan passes
right up until the commit that makes it too late. At the time this was found, the scan was covering
34 of the repository's 156 files and none of the implementation written in this phase.
`--exclude-standard` keeps gitignored build output and local scratch files out, so the scan covers
exactly what a commit would add.
## Patterns
| Pattern | Exemptions |
| --- | --- |
| GUIDs | Placeholder-shaped GUIDs (`00000000-0000-0000-0000-0000000000a0`); the module manifest's own `GUID =` identity line |
| Email addresses and UPNs | RFC 2606 / RFC 6761 reserved domains: `example.com/net/org`, `.invalid`, `.test`, `.localhost` |
| `onmicrosoft.com` domains | none |
| JWT and bearer-token shapes | none |
| Assigned secret, password, or key literals | none |
| PEM private key blocks | none |
Two exemptions were added during this scan, both narrow and both for things that cannot be replaced
with a placeholder:
**Reserved domains.** `alex.employee@example.invalid` is guaranteed by RFC to be unresolvable.
Rejecting reserved domains would push fixtures toward addresses that merely *look* fake, which is
worse — the difference between "obviously synthetic" and "probably nobody's" is the entire reason the
reserved list exists.
**The module manifest GUID.** A PowerShell module manifest must carry a genuine unique GUID as its
identity; it is what distinguishes this module from another of the same name. It identifies the
module, not a tenant. The exemption is **line-level** (`^\s*GUID\s*=`), not file-level: exempting the
whole manifest would let a real identifier land anywhere in it.
## Verification of the scanner itself
A negative control was run: a scratch file containing an email address on a real-world commercial
domain and a randomly generated real-shaped GUID was added to the working tree **without** committing
it. The scan failed with two findings and named both, by file and line. The file was then removed and
the scan returned to PASS.
The offending values are described here rather than quoted, because quoting them would make this
record itself a finding — which the scan promptly demonstrated when an earlier draft did exactly
that. That is the control working.
Without a negative control, a scanner that had silently stopped matching would report the same green
result as one that is working.
## Result
```
Sanitization scan passed: no tenant data, credentials, or real identifiers found.
```
## Standing obligations
This is a point-in-time result, not a property of the repository. The scan is **gate 1** of
[pipelines/validate.yml](../../../pipelines/validate.yml) and runs before every other gate on every
pull request — deliberately first, because a leaked identifier is a problem whether or not the code
compiles, and every later gate prints file contents into build logs.
Runtime audit records legitimately contain real UPNs and Object IDs, which are approved for logs. No
such value may ever be committed. When attaching evidence to a verification record (V-1, V-2, V-3),
redact identifiers to placeholders first.
+59
View File
@@ -0,0 +1,59 @@
function Export-PersonaRunReport {
<#
.SYNOPSIS
Builds the RunComplete record closing out a run (FR-022, NFR-005).
.DESCRIPTION
The last record of every run, successful or not. It carries the final
counters, the wall-clock span, and the exit code the process returned.
Emitted even on a fatal error. A run that died at user 400 of 5000 leaves a
RunComplete saying exactly that, which is what lets an operator tell "the
engine stopped early" from "the engine never started" - two very different
incidents that produce identical evidence if the record is written only on
success.
startedUtc and completedUtc are wall-clock, unlike per-user durations, which
use a monotonic stopwatch. They are here for correlation with other systems'
logs, never as an input to a decision.
.PARAMETER Context
The audit context.
.PARAMETER Counters
The final run counters.
.PARAMETER StartedUtc
Run start timestamp.
.PARAMETER ExitCode
The exit code the run will return (0 - 6).
.OUTPUTS
An ordered dictionary ready for Write-PersonaAuditRecord.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[object] $Context,
[Parameter(Mandatory)]
[object] $Counters,
[Parameter(Mandatory)]
[datetime] $StartedUtc,
[Parameter(Mandatory)]
[ValidateRange(0, 6)]
[int] $ExitCode
)
$completed = [DateTime]::UtcNow
New-PersonaAuditRecord -Context $Context -RecordType 'RunComplete' -Counters $Counters -Properties @{
startedUtc = $StartedUtc.ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
completedUtc = $completed.ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
durationMs = [int]($completed - $StartedUtc).TotalMilliseconds
exitCode = $ExitCode
}
}
+227
View File
@@ -0,0 +1,227 @@
function New-PersonaAuditContext {
<#
.SYNOPSIS
Creates the constant envelope shared by every audit record in a run.
.DESCRIPTION
Run ID, engine version, configuration version, configuration hash, and mode
are identical on every record (NFR-005). Building them once and carrying the
context means no call site can emit a record missing them, and no call site
can disagree about the mode.
.PARAMETER RunId
The run identifier, from -CorrelationId or generated.
.PARAMETER EngineVersion
Module version.
.PARAMETER Configuration
The loaded configuration, source of configVersion and configurationHash.
.PARAMETER Mode
Preview or Enforce, derived by the caller from ShouldProcess alone.
.OUTPUTS
PersonaEngine.AuditContext
#>
[CmdletBinding()]
[OutputType([pscustomobject])]
param(
[Parameter(Mandatory)]
[string] $RunId,
[Parameter(Mandatory)]
[string] $EngineVersion,
[Parameter(Mandatory)]
[object] $Configuration,
[Parameter(Mandatory)]
[ValidateSet('Preview', 'Enforce')]
[string] $Mode
)
[pscustomobject]@{
PSTypeName = 'PersonaEngine.AuditContext'
RunId = $RunId
EngineVersion = $EngineVersion
ConfigVersion = [string]$Configuration.ConfigVersion
ConfigurationHash = [string]$Configuration.ConfigurationHash
Mode = $Mode
}
}
function New-PersonaAuditRecord {
<#
.SYNOPSIS
Builds a structured audit record of the requested type (FR-022, NFR-005).
.DESCRIPTION
Every record type shares the common envelope from contracts/audit-record.md
and adds its own fields. One builder rather than five keeps the envelope in
a single place, so a field added to it appears on every record type without
five separate edits.
Prohibited content - tokens, Authorization headers, secrets, raw Graph
responses - is not merely undocumented here, it is unreachable: this
function accepts only named, typed values from the decision result and the
counters. There is no pass-through of an arbitrary object, so there is
nothing for a secret to ride in on (Principle V).
.PARAMETER Context
The audit context from New-PersonaAuditContext.
.PARAMETER RecordType
RunStart, UserEvent, Summary, RunComplete, or EngineDefect.
.PARAMETER Result
For UserEvent: the PersonaDecisionResult.
.PARAMETER PreviousValue
For UserEvent: the value captured at write time on an Updated record.
.PARAMETER Counters
For Summary and RunComplete: the run counter object.
.PARAMETER IncludeTrace
Emits conditionTrace on a UserEvent. Gated by the caller on -Debug or
logging.traceConditionValues, never enabled by default (VR-003).
.PARAMETER Properties
Additional fields for RunStart, RunComplete, and EngineDefect.
.OUTPUTS
System.Collections.Specialized.OrderedDictionary - ordered so serialized
records list their fields in the documented sequence on every run.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[object] $Context,
[Parameter(Mandatory)]
[ValidateSet('RunStart', 'UserEvent', 'Summary', 'RunComplete', 'EngineDefect')]
[string] $RecordType,
[object] $Result,
[AllowNull()]
[AllowEmptyString()]
[string] $PreviousValue,
[object] $Counters,
[switch] $IncludeTrace,
[hashtable] $Properties = @{}
)
$record = [ordered]@{
timestamp = [DateTime]::UtcNow.ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
recordType = $RecordType
runId = $Context.RunId
engineVersion = $Context.EngineVersion
configVersion = $Context.ConfigVersion
configurationHash = $Context.ConfigurationHash
mode = $Context.Mode
}
switch ($RecordType) {
'UserEvent' {
if ($null -eq $Result) { throw 'A UserEvent record requires -Result.' }
$record['accountObjectId'] = [string]$Result.AccountObjectId
$record['userPrincipalName'] = [string]$Result.UserPrincipalName
$record['outcome'] = [string]$Result.Outcome
$record['matchedRuleId'] = $Result.MatchedRuleId
$record['storedPersona'] = $Result.StoredPersona
$record['calculatedPersona'] = $Result.CalculatedPersona
# Present only on Updated. On any other action there is nothing that was
# replaced, and a populated previousValue would imply otherwise to a
# rollback tool reading these records later.
$record['previousValue'] = ($Result.Action -eq 'Updated') ? $PreviousValue : $null
$record['action'] = [string]$Result.Action
$record['rulesEvaluated'] = [int]$Result.RulesEvaluated
$record['durationMs'] = [int]$Result.DurationMs
$record['evaluationErrorReason'] = $Result.EvaluationErrorReason
if ($IncludeTrace -and $Result.ConditionTrace) {
$record['conditionTrace'] = @(
foreach ($entry in $Result.ConditionTrace) {
[ordered]@{
ruleId = [string]$entry.RuleId
priority = [int]$entry.Priority
result = [string]$entry.Result
}
}
)
}
}
'Summary' {
if ($null -eq $Counters) { throw 'A Summary record requires -Counters.' }
$record['summaryType'] = $Properties.ContainsKey('summaryType') ? [string]$Properties['summaryType'] : 'Interim'
Add-PersonaCounterField -Record $record -Counters $Counters
$record['ruleCounts'] = @(
foreach ($entry in $Counters.RuleCounts) {
[ordered]@{
ruleId = [string]$entry.RuleId
name = [string]$entry.Name
enabled = [bool]$entry.Enabled
matches = [int]$entry.Matches
}
}
)
}
'RunComplete' {
if ($null -eq $Counters) { throw 'A RunComplete record requires -Counters.' }
$record['startedUtc'] = $Properties['startedUtc']
$record['completedUtc'] = $Properties['completedUtc']
$record['durationMs'] = [int]$Properties['durationMs']
Add-PersonaCounterField -Record $record -Counters $Counters
$record['exitCode'] = [int]$Properties['exitCode']
}
default {
# RunStart and EngineDefect carry only the envelope plus whatever the
# caller names explicitly.
foreach ($key in $Properties.Keys) { $record[$key] = $Properties[$key] }
}
}
$record
}
function Add-PersonaCounterField {
<#
.SYNOPSIS
Adds the shared counter block to a Summary or RunComplete record.
.DESCRIPTION
Summary and RunComplete carry the same counters. Sharing the block means the
two record types cannot drift apart, which matters because reconciliation
tooling reads both.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)] [System.Collections.Specialized.OrderedDictionary] $Record,
[Parameter(Mandatory)] [object] $Counters
)
$Record['processed'] = [int]$Counters.Processed
$Record['matched'] = [int]$Counters.Matched
$Record['unclassified'] = [int]$Counters.Unclassified
$Record['evaluationError'] = [int]$Counters.EvaluationError
$Record['unchanged'] = [int]$Counters.Unchanged
$Record['wouldUpdate'] = [int]$Counters.WouldUpdate
$Record['updated'] = [int]$Counters.Updated
$Record['updateFailed'] = [int]$Counters.UpdateFailed
$Record['skipped'] = [int]$Counters.Skipped
$Record['reconciliationPassed'] = [bool](Test-PersonaReconciliation -Counters $Counters)
}
+116
View File
@@ -0,0 +1,116 @@
function Write-PersonaAuditRecord {
<#
.SYNOPSIS
The single emission point for audit records (FR-022, OTD-006).
.DESCRIPTION
Serializes one record as newline-delimited JSON and emits it to file, to the
object stream, or both, per logging.destination.
Every audit record in the engine passes through here. That is the whole
design: adding a transport - an approved logging platform, an event hub, a
different file layout - is a change to this function and nothing else. 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.
Emission failure never ends the run. A full disk or a locked file is an
operational problem with the audit sink, not a reason to abandon a
classification run mid-population and leave the directory in a half-reconciled
state. The failure is surfaced as a warning, once, and processing continues.
.PARAMETER Record
An ordered dictionary from New-PersonaAuditRecord.
.PARAMETER Destination
file, stream, both, or none.
.PARAMETER Path
Output file for the file and both destinations.
.PARAMETER State
Optional sink state carrying the one-warning latch, so a failing sink warns
once per run rather than once per user.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory, ValueFromPipeline)]
[object] $Record,
[ValidateSet('file', 'stream', 'both', 'none')]
[string] $Destination = 'stream',
[string] $Path,
[object] $State
)
process {
if ($Destination -eq 'none') { return }
if ($Destination -in @('stream', 'both')) {
# The Information stream, not the success stream. Audit records emitted
# onto the success stream would be indistinguishable from a function's
# return value: the run loop returns its outcome there, and mixing the two
# would turn one object into an array of several thousand.
#
# The record object is emitted, not a string, so a caller capturing it
# with -InformationVariable can assert on fields without reparsing.
Write-Information -MessageData $Record -Tags 'PersonaEngine.Audit'
}
if ($Destination -in @('file', 'both')) {
if (-not $Path) {
Write-Warning 'logging.destination requests file output but no path is configured. No audit file was written.'
return
}
try {
$line = $Record | ConvertTo-Json -Depth 16 -Compress
$directory = Split-Path -Parent $Path
if ($directory -and -not (Test-Path -LiteralPath $directory)) {
$null = New-Item -ItemType Directory -Path $directory -Force -WhatIf:$false -Confirm:$false
}
# Append, one record per line. UTF-8 without BOM so the file is
# machine-readable by any NDJSON consumer.
#
# -WhatIf:$false / -Confirm:$false pin this call regardless of any
# ambient $WhatIfPreference in the caller's session (e.g. left set by
# dot-sourcing an earlier -WhatIf run). Add-Content honours
# ShouldProcess, and this sink is not optional under -WhatIf - the
# audit trail is what makes preview mode auditable at all, so it must
# write unconditionally, independent of anything the caller's scope
# happens to have set.
Add-Content -LiteralPath $Path -Value $line -Encoding utf8NoBOM -ErrorAction Stop -WhatIf:$false -Confirm:$false
}
catch {
if ($null -ne $State -and $State.FileSinkFailed) { return }
if ($null -ne $State) { $State.FileSinkFailed = $true }
Write-Warning "Audit file sink failed; the run continues without file output: $($_.Exception.Message)"
}
}
}
}
function New-PersonaAuditSinkState {
<#
.SYNOPSIS
Creates the per-run sink state for Write-PersonaAuditRecord.
.DESCRIPTION
Holds the latch that keeps a failing file sink from emitting one warning per
user. A run over five thousand accounts with a locked log file should warn
once, not five thousand times, or the warning that matters is buried in the
noise it generates.
#>
[CmdletBinding()]
[OutputType([pscustomobject])]
param()
[pscustomobject]@{
PSTypeName = 'PersonaEngine.AuditSinkState'
FileSinkFailed = $false
}
}
@@ -0,0 +1,59 @@
function Connect-PersonaGraphInteractive {
<#
.SYNOPSIS
Establishes a delegated Microsoft Graph connection for local development.
.DESCRIPTION
Stage A2 authentication (plan.md). Requests only the scopes the enabled
rules require (OTD-002) never Directory.Read.All, which is materially
broader than the three read scopes combined.
Returns an opaque handle. No token, header, or secret is ever returned to a
caller, logged, or written to an audit record (Principle V).
Note on V-3: effective access is the intersection of the requested scope and
the signed-in account's directory roles. Signing in as a Global Administrator
makes the least-privilege test meaningless, because the account's roles grant
broad read regardless of the scope requested.
.PARAMETER IncludeWrite
Adds User.ReadWrite.All. Enforcement only, and only after the V-4 security
sign-off is recorded.
.PARAMETER TenantId
Optional tenant hint.
.EXAMPLE
Connect-PersonaGraphInteractive
#>
[CmdletBinding()]
[OutputType([pscustomobject])]
param(
[switch] $IncludeWrite,
[string] $TenantId,
[switch] $IncludeGroups,
[switch] $IncludeRoles
)
$scopes = [System.Collections.Generic.List[string]]::new()
$scopes.Add($IncludeWrite ? 'User.ReadWrite.All' : 'User.Read.All')
if ($IncludeGroups) { $scopes.Add('GroupMember.Read.All') }
if ($IncludeRoles) { $scopes.Add('RoleManagement.Read.Directory') }
$connectArgs = @{ Scopes = $scopes.ToArray(); NoWelcome = $true; ErrorAction = 'Stop' }
if ($TenantId) { $connectArgs['TenantId'] = $TenantId }
Write-Verbose "Connecting to Microsoft Graph with scopes: $($scopes -join ', ')"
Connect-MgGraph @connectArgs | Out-Null
$context = Get-MgContext
[pscustomobject]@{
PSTypeName = 'PersonaEngine.GraphConnection'
AuthType = 'Delegated'
Account = $context.Account
TenantId = $context.TenantId
Scopes = @($context.Scopes)
WriteCapable = [bool]$IncludeWrite
}
}
@@ -0,0 +1,56 @@
function Add-PersonaConditionNode {
<#
.SYNOPSIS
Appends a condition or nested group to a group in a rule's condition tree (FR-028).
.DESCRIPTION
Locates the group at ParentPath and appends Node to its `conditions`
collection. Structural checks only - id/priority-style collisions do not apply
to conditions, and nesting-depth enforcement is deliberately not duplicated
here.
Depth is enforced by re-running the real validator (VR-001) against the whole
candidate document after the edit, the same way a hand-edited file would be
checked - see Edit-PersonaEngineConfig.ps1's candidate-edit wrapper. A second,
local depth calculation here would risk drifting from Test-PersonaConditionGroup's
engine semantics (RE-004), which count nesting by group levels, not by leaf
conditions - the exact mismatch VR-002's own depth check is careful to avoid
(see Test-PersonaConfigurationSemantic.ps1).
.PARAMETER Group
The rule's `match` condition group (the root).
.PARAMETER ParentPath
Path (see Get-PersonaConditionNode) to the group to append into. Empty selects
the root.
.PARAMETER Node
The condition or condition group to append.
.OUTPUTS
System.Object - the same Group, mutated in place, returned for convenience.
#>
[CmdletBinding()]
[OutputType([object])]
param(
[Parameter(Mandatory)]
[object] $Group,
[Parameter(Mandatory)]
[AllowEmptyCollection()]
[int[]] $ParentPath,
[Parameter(Mandatory)]
[object] $Node
)
$parent = Get-PersonaConditionNode -Group $Group -Path $ParentPath
if ($null -eq $parent.PSObject.Properties['conditions']) {
throw 'The target node is a leaf condition, not a group. Choose a group (all/any) to add into.'
}
$parent.conditions = @($parent.conditions) + $Node
$Group
}
@@ -0,0 +1,56 @@
function Add-PersonaConfigRule {
<#
.SYNOPSIS
Appends a new rule to a rules collection, rejecting an id/priority collision (FR-027).
.DESCRIPTION
Pure: takes the existing rules and a candidate rule, returns a new array. Never
mutates the input collection, so a caller that discards the result on a
validation failure has changed nothing.
The id/priority check is duplicated with PE-SEM-001/PE-SEM-002 by design - the
interactive editor should not need a full validation round trip just to tell an
operator they typed an id that already exists. Anything that reaches the
semantic layer anyway (for example a collision introduced by two concurrent
edits) is still caught there.
.PARAMETER Rules
The rule collection to append to. Not mutated.
.PARAMETER Rule
The candidate rule. Must carry non-blank `id` and a `priority`.
.OUTPUTS
System.Object[] - the new rules collection, existing rules first.
#>
[CmdletBinding()]
[OutputType([object[]])]
param(
[Parameter(Mandatory)]
[AllowEmptyCollection()]
[object[]] $Rules,
[Parameter(Mandatory)]
[object] $Rule
)
$id = [string]$Rule.id
if ([string]::IsNullOrWhiteSpace($id)) {
throw 'The new rule requires a non-blank id.'
}
$existing = @($Rules)
$byId = @($existing | Where-Object { [string]$_.id -eq $id })
if ($byId.Count -gt 0) {
throw "Rule id '$id' already exists (priority $($byId[0].priority)). Rule ids must be unique (RE-002)."
}
$priority = $Rule.priority
$byPriority = @($existing | Where-Object { [int]$_.priority -eq [int]$priority })
if ($byPriority.Count -gt 0) {
throw "Priority $priority is already used by rule '$($byPriority[0].id)'. Priorities must be unique (RE-002)."
}
, ($existing + $Rule)
}
@@ -0,0 +1,54 @@
function Get-PersonaConditionNode {
<#
.SYNOPSIS
Navigates a rule's condition tree to the node at a path (FR-028).
.DESCRIPTION
A path is a sequence of zero-based indices into successive `.conditions`
collections, root first. An empty path returns the root group itself.
The returned node is the same object reference that lives inside the tree -
deliberately. `ConvertFrom-Json` produces `PSCustomObject`/array trees of
reference types, so a caller that sets a property on the returned leaf (for
example `$node.value = 'x'`) mutates the tree in place. Editing a leaf's
fields is therefore a plain property assignment, not a separate setter
function; see Set-PersonaConditionLeaf for the one case (switching a
condition's operator/type) where stale fields must also be cleared.
.PARAMETER Group
The rule's `match` condition group (the root).
.PARAMETER Path
Zero-based indices from the root, one per nesting level. Empty selects the root.
.OUTPUTS
System.Object - the condition or condition group at that path.
#>
[CmdletBinding()]
[OutputType([object])]
param(
[Parameter(Mandatory)]
[object] $Group,
[Parameter(Mandatory)]
[AllowEmptyCollection()]
[int[]] $Path
)
$node = $Group
foreach ($index in $Path) {
if ($null -eq $node.PSObject.Properties['conditions'] -or $null -eq $node.conditions) {
throw "Path segment $index does not resolve: the node above it is a leaf condition, not a group."
}
$children = @($node.conditions)
if ($index -lt 0 -or $index -ge $children.Count) {
throw "Path segment $index is out of range - this group has $($children.Count) condition(s)."
}
$node = $children[$index]
}
$node
}
@@ -0,0 +1,68 @@
function Get-PersonaRequiredFacets {
<#
.SYNOPSIS
Determines which membership facets the enabled rules actually need.
.DESCRIPTION
Least privilege applied to data retrieval: a configuration with no role
conditions never calls the role endpoint, so a tenant where role reads are
unavailable can still run property-only rules.
Walks every enabled rule's condition tree. A membership condition resolves
to the direct or transitive facet by its own membershipMode, falling back to
the configured default (RE-007).
.PARAMETER Rules
The business rule collection.
.PARAMETER DefaultMembershipMode
Mode for conditions that do not specify one.
.OUTPUTS
A hashtable with Direct, Transitive, and Roles boolean keys.
#>
[CmdletBinding()]
[OutputType([hashtable])]
param(
[Parameter(Mandatory)]
[AllowEmptyCollection()]
[object[]] $Rules,
[ValidateSet('Direct', 'Transitive')]
[string] $DefaultMembershipMode = 'Direct'
)
$need = @{ Direct = $false; Transitive = $false; Roles = $false }
function Test-Node {
param($Node)
if ($null -eq $Node) { return }
if ($Node.PSObject.Properties['conditions'] -and $Node.conditions) {
foreach ($child in $Node.conditions) { Test-Node $child }
return
}
$type = [string]$Node.type
if ($type -eq 'role') {
$need.Roles = $true
return
}
if ($type -eq 'membership') {
$mode = $Node.PSObject.Properties['membershipMode'] -and $Node.membershipMode `
? [string]$Node.membershipMode `
: $DefaultMembershipMode
if ($mode -ieq 'transitive') { $need.Transitive = $true } else { $need.Direct = $true }
}
}
foreach ($rule in @($Rules)) {
if (-not $rule.enabled) { continue }
Test-Node $rule.match
}
$need
}
@@ -0,0 +1,76 @@
function Import-PersonaConfiguration {
<#
.SYNOPSIS
Loads a JSON configuration file into the object shape the engine consumes.
.DESCRIPTION
Layer 1 of validation (syntax) happens implicitly here: malformed JSON
throws. Layers 2-4 are Test-PersonaConfiguration's responsibility, and the
caller runs them before using the result (FR-002).
Computes ConfigurationHash as the SHA-256 of the file bytes, which is
recorded on every audit record (NFR-005) so a run can be tied to the exact
configuration that produced it.
.PARAMETER Path
Path to the JSON configuration file.
.OUTPUTS
PersonaEngine.Configuration
#>
[CmdletBinding()]
[OutputType([pscustomobject])]
param(
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[string] $Path
)
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
throw "Configuration file not found: '$Path'."
}
$raw = Get-Content -LiteralPath $Path -Raw -ErrorAction Stop
try {
$document = $raw | ConvertFrom-Json -Depth 32 -ErrorAction Stop
}
catch {
throw "Configuration is not valid JSON: $($_.Exception.Message)"
}
# Hash the file bytes rather than the parsed object: two files that differ only
# in whitespace are different configurations for audit purposes, and the hash
# must be reproducible from the artifact on disk.
$sha = [System.Security.Cryptography.SHA256]::Create()
try {
$bytes = [System.IO.File]::ReadAllBytes((Resolve-Path -LiteralPath $Path).ProviderPath)
$hash = [System.BitConverter]::ToString($sha.ComputeHash($bytes)).Replace('-', '').ToLowerInvariant()
}
finally {
$sha.Dispose()
}
$engine = $document.engine
[pscustomobject]@{
PSTypeName = 'PersonaEngine.Configuration'
ConfigVersion = [string]$document.configVersion
ConfigurationHash = $hash
SourcePath = (Resolve-Path -LiteralPath $Path).ProviderPath
TargetAttribute = [string]$engine.targetAttribute
ApprovedWritableAttributes = @($engine.approvedWritableAttributes)
MaxConditionDepth = ($null -ne $engine.maxConditionDepth) ? [int]$engine.maxConditionDepth : 5
SummaryInterval = ($null -ne $engine.summaryInterval) ? [int]$engine.summaryInterval : 25
DefaultMembershipMode = $engine.defaultMembershipMode ? (Get-Culture).TextInfo.ToTitleCase([string]$engine.defaultMembershipMode) : 'Direct'
EvaluationErrorThreshold = ($null -ne $engine.evaluationErrorThreshold) ? [int]$engine.evaluationErrorThreshold : $null
DataSources = $document.dataSources
Logging = $document.logging
Personas = @($document.personas)
Rules = @($document.rules)
Raw = $document
}
}
@@ -0,0 +1,78 @@
function New-PersonaValidationFinding {
<#
.SYNOPSIS
Creates a structured validation finding (VR-004).
.DESCRIPTION
Every one of the four validation layers emits this shape, so a caller
console, pipeline, or editor handles findings uniformly regardless of
which layer produced them.
Finding codes are stable and namespaced by layer, because pipelines and
runbooks will match on them:
PE-SYN-nnn syntax
PE-SCH-nnn schema
PE-SEM-nnn semantic
PE-SAF-nnn safety
.PARAMETER Severity
Error blocks execution and saving. Warning blocks only under
-TreatWarningsAsErrors. Information never blocks (VR-005).
.PARAMETER Code
Stable finding code, e.g. PE-SEM-012.
.PARAMETER Location
JSON path or rule ID identifying where the problem is.
.PARAMETER SuggestedResolution
What the author should do. Required a finding without a resolution just
tells someone they are wrong.
.EXAMPLE
New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-002' -Location 'rules[3].priority' -Description 'Duplicate priority 30.' -SuggestedResolution 'Assign a unique priority.'
#>
[CmdletBinding()]
[OutputType([pscustomobject])]
param(
[Parameter(Mandatory)]
[ValidateSet('Error', 'Warning', 'Information')]
[string] $Severity,
[Parameter(Mandatory)]
[ValidatePattern('^PE-(SYN|SCH|SEM|SAF)-\d{3}$')]
[string] $Code,
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[string] $Location,
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[string] $Description,
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[string] $SuggestedResolution
)
# The layer is derivable from the code, so it cannot drift out of agreement
# with it.
$layer = switch -Regex ($Code) {
'^PE-SYN-' { 'Syntax' }
'^PE-SCH-' { 'Schema' }
'^PE-SEM-' { 'Semantic' }
'^PE-SAF-' { 'Safety' }
}
[pscustomobject]@{
PSTypeName = 'PersonaEngine.ValidationFinding'
Severity = $Severity
Code = $Code
Location = $Location
Description = $Description
SuggestedResolution = $SuggestedResolution
Layer = $layer
}
}
@@ -0,0 +1,67 @@
function Remove-PersonaConditionNode {
<#
.SYNOPSIS
Removes a condition or nested group from a rule's condition tree (FR-028).
.DESCRIPTION
Two structural rules are enforced here rather than left to schema validation,
because both would otherwise produce a document the schema calls invalid with
no indication which editor action caused it:
- The root group itself cannot be removed - a rule's `match` is required
(RE-001). Delete the rule instead (Remove-PersonaConfigRule).
- A group's last remaining condition cannot be removed - an empty
`conditions` array violates the schema's `minItems: 1` on conditionGroup.
Remove the parent group instead, or add a replacement first.
.PARAMETER Group
The rule's `match` condition group (the root).
.PARAMETER Path
Path (see Get-PersonaConditionNode) to the node to remove. Must not be empty.
.OUTPUTS
System.Object - the same Group, mutated in place, returned for convenience.
#>
[CmdletBinding()]
[OutputType([object])]
param(
[Parameter(Mandatory)]
[object] $Group,
[Parameter(Mandatory)]
[AllowEmptyCollection()]
[int[]] $Path
)
if (@($Path).Count -eq 0) {
throw "The root condition group cannot be removed this way - delete the rule instead."
}
# Special-cased at length 1 because PowerShell's range operator treats 0..-1 as
# the two-element descending range (0, -1), not an empty range - the naive slice
# would silently pick up the wrong parent for a top-level path.
$parentPath = (@($Path).Count -eq 1) ? @() : @($Path)[0..($Path.Count - 2)]
$index = @($Path)[-1]
$parent = Get-PersonaConditionNode -Group $Group -Path $parentPath
$children = @($parent.conditions)
if ($index -lt 0 -or $index -ge $children.Count) {
throw "Path segment $index is out of range - this group has $($children.Count) condition(s)."
}
if ($children.Count -eq 1) {
throw 'This is the only condition in its group. A group requires at least one condition - remove the group itself instead, or add a replacement first.'
}
# Removed by position, not by value or reference - two structurally identical
# sibling conditions are otherwise indistinguishable.
$remaining = [System.Collections.Generic.List[object]]::new()
for ($i = 0; $i -lt $children.Count; $i++) {
if ($i -ne $index) { $remaining.Add($children[$i]) }
}
$parent.conditions = $remaining.ToArray()
$Group
}
@@ -0,0 +1,39 @@
function Remove-PersonaConfigRule {
<#
.SYNOPSIS
Removes a rule from a rules collection by id (FR-029).
.DESCRIPTION
Pure: takes the existing rules and an id, returns a new array with that rule
absent. Throws if no rule carries the id, because a silent no-op would let an
operator believe a delete happened when it did not.
.PARAMETER Rules
The rule collection to remove from. Not mutated.
.PARAMETER RuleId
The id of the rule to remove.
.OUTPUTS
System.Object[] - the remaining rules.
#>
[CmdletBinding()]
[OutputType([object[]])]
param(
[Parameter(Mandatory)]
[AllowEmptyCollection()]
[object[]] $Rules,
[Parameter(Mandatory)]
[string] $RuleId
)
$existing = @($Rules)
$match = @($existing | Where-Object { [string]$_.id -eq $RuleId })
if ($match.Count -eq 0) {
throw "No rule with id '$RuleId'."
}
, @($existing | Where-Object { [string]$_.id -ne $RuleId })
}
@@ -0,0 +1,50 @@
function Resolve-TargetAttribute {
<#
.SYNOPSIS
Resolves the attribute the engine is permitted to write (NFR-006).
.DESCRIPTION
Returns the configured target attribute only when it is non-blank and
present in approvedWritableAttributes. Anything else throws.
Throwing rather than returning $null is the point. A caller that treated a
null return as "no writes this run" would be indistinguishable from a caller
that forgot to check, and the second one writes to whatever name it was
holding. There is no safe fallback value for an attribute name, so there is
no fallback.
Comparison against the approved list is ORDINAL. Extension property names
are case-sensitive in Graph: extension_<id>_Persona and
extension_<id>_persona are two different attributes, and approving one does
not approve the other. Rule matching is case-insensitive (RE-006); attribute
approval is not, and the difference is deliberate.
.PARAMETER Configuration
The loaded configuration.
.EXAMPLE
$target = Resolve-TargetAttribute -Configuration $config
.OUTPUTS
System.String
#>
[CmdletBinding()]
[OutputType([string])]
param(
[Parameter(Mandatory)]
[object] $Configuration
)
$target = [string]$Configuration.TargetAttribute
$approved = @($Configuration.ApprovedWritableAttributes)
if ([string]::IsNullOrWhiteSpace($target)) {
throw 'engine.targetAttribute is blank. There is no attribute to compare against or write.'
}
if ($target -cnotin $approved) {
throw "engine.targetAttribute '$target' is not present in engine.approvedWritableAttributes. Comparison is ordinal: extension property names are case-sensitive, so a casing difference is a different attribute."
}
$target
}
@@ -0,0 +1,56 @@
function Set-PersonaConditionLeaf {
<#
.SYNOPSIS
Replaces a leaf condition's fields in place (FR-028).
.DESCRIPTION
Every condition-specific field (`property`, `value`, `values`,
`groupObjectIds`, `roleIds`, `membershipMode`) is cleared before Fields is
applied, then only the keys in Fields are set. This is not tidiness - the
schema's `additionalProperties: false` on `condition` (VR-001 layer 2) rejects
a stray field outright, and switching, say, a condition from `equals` (which
carries `value`) to `in` (which carries `values`) would otherwise leave the
old `value` behind and fail schema validation for a reason the editor caused
but did not explain.
`type` and `operator` are cleared and reset the same way, since a caller
editing a condition typically passes both.
.PARAMETER Node
The leaf condition to edit (from Get-PersonaConditionNode). Mutated in place.
.PARAMETER Fields
The complete replacement field set for this leaf, for example:
`@{ type = 'property'; property = 'Department'; operator = 'isNotNull' }`.
.OUTPUTS
System.Object - the same Node, mutated in place, returned for convenience.
#>
[CmdletBinding()]
[OutputType([object])]
param(
[Parameter(Mandatory)]
[object] $Node,
[Parameter(Mandatory)]
[hashtable] $Fields
)
if ($null -ne $Node.PSObject.Properties['conditions']) {
throw 'The target node is a condition group, not a leaf condition.'
}
$knownFields = @('type', 'property', 'operator', 'value', 'values', 'groupObjectIds', 'roleIds', 'membershipMode', 'caseSensitive')
foreach ($key in $knownFields) {
if ($null -ne $Node.PSObject.Properties[$key]) {
$Node.PSObject.Properties.Remove($key)
}
}
foreach ($entry in $Fields.GetEnumerator()) {
$Node | Add-Member -NotePropertyName $entry.Key -NotePropertyValue $entry.Value -Force
}
$Node
}
@@ -0,0 +1,261 @@
function Test-PersonaConfiguration {
<#
.SYNOPSIS
Runs all four validation layers over a configuration file (VR-001, FR-002).
.DESCRIPTION
Layers run in order and fail fast between them:
1. Syntax ConvertFrom-Json PE-SYN-nnn
2. Schema Test-Json -SchemaFile PE-SCH-nnn
3. Semantic Test-PersonaConfigurationSemantic PE-SEM-nnn
4. Safety Test-PersonaConfigurationSafety PE-SAF-nnn
A layer that produces Error findings stops the sequence. Running semantic
checks over a structurally invalid document produces noise, not signal: every
missing field yields a cascade of consequent errors, and the author has to
guess which one is the actual cause.
Layer 2 error handling is driven by the V-5a observation
(specs/001-persona-engine/verification/V-5a.md), which matters more than it
looks. On PowerShell 7.6.5, Test-Json returns $true when the SCHEMA ITSELF is
unparseable, writing the failure to the error stream instead. A wrapper that
trusted the return value would report a configuration as schema-valid when
the schema never ran. So this function treats a non-empty error variable as
failure regardless of what was returned, and separates the "schema is broken"
case from the "configuration is invalid" case, because they need different
exit codes.
.PARAMETER Path
Configuration file to validate.
.PARAMETER SchemaPath
Schema override. Defaults to the shipped config/persona-engine.schema.json.
.PARAMETER PreviousConfigPath
A previously deployed configuration, enabling the VR-003 comparison checks
(version downgrade, undeclared rule deletion or reorder).
.PARAMETER EnforcementEnabled
Whether this configuration will be used for an enforcing run. Raises the
severity of several safety findings.
.PARAMETER SkipSafety
Runs layers 1-3 only. Used by the editor while a document is mid-edit.
.OUTPUTS
PersonaEngine.ValidationResult
#>
[CmdletBinding()]
[OutputType([pscustomobject])]
param(
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[string] $Path,
[string] $SchemaPath,
[string] $PreviousConfigPath,
[switch] $EnforcementEnabled,
[switch] $SkipSafety
)
$findings = [System.Collections.Generic.List[object]]::new()
$document = $null
$stoppedAtLayer = $null
$schemaUnusable = $false
# ---------------------------------------------------------------- Layer 1
if (-not (Test-Path -LiteralPath $Path -PathType Leaf)) {
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SYN-001' `
-Location $Path `
-Description "Configuration file not found or is not a file: '$Path'." `
-SuggestedResolution 'Check the path. In a pipeline, confirm the file was checked out and the working directory is what you expect.'))
return New-PersonaValidationResult -Findings $findings -Document $null -StoppedAtLayer 'Syntax' -SchemaUnusable $false
}
$raw = $null
try {
$raw = Get-Content -LiteralPath $Path -Raw -ErrorAction Stop
}
catch {
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SYN-002' `
-Location $Path `
-Description "Configuration file could not be read: $($_.Exception.Message)" `
-SuggestedResolution 'Check file permissions and that no other process holds an exclusive lock.'))
return New-PersonaValidationResult -Findings $findings -Document $null -StoppedAtLayer 'Syntax' -SchemaUnusable $false
}
try {
$document = $raw | ConvertFrom-Json -Depth 32 -ErrorAction Stop
}
catch {
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SYN-003' `
-Location $Path `
-Description "Configuration is not valid JSON: $($_.Exception.Message)" `
-SuggestedResolution 'Fix the JSON syntax. A trailing comma or an unquoted key is the usual cause.'))
return New-PersonaValidationResult -Findings $findings -Document $null -StoppedAtLayer 'Syntax' -SchemaUnusable $false
}
# ---------------------------------------------------------------- Layer 2
if (-not $SchemaPath) {
$moduleRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent
$SchemaPath = Join-Path $moduleRoot 'config/persona-engine.schema.json'
}
if (-not (Test-Path -LiteralPath $SchemaPath -PathType Leaf)) {
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SCH-002' `
-Location $SchemaPath `
-Description "Schema file not found: '$SchemaPath'." `
-SuggestedResolution 'Supply -SchemaPath, or restore config/persona-engine.schema.json.'))
return New-PersonaValidationResult -Findings $findings -Document $document -StoppedAtLayer 'Schema' -SchemaUnusable $true
}
$schemaErrors = $null
$schemaOk = $raw | Test-Json -SchemaFile $SchemaPath -ErrorAction SilentlyContinue -ErrorVariable schemaErrors
foreach ($schemaError in @($schemaErrors)) {
$message = [string]$schemaError.Exception.Message
# V-5a: this message arrives with a $true return value. Treating it as a
# pass would validate every configuration against a schema that never ran.
if ($message -match 'Cannot parse the JSON schema') {
$schemaUnusable = $true
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SCH-003' `
-Location $SchemaPath `
-Description "The schema file itself is not valid JSON Schema and could not be used: $message" `
-SuggestedResolution 'Repair the schema file. Until it parses, no configuration can be schema-validated, and a passing result would be meaningless.'))
continue
}
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SCH-001' `
-Location (Get-PersonaSchemaErrorLocation -Message $message) `
-Description $message `
-SuggestedResolution 'Correct the document to match config/persona-engine.schema.json. Test-Json reports only the first violation per run, so re-validate after each fix.'))
}
if ($schemaUnusable -or -not $schemaOk -or @($schemaErrors).Count -gt 0) {
return New-PersonaValidationResult -Findings $findings -Document $document -StoppedAtLayer 'Schema' -SchemaUnusable $schemaUnusable
}
# ---------------------------------------------------------------- Layer 3
foreach ($finding in (Test-PersonaConfigurationSemantic -Document $document)) { $findings.Add($finding) }
if (@($findings | Where-Object Severity -EQ 'Error').Count -gt 0) {
return New-PersonaValidationResult -Findings $findings -Document $document -StoppedAtLayer 'Semantic' -SchemaUnusable $false
}
# ---------------------------------------------------------------- Layer 4
if (-not $SkipSafety) {
$safetyParams = @{ Document = $document; EnforcementEnabled = $EnforcementEnabled }
if ($PreviousConfigPath) { $safetyParams['PreviousConfigPath'] = $PreviousConfigPath }
foreach ($finding in (Test-PersonaConfigurationSafety @safetyParams)) { $findings.Add($finding) }
}
New-PersonaValidationResult -Findings $findings -Document $document -StoppedAtLayer $null -SchemaUnusable $false
}
function New-PersonaValidationResult {
<#
.SYNOPSIS
Wraps a finding collection into the shape every caller consumes.
.DESCRIPTION
IsValid is computed here rather than by each caller, so console, pipeline,
and editor cannot disagree about what "valid" means. Warnings never affect
IsValid; escalation under -TreatWarningsAsErrors is the caller's decision
(VR-005) and belongs where the exit code is chosen.
#>
[CmdletBinding()]
[OutputType([pscustomobject])]
param(
[Parameter(Mandatory)] [AllowEmptyCollection()] [object] $Findings,
[AllowNull()] [object] $Document,
[AllowNull()] [string] $StoppedAtLayer,
[bool] $SchemaUnusable
)
$all = @($Findings)
[pscustomobject]@{
PSTypeName = 'PersonaEngine.ValidationResult'
IsValid = (@($all | Where-Object Severity -EQ 'Error').Count -eq 0)
Findings = $all
ErrorCount = @($all | Where-Object Severity -EQ 'Error').Count
WarningCount = @($all | Where-Object Severity -EQ 'Warning').Count
Document = $Document
StoppedAtLayer = $StoppedAtLayer
SchemaUnusable = $SchemaUnusable
}
}
function Get-PersonaSchemaErrorLocation {
<#
.SYNOPSIS
Extracts the JSON pointer from a Test-Json error message.
.DESCRIPTION
Test-Json embeds the failing location in prose - "... at '/rules/3/priority'".
VR-004 requires a location on every finding, so it is lifted out here rather
than leaving the caller to read it out of the description. When no pointer is
present the document root is reported, which is honest: the violation is
somewhere in the document and the message says where in words.
#>
[CmdletBinding()]
[OutputType([string])]
param([Parameter(Mandatory)] [AllowEmptyString()] [string] $Message)
if ($Message -match "at '([^']*)'") {
$pointer = $Matches[1]
return $pointer ? $pointer : '/'
}
'/'
}
function Write-PersonaValidationFinding {
<#
.SYNOPSIS
Renders validation findings for a human reader.
.DESCRIPTION
Grouped by severity, most serious first, with the suggested resolution on
its own line. A finding without a visible resolution just tells someone they
are wrong, which is why VR-004 makes the field mandatory and why it is
printed rather than hidden behind a verbose switch.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)] [AllowEmptyCollection()] [object[]] $Findings
)
if (@($Findings).Count -eq 0) {
Write-Host 'Validation passed with no findings.' -ForegroundColor Green
return
}
foreach ($severity in @('Error', 'Warning', 'Information')) {
$set = @($Findings | Where-Object Severity -EQ $severity)
if ($set.Count -eq 0) { continue }
$colour = switch ($severity) { 'Error' { 'Red' } 'Warning' { 'Yellow' } default { 'Gray' } }
foreach ($finding in $set) {
Write-Host ("[{0}] {1} {2}" -f $finding.Severity.ToUpperInvariant(), $finding.Code, $finding.Location) -ForegroundColor $colour
Write-Host (" {0}" -f $finding.Description)
Write-Host (" -> {0}" -f $finding.SuggestedResolution) -ForegroundColor DarkGray
}
}
}
@@ -0,0 +1,266 @@
function Test-PersonaConfigurationSafety {
<#
.SYNOPSIS
Validation layer 4: safety checks (VR-003).
.DESCRIPTION
Layer 3 asks "does this configuration make sense?". Layer 4 asks "what
happens to the directory if we run it?" - a configuration can be perfectly
coherent and still be dangerous.
PE-SAF-001 production-capable configuration with a blank target attribute
PE-SAF-002 unsupported writable attribute in the approved list
PE-SAF-003 enabled group rules while group retrieval is disabled
PE-SAF-004 prohibited configuration version downgrade
PE-SAF-005 rule deletion or reorder without a version change, enforcing
PE-SAF-006 condition tracing enabled without explicit acknowledgement
PE-SAF-007 save path overwrites the only valid configuration with no backup
PE-SAF-004 and PE-SAF-005 need a baseline to compare against and are skipped
when -PreviousConfigPath is absent. Skipped, not passed: a check that cannot
run has not been satisfied, and an Information finding says so rather than
leaving silence to be read as approval.
.PARAMETER Document
The parsed configuration document.
.PARAMETER PreviousConfigPath
The currently deployed configuration, for the comparison checks.
.PARAMETER EnforcementEnabled
Whether this configuration will drive an enforcing run. Several findings are
Errors under enforcement and Warnings in preview, because the same
configuration carries very different risk in the two modes.
.PARAMETER SavePath
Intended save target, for PE-SAF-007.
.PARAMETER BackupPlanned
A timestamped backup or Save-As will be taken, satisfying PE-SAF-007.
.OUTPUTS
PersonaEngine.ValidationFinding objects.
#>
[CmdletBinding()]
[OutputType([pscustomobject])]
param(
[Parameter(Mandatory)]
[object] $Document,
[string] $PreviousConfigPath,
[switch] $EnforcementEnabled,
[string] $SavePath,
[switch] $BackupPlanned
)
$findings = [System.Collections.Generic.List[object]]::new()
$engine = $Document.engine
$target = [string]$engine.targetAttribute
$approved = @($engine.approvedWritableAttributes)
$rules = @($Document.rules)
# ------------------------------------------------- PE-SAF-001
if ([string]::IsNullOrWhiteSpace($target)) {
$severity = $EnforcementEnabled ? 'Error' : 'Warning'
$findings.Add((New-PersonaValidationFinding -Severity $severity -Code 'PE-SAF-001' `
-Location 'engine.targetAttribute' `
-Description 'The target attribute is blank. In enforce mode there is no attribute to write and every changed result becomes Skipped, so the run reports success while changing nothing.' `
-SuggestedResolution 'Set engine.targetAttribute before running with enforcement.'))
}
# ------------------------------------------------- PE-SAF-002
foreach ($attribute in $approved) {
if (Test-PersonaWritableAttributeShape -Name $attribute) { continue }
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SAF-002' `
-Location 'engine.approvedWritableAttributes' `
-Description "'$attribute' is not a supported writable attribute. Only directory (schema) extension properties may be written by this engine (OTD-001); built-in attributes such as department or jobTitle are authoritative elsewhere and writing them would overwrite another system's data." `
-SuggestedResolution 'Remove the entry, or replace it with a directory extension property named extension_<APP-ID>_<NAME>.'))
}
# ------------------------------------------------- PE-SAF-003
$groupsEnabled = [bool]$Document.dataSources.groups.enabled
$rolesEnabled = [bool]$Document.dataSources.roles.enabled
$needs = Get-PersonaRequiredFacets -Rules $rules -DefaultMembershipMode (
$engine.defaultMembershipMode ? (Get-Culture).TextInfo.ToTitleCase([string]$engine.defaultMembershipMode) : 'Direct')
if (($needs.Direct -or $needs.Transitive) -and -not $groupsEnabled) {
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SAF-003' `
-Location 'dataSources.groups.enabled' `
-Description 'Enabled rules contain group membership conditions while group retrieval is disabled. Every account those rules reach becomes EvaluationError, so the run preserves stored values and classifies nobody - a silent no-op that still reports success.' `
-SuggestedResolution 'Enable dataSources.groups, or disable the rules that depend on membership.'))
}
if ($needs.Roles -and -not $rolesEnabled) {
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SAF-003' `
-Location 'dataSources.roles.enabled' `
-Description 'Enabled rules contain directory role conditions while role retrieval is disabled. Tier 0 rules are the usual casualty, and an account that should have matched a privileged rule falls through to a lower-privilege persona or to EvaluationError.' `
-SuggestedResolution 'Enable dataSources.roles, or disable the rules that depend on role assignments.'))
}
# ------------------------------------------------- PE-SAF-006
$logging = $Document.logging
$tracing = $logging -and $logging.PSObject.Properties['traceConditionValues'] -and [bool]$logging.traceConditionValues
$acknowledged = $logging -and $logging.PSObject.Properties['acknowledgeConditionTracing'] -and [bool]$logging.acknowledgeConditionTracing
if ($tracing -and -not $acknowledged) {
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SAF-006' `
-Location 'logging.traceConditionValues' `
-Description 'Condition-value tracing is enabled without acknowledgement. Tracing writes evaluated attribute values into audit records, widening what the log contains beyond the UPN and Object ID that are approved by default (Principle V).' `
-SuggestedResolution 'Set logging.acknowledgeConditionTracing to true in the same change that enables tracing, so the decision is visible in review, or disable tracing.'))
}
# ------------------------------------------------- PE-SAF-004 / PE-SAF-005
if (-not $PreviousConfigPath) {
$findings.Add((New-PersonaValidationFinding -Severity Information -Code 'PE-SAF-004' `
-Location 'configVersion' `
-Description 'No previous configuration was supplied, so the version-downgrade and rule-drift checks did not run. This is not a pass - the checks were skipped.' `
-SuggestedResolution 'Pass -PreviousConfigPath pointing at the currently deployed configuration to enable the comparison checks. In CI, this is the copy from the deployed branch.'))
}
elseif (-not (Test-Path -LiteralPath $PreviousConfigPath -PathType Leaf)) {
$findings.Add((New-PersonaValidationFinding -Severity Warning -Code 'PE-SAF-004' `
-Location $PreviousConfigPath `
-Description 'The previous configuration path was supplied but does not exist. The comparison checks did not run.' `
-SuggestedResolution 'Correct the path, or omit it deliberately if this is the first deployment.'))
}
else {
foreach ($finding in (Compare-PersonaConfigurationVersion -Document $Document -PreviousConfigPath $PreviousConfigPath -EnforcementEnabled:$EnforcementEnabled)) {
$findings.Add($finding)
}
}
# ------------------------------------------------- PE-SAF-007
if ($SavePath -and -not $BackupPlanned -and (Test-Path -LiteralPath $SavePath -PathType Leaf)) {
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SAF-007' `
-Location $SavePath `
-Description 'The save would overwrite an existing configuration with no backup. If the replacement turns out to be wrong, the only known-good copy is gone and there is nothing to roll back to.' `
-SuggestedResolution 'Allow the timestamped backup, or supply -OutputPath to save alongside the original (FR-026).'))
}
$findings
}
function Compare-PersonaConfigurationVersion {
<#
.SYNOPSIS
Compares a candidate configuration against the deployed one (PE-SAF-004, PE-SAF-005).
.DESCRIPTION
Two questions, both about change control rather than correctness:
Did the version go backwards? A downgrade means an older rule set is about
to replace a newer one, and audit records would carry a configVersion that
has already been superseded - so two different rule sets share one version
number and no record can tell them apart.
Did rules disappear or change order without the version changing? Deleting
a rule silently reclassifies everyone it used to match; reordering does the
same for anyone matched by an overtaken rule. Neither is wrong in itself,
but doing it under an unchanged version number makes the change invisible
in the audit trail.
Under enforcement these are Errors, because the consequence is a directory
write. In preview they are Warnings: the same drift is worth seeing but costs
nothing yet.
#>
[CmdletBinding()]
[OutputType([pscustomobject])]
param(
[Parameter(Mandatory)] [object] $Document,
[Parameter(Mandatory)] [string] $PreviousConfigPath,
[switch] $EnforcementEnabled
)
$findings = [System.Collections.Generic.List[object]]::new()
$severity = $EnforcementEnabled ? 'Error' : 'Warning'
$previous = $null
try {
$previous = Get-Content -LiteralPath $PreviousConfigPath -Raw -ErrorAction Stop | ConvertFrom-Json -Depth 32 -ErrorAction Stop
}
catch {
$findings.Add((New-PersonaValidationFinding -Severity Warning -Code 'PE-SAF-004' `
-Location $PreviousConfigPath `
-Description "The previous configuration could not be parsed, so the comparison checks did not run: $($_.Exception.Message)" `
-SuggestedResolution 'Point -PreviousConfigPath at a valid configuration, or omit it.'))
return $findings
}
$currentVersion = $null
$previousVersion = $null
$parsedBoth = [version]::TryParse([string]$Document.configVersion, [ref] $currentVersion) -and
[version]::TryParse([string]$previous.configVersion, [ref] $previousVersion)
if ($parsedBoth -and $currentVersion -lt $previousVersion) {
$findings.Add((New-PersonaValidationFinding -Severity $severity -Code 'PE-SAF-004' `
-Location 'configVersion' `
-Description "configVersion $currentVersion is lower than the deployed version $previousVersion. Audit records would report a version that has already been superseded, making two different rule sets indistinguishable in the log." `
-SuggestedResolution 'Raise configVersion above the deployed version. If a rollback is genuinely intended, publish it as a new higher version rather than reusing the old number.'))
}
if ($parsedBoth -and $currentVersion -ne $previousVersion) {
# The version moved, so drift is declared. Nothing further to report.
return $findings
}
$currentIds = @($Document.rules | ForEach-Object { [string]$_.id })
$previousIds = @($previous.rules | ForEach-Object { [string]$_.id })
$removed = @($previousIds | Where-Object { $_ -notin $currentIds })
if ($removed.Count -gt 0) {
$findings.Add((New-PersonaValidationFinding -Severity $severity -Code 'PE-SAF-005' `
-Location 'rules' `
-Description "Rules removed without a configVersion change: $($removed -join ', '). Every account these rules matched will be reclassified by a later rule, or become Unclassified, with nothing in the audit trail marking the change." `
-SuggestedResolution 'Raise configVersion so the change is declared, or disable the rules instead of deleting them so the audit trail keeps reporting zero matches against them.'))
}
$currentOrder = @($Document.rules | Sort-Object -Property @{ Expression = { [int]$_.priority } } | ForEach-Object { [string]$_.id })
$previousOrder = @($previous.rules | Sort-Object -Property @{ Expression = { [int]$_.priority } } | ForEach-Object { [string]$_.id })
$shared = @($currentOrder | Where-Object { $_ -in $previousIds })
$sharedPrevious = @($previousOrder | Where-Object { $_ -in $currentIds })
if (($shared -join '>') -ne ($sharedPrevious -join '>')) {
$findings.Add((New-PersonaValidationFinding -Severity $severity -Code 'PE-SAF-005' `
-Location 'rules[*].priority' `
-Description 'Rule evaluation order changed without a configVersion change. First match wins (FR-009), so a reorder silently reassigns every account matched by more than one rule.' `
-SuggestedResolution 'Raise configVersion so the reorder is declared and traceable in audit records.'))
}
$findings
}
function Test-PersonaWritableAttributeShape {
<#
.SYNOPSIS
Reports whether an attribute name is shaped like a writable extension property.
.DESCRIPTION
OTD-001 selected directory (schema) extension properties as the persona
store. Only those may be written. Built-in attributes are deliberately
excluded even when the operator holds permission to write them: they are
authoritative in HR or in the sync source, and this engine is not their owner.
The placeholder form is accepted so the committed example configuration
passes its own validator without carrying a real application ID (SC-013).
#>
[CmdletBinding()]
[OutputType([bool])]
param([Parameter(Mandatory)] [AllowEmptyString()] [string] $Name)
if ($Name -match '^extension_[0-9a-fA-F]{32}_[A-Za-z0-9]+$') { return $true }
if ($Name -match '^extension_<[^>]+>_<?[^>]+>?$') { return $true }
$false
}
@@ -0,0 +1,352 @@
function Test-PersonaConfigurationSemantic {
<#
.SYNOPSIS
Validation layer 3: semantic checks (VR-002).
.DESCRIPTION
Every condition VR-002 names, one stable code each. Codes are part of the
contract - pipelines and runbooks match on them - so a code is never reused
for a different condition and never renumbered.
PE-SEM-001 duplicate rule IDs
PE-SEM-002 duplicate priorities
PE-SEM-003 no enabled rules
PE-SEM-004 invalid or blank target attribute
PE-SEM-005 target attribute absent from the approved writable list
PE-SEM-006 reference to an unavailable data source
PE-SEM-007 memberOf/notMemberOf without group Object IDs
PE-SEM-008 in/notIn without values
PE-SEM-009 isNull/isNotNull carrying a comparison value
PE-SEM-010 undefined or prohibited persona value
PE-SEM-011 Unclassified used as an ordinary rule persona
PE-SEM-012 condition depth over the configured maximum
PE-SEM-013 configured maximum over the hard ceiling of 10
PE-SEM-014 membership mode not enabled globally
PE-SEM-015 unsupported property name
PE-SEM-016 invalid regular expression
Several of these are also expressible in JSON Schema and some are already
caught there. They are repeated here deliberately: layer 2 can be bypassed
with -SchemaPath, and V-5a showed that an unparseable schema silently passes
on this build. A rule that can misclassify a privileged account should not
depend on one layer alone.
.PARAMETER Document
The parsed configuration document.
.OUTPUTS
PersonaEngine.ValidationFinding objects. Empty when the document is sound.
#>
[CmdletBinding()]
[OutputType([pscustomobject])]
param(
[Parameter(Mandatory)]
[object] $Document
)
$findings = [System.Collections.Generic.List[object]]::new()
$engine = $Document.engine
$rules = @($Document.rules)
$personas = @($Document.personas)
# ---------------------------------------------------- target attribute
$target = [string]$engine.targetAttribute
$approved = @($engine.approvedWritableAttributes)
if ([string]::IsNullOrWhiteSpace($target)) {
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-004' `
-Location 'engine.targetAttribute' `
-Description 'The target attribute is blank. The engine has no attribute to compare against or write.' `
-SuggestedResolution 'Set engine.targetAttribute to the approved persona extension property name.'))
}
elseif ($target -cnotin $approved) {
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-005' `
-Location 'engine.targetAttribute' `
-Description "The target attribute '$target' does not appear in engine.approvedWritableAttributes. Comparison is ordinal, so a casing difference counts as absent." `
-SuggestedResolution 'Add the exact attribute name to approvedWritableAttributes, or correct the target attribute. Extension property names are case-sensitive in Graph.'))
}
# ---------------------------------------------------- depth ceiling
$maxDepth = ($null -ne $engine.maxConditionDepth) ? [int]$engine.maxConditionDepth : 5
if ($maxDepth -gt 10) {
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-013' `
-Location 'engine.maxConditionDepth' `
-Description "maxConditionDepth is $maxDepth, above the hard ceiling of 10 (RE-004)." `
-SuggestedResolution 'Lower maxConditionDepth to 10 or less. A rule needing deeper nesting is better split into two rules with distinct priorities.'))
}
if ($maxDepth -lt 1) {
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-013' `
-Location 'engine.maxConditionDepth' `
-Description "maxConditionDepth is $maxDepth, below the minimum of 1 (RE-004). No rule could be evaluated." `
-SuggestedResolution 'Set maxConditionDepth to at least 1.'))
}
# ---------------------------------------------------- data sources
$groupsEnabled = [bool]$Document.dataSources.groups.enabled
$rolesEnabled = [bool]$Document.dataSources.roles.enabled
# Left null when the configuration does not pin a mode. Absent means "any mode is
# acceptable", which is the normal case: RE-007 makes mode a per-condition
# choice, and the facets are retrieved independently. Pinning it globally is a
# deliberate restriction, and only then is a per-condition override worth
# flagging.
$globalMode = $Document.dataSources.groups.PSObject.Properties['membershipMode'] `
? [string]$Document.dataSources.groups.membershipMode : $null
$defaultMode = $engine.defaultMembershipMode ? [string]$engine.defaultMembershipMode : 'direct'
# ---------------------------------------------------- rules
if ($rules.Count -eq 0) {
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-003' `
-Location 'rules' `
-Description 'The configuration contains no rules.' `
-SuggestedResolution 'Add at least one enabled rule. A run with no rules classifies every account as Unclassified.'))
}
elseif (@($rules | Where-Object { $_.enabled }).Count -eq 0) {
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-003' `
-Location 'rules' `
-Description 'Every rule in the configuration is disabled. The run would classify every account as Unclassified and, in enforce mode, propose clearing every stored persona.' `
-SuggestedResolution 'Enable at least one rule, or do not deploy this configuration.'))
}
foreach ($group in ($rules | Group-Object -Property { [string]$_.id } | Where-Object Count -GT 1)) {
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-001' `
-Location "rules[id=$($group.Name)]" `
-Description "Rule ID '$($group.Name)' is used by $($group.Count) rules. Rule IDs appear in audit records and are how a decision is traced back to its rule." `
-SuggestedResolution 'Give each rule a unique ID.'))
}
foreach ($group in ($rules | Where-Object { $_.enabled } | Group-Object -Property { [int]$_.priority } | Where-Object Count -GT 1)) {
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-002' `
-Location "rules[priority=$($group.Name)]" `
-Description "Priority $($group.Name) is shared by $($group.Count) enabled rules: $(($group.Group | ForEach-Object { [string]$_.id }) -join ', '). Evaluation order between them is not defined by the configuration (RE-002)." `
-SuggestedResolution 'Assign a unique priority to each enabled rule. The engine breaks ties by rule ID so results stay deterministic, but the resulting order is an accident rather than a decision.'))
}
foreach ($rule in $rules) {
$ruleId = [string]$rule.id
$persona = [string]$rule.persona
if ($persona -ieq 'Unclassified') {
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-011' `
-Location "rules[$ruleId].persona" `
-Description 'Unclassified is a processing result, not a rule outcome (FR-010). A rule that assigns it makes "no rule matched" indistinguishable from "this rule matched".' `
-SuggestedResolution 'Remove the rule, or give it a real persona. Accounts matching no rule already receive Unclassified.'))
}
elseif ($persona -ieq 'EvaluationError') {
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-010' `
-Location "rules[$ruleId].persona" `
-Description 'EvaluationError is an execution result and must never be assigned by a rule.' `
-SuggestedResolution 'Give the rule a persona from the personas catalogue.'))
}
elseif ($persona -and $persona -notin $personas) {
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-010' `
-Location "rules[$ruleId].persona" `
-Description "Persona '$persona' is not declared in the personas catalogue. The catalogue is what stops a typo from writing a new persona value into the directory." `
-SuggestedResolution "Add '$persona' to the personas array, or correct the spelling."))
}
if ($null -eq $rule.match) { continue }
$context = @{
RuleId = $ruleId
MaxDepth = $maxDepth
GroupsEnabled = $groupsEnabled
RolesEnabled = $rolesEnabled
GlobalMode = $globalMode
DefaultMode = $defaultMode
Findings = $findings
}
Test-PersonaSemanticNode -Node $rule.match -Depth 1 -PathText "rules[$ruleId].match" -Context $context
}
$findings
}
function Test-PersonaSemanticNode {
<#
.SYNOPSIS
Recursively validates one condition group or condition.
.DESCRIPTION
Depth is counted the same way the engine counts it, so the validator and the
runtime agree about what "too deep" means. A validator with its own depth
arithmetic would eventually pass a configuration the engine rejects at run
time, against a live tenant, which is the worst place to discover it.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)] [object] $Node,
[Parameter(Mandatory)] [int] $Depth,
[Parameter(Mandatory)] [string] $PathText,
[Parameter(Mandatory)] [hashtable] $Context
)
$findings = $Context.Findings
if ($Depth -gt $Context.MaxDepth) {
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-012' `
-Location $PathText `
-Description "Condition nesting reaches depth $Depth, above the configured maximum of $($Context.MaxDepth) (RE-004). The engine returns Unknown beyond the limit, which becomes EvaluationError for every account this rule reaches." `
-SuggestedResolution 'Flatten the condition tree, or raise engine.maxConditionDepth up to the ceiling of 10.'))
return
}
# A group: recurse and stop. Groups carry no operator-level semantics of their own.
if ($Node.PSObject.Properties['conditions'] -and $Node.conditions) {
$index = 0
foreach ($child in $Node.conditions) {
Test-PersonaSemanticNode -Node $child -Depth ($Depth + 1) -PathText "$PathText.conditions[$index]" -Context $Context
$index++
}
return
}
$type = [string]$Node.type
$operator = [string]$Node.operator
$hasValue = $Node.PSObject.Properties['value'] -and $null -ne $Node.value
# @($Node.values) on an absent property yields @($null) - a one-element array -
# which would make an empty list look populated and a populated one look no
# different. Every array read here goes through the null filter for that reason.
$values = @($Node.values | Where-Object { $null -ne $_ })
$groupIds = @($Node.groupObjectIds | Where-Object { $null -ne $_ })
$roleIds = @($Node.roleIds | Where-Object { $null -ne $_ })
switch ($operator) {
{ $_ -in @('memberOf', 'notMemberOf') } {
if ($type -eq 'role') {
if ($roleIds.Count -eq 0) {
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-007' `
-Location $PathText `
-Description "A role condition using '$operator' carries no roleIds. It can never evaluate to a meaningful result." `
-SuggestedResolution 'Add at least one role template ID to roleIds.'))
}
if (-not $Context.RolesEnabled) {
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-006' `
-Location $PathText `
-Description 'This rule requires directory role data, but dataSources.roles.enabled is false. The role facet is never retrieved, so every account reaching this rule becomes EvaluationError (FR-013).' `
-SuggestedResolution 'Enable dataSources.roles, or remove the role conditions.'))
}
}
else {
if ($groupIds.Count -eq 0) {
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-007' `
-Location $PathText `
-Description "A membership condition using '$operator' carries no groupObjectIds." `
-SuggestedResolution 'Add at least one group Object ID to groupObjectIds. Object IDs are used rather than names because names are mutable (RE-009).'))
}
if (-not $Context.GroupsEnabled) {
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-006' `
-Location $PathText `
-Description 'This rule requires group membership data, but dataSources.groups.enabled is false. Every account reaching this rule becomes EvaluationError (FR-013).' `
-SuggestedResolution 'Enable dataSources.groups, or remove the membership conditions.'))
}
$mode = ($Node.PSObject.Properties['membershipMode'] -and $Node.membershipMode) ? [string]$Node.membershipMode : $Context.DefaultMode
if ($Context.GroupsEnabled -and $mode -ine $Context.GlobalMode -and $Context.GlobalMode) {
# Not an error. The three facets are retrieved independently, so a
# per-condition mode differing from the global one is served
# correctly - but it is worth flagging, because it usually means
# the author did not realise the global setting was there.
$findings.Add((New-PersonaValidationFinding -Severity Warning -Code 'PE-SEM-014' `
-Location $PathText `
-Description "This condition requests '$mode' membership while dataSources.groups.membershipMode is '$($Context.GlobalMode)'. Both facets will be retrieved, at the cost of an extra call per account." `
-SuggestedResolution "Confirm '$mode' is intended here. If every rule wants the same mode, set it globally and drop the per-condition override."))
}
}
}
{ $_ -in @('in', 'notIn') } {
if ($values.Count -eq 0) {
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-008' `
-Location $PathText `
-Description "Operator '$operator' requires a values array, which is absent or empty. An empty set matches nothing and would silently never fire." `
-SuggestedResolution 'Populate values, or use equals/notEquals for a single comparison.'))
}
}
{ $_ -in @('isNull', 'isNotNull') } {
if ($hasValue -or $values.Count -gt 0) {
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-009' `
-Location $PathText `
-Description "Operator '$operator' tests for presence and ignores any comparison value. A value here is silently discarded, so the rule does not do what it appears to do." `
-SuggestedResolution 'Remove value/values, or switch to equals if a comparison was intended.'))
}
}
'matchesRegex' {
if (-not $hasValue) {
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-016' `
-Location $PathText `
-Description 'matchesRegex requires a pattern in value.' `
-SuggestedResolution 'Supply a regular expression in the value field.'))
}
else {
try {
# Compiling proves the pattern parses. Validating here rather than
# at run time means a bad pattern fails a pipeline, not a
# production run in which every account becomes EvaluationError.
$null = [System.Text.RegularExpressions.Regex]::new([string]$Node.value)
}
catch {
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-016' `
-Location $PathText `
-Description "Invalid regular expression: $($_.Exception.Message)" `
-SuggestedResolution 'Correct the pattern. Remember that JSON requires backslashes to be escaped, so \d is written \\d.'))
}
}
}
}
if ($type -eq 'property') {
$property = [string]$Node.property
if (-not (Test-PersonaSupportedProperty -Name $property)) {
$findings.Add((New-PersonaValidationFinding -Severity Error -Code 'PE-SEM-015' `
-Location $PathText `
-Description "Property '$property' is not a supported property name. Unsupported properties are never retrieved, so the condition would compare against a permanently absent value." `
-SuggestedResolution 'Use one of AccountObjectId, UserPrincipalName, DisplayName, UserType, AccountEnabled, CompanyName, JobTitle, Department, or an extension property named extension_<APP-ID>_<NAME>.'))
}
}
}
function Test-PersonaSupportedProperty {
<#
.SYNOPSIS
Reports whether a property name can actually be retrieved and evaluated.
.DESCRIPTION
The FR-005 baseline plus directory extension properties. Extension names are
accepted on shape alone - extension_<32 hex>_<name> - because the set of
registered extensions is tenant-specific and cannot be known offline, and
SC-008 requires validation to run with no tenant.
#>
[CmdletBinding()]
[OutputType([bool])]
param([Parameter(Mandatory)] [AllowEmptyString()] [string] $Name)
$supported = @(
'AccountObjectId', 'UserPrincipalName', 'DisplayName', 'UserType',
'AccountEnabled', 'CompanyName', 'JobTitle', 'Department'
)
if ($Name -in $supported) { return $true }
if ($Name -match '^extension_[0-9a-fA-F]{32}_[A-Za-z0-9]+$') { return $true }
# The placeholder form used in committed examples (SC-013) must validate, or the
# shipped example configuration could never pass its own validator.
if ($Name -match '^extension_<[^>]+>_<?[^>]+>?$') { return $true }
$false
}
@@ -0,0 +1,45 @@
function Get-PersonaDirectoryRoles {
<#
.SYNOPSIS
Retrieves active directory role assignments for one user.
.DESCRIPTION
Uses the unified role management endpoint, which returns roleDefinitionId
values (role template IDs) stable across tenants, unlike role instance IDs,
so a configuration written against them is portable.
Eligible (PIM) assignments are out of scope for v1 unless authorization is
confirmed and a provider is implemented (spec Out of Scope).
Throws on failure. The caller (Get-PersonaGroupMembership) contains the
failure into the Roles facet so it never becomes a false non-match.
.PARAMETER UserObjectId
The principal to query.
#>
[CmdletBinding()]
[OutputType([string[]])]
param(
[Parameter(Mandatory)]
[string] $UserObjectId
)
$uri = "/v1.0/roleManagement/directory/roleAssignments?`$filter=principalId eq '$UserObjectId'"
$ids = [System.Collections.Generic.List[string]]::new()
while ($uri) {
$response = Invoke-PersonaGraphRequest -Uri $uri
if ($null -eq $response -or -not $response.ContainsKey('value')) {
throw 'Role assignment endpoint returned an unexpected response shape.'
}
foreach ($assignment in $response['value']) {
if ($assignment['roleDefinitionId']) { $ids.Add([string]$assignment['roleDefinitionId']) }
}
$uri = $response.ContainsKey('@odata.nextLink') ? $response['@odata.nextLink'] : $null
}
, $ids.ToArray()
}
@@ -0,0 +1,130 @@
function Get-PersonaGroupMembership {
<#
.SYNOPSIS
Retrieves group membership and directory roles for one user.
.DESCRIPTION
Returns a MembershipRecord ALWAYS. On failure it returns a record with the
affected facet unretrieved and a FailureReason. It never returns an empty
list on failure and never throws past the per-user boundary.
That single behaviour is what makes FR-013 work: unknown membership becomes
EvaluationError, never a false non-match. If this function ever throws or
returns empty on error, a transient Graph outage silently reclassifies
privileged accounts.
Fetches only the facets the configuration actually needs, and each one
independently, so a failure in one does not make the others unevaluable.
.PARAMETER UserObjectId
The user to query.
.PARAMETER NeedDirect
Fetch direct group membership.
.PARAMETER NeedTransitive
Fetch transitive group membership.
.PARAMETER NeedRoles
Fetch directory role assignments.
#>
[CmdletBinding()]
[OutputType([pscustomobject])]
param(
[Parameter(Mandatory)]
[string] $UserObjectId,
[switch] $NeedDirect,
[switch] $NeedTransitive,
[switch] $NeedRoles
)
$direct = @()
$transitive = @()
$roles = @()
$directOk = $false
$transitiveOk = $false
$rolesOk = $false
$directErr = $null
$transitiveErr = $null
$rolesErr = $null
if ($NeedDirect) {
try {
$direct = Get-PersonaGroupIdPage -Uri "/v1.0/users/$UserObjectId/memberOf?`$select=id&`$top=999"
$directOk = $true
}
catch {
$directErr = $_.Exception.Message
Write-Verbose "Direct membership lookup failed for $UserObjectId : $directErr"
}
}
if ($NeedTransitive) {
try {
$transitive = Get-PersonaGroupIdPage -Uri "/v1.0/users/$UserObjectId/transitiveMemberOf?`$select=id&`$top=999"
$transitiveOk = $true
}
catch {
$transitiveErr = $_.Exception.Message
Write-Verbose "Transitive membership lookup failed for $UserObjectId : $transitiveErr"
}
}
if ($NeedRoles) {
try {
$roles = Get-PersonaDirectoryRoles -UserObjectId $UserObjectId
$rolesOk = $true
}
catch {
$rolesErr = $_.Exception.Message
Write-Verbose "Role lookup failed for $UserObjectId : $rolesErr"
}
}
$params = @{
DirectGroupObjectIds = $direct
TransitiveGroupObjectIds = $transitive
DirectoryRoleIds = $roles
}
if ($directOk) { $params['DirectRetrieved'] = $true } elseif ($NeedDirect) { $params['DirectFailureReason'] = $directErr }
if ($transitiveOk) { $params['TransitiveRetrieved'] = $true } elseif ($NeedTransitive) { $params['TransitiveFailureReason'] = $transitiveErr }
if ($rolesOk) { $params['RolesRetrieved'] = $true } elseif ($NeedRoles) { $params['RolesFailureReason'] = $rolesErr }
New-PersonaMembershipRecord @params
}
function Get-PersonaGroupIdPage {
<#
.SYNOPSIS
Collects group Object IDs across all pages of a membership endpoint.
.DESCRIPTION
memberOf returns directory objects of mixed type. Only group IDs are
collected; administrative units and other object types are ignored.
#>
[CmdletBinding()]
[OutputType([string[]])]
param([Parameter(Mandatory)] [string] $Uri)
$ids = [System.Collections.Generic.List[string]]::new()
$next = $Uri
while ($next) {
$response = Invoke-PersonaGraphRequest -Uri $next
if ($null -eq $response -or -not $response.ContainsKey('value')) {
throw 'Membership endpoint returned an unexpected response shape.'
}
foreach ($item in $response['value']) {
$type = $item['@odata.type']
if ($type -and $type -ne '#microsoft.graph.group') { continue }
if ($item['id']) { $ids.Add([string]$item['id']) }
}
$next = $response.ContainsKey('@odata.nextLink') ? $response['@odata.nextLink'] : $null
}
, $ids.ToArray()
}
+153
View File
@@ -0,0 +1,153 @@
function Get-PersonaUsers {
<#
.SYNOPSIS
Retrieves in-scope user objects, following pagination to exhaustion.
.DESCRIPTION
Requests only the properties enabled rules actually need (FR-005) plus the
configured target attribute and the operational fields required for logging.
Pagination follows @odata.nextLink until absent (FR-004). A truncated
enumeration raises rather than returning a partial population silently
classifying half a tenant is worse than failing.
Emits raw Graph objects. Normalization is ConvertTo-PersonaUserRecord's job;
the rule engine never sees what this returns.
.PARAMETER SelectProperties
Property names for $select.
.PARAMETER UserObjectId
Retrieves a single user instead of enumerating (the -UserObjectId path).
.PARAMETER PageSize
$top value. Graph caps user enumeration at 999.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string[]] $SelectProperties,
[string] $UserObjectId,
[ValidateRange(1, 999)]
[int] $PageSize = 999
)
$select = ($SelectProperties | Select-Object -Unique) -join ','
if ($UserObjectId) {
Write-Verbose "Retrieving single user $UserObjectId"
return @(Invoke-PersonaGraphRequest -Uri "/v1.0/users/$UserObjectId`?`$select=$select")
}
$uri = "/v1.0/users?`$select=$select&`$top=$PageSize"
$page = 0
while ($uri) {
$page++
Write-Verbose "Retrieving user page $page"
$response = Invoke-PersonaGraphRequest -Uri $uri
if ($null -eq $response -or -not $response.ContainsKey('value')) {
throw "User enumeration returned an unexpected response shape on page $page. Refusing to continue with a partial population."
}
foreach ($user in $response['value']) { $user }
$uri = $response.ContainsKey('@odata.nextLink') ? $response['@odata.nextLink'] : $null
}
}
function Get-PersonaRequiredProperties {
<#
.SYNOPSIS
Builds the $select list from the configuration and its enabled rules.
.DESCRIPTION
The FR-005 baseline plus the target attribute plus every property an enabled
rule references. Properties nothing references are not requested least
privilege applies to data as well as permissions.
#>
[CmdletBinding()]
[OutputType([string[]])]
param(
[Parameter(Mandatory)] [object] $Configuration
)
# Graph property names, which differ in casing from the normalized record.
$baseline = @(
'id', 'userPrincipalName', 'displayName', 'userType',
'accountEnabled', 'companyName', 'jobTitle', 'department'
)
$properties = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
foreach ($p in $baseline) { $null = $properties.Add($p) }
if ($Configuration.TargetAttribute) { $null = $properties.Add($Configuration.TargetAttribute) }
foreach ($name in (Get-PersonaReferencedProperties -Rules $Configuration.Rules)) {
# Intrinsics are already covered by the baseline under their Graph names.
if ($name -in @('AccountObjectId', 'UserPrincipalName', 'DisplayName', 'UserType', 'AccountEnabled')) { continue }
$null = $properties.Add((Get-PersonaGraphPropertyName $name))
}
, @($properties)
}
function Get-PersonaReferencedProperties {
<#
.SYNOPSIS
Walks enabled rules and collects every referenced property name.
#>
[CmdletBinding()]
[OutputType([string[]])]
param([Parameter(Mandatory)] [AllowEmptyCollection()] [object[]] $Rules)
$found = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
function Walk {
param($Node)
if ($null -eq $Node) { return }
if ($Node.PSObject.Properties['conditions'] -and $Node.conditions) {
foreach ($child in $Node.conditions) { Walk $child }
return
}
if ($Node.PSObject.Properties['property'] -and $Node.property) {
$null = $found.Add([string]$Node.property)
}
}
foreach ($rule in @($Rules)) {
if (-not $rule.enabled) { continue }
Walk $rule.match
}
, @($found)
}
function Get-PersonaGraphPropertyName {
<#
.SYNOPSIS
Maps a normalized property name to its Graph equivalent.
.DESCRIPTION
Extension property names pass through unchanged they are already in Graph
form and are case-sensitive, unlike the built-in properties.
#>
[CmdletBinding()]
[OutputType([string])]
param([Parameter(Mandatory, Position = 0)] [string] $Name)
if ($Name -like 'extension_*') { return $Name }
# camelCase the first letter; Graph built-ins are camelCase.
if ($Name.Length -gt 0) {
return $Name.Substring(0, 1).ToLowerInvariant() + $Name.Substring(1)
}
$Name
}
@@ -0,0 +1,219 @@
function Invoke-PersonaGraphRequest {
<#
.SYNOPSIS
Issues a Microsoft Graph request with the retry policy from OTD-007.
.DESCRIPTION
The single point at which the engine touches Graph. Direct REST via
Invoke-MgGraphRequest (OTD-004), so request bodies are explicit values that
tests can assert on which is what makes SC-005 provable.
Retry policy:
Retryable 429, 500, 502, 503, 504, transport timeout
Never 400, 401, 403, 404, 409 (configuration, authorization, or
logic defects retrying masks them)
Retry-After honoured when present; overrides computed backoff
Attempts max 5, exponential from 1s, full jitter, per-delay cap 60s
Never logs tokens, Authorization headers, or full response bodies.
.PARAMETER Uri
Absolute or Graph-relative URI.
.PARAMETER Method
HTTP method. Defaults to GET.
.PARAMETER Body
Request body. Passed through unchanged so the caller controls exactly what
is sent.
.PARAMETER MaxAttempts
Maximum attempts including the first. Default 5.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[string] $Uri,
[ValidateSet('GET', 'POST', 'PATCH', 'PUT', 'DELETE')]
[string] $Method = 'GET',
[object] $Body,
[ValidateRange(1, 10)]
[int] $MaxAttempts = 5,
[int] $BaseDelayMs = 1000,
[int] $MaxDelayMs = 60000
)
$retryableStatus = @(429, 500, 502, 503, 504)
$attempt = 0
while ($true) {
$attempt++
try {
$params = @{ Uri = $Uri; Method = $Method; ErrorAction = 'Stop' }
if ($null -ne $Body) {
$params['Body'] = ($Body -is [string]) ? $Body : ($Body | ConvertTo-Json -Depth 16 -Compress)
$params['ContentType'] = 'application/json'
}
return Invoke-MgGraphRequest @params
}
catch {
$status = Get-PersonaGraphStatusCode -ErrorRecord $_
$isRetryable = ($null -eq $status) -or ($status -in $retryableStatus)
if (-not $isRetryable) {
# A definite client-side failure. Retrying would hide a
# configuration or authorization defect behind a timeout.
throw
}
if ($attempt -ge $MaxAttempts) {
throw "Graph request failed after $attempt attempt(s) (last status: $status): $($_.Exception.Message)"
}
$retryAfter = Get-PersonaRetryAfterMs -ErrorRecord $_
if ($null -ne $retryAfter) {
$delay = [Math]::Min($retryAfter, $MaxDelayMs)
}
else {
# Exponential with full jitter: a uniform draw from [0, backoff]
# rather than backoff itself, so concurrent callers do not retry in
# lockstep and re-create the throttling they are backing off from.
$backoff = [Math]::Min($BaseDelayMs * [Math]::Pow(2, $attempt - 1), $MaxDelayMs)
$delay = Get-Random -Minimum 0 -Maximum ([int]$backoff)
}
Write-Verbose "Graph request attempt $attempt failed with status $status; retrying in $delay ms."
Start-Sleep -Milliseconds $delay
}
}
}
function Get-PersonaGraphStatusCode {
<#
.SYNOPSIS
Extracts an HTTP status code from a Graph error record. Internal helper.
.DESCRIPTION
Tries the structured properties first, then falls back to the message text.
The message fallback is not cosmetic. Invoke-MgGraphRequest does not always
surface a Response object, and several of its failure paths put the status
only in prose: "Response status code does not indicate success: 403
(Forbidden)." Without the fallback those failures return $null, which the
retry policy treats as a transport error and retries - so a single 403 becomes
five requests per account, hammering a tenant that is already refusing and
turning an instant authorization failure into a long, expensive one.
A genuine transport failure still returns $null and is still retried. The
distinction being drawn is "no status exists" versus "the status was not in
the property I looked at first".
#>
[CmdletBinding()]
param([Parameter(Mandatory)] $ErrorRecord)
$response = $ErrorRecord.Exception.PSObject.Properties['Response']
if ($response -and $response.Value) {
$code = $response.Value.PSObject.Properties['StatusCode']
if ($code -and $code.Value) { return [int]$code.Value }
}
if ($ErrorRecord.Exception.PSObject.Properties['StatusCode']) {
return [int]$ErrorRecord.Exception.StatusCode
}
$message = [string]$ErrorRecord.Exception.Message
foreach ($pattern in @(
'status code does not indicate success:\s*(\d{3})'
'status(?:\s*code)?[\s:=]+(\d{3})'
'HTTP\s+(\d{3})'
'\((\d{3})\)'
)) {
if ($message -match $pattern) {
$parsed = [int]$Matches[1]
# Only real HTTP error codes. A three-digit number elsewhere in a message
# is not a status, and guessing one would suppress a legitimate retry.
if ($parsed -ge 400 -and $parsed -le 599) { return $parsed }
}
}
# No status anywhere: a transport failure. Retryable.
return $null
}
function Test-PersonaEnumerationRecoverable {
<#
.SYNOPSIS
True when a Graph 400 during user enumeration is plausibly caused by the
target attribute not existing in this tenant, and safe to retry without it.
.DESCRIPTION
A dev tenant with no app registration has no persona extension property, and
requesting it in $select then fails with 400. The obvious approach - reading
the offending property name out of the error - does not hold up in practice:
Graph often returns a 400 with an empty body, and Invoke-MgGraphRequest then
reports only "Response status code does not indicate success: BadRequest
(Bad Request)." with no property name and not even a numeric status in the
text (Get-PersonaGraphStatusCode still resolves it, from the structured
Response.StatusCode rather than the message).
So this checks the only two facts actually available: the status was 400,
and the target attribute was one of the properties requested. That is not
proof the attribute is the cause, but confirming it is impossible from what
Graph sends back, and the retry this justifies is cheap, read-only, and
confined to preview mode - if the attribute was not the problem, the retry
fails too and the original error still surfaces untouched.
.PARAMETER ErrorRecord
The error caught from Get-PersonaUsers.
.PARAMETER TargetAttribute
The configured target attribute name.
.PARAMETER SelectProperties
The $select list the failing request used.
#>
[CmdletBinding()]
[OutputType([bool])]
param(
[Parameter(Mandatory)] $ErrorRecord,
[Parameter(Mandatory)] [string] $TargetAttribute,
[Parameter(Mandatory)] [AllowEmptyCollection()] [string[]] $SelectProperties
)
((Get-PersonaGraphStatusCode -ErrorRecord $ErrorRecord) -eq 400) -and ($SelectProperties -ccontains $TargetAttribute)
}
function Get-PersonaRetryAfterMs {
<#
.SYNOPSIS
Reads a Retry-After header, in milliseconds, if present. Internal helper.
#>
[CmdletBinding()]
param([Parameter(Mandatory)] $ErrorRecord)
try {
$headers = $ErrorRecord.Exception.Response.Headers
if (-not $headers) { return $null }
$value = $null
if ($headers.PSObject.Properties['RetryAfter'] -and $headers.RetryAfter.Delta) {
$value = [int]$headers.RetryAfter.Delta.TotalSeconds
}
if ($null -ne $value -and $value -gt 0) { return $value * 1000 }
}
catch {
return $null
}
return $null
}
+99
View File
@@ -0,0 +1,99 @@
function New-PersonaDataCache {
<#
.SYNOPSIS
Creates a run-scoped cache for group and role lookups (NFR-002, FR-006).
.DESCRIPTION
The cache exists for exactly one run and is discarded with it. It is never
written to disk and never reused across runs a stale membership record
surviving into a later run would reclassify accounts from data nobody
checked, which is the same failure mode FR-013 guards against, arriving by a
different route.
Scope is deliberately narrow. Only lookups that are stable for the duration
of a single run are cached: a user's membership facets, keyed by Object ID.
Nothing derived from a rule evaluation is cached, so the cache can never
change a decision only how many times the same question is asked.
.OUTPUTS
PersonaEngine.DataCache
#>
[CmdletBinding()]
[OutputType([pscustomobject])]
param()
[pscustomobject]@{
PSTypeName = 'PersonaEngine.DataCache'
Membership = [System.Collections.Generic.Dictionary[string, object]]::new(
[System.StringComparer]::OrdinalIgnoreCase)
Hits = 0
Misses = 0
}
}
function Get-PersonaCachedMembership {
<#
.SYNOPSIS
Returns a cached MembershipRecord, or retrieves and caches one.
.DESCRIPTION
A failed lookup is cached alongside a successful one. That is intentional:
retrying the same failing endpoint once per rule would multiply load on an
endpoint already in trouble, and a user's classification must not depend on
which attempt happened to succeed. One answer per user per run, whatever it
was.
.PARAMETER Cache
The run-scoped cache from New-PersonaDataCache. When omitted, the retrieval
runs uncached the offline test path uses this.
.PARAMETER UserObjectId
The user to resolve.
.PARAMETER NeedDirect
Direct group membership is required by at least one enabled rule.
.PARAMETER NeedTransitive
Transitive group membership is required by at least one enabled rule.
.PARAMETER NeedRoles
Directory role assignments are required by at least one enabled rule.
#>
[CmdletBinding()]
[OutputType([pscustomobject])]
param(
[AllowNull()]
[object] $Cache,
[Parameter(Mandatory)]
[string] $UserObjectId,
[switch] $NeedDirect,
[switch] $NeedTransitive,
[switch] $NeedRoles
)
if ($null -eq $Cache) {
return Get-PersonaGroupMembership -UserObjectId $UserObjectId `
-NeedDirect:$NeedDirect -NeedTransitive:$NeedTransitive -NeedRoles:$NeedRoles
}
# The key includes the requested facets. A record fetched for direct membership
# only cannot answer a transitive question, and returning it would present an
# unretrieved facet as though it had been checked.
$key = '{0}|{1}{2}{3}' -f $UserObjectId, [int]$NeedDirect.IsPresent, [int]$NeedTransitive.IsPresent, [int]$NeedRoles.IsPresent
$existing = $null
if ($Cache.Membership.TryGetValue($key, [ref] $existing)) {
$Cache.Hits++
return $existing
}
$Cache.Misses++
$record = Get-PersonaGroupMembership -UserObjectId $UserObjectId `
-NeedDirect:$NeedDirect -NeedTransitive:$NeedTransitive -NeedRoles:$NeedRoles
$Cache.Membership[$key] = $record
$record
}
+283
View File
@@ -0,0 +1,283 @@
function Invoke-PersonaEngineRun {
<#
.SYNOPSIS
The classification run loop: retrieve, evaluate, compare, report, persist.
.DESCRIPTION
Lives in the module rather than in Invoke-PersonaEngine.ps1 so it can be
exercised offline with mocked data providers. That is not a testing
convenience - SC-004 requires proof that a -WhatIf run issues zero writes
across a full population, and a loop that only exists inside an entry script
needing a live tenant cannot be proven at all. What ships and what is tested
are the same code.
The write gate is supplied by the caller as a scriptblock, not re-derived
here. Invoke-PersonaEngine.ps1 passes one that closes over
$PSCmdlet.ShouldProcess, so there is still exactly one origin for the
decision (Principle III); this function does not know what -WhatIf is and
cannot accidentally disagree with it.
Per-user failures are contained. A membership lookup that fails yields
EvaluationError for that account and the run continues; a write that fails
yields UpdateFailed and the run continues. Only enumeration and
authentication failures end a run, because those affect the whole population
rather than one account.
.PARAMETER Configuration
The loaded configuration.
.PARAMETER TargetAttribute
The resolved target attribute.
.PARAMETER Context
The audit context.
.PARAMETER AuditParameters
Splat for Write-PersonaAuditRecord.
.PARAMETER IsEnforcing
Whether the run-level gate returned true. Controls the Action assigned by
Compare-PersonaValue; the per-user gate below still applies.
.PARAMETER ShouldProcessGate
Scriptblock taking (upn, description) and returning a boolean. Called once
per user that would otherwise be written. Defaults to a gate that always
refuses - the safe default, so a caller that forgets to supply one previews
rather than writes.
.PARAMETER UserObjectId
Single-user run.
.PARAMETER Tracing
Include ConditionTrace on results and audit records.
.PARAMETER StartedUtc
Run start timestamp, threaded down to Write-PersonaSummary so each summary
can show elapsed wall-clock time. Optional; a caller that omits it just gets
summaries without an elapsed figure.
.PARAMETER ResultsPath
Destination for the per-account results CSV. When supplied, it is
(re)written after every summary - interim and final. Optional; a caller
that omits it skips the CSV export entirely.
.OUTPUTS
PersonaEngine.RunOutcome carrying the counters and the exit code.
#>
[CmdletBinding()]
[OutputType([pscustomobject])]
param(
[Parameter(Mandatory)]
[object] $Configuration,
[Parameter(Mandatory)]
[string] $TargetAttribute,
[Parameter(Mandatory)]
[object] $Context,
[hashtable] $AuditParameters = @{ Destination = 'none' },
[switch] $IsEnforcing,
[scriptblock] $ShouldProcessGate = { param($Target, $Description) $false },
[string] $UserObjectId,
[switch] $Tracing,
[Nullable[datetime]] $StartedUtc,
[string] $ResultsPath
)
$EXIT_OK = 0
$EXIT_ENUMERATION = 3
$EXIT_DATA = 4
$EXIT_RECONCILIATION = 5
$exitCode = $EXIT_OK
$facets = Get-PersonaRequiredFacets -Rules $Configuration.Rules -DefaultMembershipMode $Configuration.DefaultMembershipMode
$needMembership = [bool]($facets.Direct -or $facets.Transitive -or $facets.Roles)
$selectProperties = Get-PersonaRequiredProperties -Configuration $Configuration
$cache = New-PersonaDataCache
$counters = New-PersonaRunCounter -Rules $Configuration.Rules
$users = $null
try {
$users = $UserObjectId `
? @(Get-PersonaUsers -SelectProperties $selectProperties -UserObjectId $UserObjectId) `
: @(Get-PersonaUsers -SelectProperties $selectProperties)
}
catch {
if ((-not $IsEnforcing) -and (Test-PersonaEnumerationRecoverable -ErrorRecord $_ -TargetAttribute $TargetAttribute -SelectProperties $selectProperties)) {
# Dev/test tenants often have no app registration yet, so the persona
# extension property was never created. Preview mode never writes
# regardless of what StoredPersona holds, so treating the attribute as
# absent (null) here is safe and lets development proceed without one.
# Enforcement still fails loudly - IsEnforcing gates this precisely
# because writing to an attribute that does not exist must never be
# silently tolerated.
Write-Warning "Graph rejected the request with a 400 while '$TargetAttribute' was in the requested properties. Retrying without it and treating it as null for this What-If run - if the retry also fails, the underlying error will be reported."
$fallbackProperties = @($selectProperties | Where-Object { $_ -cne $TargetAttribute })
try {
$users = $UserObjectId `
? @(Get-PersonaUsers -SelectProperties $fallbackProperties -UserObjectId $UserObjectId) `
: @(Get-PersonaUsers -SelectProperties $fallbackProperties)
}
catch {
return New-PersonaRunOutcome -Counters $counters -ExitCode $EXIT_ENUMERATION -FailureReason $_.Exception.Message
}
}
else {
# A partial population is worse than none: half a tenant classified looks
# like a successful run to everything downstream.
return New-PersonaRunOutcome -Counters $counters -ExitCode $EXIT_ENUMERATION -FailureReason $_.Exception.Message
}
}
foreach ($graphUser in $users) {
$membership = $null
if ($needMembership) {
$membership = Get-PersonaCachedMembership -Cache $cache `
-UserObjectId ([string](Get-PersonaMemberValue -Item $graphUser -Key 'id')) `
-NeedDirect:([bool]$facets.Direct) `
-NeedTransitive:([bool]$facets.Transitive) `
-NeedRoles:([bool]$facets.Roles)
}
$record = ConvertTo-PersonaUserRecord -GraphUser $graphUser -TargetAttribute $TargetAttribute -Membership $membership
$result = Resolve-UserPersona -UserRecord $record -Rules $Configuration.Rules `
-MaxDepth $Configuration.MaxConditionDepth `
-DefaultMembershipMode $Configuration.DefaultMembershipMode `
-IncludeTrace:$Tracing
$result = Compare-PersonaValue -Result $result -IsEnforcing:$IsEnforcing `
-TargetAttribute $TargetAttribute -ApprovedWritableAttributes $Configuration.ApprovedWritableAttributes
$previousValue = $null
# The only branch from which a write is reachable. Compare-PersonaValue has
# already applied FR-016 conditions 1-3; the gate below is condition 4.
if ($result.Action -eq 'Updated') {
$description = "Set '$TargetAttribute' to '$($result.CalculatedPersona)'"
if (& $ShouldProcessGate $result.UserPrincipalName $description) {
# Captured before the PATCH, never read back afterwards - a read-back
# returns the new value, and OTD-010 rollback needs the old one.
$previousValue = [string]$result.StoredPersona
$write = Set-UserPersonaAttribute `
-UserObjectId $result.AccountObjectId `
-AttributeName $TargetAttribute `
-Value ([string]$result.CalculatedPersona) `
-PreviousValue $previousValue `
-TargetAttribute $TargetAttribute `
-ApprovedWritableAttributes $Configuration.ApprovedWritableAttributes `
-Confirmed
if (-not $write.Succeeded) {
$result.Action = 'UpdateFailed'
$previousValue = $null
}
}
else {
$result.Action = 'WouldUpdate'
}
}
Write-UserPersonaResult -Result $result
Add-PersonaRunResult -Counters $counters -Result $result -UserRecord $record
New-PersonaAuditRecord -Context $Context -RecordType 'UserEvent' -Result $result `
-PreviousValue $previousValue -IncludeTrace:$Tracing |
Write-PersonaAuditRecord @AuditParameters
if ($Configuration.SummaryInterval -gt 0 -and ($counters.Processed % $Configuration.SummaryInterval) -eq 0) {
Write-PersonaSummary -Counters $counters -SummaryType 'Interim' -Mode $Context.Mode -StartedUtc $StartedUtc
if ($ResultsPath) { Export-PersonaResultsCsv -Counters $counters -Path $ResultsPath }
New-PersonaAuditRecord -Context $Context -RecordType 'Summary' -Counters $counters `
-Properties @{ summaryType = 'Interim' } | Write-PersonaAuditRecord @AuditParameters
if (-not (Test-PersonaReconciliation -Counters $counters)) {
$exitCode = $EXIT_RECONCILIATION
New-PersonaAuditRecord -Context $Context -RecordType 'EngineDefect' `
-Properties (Get-PersonaReconciliationDetail -Counters $counters) |
Write-PersonaAuditRecord @AuditParameters
}
}
}
# Always emitted, whatever the interval - including 0 (FR-020).
Write-PersonaSummary -Counters $counters -SummaryType 'Final' -Mode $Context.Mode -StartedUtc $StartedUtc
if ($ResultsPath) { Export-PersonaResultsCsv -Counters $counters -Path $ResultsPath }
New-PersonaAuditRecord -Context $Context -RecordType 'Summary' -Counters $counters `
-Properties @{ summaryType = 'Final' } | Write-PersonaAuditRecord @AuditParameters
if (-not (Test-PersonaReconciliation -Counters $counters)) {
$exitCode = $EXIT_RECONCILIATION
New-PersonaAuditRecord -Context $Context -RecordType 'EngineDefect' `
-Properties (Get-PersonaReconciliationDetail -Counters $counters) |
Write-PersonaAuditRecord @AuditParameters
}
elseif ($null -ne $Configuration.EvaluationErrorThreshold -and
$counters.EvaluationError -gt $Configuration.EvaluationErrorThreshold) {
# Past this count the population was classified from data that could not be
# trusted. Stored values were preserved (FR-014), so nothing is damaged - but
# reporting success would invite someone to draw conclusions from the run.
$exitCode = $EXIT_DATA
New-PersonaAuditRecord -Context $Context -RecordType 'EngineDefect' -Properties @{
severity = 'Error'
defect = 'EvaluationErrorThresholdExceeded'
evaluationError = [int]$counters.EvaluationError
threshold = [int]$Configuration.EvaluationErrorThreshold
processed = [int]$counters.Processed
description = 'Too many accounts could not be evaluated from trusted data. Stored personas were preserved; no classification conclusion should be drawn from this run.'
} | Write-PersonaAuditRecord @AuditParameters
}
New-PersonaRunOutcome -Counters $counters -ExitCode $exitCode
}
function New-PersonaRunOutcome {
<#
.SYNOPSIS
Wraps the run result for the entry script.
.DESCRIPTION
Carries the counters and the exit code together, so the caller cannot report
an exit code that disagrees with the numbers it prints.
#>
[CmdletBinding()]
[OutputType([pscustomobject])]
param(
[Parameter(Mandatory)] [object] $Counters,
[Parameter(Mandatory)] [int] $ExitCode,
[string] $FailureReason
)
[pscustomobject]@{
PSTypeName = 'PersonaEngine.RunOutcome'
Counters = $Counters
ExitCode = $ExitCode
FailureReason = $FailureReason
}
}
@@ -0,0 +1,175 @@
function ConvertTo-PersonaMembershipRecord {
<#
.SYNOPSIS
Converts raw membership responses into a normalized MembershipRecord.
.DESCRIPTION
The membership half of the normalization boundary (Principle IV). Callers
that already hold raw Graph collections the offline replay path, and any
future provider that fetches membership in bulk rather than per user use
this instead of reaching for New-PersonaMembershipRecord directly, so the
filtering rules live in one place.
Two filtering rules are applied and are the reason this function exists
rather than a straight constructor call:
1. memberOf and transitiveMemberOf return directory objects of mixed type.
Only `#microsoft.graph.group` entries become group Object IDs;
administrative units and directory roles arriving on that endpoint are
discarded. 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.
2. Role assignments are reduced to their roleDefinitionId (the role template
ID), which is stable across tenants. Assignment instance IDs are not, so
a configuration written against them would not survive a tenant move.
Retrieval status is supplied by the caller, never inferred from an empty
collection. An empty list means "checked, member of nothing"; only an unset
flag means "unknown".
.PARAMETER DirectMemberOf
Raw objects from the memberOf endpoint. Omit when not retrieved.
.PARAMETER TransitiveMemberOf
Raw objects from the transitiveMemberOf endpoint. Omit when not retrieved.
.PARAMETER RoleAssignments
Raw objects from the roleAssignments endpoint. Omit when not retrieved.
.PARAMETER DirectRetrieved
The direct lookup completed.
.PARAMETER TransitiveRetrieved
The transitive lookup completed.
.PARAMETER RolesRetrieved
The role lookup completed.
.PARAMETER DirectFailureReason
Sanitized reason the direct lookup failed.
.PARAMETER TransitiveFailureReason
Sanitized reason the transitive lookup failed.
.PARAMETER RolesFailureReason
Sanitized reason the role lookup failed.
.EXAMPLE
ConvertTo-PersonaMembershipRecord -DirectMemberOf $raw -DirectRetrieved
.OUTPUTS
PersonaEngine.MembershipRecord
#>
[CmdletBinding()]
[OutputType([pscustomobject])]
param(
[AllowNull()]
[object[]] $DirectMemberOf,
[AllowNull()]
[object[]] $TransitiveMemberOf,
[AllowNull()]
[object[]] $RoleAssignments,
[switch] $DirectRetrieved,
[switch] $TransitiveRetrieved,
[switch] $RolesRetrieved,
[string] $DirectFailureReason,
[string] $TransitiveFailureReason,
[string] $RolesFailureReason
)
$params = @{
DirectGroupObjectIds = ConvertTo-PersonaGroupIdList -Objects $DirectMemberOf
TransitiveGroupObjectIds = ConvertTo-PersonaGroupIdList -Objects $TransitiveMemberOf
DirectoryRoleIds = ConvertTo-PersonaRoleIdList -Objects $RoleAssignments
}
if ($DirectRetrieved) { $params['DirectRetrieved'] = $true }
if ($TransitiveRetrieved) { $params['TransitiveRetrieved'] = $true }
if ($RolesRetrieved) { $params['RolesRetrieved'] = $true }
if ($DirectFailureReason) { $params['DirectFailureReason'] = $DirectFailureReason }
if ($TransitiveFailureReason) { $params['TransitiveFailureReason'] = $TransitiveFailureReason }
if ($RolesFailureReason) { $params['RolesFailureReason'] = $RolesFailureReason }
New-PersonaMembershipRecord @params
}
function ConvertTo-PersonaGroupIdList {
<#
.SYNOPSIS
Extracts group Object IDs from a mixed directory-object collection.
.DESCRIPTION
Entries carrying an @odata.type other than #microsoft.graph.group are
discarded. An entry with no @odata.type is kept: the membership endpoints
omit the annotation when the collection is homogeneous, and discarding those
would silently empty the list.
#>
[CmdletBinding()]
[OutputType([string[]])]
param([AllowNull()] [object[]] $Objects)
$ids = [System.Collections.Generic.List[string]]::new()
foreach ($item in @($Objects)) {
if ($null -eq $item) { continue }
$type = Get-PersonaMemberValue -Item $item -Key '@odata.type'
if ($type -and $type -ne '#microsoft.graph.group') { continue }
$id = Get-PersonaMemberValue -Item $item -Key 'id'
if ($id) { $ids.Add([string]$id) }
}
, $ids.ToArray()
}
function ConvertTo-PersonaRoleIdList {
<#
.SYNOPSIS
Extracts role template IDs from a role-assignment collection.
#>
[CmdletBinding()]
[OutputType([string[]])]
param([AllowNull()] [object[]] $Objects)
$ids = [System.Collections.Generic.List[string]]::new()
foreach ($item in @($Objects)) {
if ($null -eq $item) { continue }
$id = Get-PersonaMemberValue -Item $item -Key 'roleDefinitionId'
if ($id) { $ids.Add([string]$id) }
}
, $ids.ToArray()
}
function Get-PersonaMemberValue {
<#
.SYNOPSIS
Reads a key from either a hashtable or an object.
.DESCRIPTION
Invoke-MgGraphRequest returns hashtables; fixtures loaded from JSON arrive as
PSCustomObjects. Both shapes reach normalization, so both are handled here
rather than forcing every call site to know which it has.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)] [object] $Item,
[Parameter(Mandatory)] [string] $Key
)
if ($Item -is [System.Collections.IDictionary]) {
return $Item.Contains($Key) ? $Item[$Key] : $null
}
$prop = $Item.PSObject.Properties[$Key]
$prop ? $prop.Value : $null
}
@@ -0,0 +1,80 @@
function ConvertTo-PersonaUserRecord {
<#
.SYNOPSIS
Converts a raw Graph user object into a normalized UserRecord.
.DESCRIPTION
The normalization boundary (Principle IV). Everything downstream of this
function is testable offline with synthetic data, because nothing downstream
knows Graph exists.
Graph returns hashtables from Invoke-MgGraphRequest, with camelCase keys and
the persona value under its full extension property name.
.PARAMETER GraphUser
The raw object from Get-PersonaUsers.
.PARAMETER TargetAttribute
Name of the persona attribute, read into StoredPersona.
.PARAMETER Membership
Optional MembershipRecord. Omitted when no rule needs membership data.
#>
[CmdletBinding()]
[OutputType([pscustomobject])]
param(
[Parameter(Mandatory, ValueFromPipeline)]
[object] $GraphUser,
[Parameter(Mandatory)]
[string] $TargetAttribute,
[object] $Membership
)
process {
$get = {
param($key)
if ($GraphUser -is [System.Collections.IDictionary]) {
return $GraphUser.Contains($key) ? $GraphUser[$key] : $null
}
$prop = $GraphUser.PSObject.Properties[$key]
return $prop ? $prop.Value : $null
}
$id = & $get 'id'
$upn = & $get 'userPrincipalName'
if (-not $id -or -not $upn) {
# An identity-less record is an upstream defect, not a user to skip.
# Skipping would silently shrink the population and still reconcile.
throw "Graph user object is missing 'id' or 'userPrincipalName'; cannot normalize."
}
$properties = @{
CompanyName = & $get 'companyName'
JobTitle = & $get 'jobTitle'
Department = & $get 'department'
}
# Any additional selected property, including extension attributes, is
# carried through so a rule can reference it without a code change.
$keys = ($GraphUser -is [System.Collections.IDictionary]) ? $GraphUser.Keys : $GraphUser.PSObject.Properties.Name
foreach ($key in $keys) {
if ($key -like '@odata*') { continue }
if (-not $properties.ContainsKey($key)) { $properties[$key] = & $get $key }
}
$enabled = & $get 'accountEnabled'
New-PersonaUserRecord `
-AccountObjectId ([string]$id) `
-UserPrincipalName ([string]$upn) `
-DisplayName ([string](& $get 'displayName')) `
-UserType ([string](& $get 'userType')) `
-AccountEnabled ($null -eq $enabled ? $true : [bool]$enabled) `
-Properties $properties `
-StoredPersona ([string](& $get $TargetAttribute)) `
-Membership $Membership
}
}
@@ -0,0 +1,97 @@
function New-PersonaMembershipRecord {
<#
.SYNOPSIS
Creates a normalized MembershipRecord.
.DESCRIPTION
Holds three independently-retrieved facets, each with its own retrieval
status: direct group membership, transitive group membership, and directory
role assignments.
Three facets rather than one "mode" because RE-007 makes membership mode a
per-condition choice. A single rule set may legitimately ask for transitive
membership in one rule and direct membership in another, so a record
carrying only one mode cannot answer both every user would become an
EvaluationError on whichever question the record could not serve.
Independent statuses also mean a failure is contained: if the transitive
lookup times out but the direct lookup succeeded, only conditions that need
transitive data become Unknown. Collapsing them into one 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
(FR-013) instead of silently misclassifying a privileged account.
.PARAMETER DirectGroupObjectIds
Groups the user is a direct member of.
.PARAMETER TransitiveGroupObjectIds
Groups the user is a transitive member of.
.PARAMETER DirectoryRoleIds
Directory roles assigned to the user.
.PARAMETER DirectRetrieved
Set only when the direct membership lookup genuinely completed.
.PARAMETER TransitiveRetrieved
Set only when the transitive membership lookup genuinely completed.
.PARAMETER RolesRetrieved
Set only when the role lookup genuinely completed.
.EXAMPLE
New-PersonaMembershipRecord -DirectGroupObjectIds $ids -DirectRetrieved
.EXAMPLE
New-PersonaMembershipRecord -TransitiveFailureReason 'Graph 503 after 5 attempts'
#>
[CmdletBinding()]
[OutputType([pscustomobject])]
param(
[string[]] $DirectGroupObjectIds = @(),
[string[]] $TransitiveGroupObjectIds = @(),
[string[]] $DirectoryRoleIds = @(),
[switch] $DirectRetrieved,
[switch] $TransitiveRetrieved,
[switch] $RolesRetrieved,
[string] $DirectFailureReason,
[string] $TransitiveFailureReason,
[string] $RolesFailureReason,
# Marks every facet as retrieved. Convenience for tests and for the common
# production case where all required lookups succeeded.
[switch] $AllRetrieved
)
foreach ($facet in @(
@{ Name = 'Direct'; Retrieved = $DirectRetrieved; Reason = $DirectFailureReason }
@{ Name = 'Transitive'; Retrieved = $TransitiveRetrieved; Reason = $TransitiveFailureReason }
@{ Name = 'Roles'; Retrieved = $RolesRetrieved; Reason = $RolesFailureReason }
)) {
# A success claim alongside a failure reason is a caller bug, not a state
# to interpret. Fail loudly rather than guess which was meant.
if (($facet.Retrieved -or $AllRetrieved) -and $facet.Reason) {
throw "Membership facet '$($facet.Name)' cannot be both retrieved and carry a failure reason."
}
}
[pscustomobject]@{
PSTypeName = 'PersonaEngine.MembershipRecord'
DirectGroupObjectIds = @($DirectGroupObjectIds)
DirectRetrieved = [bool]($DirectRetrieved -or $AllRetrieved)
DirectFailureReason = $DirectFailureReason
TransitiveGroupObjectIds = @($TransitiveGroupObjectIds)
TransitiveRetrieved = [bool]($TransitiveRetrieved -or $AllRetrieved)
TransitiveFailureReason = $TransitiveFailureReason
DirectoryRoleIds = @($DirectoryRoleIds)
RolesRetrieved = [bool]($RolesRetrieved -or $AllRetrieved)
RolesFailureReason = $RolesFailureReason
}
}
+100
View File
@@ -0,0 +1,100 @@
function New-PersonaUserRecord {
<#
.SYNOPSIS
Creates a normalized UserRecord the only user shape the rule engine sees.
.DESCRIPTION
Constitution Principle IV: the rule engine must never receive a raw directory
response. This function is that boundary. Everything downstream of it is
testable offline with synthetic data.
Properties are stored in a case-insensitive dictionary so rule authors need
not match directory casing (RE-006). An absent property returns $null, which
ordinary string comparisons treat as empty (FR-012).
.PARAMETER AccountObjectId
Immutable directory Object ID. Required; approved for logs.
.PARAMETER UserPrincipalName
Required; approved for logs.
.PARAMETER Properties
Evaluable property values. Copied into a case-insensitive dictionary.
.PARAMETER Membership
A MembershipRecord. When omitted, an empty record with every facet marked
unretrieved is used, so any membership condition evaluated against it yields
Unknown rather than a false non-match. A rule set with no membership
conditions never consults it.
.EXAMPLE
New-PersonaUserRecord -AccountObjectId $id -UserPrincipalName $upn -Properties @{ Department = 'Finance' }
#>
[CmdletBinding()]
[OutputType([pscustomobject])]
param(
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[string] $AccountObjectId,
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[string] $UserPrincipalName,
[string] $DisplayName,
[string] $UserType,
[bool] $AccountEnabled = $true,
[hashtable] $Properties = @{},
[AllowNull()]
[string] $StoredPersona,
[AllowNull()]
[pscustomobject] $Membership
)
# StringComparer::OrdinalIgnoreCase gives case-insensitive lookup without the
# cost of normalizing every key on every read.
$bag = [System.Collections.Generic.Dictionary[string, object]]::new(
[System.StringComparer]::OrdinalIgnoreCase)
foreach ($key in $Properties.Keys) {
$bag[[string]$key] = $Properties[$key]
}
# Intrinsic fields are also addressable as properties so a rule can target
# UserPrincipalName or AccountEnabled without a separate condition type.
# Explicit entries in -Properties win, so a caller can override for testing.
foreach ($intrinsic in @(
@{ Name = 'AccountObjectId'; Value = $AccountObjectId }
@{ Name = 'UserPrincipalName'; Value = $UserPrincipalName }
@{ Name = 'DisplayName'; Value = $DisplayName }
@{ Name = 'UserType'; Value = $UserType }
@{ Name = 'AccountEnabled'; Value = $AccountEnabled }
)) {
if (-not $bag.ContainsKey($intrinsic.Name)) {
$bag[$intrinsic.Name] = $intrinsic.Value
}
}
if ($null -eq $Membership) {
# Unretrieved, not empty: an absent lookup is unknown, never "member of
# nothing". The safe default is the one that produces EvaluationError.
$Membership = New-PersonaMembershipRecord
}
[pscustomobject]@{
PSTypeName = 'PersonaEngine.UserRecord'
AccountObjectId = $AccountObjectId
UserPrincipalName = $UserPrincipalName
DisplayName = $DisplayName
UserType = $UserType
AccountEnabled = $AccountEnabled
Properties = $bag
StoredPersona = $StoredPersona
Membership = $Membership
}
}
+70
View File
@@ -0,0 +1,70 @@
function Compare-PersonaValue {
<#
.SYNOPSIS
Decides the action for a decision result by comparing stored and calculated
values (FR-015, FR-016).
.DESCRIPTION
Sets Action on the result and returns it. The state machine (data-model.md):
EvaluationError -> Skipped no write, ever
Calculated == Stored -> Unchanged
Calculated != Stored, preview mode -> WouldUpdate no request built
Calculated != Stored, enforce mode -> Updated / UpdateFailed
Comparison is ORDINAL and case-sensitive, unlike rule evaluation. A stored
value of 'employee' against a calculated 'Employee' is a real difference
worth correcting, and treating it as equal would leave the directory
permanently inconsistent with the rule set. Rule matching stays
case-insensitive (RE-006); only change detection is exact.
.PARAMETER Result
A PersonaDecisionResult.
.PARAMETER IsEnforcing
Whether the caller's ShouldProcess gate returned true.
.PARAMETER TargetAttribute
The configured target attribute.
.PARAMETER ApprovedWritableAttributes
The approved list. A target absent from it can never be written.
#>
[CmdletBinding()]
[OutputType([pscustomobject])]
param(
[Parameter(Mandatory)]
[object] $Result,
[switch] $IsEnforcing,
[string] $TargetAttribute,
[string[]] $ApprovedWritableAttributes = @()
)
if ($Result.Outcome -eq 'EvaluationError') {
# FR-014: preserve the stored value. No write is attempted, and this is the
# only branch that must never be reachable by any later condition.
$Result.Action = 'Skipped'
return $Result
}
$stored = [string]$Result.StoredPersona
$calculated = [string]$Result.CalculatedPersona
if ([string]::Equals($stored, $calculated, [System.StringComparison]::Ordinal)) {
$Result.Action = 'Unchanged'
return $Result
}
# A target that is blank or unapproved can never be written, whatever the mode.
$targetValid = $TargetAttribute -and ($TargetAttribute -in $ApprovedWritableAttributes)
if (-not $targetValid) {
$Result.Action = 'Skipped'
return $Result
}
$Result.Action = $IsEnforcing ? 'Updated' : 'WouldUpdate'
return $Result
}
+78
View File
@@ -0,0 +1,78 @@
function New-PersonaWriteBody {
<#
.SYNOPSIS
Builds the PATCH body for a persona write the only function permitted to
do so (SC-005, NFR-006).
.DESCRIPTION
Returns a hashtable whose Count is exactly 1. Nothing else in the codebase
constructs a directory write body, so the single-attribute guarantee is a
property of one testable function rather than a convention every call site
must remember.
Two rejections are enforced here, both throwing rather than returning a
corrected body. A caller that asked to write the wrong attribute has a
defect; quietly substituting the right one would hide it until the day the
substitution was also wrong.
1. AttributeName must equal the configured target attribute.
2. The target attribute must appear in approvedWritableAttributes.
The second check is deliberately redundant with configuration validation.
Validation runs once at startup against the file; this runs on every write
against the values actually in hand, so a configuration object mutated
mid-run still cannot widen the blast radius.
.PARAMETER AttributeName
The attribute to write. Must equal TargetAttribute.
.PARAMETER Value
The calculated persona. May be an empty string to clear the attribute; may
not be $null, which Graph would interpret as a removal the engine never
intends to request implicitly.
.PARAMETER TargetAttribute
The configured target attribute.
.PARAMETER ApprovedWritableAttributes
The approved list from configuration.
.EXAMPLE
New-PersonaWriteBody -AttributeName $t -Value 'Employee' -TargetAttribute $t -ApprovedWritableAttributes @($t)
.OUTPUTS
System.Collections.Hashtable with exactly one key.
#>
[CmdletBinding()]
[OutputType([hashtable])]
param(
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[string] $AttributeName,
[Parameter(Mandatory)]
[AllowEmptyString()]
[string] $Value,
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[string] $TargetAttribute,
[Parameter(Mandatory)]
[AllowEmptyCollection()]
[string[]] $ApprovedWritableAttributes
)
# Ordinal comparison: extension property names are case-sensitive in Graph, and
# a case difference here means the caller is not writing the attribute the
# configuration approved.
if (-not [string]::Equals($AttributeName, $TargetAttribute, [System.StringComparison]::Ordinal)) {
throw "Refusing to build a write body for '$AttributeName': only the configured target attribute may be written."
}
if ($TargetAttribute -cnotin $ApprovedWritableAttributes) {
throw "Refusing to build a write body for '$TargetAttribute': the attribute is not present in approvedWritableAttributes."
}
@{ $AttributeName = $Value }
}
@@ -0,0 +1,110 @@
function Set-UserPersonaAttribute {
<#
.SYNOPSIS
Writes the calculated persona to one user (FR-016).
.DESCRIPTION
Issues PATCH /v1.0/users/{id} with a body built by New-PersonaWriteBody.
This function is reachable only when the caller's ShouldProcess gate has
already returned true. It does not re-derive the mode and does not own a
preview flag of its own a second source of truth for the write gate is
the defect class Principle III exists to prevent. What it does own is the
refusal to proceed without an explicit -Confirmed switch, so a call that
skipped the gate entirely fails loudly instead of writing.
previousValue is captured here, at write time, from the value the engine
actually observed before the PATCH. Reading it back afterwards would return
the new value; deriving it from the decision result would record what the
engine believed rather than what it replaced. Without it, OTD-010 rollback
is impossible retroactively no later run can reconstruct what a value used
to be.
.PARAMETER UserObjectId
The user to update.
.PARAMETER AttributeName
The attribute to write. Validated against the target by New-PersonaWriteBody.
.PARAMETER Value
The calculated persona.
.PARAMETER PreviousValue
The stored value observed before the write, recorded for rollback.
.PARAMETER TargetAttribute
The configured target attribute.
.PARAMETER ApprovedWritableAttributes
The approved list from configuration.
.PARAMETER Confirmed
Asserts that the caller's ShouldProcess gate returned true. Required.
.OUTPUTS
PersonaEngine.WriteResult
#>
[CmdletBinding()]
[OutputType([pscustomobject])]
param(
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[string] $UserObjectId,
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[string] $AttributeName,
[Parameter(Mandatory)]
[AllowEmptyString()]
[string] $Value,
[AllowNull()]
[AllowEmptyString()]
[string] $PreviousValue,
[Parameter(Mandatory)]
[ValidateNotNullOrEmpty()]
[string] $TargetAttribute,
[Parameter(Mandatory)]
[AllowEmptyCollection()]
[string[]] $ApprovedWritableAttributes,
[switch] $Confirmed
)
if (-not $Confirmed) {
# Not a guess about intent. A caller that reached here without the gate has
# a control-flow defect, and the only safe response is to refuse.
throw 'Set-UserPersonaAttribute was called without a confirmed ShouldProcess gate. No write was attempted.'
}
$body = New-PersonaWriteBody -AttributeName $AttributeName -Value $Value `
-TargetAttribute $TargetAttribute -ApprovedWritableAttributes $ApprovedWritableAttributes
$succeeded = $false
$failureReason = $null
try {
$null = Invoke-PersonaGraphRequest -Uri "/v1.0/users/$UserObjectId" -Method 'PATCH' -Body $body
$succeeded = $true
}
catch {
# A failed write is a per-user outcome, not a run-ending one. The run
# continues and the count surfaces in the summary; the stored value is
# untouched because the PATCH did not land.
$failureReason = $_.Exception.Message
Write-Verbose "Write failed for $UserObjectId : $failureReason"
}
[pscustomobject]@{
PSTypeName = 'PersonaEngine.WriteResult'
AccountObjectId = $UserObjectId
AttributeName = $AttributeName
Value = $Value
PreviousValue = $PreviousValue
Succeeded = $succeeded
FailureReason = $failureReason
}
}
@@ -0,0 +1,49 @@
function Export-PersonaResultsCsv {
<#
.SYNOPSIS
Writes the per-account results CSV (object ID, UPN, persona/status).
.DESCRIPTION
Called after every summary, interim and final, and overwrites the file each
time. Counters.Results accumulates for the whole run, so the file on disk
always lists every account processed so far, not just those since the last
summary.
Export failure never ends the run, matching Write-PersonaAuditRecord's
failure handling for the same reason: a locked file or a full disk is an
operational problem with the export, not a reason to abandon a
classification run mid-population.
.PARAMETER Counters
The run counter set.
.PARAMETER Path
Destination CSV file. The parent directory is created if it does not exist.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[object] $Counters,
[Parameter(Mandatory)]
[string] $Path
)
if ($Counters.Results.Count -eq 0) { return }
try {
$directory = Split-Path -Parent $Path
if ($directory -and -not (Test-Path -LiteralPath $directory)) {
$null = New-Item -ItemType Directory -Path $directory -Force -WhatIf:$false -Confirm:$false
}
# -WhatIf:$false / -Confirm:$false pin this write regardless of any ambient
# $WhatIfPreference in the caller's session, the same reason
# Write-PersonaAuditRecord pins its own sink writes: this is a report, not a
# directory mutation, and must not silently no-op under -WhatIf.
$Counters.Results | Export-Csv -LiteralPath $Path -NoTypeInformation -Encoding utf8NoBOM -Force -WhatIf:$false -Confirm:$false
}
catch {
Write-Warning "Results CSV export failed; the run continues without it: $($_.Exception.Message)"
}
}
+154
View File
@@ -0,0 +1,154 @@
function New-PersonaRunCounter {
<#
.SYNOPSIS
Creates the run counter set used by summaries and reconciliation (FR-019 - FR-021).
.DESCRIPTION
Holds two independent tallies that must never be conflated:
Outcome buckets Matched, Unclassified, EvaluationError - what the engine
decided. Mutually exclusive, and their sum must equal
Processed (SC-001, FR-021).
Action buckets Unchanged, WouldUpdate, Updated, UpdateFailed, Skipped -
what happened to the directory. Also mutually exclusive,
but they do NOT reconcile against Processed, because a
user can be Matched and Unchanged at the same time.
Reconciliation checks the outcome buckets only. Checking the action buckets
instead would pass on a run that lost users, because Skipped absorbs
anything unexplained.
RuleCounts is seeded from the full rule set, including disabled rules, at
construction. Seeding at construction rather than on first match is what
makes a zero-match rule distinguishable from an absent one - an operator
asking "did RULE-0030 fire?" gets "no, zero matches" rather than silence.
Results accumulates one row per processed account (AccountObjectId,
UserPrincipalName, persona/status) for Export-PersonaResultsCsv. It grows
for the life of the run, not just since the last summary, so the CSV a
summary writes always reflects every account processed so far.
.PARAMETER Rules
The business rule collection, used to seed RuleCounts.
.OUTPUTS
PersonaEngine.RunCounter
#>
[CmdletBinding()]
[OutputType([pscustomobject])]
param(
[Parameter(Mandatory)]
[AllowEmptyCollection()]
[object[]] $Rules
)
$ruleCounts = [System.Collections.Generic.List[object]]::new()
foreach ($rule in (@($Rules) | Sort-Object -Property @{ Expression = { [int]$_.priority } }, @{ Expression = { [string]$_.id } })) {
$ruleCounts.Add([pscustomobject]@{
RuleId = [string]$rule.id
Name = [string]$rule.name
Priority = [int]$rule.priority
Enabled = [bool]$rule.enabled
Persona = [string]$rule.persona
Matches = 0
})
}
[pscustomobject]@{
PSTypeName = 'PersonaEngine.RunCounter'
Processed = 0
Matched = 0
Unclassified = 0
EvaluationError = 0
Unchanged = 0
WouldUpdate = 0
Updated = 0
UpdateFailed = 0
Skipped = 0
RuleCounts = $ruleCounts
Results = [System.Collections.Generic.List[object]]::new()
}
}
function Add-PersonaRunResult {
<#
.SYNOPSIS
Records one decision result into the run counters.
.DESCRIPTION
The only function that increments counters. A single entry point is what
makes reconciliation meaningful: if call sites incremented directly, a
missed increment would look identical to a lost user, and the reconciliation
check would be reporting on its own bookkeeping rather than on the run.
Processed increments exactly once per result, before the outcome switch, so
an unrecognized outcome shows up as a reconciliation failure rather than
being quietly dropped.
.PARAMETER Counters
The run counter set.
.PARAMETER Result
A PersonaDecisionResult with both Outcome and Action populated.
.PARAMETER UserRecord
The normalized UserRecord the decision was made from. Optional, and used
only to carry CompanyName/Department onto the results CSV row - it is never
written to counters or audit records, so this does not widen what a
PersonaDecisionResult itself carries.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)] [object] $Counters,
[Parameter(Mandatory)] [object] $Result,
[object] $UserRecord
)
$Counters.Processed++
switch ([string]$Result.Outcome) {
'Matched' {
$Counters.Matched++
$entry = $Counters.RuleCounts | Where-Object { $_.RuleId -eq [string]$Result.MatchedRuleId } | Select-Object -First 1
if ($entry) { $entry.Matches++ }
}
'Unclassified' { $Counters.Unclassified++ }
'EvaluationError' { $Counters.EvaluationError++ }
}
switch ([string]$Result.Action) {
'Unchanged' { $Counters.Unchanged++ }
'WouldUpdate' { $Counters.WouldUpdate++ }
'Updated' { $Counters.Updated++ }
'UpdateFailed' { $Counters.UpdateFailed++ }
'Skipped' { $Counters.Skipped++ }
}
# TryGetValue rather than the indexer: Properties is a case-insensitive
# Dictionary, whose indexer throws on a missing key rather than returning
# $null. CompanyName/Department are always present when UserRecord comes from
# ConvertTo-PersonaUserRecord, but this stays safe for any other caller too.
$companyName = $null
$department = $null
if ($UserRecord) {
$null = $UserRecord.Properties.TryGetValue('CompanyName', [ref]$companyName)
$null = $UserRecord.Properties.TryGetValue('Department', [ref]$department)
}
# Matched carries the assigned persona; Unclassified/EvaluationError carry
# their outcome name, since CalculatedPersona is 'Unclassified' or $null there.
$Counters.Results.Add([pscustomobject]@{
AccountObjectId = [string]$Result.AccountObjectId
UserPrincipalName = [string]$Result.UserPrincipalName
PersonaStatus = [string]$Result.Outcome -eq 'Matched' ? [string]$Result.CalculatedPersona : [string]$Result.Outcome
CompanyName = [string]$companyName
Department = [string]$department
})
}
@@ -0,0 +1,70 @@
function Test-PersonaReconciliation {
<#
.SYNOPSIS
Verifies Processed = Matched + Unclassified + EvaluationError (FR-021, SC-007).
.DESCRIPTION
Run at every summary and once at completion. Returns $true when the outcome
buckets account for every processed user.
A mismatch is not a data condition and is never reported as one. Outcomes are
assigned by the engine, exactly one per user (SC-001), so if the totals do
not add up the engine lost a user or double-counted one. That is a defect in
this codebase, and the caller emits an EngineDefect record and exit code 5
rather than folding the discrepancy into an ordinary counter where it would
be invisible.
Deliberately checks only the outcome buckets. The action buckets - Unchanged,
WouldUpdate, Updated, UpdateFailed, Skipped - also sum to Processed in a
correct run, but Skipped is a catch-all that would absorb a lost user and let
the check pass on a broken run.
.PARAMETER Counters
The run counter set.
.OUTPUTS
System.Boolean
#>
[CmdletBinding()]
[OutputType([bool])]
param(
[Parameter(Mandatory)]
[object] $Counters
)
$sum = [int]$Counters.Matched + [int]$Counters.Unclassified + [int]$Counters.EvaluationError
[int]$Counters.Processed -eq $sum
}
function Get-PersonaReconciliationDetail {
<#
.SYNOPSIS
Describes a reconciliation failure precisely enough to debug it.
.DESCRIPTION
Emitted onto the EngineDefect record. Carries the expected total, the actual
total, and the difference, because "reconciliation failed" alone does not
tell a maintainer whether users were lost or double-counted - and the sign of
the difference does.
#>
[CmdletBinding()]
[OutputType([hashtable])]
param(
[Parameter(Mandatory)]
[object] $Counters
)
$sum = [int]$Counters.Matched + [int]$Counters.Unclassified + [int]$Counters.EvaluationError
@{
severity = 'Error'
defect = 'ReconciliationFailure'
processed = [int]$Counters.Processed
outcomeTotal = $sum
difference = [int]$Counters.Processed - $sum
matched = [int]$Counters.Matched
unclassified = [int]$Counters.Unclassified
evaluationError = [int]$Counters.EvaluationError
description = 'Processed does not equal Matched + Unclassified + EvaluationError. Every processed user must land in exactly one outcome bucket (SC-001); a mismatch is an engine defect, not a property of the data.'
}
}
+98
View File
@@ -0,0 +1,98 @@
function Write-PersonaSummary {
<#
.SYNOPSIS
Renders the rule-match table, outcome totals, and reconciliation result
(FR-019, FR-020, FR-021).
.DESCRIPTION
Emitted every summaryInterval users and once at completion.
Every business rule appears, including disabled rules and rules with zero
matches. A rule that never fired and a rule that is not in the configuration
look identical if zero-match rules are omitted, and the difference is exactly
what an operator investigating "why did nobody get classified as Tier0" needs
to see.
Reconciliation is displayed on every summary, not only when it fails. A check
that is only visible when broken gives an operator no reason to believe it
ran at all.
.PARAMETER Counters
The run counter set.
.PARAMETER SummaryType
Interim or Final. Final is emitted regardless of interval, including when
the interval is 0 (FR-020).
.PARAMETER Mode
Preview or Enforce, shown in the header so a screenshot of a summary is
self-describing.
.PARAMETER StartedUtc
Run start timestamp. When supplied, the header shows elapsed wall-clock
time since the run began. Optional so callers that only care about counts
are not forced to thread a clock through.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[object] $Counters,
[ValidateSet('Interim', 'Final')]
[string] $SummaryType = 'Interim',
[ValidateSet('Preview', 'Enforce')]
[string] $Mode = 'Preview',
[Nullable[datetime]] $StartedUtc
)
$reconciled = Test-PersonaReconciliation -Counters $Counters
# $StartedUtc arrives here already unwrapped to a plain [datetime] - PowerShell
# collapses [Nullable[datetime]] to DateTime (or $null) at the call boundary, so
# a null check is used rather than .Value / .HasValue.
$elapsed = ($null -ne $StartedUtc) ? ('{0:hh\:mm\:ss}' -f ([DateTime]::UtcNow - $StartedUtc)) : $null
Write-Host ''
Write-Host ('=' * 100) -ForegroundColor DarkGray
Write-Host (
$elapsed `
? ("{0} summary - mode: {1} - elapsed: {2} - processed: {3}" -f $SummaryType, $Mode, $elapsed, $Counters.Processed) `
: ("{0} summary - mode: {1} - processed: {2}" -f $SummaryType, $Mode, $Counters.Processed)
) -ForegroundColor Cyan
Write-Host ('=' * 100) -ForegroundColor DarkGray
Write-Host ('{0,-28} {1,-40} {2,-9} {3,10} {4,8}' -f 'Rule ID', 'Name', 'Priority', 'Enabled', 'Matches') -ForegroundColor DarkGray
foreach ($entry in $Counters.RuleCounts) {
# A disabled rule is dimmed rather than hidden: it is part of the
# configuration and its absence from the output would read as a deletion.
$colour = if (-not $entry.Enabled) { 'DarkGray' } elseif ($entry.Matches -gt 0) { 'Green' } else { 'Gray' }
Write-Host ('{0,-28} {1,-40} {2,-9} {3,10} {4,8}' -f
$entry.RuleId,
($entry.Name.Length -gt 40 ? $entry.Name.Substring(0, 37) + '...' : $entry.Name),
$entry.Priority,
$entry.Enabled,
$entry.Matches) -ForegroundColor $colour
}
Write-Host ''
Write-Host ('Outcomes Matched: {0} Unclassified: {1} EvaluationError: {2}' -f
$Counters.Matched, $Counters.Unclassified, $Counters.EvaluationError)
Write-Host ('Actions Unchanged: {0} WouldUpdate: {1} Updated: {2} UpdateFailed: {3} Skipped: {4}' -f
$Counters.Unchanged, $Counters.WouldUpdate, $Counters.Updated, $Counters.UpdateFailed, $Counters.Skipped)
if ($reconciled) {
Write-Host ('Reconciliation PASS {0} = {1} + {2} + {3}' -f
$Counters.Processed, $Counters.Matched, $Counters.Unclassified, $Counters.EvaluationError) -ForegroundColor Green
}
else {
Write-Host ('Reconciliation FAIL {0} != {1} + {2} + {3} - this is an engine defect (FR-021)' -f
$Counters.Processed, $Counters.Matched, $Counters.Unclassified, $Counters.EvaluationError) -ForegroundColor Red
}
Write-Host ('=' * 100) -ForegroundColor DarkGray
Write-Host ''
}
@@ -0,0 +1,52 @@
function Write-UserPersonaResult {
<#
.SYNOPSIS
Displays one user's result immediately after evaluation (FR-018, SC-012).
.DESCRIPTION
Emitted per user as it is processed, not batched at the end, so an operator
watching a long run sees progress and can stop early if the impact looks
wrong. That per-user visibility is the whole point of a preview run.
Carries UPN and Account Object ID, which are approved for logs. Never emits
tokens, headers, or raw responses (Principle V).
.PARAMETER Result
A PersonaDecisionResult with Action already set by Compare-PersonaValue.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory, ValueFromPipeline)]
[object] $Result
)
process {
$colour = switch ($Result.Action) {
'Updated' { 'Green' }
'WouldUpdate' { 'Yellow' }
'UpdateFailed' { 'Red' }
'Skipped' { 'Red' }
default { 'Gray' }
}
$detail = switch ($Result.Outcome) {
'Matched' { "$($Result.CalculatedPersona) [$($Result.MatchedRuleId)]" }
'Unclassified' { 'Unclassified' }
'EvaluationError' { "EvaluationError - $($Result.EvaluationErrorReason)" }
}
$change = switch ($Result.Action) {
'Unchanged' { '=' }
'WouldUpdate' { "'$($Result.StoredPersona)' -> '$($Result.CalculatedPersona)'" }
'Updated' { "'$($Result.StoredPersona)' -> '$($Result.CalculatedPersona)'" }
'UpdateFailed' { "write failed; '$($Result.StoredPersona)' retained" }
'Skipped' { "'$($Result.StoredPersona)' retained" }
default { '' }
}
$line = '{0,-14} {1,-45} {2,-40} {3}' -f $Result.Action, $Result.UserPrincipalName, $detail, $change
Write-Host $line -ForegroundColor $colour
Write-Verbose " ObjectId=$($Result.AccountObjectId) RulesEvaluated=$($Result.RulesEvaluated) DurationMs=$($Result.DurationMs)"
}
}
+131
View File
@@ -0,0 +1,131 @@
function Resolve-UserPersona {
<#
.SYNOPSIS
Produces the authoritative persona decision for one user.
.DESCRIPTION
The engine's core. Pure: it takes a normalized record and a rule set and
returns a decision. No Graph, no authentication, no console, no filesystem,
no clock (constitution Principle IV, enforced by tests/Test-EnginePurity.ps1).
Evaluation order (FR-008, FR-009):
1. Discard disabled rules.
2. Sort by ascending priority lower evaluates first (RE-002).
3. Evaluate in order and STOP at the first True.
Outcomes are mutually exclusive (SC-001):
Matched a rule returned True
Unclassified every enabled rule returned False (FR-010)
EvaluationError any rule returned Unknown before a match was found
The EvaluationError rule is subtle and deliberate: an Unknown encountered
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 precisely the
privilege-downgrade misclassification FR-013 exists to prevent. Preserving
the stored value is the only safe answer.
Timing uses a monotonic stopwatch rather than the wall clock, so no
wall-clock value can influence a decision.
.PARAMETER UserRecord
Normalized record from New-PersonaUserRecord.
.PARAMETER Rules
The business rule collection.
.PARAMETER MaxDepth
Maximum condition nesting depth (RE-004).
.PARAMETER DefaultMembershipMode
Membership mode for conditions that do not specify one (RE-007).
.PARAMETER IncludeTrace
Populates ConditionTrace with per-rule diagnostic results. Off by default;
the caller gates this behind -Debug (Principle V).
.OUTPUTS
PersonaEngine.PersonaDecisionResult
#>
[CmdletBinding()]
[OutputType([pscustomobject])]
param(
[Parameter(Mandatory)]
[object] $UserRecord,
[Parameter(Mandatory)]
[AllowEmptyCollection()]
[object[]] $Rules,
[ValidateRange(1, 10)]
[int] $MaxDepth = 5,
[ValidateSet('Direct', 'Transitive')]
[string] $DefaultMembershipMode = 'Direct',
[switch] $IncludeTrace
)
$stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
$outcome = 'Unclassified'
$matchedRuleId = $null
$calculatedPersona = 'Unclassified'
$errorReason = $null
$rulesEvaluated = 0
$trace = [System.Collections.Generic.List[object]]::new()
# Sort by priority, then by Id. The Id tiebreak matters: duplicate priorities
# are a validation error, but if one reaches the engine the result must still
# be the same on every run and every host (SC-003) rather than depending on
# collection order.
$ordered = @($Rules) |
Where-Object { $_.enabled } |
Sort-Object -Property @{ Expression = { [int]$_.priority } }, @{ Expression = { [string]$_.id } }
foreach ($rule in $ordered) {
$rulesEvaluated++
$result = Test-PersonaRule -Rule $rule -UserRecord $UserRecord `
-MaxDepth $MaxDepth -DefaultMembershipMode $DefaultMembershipMode
if ($IncludeTrace) {
$trace.Add([pscustomobject]@{
RuleId = [string]$rule.id
Priority = [int]$rule.priority
Result = $result
})
}
if ($result -eq 'True') {
$outcome = 'Matched'
$matchedRuleId = [string]$rule.id
$calculatedPersona = [string]$rule.persona
break
}
if ($result -eq 'Unknown') {
$outcome = 'EvaluationError'
$calculatedPersona = $null
$errorReason = "Rule '$([string]$rule.id)' could not be evaluated: required data was unavailable or the condition could not be interpreted."
break
}
}
$stopwatch.Stop()
[pscustomobject]@{
PSTypeName = 'PersonaEngine.PersonaDecisionResult'
AccountObjectId = $UserRecord.AccountObjectId
UserPrincipalName = $UserRecord.UserPrincipalName
Outcome = $outcome
MatchedRuleId = $matchedRuleId
CalculatedPersona = $calculatedPersona
StoredPersona = $UserRecord.StoredPersona
Action = 'Pending' # set by the comparison stage (Compare-PersonaValue)
EvaluationErrorReason = $errorReason
RulesEvaluated = $rulesEvaluated
DurationMs = [int]$stopwatch.ElapsedMilliseconds
ConditionTrace = $IncludeTrace ? $trace.ToArray() : $null
}
}
+235
View File
@@ -0,0 +1,235 @@
function Test-PersonaCondition {
<#
.SYNOPSIS
Evaluates one leaf condition against a normalized user record.
.DESCRIPTION
Returns 'True', 'False', or 'Unknown' never a boolean.
The tri-state is the whole safety argument (FR-013). A boolean return has
no way to distinguish "the user is not in that group" from "we could not
find out", and collapsing the second into the first is exactly how an
unavailable data source misclassifies a privileged account as an ordinary
user. 'Unknown' propagates upward and ultimately produces EvaluationError,
which preserves the stored persona.
Null handling (FR-012): for ordinary string comparisons an absent or null
property is treated as an empty string and never fails evaluation. The
isNull / isNotNull operators exist for intentional null matching, and treat
both $null and the empty string as null.
Comparison is case-insensitive (RE-006).
.PARAMETER Condition
A leaf condition object with Type, Operator, and the operands its operator
requires.
.PARAMETER UserRecord
The normalized record produced by New-PersonaUserRecord.
.PARAMETER DefaultMembershipMode
Membership mode used when the condition does not specify one (RE-007).
.OUTPUTS
System.String 'True', 'False', or 'Unknown'.
#>
[CmdletBinding()]
[OutputType([string])]
param(
[Parameter(Mandatory)]
[object] $Condition,
[Parameter(Mandatory)]
[object] $UserRecord,
[ValidateSet('Direct', 'Transitive')]
[string] $DefaultMembershipMode = 'Direct'
)
$operator = [string]$Condition.operator
$type = if ($Condition.type) { [string]$Condition.type } else { 'property' }
switch ($type) {
{ $_ -in @('membership', 'role') } {
return Test-PersonaMembershipCondition -Condition $Condition -UserRecord $UserRecord -DefaultMembershipMode $DefaultMembershipMode
}
'property' {
$name = [string]$Condition.property
$raw = $UserRecord.Properties[$name]
# Intentional null matching happens before the empty-string coercion,
# otherwise isNull could never be true.
switch ($operator) {
'isNull' { return (Test-PersonaValueIsNull $raw) ? 'True' : 'False' }
'isNotNull' { return (Test-PersonaValueIsNull $raw) ? 'False' : 'True' }
}
# FR-012: null and absent both compare as empty.
$value = if ($null -eq $raw) { '' } else { [string]$raw }
switch ($operator) {
'equals' { return (Test-PersonaStringEquals $value ([string]$Condition.value)) ? 'True' : 'False' }
'notEquals' { return (Test-PersonaStringEquals $value ([string]$Condition.value)) ? 'False' : 'True' }
'contains' { return ($value.ToLowerInvariant().Contains(([string]$Condition.value).ToLowerInvariant())) ? 'True' : 'False' }
'notContains' { return ($value.ToLowerInvariant().Contains(([string]$Condition.value).ToLowerInvariant())) ? 'False' : 'True' }
'startsWith' { return ($value.StartsWith([string]$Condition.value, [System.StringComparison]::OrdinalIgnoreCase)) ? 'True' : 'False' }
'endsWith' { return ($value.EndsWith([string]$Condition.value, [System.StringComparison]::OrdinalIgnoreCase)) ? 'True' : 'False' }
'matchesRegex' {
$pattern = [string]$Condition.value
# RE-006: validate before execution. An invalid pattern is a
# configuration defect, and treating it as a non-match would
# hide the defect behind a plausible-looking result.
try {
$regex = [regex]::new($pattern, [System.Text.RegularExpressions.RegexOptions]::IgnoreCase)
}
catch {
return 'Unknown'
}
return ($regex.IsMatch($value)) ? 'True' : 'False'
}
'in' {
foreach ($candidate in @($Condition.values)) {
if (Test-PersonaStringEquals $value ([string]$candidate)) { return 'True' }
}
return 'False'
}
'notIn' {
foreach ($candidate in @($Condition.values)) {
if (Test-PersonaStringEquals $value ([string]$candidate)) { return 'False' }
}
return 'True'
}
default {
# An unsupported operator reaching evaluation means validation
# let it through. Unknown preserves the stored value rather than
# inventing a decision from a condition nobody can interpret.
return 'Unknown'
}
}
}
default { return 'Unknown' }
}
}
function Test-PersonaMembershipCondition {
<#
.SYNOPSIS
Evaluates a membership or directory-role condition. Internal helper.
.DESCRIPTION
Selects the facet of the membership record that answers the question the
condition actually asks direct groups, transitive groups, or directory
roles and returns 'Unknown' if that specific facet was not retrieved
(FR-013).
Facet selection is deliberately exact. Transitive membership is a superset
of direct, so answering a direct question from transitive data would produce
false positives, and answering a transitive question from direct data would
produce false negatives. Neither is acceptable when the answer decides
whether an account is classified as an administrator.
Because each facet carries its own retrieval status, a failure in one does
not contaminate the others: a transitive lookup that times out leaves
direct-membership conditions fully evaluable.
#>
[CmdletBinding()]
[OutputType([string])]
param(
[Parameter(Mandatory)] [object] $Condition,
[Parameter(Mandatory)] [object] $UserRecord,
[string] $DefaultMembershipMode = 'Direct'
)
$membership = $UserRecord.Membership
if ($null -eq $membership) { return 'Unknown' }
$isRole = ([string]$Condition.type -eq 'role')
if ($isRole) {
if (-not $membership.RolesRetrieved) { return 'Unknown' }
$haystack = @($membership.DirectoryRoleIds)
$needles = @($Condition.roleIds)
}
else {
$requestedMode = if ($Condition.membershipMode) { [string]$Condition.membershipMode } else { $DefaultMembershipMode }
switch ($requestedMode.ToLowerInvariant()) {
'transitive' {
if (-not $membership.TransitiveRetrieved) { return 'Unknown' }
$haystack = @($membership.TransitiveGroupObjectIds)
}
'direct' {
if (-not $membership.DirectRetrieved) { return 'Unknown' }
$haystack = @($membership.DirectGroupObjectIds)
}
default { return 'Unknown' }
}
$needles = @($Condition.groupObjectIds)
}
$found = $false
foreach ($needle in $needles) {
foreach ($held in $haystack) {
if (Test-PersonaStringEquals ([string]$held) ([string]$needle)) {
$found = $true
break
}
}
if ($found) { break }
}
switch ([string]$Condition.operator) {
'memberOf' { return $found ? 'True' : 'False' }
'notMemberOf' { return $found ? 'False' : 'True' }
default { return 'Unknown' }
}
}
function Test-PersonaStringEquals {
<#
.SYNOPSIS
Case-insensitive ordinal string comparison (RE-006). Internal helper.
#>
[CmdletBinding()]
[OutputType([bool])]
param(
[AllowNull()] [string] $Left,
[AllowNull()] [string] $Right
)
[string]::Equals($Left, $Right, [System.StringComparison]::OrdinalIgnoreCase)
}
function Test-PersonaValueIsNull {
<#
.SYNOPSIS
Determines whether a property value counts as null. Internal helper.
.DESCRIPTION
Both $null and the empty string count. A directory routinely returns an
empty string for a cleared attribute, and a rule author asking "is this
unset" means the same thing in both cases.
#>
[CmdletBinding()]
[OutputType([bool])]
param(
[Parameter(Position = 0)]
[AllowNull()]
[object] $Value
)
if ($null -eq $Value) { return $true }
return [string]::IsNullOrEmpty([string]$Value)
}
@@ -0,0 +1,101 @@
function Test-PersonaConditionGroup {
<#
.SYNOPSIS
Evaluates an all/any condition group, propagating Unknown correctly.
.DESCRIPTION
The propagation table (data-model.md) is the safety argument in four rows:
all + any False -> False a definite non-match wins
all + only True and Unknown -> Unknown cannot confirm
any + any True -> True a definite match wins
any + only False and Unknown -> Unknown cannot rule out
The two "definite wins" rows matter as much as the two Unknown rows. If an
'all' group already contains a False, the result is False regardless of any
unknown sibling the rule cannot match either way, so degrading to
EvaluationError there would produce spurious errors and mask real ones.
Depth is bounded (RE-004). The root group is depth 1.
.PARAMETER Group
A condition group with operator 'all' or 'any' and a conditions collection.
.PARAMETER UserRecord
The normalized record to evaluate against.
.PARAMETER MaxDepth
Maximum nesting depth. Default 5, hard ceiling 10.
.PARAMETER CurrentDepth
Internal recursion counter. Callers leave this at its default.
.OUTPUTS
System.String 'True', 'False', or 'Unknown'.
#>
[CmdletBinding()]
[OutputType([string])]
param(
[Parameter(Mandatory)]
[object] $Group,
[Parameter(Mandatory)]
[object] $UserRecord,
[ValidateRange(1, 10)]
[int] $MaxDepth = 5,
[ValidateSet('Direct', 'Transitive')]
[string] $DefaultMembershipMode = 'Direct',
[int] $CurrentDepth = 1
)
# Exceeding the depth limit is a configuration defect that validation should
# have caught. Unknown rather than silent truncation: a truncated condition
# tree evaluates a rule the author did not write.
if ($CurrentDepth -gt $MaxDepth) { return 'Unknown' }
$operator = ([string]$Group.operator).ToLowerInvariant()
if ($operator -notin @('all', 'any')) { return 'Unknown' }
$children = @($Group.conditions)
if ($children.Count -eq 0) { return 'Unknown' }
$sawUnknown = $false
foreach ($child in $children) {
# A child is a group when it carries its own conditions collection.
$isGroup = $null -ne $child.PSObject.Properties['conditions'] -and $null -ne $child.conditions
$result = if ($isGroup) {
Test-PersonaConditionGroup -Group $child -UserRecord $UserRecord `
-MaxDepth $MaxDepth -DefaultMembershipMode $DefaultMembershipMode `
-CurrentDepth ($CurrentDepth + 1)
}
else {
Test-PersonaCondition -Condition $child -UserRecord $UserRecord `
-DefaultMembershipMode $DefaultMembershipMode
}
switch ($result) {
'Unknown' { $sawUnknown = $true }
'False' {
# Short-circuit only on the definite result that decides the group.
if ($operator -eq 'all') { return 'False' }
}
'True' {
if ($operator -eq 'any') { return 'True' }
}
}
}
# No definite result decided the group. If anything was unknown, the group is
# unknown; otherwise every child agreed with the group's identity element.
if ($sawUnknown) { return 'Unknown' }
return ($operator -eq 'all') ? 'True' : 'False'
}
+40
View File
@@ -0,0 +1,40 @@
function Test-PersonaRule {
<#
.SYNOPSIS
Evaluates a single business rule's root condition group against a user.
.DESCRIPTION
Returns 'True', 'False', or 'Unknown'. Disabled rules are not evaluated
here Resolve-UserPersona filters them out before evaluation so they are
excluded from the enabled rule count as well as from the result.
.PARAMETER Rule
A business rule with a match condition group.
.PARAMETER UserRecord
The normalized record to evaluate against.
.OUTPUTS
System.String 'True', 'False', or 'Unknown'.
#>
[CmdletBinding()]
[OutputType([string])]
param(
[Parameter(Mandatory)]
[object] $Rule,
[Parameter(Mandatory)]
[object] $UserRecord,
[ValidateRange(1, 10)]
[int] $MaxDepth = 5,
[ValidateSet('Direct', 'Transitive')]
[string] $DefaultMembershipMode = 'Direct'
)
if ($null -eq $Rule.match) { return 'Unknown' }
Test-PersonaConditionGroup -Group $Rule.match -UserRecord $UserRecord `
-MaxDepth $MaxDepth -DefaultMembershipMode $DefaultMembershipMode
}