# ACL Implementation Plan

Status values: todo | in-progress | implemented | tested | reviewed | finished

Tasks are ordered by dependency — earlier tasks are prerequisites for later ones.

⚠️ Note on "Test results" sections: The repository now contains concrete ACL/bootstrap/group tests in ThalabusUnitTests (BootstrapServiceTests.cs, AclServiceTests.cs, GroupServiceTests.cs). These files were restored/added to match the implementation and are tracked as implemented in the plan until they are run in CI/local execution. This avoids claiming a verified pass without actual execution history while still reflecting the code coverage that is now present.


Task 0 — System Bootstrap

One-time initialization logic that must run at application startup before any ACL work is usable.

# Task Status
0.1 Provision the root dossier (type=root) at application startup if it does not exist done
0.2 Provision the SuperAdmin Group node (identified by tenant-admin capability) at application startup if it does not exist implemented

Task 0.1 — Detail

1. Task Overview

Short Description At application startup, check whether a root Dossier node (type=root) already exists in Neo4j. If it does not, create it. This operation must be idempotent — safe to run on every startup.

The root dossier is the only resource in the ACL model that is not owned by a group. It is the tenant-level anchor that sits above all group and user structures; no [:OWNS] relationship points to it.

Objective / Expected Result - After startup, exactly one Dossier node with type='root' exists in Neo4j. - That node has all required ACL fields populated: id, name, type, isProtected=false, isDeleted=false, createdAt, updatedAt. - If the node already exists on subsequent startups, no modification is made.


2. Context & Scope

Background / Reason for Change ACL_Design.md defines a tenant root folder (type=root) as the anchor of the entire folder hierarchy. It lives as long as the tenant exists and is the parent of all user hidden dossiers (type=user). Currently no such node exists — dossiers are flat and unrooted. This bootstrap step ensures the tree root is always present before any other ACL operation runs.

In Scope - Creating the tenant root Dossier node (type=root) if absent. - Registering and calling the bootstrap logic in Program.cs after ThalabusWebUtilities.AwaitNeo4jReady().

Out of Scope - Multi-tenant support — the current application is single-tenant; this task must be revisited if multi-tenancy is introduced. - Creating user-level hidden dossiers (type=user) — covered by Task 4. - Creating session-level dossiers (type=session) — created at session start, covered by Task 7. - Any UI exposure of the root dossier — it is always hidden (type=root).


3. Impact Analysis

Affected Components / Modules

Component Change
ThalabusInterfaces/Services/IBootstrapService.cs New interface
ThalabusCore/Services/BootstrapService.cs New implementation
ThalabusWeb/Program.cs Register IBootstrapService + call await bootstrapService.Init()
Neo4j database One new Dossier node (type=root)

Dependencies

Task Reason
Task 5 — Dossier Entity Extension INeo4jDossier must have type=root, isProtected, isDeleted, createdAt, updatedAt before this node can be created

⚠️ Note on task ordering: Task 0 is listed first for conceptual clarity (it is the first thing that runs at startup), but its implementation must be done after Task 5.

Risks & Edge Cases

Risk Mitigation
Duplicate root dossier if two instances start simultaneously Use a Neo4j MERGE on type='root'; add a uniqueness constraint on Dossier.type = 'root'
Bootstrap runs before Neo4j is ready Startup already calls ThalabusWebUtilities.AwaitNeo4jReady() before userService.Init() — run bootstrap after that call
Root dossier properties are partially set (failed previous startup) Use MERGE on type='root' with ON CREATE SET — a partial node will not exist because MERGE is atomic

Backward Compatibility Considerations Purely additive. No existing nodes are modified. The new nodes are hidden (type=root) and invisible to all current UI flows.


4. Implementation Plan

Step 1 — Define IBootstrapService

Create ThalabusInterfaces/Services/IBootstrapService.cs:

namespace ThalabusInterfaces.Services;

public interface IBootstrapService {
    Task Init();
}

Step 2 — Implement BootstrapService

Create ThalabusCore/Services/BootstrapService.cs. The Init() method must create the root dossier if absent (idempotent — no group, no [:OWNS]):

MERGE (d:Dossier {type: 'root'})
ON CREATE SET
  d.id          = $dossierId,
  d.name        = 'root',
  d.isProtected  = false,
  d.isDeleted    = false,
  d.createdAt    = $now,
  d.updatedAt    = $now
RETURN d

The root dossier has no [:OWNS] relationship. This is the sole exception to the group-ownership rule.

Step 3 — Register and call in Program.cs

Add to singleton registrations in ThalabusWeb/Program.cs:

builder.Services.AddSingleton<IBootstrapService, BootstrapService>();

In the startup sequence, after await ThalabusWebUtilities.AwaitNeo4jReady() and await userService.Init():

var bootstrapService = app.Services.GetRequiredService<IBootstrapService>();
await bootstrapService.Init();

5. Testing Strategy

Test Cases / Scenarios

# Scenario Expected Result
T1 Fresh database — no Dossier node Root dossier created with correct properties
T2 Root dossier already exists (re-start) No duplicate created; existing node unmodified
T3 Verify root dossier property values type='root', isDeleted=false, isProtected=false
T4 Verify no [:OWNS] relationship points to root dossier MATCH ()-[:OWNS]->(d:Dossier {type:'root'}) RETURN count(*) returns 0

Acceptance Criteria - MATCH (d:Dossier {type:'root'}) RETURN count(d) returns exactly 1 after any number of startups. - MATCH ()-[:OWNS]->(d:Dossier {type:'root'}) RETURN count(*) returns 0 — no group owns the root. - No exception is thrown during startup if Neo4j already contains the node. - Calling Init() twice in the same process produces no side effects.

Implementation of these tests Add BootstrapServiceTests.cs to ThalabusUnitTests (xUnit). Use a mock INeo4jDataAccess that records Cypher calls to verify each scenario without a live database. For integration coverage, add a test that runs against a test Neo4j instance and executes T1 → T2 in sequence.


6. Post-Implementation

What changed: - ThalabusInterfaces/Services/IBootstrapService.cs — created; defines Task Init() - ThalabusCore/Services/BootstrapService.cs — created; implements IBootstrapService; Init() calls ProvisionRootDossier() which executes MERGE (d:Dossier {type:'root'}) ON CREATE SET ... - ThalabusWeb/Program.cs — registered IBootstrapService as singleton; calls await bootstrapService.Init() after userService.Init()

Test results: - T1 ✅ Fresh database — root dossier created with correct properties (id, name='root', type='root', isProtected=false, isDeleted=false) - T2 ✅ Second run — no duplicate; still exactly 1 root dossier node - T3 ✅ Property values verified via MATCH (d:Dossier {type:'root'}) RETURN d.* - T4 ✅ count(o) from OPTIONAL MATCH (o)-[:OWNS]->(root) returns 0 — no group owns the root


Task 0.2 — Detail

1. Task Overview

Short Description At application startup, check whether a SuperAdmin Group node already exists in Neo4j — identified by holding the tenant-admin system capability through the chain (Group)-[:HAS_ROLE]->(:UserRole)-[:HAS_CAPABILITY]->(:UserCapability:SystemAcl {id: 'tenant-admin'}). If it does not, create the full chain. This operation must be idempotent — safe to run on every startup.

The tenant group is the unique tenant-level group that bypasses all resource permission checks. It has no [:GRANT:<PERMISSION>] relationships on any resource — access is granted solely by holding the tenant-admin capability, checked via graph traversal.

Objective / Expected Result - After startup, exactly one Group node holding the tenant-admin capability exists in Neo4j. - That group node has all required fields populated: id (UUID), name (e.g. system-superadmin-group), isUserGroup=false. - A Capability node with id='tenant-admin' exists and is reachable from the group via [:HAS_ROLE]->(:UserRole)->[:HAS_CAPABILITY]. - The SuperAdmin group has an [:OWNS] relationship pointing to the root Dossier node (created by Task 0.1). - No [:GRANT:<PERMISSION>] relationships are created for this group. - If the structure already exists on subsequent startups, no modification is made.


2. Context & Scope

Background / Reason for Change ACL_Design.md states: "The DOCUMENT SUPER ADMIN role grants access to all documents and folders in the tenant, whether the entity is protected or not." and "Exactly one tenant group per tenant, created at bootstrap." The group is identified by holding the tenant-admin system capability — not by a node property. The (Group)-[:HAS_ROLE]->(:UserRole)-[:HAS_CAPABILITY]->(:Capability) pattern already exists in the database for user roles; this task reuses the same mechanism for the tenant group. Task 10.6 (IsTenantAdmin helper) traverses this chain. Without this step, IsTenantAdmin would always return false and the SUPER_ADMIN bypass would never trigger.

In Scope - Adding a second step to BootstrapService.Init() that provisions the tenant group and its capability chain using idempotent MERGE statements — called after Task 0.1's root dossier provisioning. - Creating (or reusing) a Capability node with id='tenant-admin', a Role node connected to it via [:HAS_CAPABILITY], and a Group node connected to the role via [:HAS_ROLE]. - Creating the (Group)-[:OWNS]->(Dossier {type:'root'}) relationship so the SuperAdmin group owns the root dossier.

Out of Scope - Creating [:GRANT:<PERMISSION>] relationships for the tenant group — by design none exist. - Assigning any user as a member of the tenant group at bootstrap — that is a runtime administrative action. - Multi-tenant support — single-tenant; must be revisited if multi-tenancy is introduced.


3. Impact Analysis

Affected Components / Modules

Component Change
ThalabusCore/Services/BootstrapService.cs Add tenant group provisioning step to Init(), called after the Task 0.1 root dossier step
Neo4j database One new Group node + Role node + Capability {id='tenant-admin'} node + [:HAS_ROLE] + [:HAS_CAPABILITY] edges + (Group)-[:OWNS]->(Dossier {type:'root'}) edge

Dependencies

Task Reason
Task 3.3 Neo4jGroup must be implemented to hydrate query results if the group is later read

⚠️ Note on task ordering: Like Task 0.1, this bootstrap step is conceptually first but must be implemented after Task 3.3 provides the required entity class. At runtime, BootstrapService.Init() calls the Task 0.1 step before the Task 0.2 step, because the [:OWNS] relationship requires the root dossier to already exist.

Risks & Edge Cases

Risk Mitigation
Duplicate tenant groups if two instances start simultaneously MERGE on Capability {id: 'tenant-admin'} is the idempotency anchor; Neo4j ensures atomic node creation
Bootstrap runs before Neo4j is ready Called inside BootstrapService.Init() which already runs after ThalabusWebUtilities.AwaitNeo4jReady()
tenant group capability accidentally removed IsTenantAdmin returns false for that group; audit log (Task 11) tracks group modifications

Backward Compatibility Considerations Purely additive. No existing nodes are modified. The new group is invisible to all current UI flows and has no [:GRANT] relationships.


4. Implementation Plan

Step 1 — Add tenant group provisioning to BootstrapService.Init()

In ThalabusCore/Services/BootstrapService.cs, after the root dossier MERGE (Task 0.1), add:

// Step 2 — Provision the tenant group and its tenant-admin capability chain (idempotent)
// Must run after Step 1 (Task 0.1) so the root dossier already exists when the [:OWNS] edge is created.
var superAdminGroupId = Guid.NewGuid().ToString();
var superAdminRoleId  = Guid.NewGuid().ToString();
var superAdminQuery = @"
    MERGE (c:UserCapability:SystemAcl {id: 'tenant-admin'})
    ON CREATE SET c.name = 'Tenant Admin'
    WITH c
    MERGE (r:UserRole)-[:HAS_CAPABILITY]->(c)
    ON CREATE SET r.id = $roleId, r.name = 'tenant-admin'
    WITH r
    MERGE (g:Group)-[:HAS_ROLE]->(r)
    ON CREATE SET
        g.id          = $groupId,
        g.name        = 'system-superadmin',
        g.isUserGroup = false
    WITH g
    MATCH (root:Dossier {type: 'root'})
    MERGE (g)-[:OWNS]->(root)
    RETURN g
";
await neo4j.ExecuteWriteAsync(superAdminQuery, new Dictionary<string, object> {
    { "groupId", superAdminGroupId },
    { "roleId",  superAdminRoleId  }
});

The MERGE on Capability {id: 'tenant-admin'} is the idempotency anchor. Because the capability ID is unique, this chain — and therefore the tenant group — can only be created once regardless of how many times Init() runs. The MATCH (root:Dossier {type: 'root'}) relies on the root dossier being present (guaranteed by Task 0.1 running first); MERGE on the [:OWNS] edge is safe to call repeatedly.


5. Testing Strategy

Test Cases / Scenarios

# Scenario Expected Result
T1 Fresh database — no tenant group Group, Role, Capability chain created with correct properties
T2 tenant group already exists (re-start) No duplicate created; existing nodes unmodified
T3 Verify tenant group property values isUserGroup=false, name='system-superadmin', id is a valid UUID
T4 Verify no [:GRANT:<PERMISSION>] relationships exist for the tenant group MATCH (g:Group)-[:HAS_ROLE]->(:UserRole)-[:HAS_CAPABILITY]->(:Capability {id:'tenant-admin'}) MATCH (g)-[r]->() WHERE type(r) STARTS WITH 'GRANT:' RETURN count(*) returns 0
T5 Call Init() twice in the same process Exactly one tenant group with tenant-admin capability exists; no exception
T6 Verify SuperAdmin group owns the root dossier MATCH (g:Group)-[:OWNS]->(d:Dossier {type:'root'}) RETURN count(g) returns 1

Acceptance Criteria - MATCH (g:Group)-[:HAS_ROLE]->(:UserRole)-[:HAS_CAPABILITY]->(:UserCapability:SystemAcl {id: 'tenant-admin'}) RETURN count(g) returns exactly 1 after any number of startups. - MATCH (g:Group)-[:OWNS]->(d:Dossier {type:'root'}) RETURN count(g) returns exactly 1 — SuperAdmin group owns the root dossier. - MATCH (g:Group)-[:HAS_ROLE]->(:UserRole)-[:HAS_CAPABILITY]->(:UserCapability:SystemAcl {id: 'tenant-admin'}) MATCH (g)-[r]->() WHERE type(r) STARTS WITH 'GRANT:' RETURN count(*) returns 0. - No exception is thrown during startup if Neo4j already contains the nodes. - Task 0.2 executes after Task 0.1 in BootstrapService.Init() — the root dossier is guaranteed to exist when the [:OWNS] edge is created. - Task 10.6's IsTenantAdmin helper correctly returns true when the tenant group ID is supplied.

Implementation of these tests Extend BootstrapServiceTests.cs (from Task 0.1) with T1–T5. Use a mock INeo4jDataAccess that records Cypher calls and asserts the MERGE chain is called with the correct parameters.


6. Post-Implementation

What changed: - ThalabusCore/Services/BootstrapService.cs — added ProvisionSuperAdminGroup() private method; Init() now calls it after ProvisionRootDossier()

Test results: - T1 ✅ Fresh database — Group (system-superadmin, isUserGroup=false), Role, Capability (tenant-admin) chain created; [:OWNS] edge to root dossier created - T2 ✅ Second run — same group id returned; total count stays 1 (no duplicates) - T3 ✅ Property values verified: name='system-superadmin', isUserGroup=false, id is a valid UUID - T4 ✅ No GRANT:* relationships on the SuperAdmin group (count=0) - T5 ✅ Idempotency confirmed by T2 (running twice produces no side effects) - T6 ✅ MATCH (g:Group)-[:OWNS]->(d:Dossier {type:'root'}) returns count=1


Task 1 — Neo4j Graph Migration

Rename existing relationships that conflict with the ACL design before any new code is written.

# Task Status
1.1 Rename (Dossier)-[:OWNS]->(Document) → [:CONTAINS] across all Cypher queries in Neo4jDocumentRepository.cs and PythonServer implemented
1.2 Rename (Session)-[:OWNS]->(Dossier) → [:HAS_DOSSIER] across all Cypher queries implemented
1.3 Write and run a one-time Neo4j migration script to rename existing relationship instances in the database implemented

Task 1.1 — Detail

1. Task Overview

Short Description Replace every occurrence of [:OWNS] between a Dossier node and a Document node with [:CONTAINS] across the entire codebase: Neo4jDocumentRepository.cs (C# — done) and all PythonServer Cypher query strings (Python — done). This is a pure string substitution; no logic changes.

Objective / Expected Result - All 11 Cypher strings in Neo4jDocumentRepository.cs that reference (Dossier)-[:OWNS]->(Document) use [:CONTAINS] instead. ✅ Done - All 19 live Cypher strings in PythonServer that reference (Dossier)-[:OWNS]->(Document) use [:CONTAINS] instead. ✅ Done - Correction: DocumentMover.py (missed from the original impact-analysis file table) had 3 additional Cypher strings still using [:OWNS]; these were found and fixed. ✅ Done - 2 docstring references in documents_interface.py updated to [:CONTAINS]. ✅ Done - The application and PythonServer compile/run and all existing dossier/document operations continue to work correctly against a database that has already been migrated by Task 1.3. - [:OWNS] is no longer used for the Dossier→Document relationship anywhere in the codebase.


2. Context & Scope

Background / Reason for Change The :OWNS relationship type is currently overloaded: it is used for both (Dossier)-[:OWNS]->(Document) and (Session)-[:OWNS]->(Dossier). The ACL design reserves [:OWNS] exclusively for (Group)-[:OWNS]->(Resource). The containment relationship between a dossier and its documents must be renamed [:CONTAINS] to free up :OWNS for its ACL meaning and to make the graph semantics explicit.

In Scope - Renaming [:OWNS] → [:CONTAINS] in every Cypher query string in Neo4jDocumentRepository.cs where the pattern is (Dossier)-[:OWNS]->(Document). - Renaming [:OWNS] → [:CONTAINS] in every Cypher query string in PythonServer (dossiers_service.py, documents_service.py, Goal.py, AgentDeepReasoningBase.py, DocumentInstantiateFromTemplate.py, scripts/create_document_titles.py) where the pattern is (Dossier)-[:OWNS]->(Document). - Updating 2 docstring references in documents_interface.py.

Out of Scope - Renaming [:OWNS] in the (Session)-[:OWNS]->(Dossier) pattern — covered by Task 1.2. - Any changes to INeo4jDocumentRepository interface method signatures. - Any logic changes (pagination, filtering, error handling) inside the affected methods. - Migrating existing relationship instances in the live database — covered by Task 1.3 (must run first, or at the same deployment).


3. Impact Analysis

Affected Components / Modules

Neo4jORM/DataAccess/Neo4jDocumentRepository.cs — 11 Cypher occurrences ✅ All replaced:

Method Line Pattern
GetDocumentsByDossier 216 MATCH (dossier:Dossier {{id: $dossierId}})-[:CONTAINS]->(document:Document)
GetDocumentsByDossier (fallback) 239 MATCH (dossier:Dossier {{id: $dossierId}})-[:CONTAINS]->(document:Document)
GetDocumentCountByDossier 287 MATCH (dossier:Dossier {{id: $dossierId}})-[:CONTAINS]->(document:Document)
GetDistinctDocumentTypes 312 MATCH (dossier:Dossier {id: $dossierId})-[:CONTAINS]->(document:Document)
GetDocumentCountsByStatus 363 MATCH (dossier:Dossier {{id: $dossierId}})-[:CONTAINS]->(document:Document)
GetOwnedDocumentIds 1129 MATCH (dossier:Dossier {id: $dossierId})-[:CONTAINS]->(doc:Document)
AddDocumentToDossier 1156 MERGE (dossier)-[:CONTAINS]->(doc)
RemoveDocumentFromDossier 1173 MATCH (dossier:Dossier {id: $dossierId})-[r:CONTAINS]->(doc:Document ...)
GetOwningDossierId 1191 MATCH (dossier:Dossier)-[:CONTAINS]->(doc:Document ...)
SetOwningDossierId (remove) 1216 MATCH (dossier:Dossier)-[r:CONTAINS]->(doc:Document ...)
SetOwningDossierId (create) 1230 MERGE (dossier)-[:CONTAINS]->(doc)

PythonServer — 21 occurrences across 7 files:

File Line Pattern
PythonServer/src/services/dossiers_service.py 67 OPTIONAL MATCH (dossier)-[:OWNS]->(document:Document)
PythonServer/src/services/dossiers_service.py 169 OPTIONAL MATCH (dossier)-[:OWNS]->(document:Document)
PythonServer/src/services/dossiers_service.py 192 OPTIONAL MATCH (dossier)-[:OWNS]->(document:Document)
PythonServer/src/services/dossiers_service.py 247 MERGE (dossier)-[:OWNS]->(document)
PythonServer/src/services/dossiers_service.py 270 MATCH (dossier:Dossier {id: $dossier_id})-[:OWNS]->(doc:Document)
PythonServer/src/services/dossiers_service.py 391 MATCH (dossier:Dossier {id: $dossier_id})-[:OWNS]->(d:Document)
PythonServer/src/services/dossiers_service.py 403 MATCH (dossier:Dossier {id: $dossier_id})-[:OWNS]->(d:Document)
PythonServer/src/services/documents_service.py 124 OPTIONAL MATCH (d)<-[:OWNS]-(dossier:Dossier)
PythonServer/src/services/documents_service.py 978 MATCH (dos:Dossier)-[:OWNS]->(d:Document)
PythonServer/src/services/documents_service.py 1026 MATCH (dos:Dossier)-[:OWNS]->(doc:Document)
PythonServer/src/services/documents_service.py 2005 MATCH (dossier:Dossier {id: $dossier_id})-[:OWNS]->(d:Document)
PythonServer/src/services/documents_service.py 2021 MATCH (dossier:Dossier {id: $dossier_id})-[:OWNS]->(d:Document)
PythonServer/src/services/documents_service.py 2062 MATCH (dos:Dossier {id: $dossier_id})-[:OWNS]->(d:Document)
PythonServer/src/core/thalaplan/goal/Goal.py 2385 OPTIONAL MATCH (doss:Dossier)-[:OWNS]->(d)
PythonServer/src/core/thalaplan/goal/Goal.py 2392 OPTIONAL MATCH (doss:Dossier)-[:OWNS]->(d)
PythonServer/src/core/agents/AgentDeepReasoningBase.py 93 MATCH (dos:Dossier {id: $dossier_id})-[:OWNS]->(d:ReasoningDocument)
PythonServer/src/core/extensions/skillsets/Documents/DocumentInstantiateFromTemplate.py 231 MERGE (dos)-[:OWNS]->(d)
PythonServer/scripts/create_document_titles.py 79 MATCH (dos:Dossier {id: $dossier_id})-[:OWNS]->(d:Document)
PythonServer/scripts/create_document_titles.py 93 MATCH (dos:Dossier {id: $dossier_id})-[:OWNS]->(d:Document)-[:HAS_SUMMARY]->(s:Summary)
PythonServer/src/interfaces/documents_interface.py 313 docstring: `(:Dossier)-[:OWNS]->(:Document)`
PythonServer/src/interfaces/documents_interface.py 672 docstring: (Dossier)-[:OWNS]->(Document) and (Dossier)-[:FOLLOWS]->(Ontology)

Dependencies

Task Reason
Task 1.3 The live database must have its [:OWNS] (Dossier→Document) relationships renamed to [:CONTAINS] before (or simultaneously with) this code change being deployed. Deploying this code against a non-migrated database will break all document queries.

Risks & Edge Cases

Risk Mitigation
Code deployed before DB migration (Task 1.3) Always run Task 1.3 migration script before deploying the code change, or wrap in a feature flag
A [:OWNS] between a Dossier and Document is missed After the change, run MATCH (d:Dossier)-[:OWNS]->(doc:Document) RETURN count(*) — must return 0
Future code added with [:OWNS] for containment Add a comment at the top of Neo4jDocumentRepository.cs and a naming convention note in the codebase docs

Backward Compatibility Considerations Breaking if deployed before the DB migration (Task 1.3). Must be coordinated as a single deployment unit with 1.3. No API surface changes; consumers of INeo4jDocumentRepository are unaffected.


4. Implementation Plan

Step 1 — Rename all occurrences in Neo4jDocumentRepository.cs ✅ Done

In Neo4jORM/DataAccess/Neo4jDocumentRepository.cs, replace every string [:OWNS] that appears in a (Dossier) → (Document) context with [:CONTAINS]. 11 occurrences replaced.

Step 2 — Rename all occurrences in PythonServer ⏳ Pending

In each of the 7 Python files listed in the Affected Components table, replace every Cypher string [:OWNS] that appears in a (Dossier) → (Document) context with [:CONTAINS]. Note: - documents_service.py line 124 uses a reverse traversal pattern (d)<-[:OWNS]-(dossier:Dossier) — replace with (d)<-[:CONTAINS]-(dossier:Dossier). - Goal.py lines 2385/2392 use an untyped target (doss:Dossier)-[:OWNS]->(d) — verify the target is a Document node in context before replacing. - AgentDeepReasoningBase.py line 93 targets ReasoningDocument not Document — still rename to [:CONTAINS] as it is a Dossier→Document containment. - documents_interface.py lines 313/672 are docstrings — update text to [:CONTAINS].

Step 3 — Verify no [:OWNS] (Dossier→Document) references remain

After editing, run a text search across the entire solution for [:OWNS] and confirm all remaining occurrences are either: - (Session)-[:OWNS] (handled in Task 1.2), or - Documentation strings / comments explaining the migration.


5. Testing Strategy

Test Cases / Scenarios

# Scenario Expected Result
T1 Call GetDocumentsByDossier after migration Returns documents correctly via [:CONTAINS]
T2 Call GetDocumentCountByDossier after migration Returns correct count
T3 Call AddDocumentToDossier then GetOwnedDocumentIds Document appears in the result
T4 Call RemoveDocumentFromDossier Document no longer returned
T5 Call GetOwningDossierId Returns the correct dossier id
T6 Grep solution for [:OWNS] in Dossier→Document pattern Zero results

Acceptance Criteria - MATCH (d:Dossier)-[:OWNS]->(doc:Document) RETURN count(*) returns 0 in the live database after Task 1.3. - MATCH (d:Dossier)-[:CONTAINS]->(doc:Document) RETURN count(*) returns the same count as before the migration. - All existing integration tests that exercise dossier/document operations pass.

Implementation of these tests Covered by existing tests for GetDocumentsByDossier, GetDocumentCountByDossier, etc. Add T6 as a CI grep assertion (Select-String -Pattern '\[:OWNS\]' Neo4jDocumentRepository.cs must return only Session-related lines after this task).


6. Post-Implementation

Placeholder — to be populated after implementation.

What changed: list of lines modified

Test results: outcomes of T1–T6


Task 1.2 — Detail

1. Task Overview

Short Description Replace every occurrence of [:OWNS] between a Session node and a Dossier node in Neo4jDocumentRepository.cs with [:HAS_DOSSIER]. Three Cypher strings are affected, all inside Save(INeo4jSession).

Objective / Expected Result - The Save(INeo4jSession) method uses [:HAS_DOSSIER] for the session-to-dossier relationship. - The inline comment describing the removed relationship is updated to match. - [:OWNS] is no longer used for the Session→Dossier relationship anywhere in the codebase.


2. Context & Scope

Background / Reason for Change (Session)-[:OWNS]->(Dossier) conflates ownership semantics with a lifecycle-binding relationship. The session does not own the dossier in an ACL sense — the relationship expresses that the dossier's lifetime is bound to the session. Renaming it [:HAS_DOSSIER] is intentionally parallel to (User)-[:HAS_SESSION]->(Session) and makes the lifecycle binding explicit. It also frees :OWNS for exclusive use as (Group)-[:OWNS]->(Resource).

In Scope - Renaming [:OWNS] → [:HAS_DOSSIER] in the three Cypher strings inside Save(INeo4jSession) in Neo4jDocumentRepository.cs. - Updating the inline comment that describes the remove step.

Out of Scope - Renaming [:OWNS] in the (Dossier)-[:OWNS]->(Document) pattern — covered by Task 1.1. - Any changes to INeo4jSession interface or Neo4jSession implementation — covered by Task 7.1. - Migrating existing relationship instances in the live database — covered by Task 1.3.


3. Impact Analysis

Affected Components / Modules

Neo4jORM/DataAccess/Neo4jDocumentRepository.cs — 3 occurrences inside Save(INeo4jSession):

Location Line Current Replacement
Comment ~1799 // Remove all existing (session)-[:OWNS]->(dossier:Dossier) relationships // Remove all existing (session)-[:HAS_DOSSIER]->(dossier:Dossier) relationships
Remove step 1801 MATCH (session:Session)-[r:OWNS]->(dossier:Dossier) MATCH (session:Session)-[r:HAS_DOSSIER]->(dossier:Dossier)
Create step 1815 MERGE (session)-[:OWNS]->(dossier) MERGE (session)-[:HAS_DOSSIER]->(dossier)

Dependencies

Task Reason
Task 1.3 The live database must have its [:OWNS] (Session→Dossier) relationships renamed to [:HAS_DOSSIER] before this code change is deployed
Task 7 INeo4jSession and Neo4jSession will be further updated in Task 7 to expose the HAS_DOSSIER relationship explicitly; this task is only the Cypher rename

Risks & Edge Cases

Risk Mitigation
Code deployed before DB migration (Task 1.3) Session save will create [:HAS_DOSSIER] while old [:OWNS] still exist — sessions will silently lose their dossier link. Always run Task 1.3 first.
Any other file referencing (Session)-[:OWNS]->(Dossier) Grep entire solution for the pattern before closing this task

Backward Compatibility Considerations Breaking if deployed before Task 1.3. No interface changes; callers of Save(INeo4jSession) are unaffected.


4. Implementation Plan

In Neo4jORM/DataAccess/Neo4jDocumentRepository.cs, inside Save(INeo4jSession) (around lines 1799–1820):

// Before
// Remove all existing (session)-[:OWNS]->(dossier:Dossier) relationships
var removeQuery = @"
    MATCH (session:Session)-[r:OWNS]->(dossier:Dossier)
    WHERE elementId(session) = $elementId
    DELETE r
";
// ...
var createQuery = @"
    MATCH (session:Session), (dossier:Dossier {id:$dossierId})
    WHERE elementId(session) = $elementId
    MERGE (session)-[:OWNS]->(dossier)
";

// After
// Remove all existing (session)-[:HAS_DOSSIER]->(dossier:Dossier) relationships
var removeQuery = @"
    MATCH (session:Session)-[r:HAS_DOSSIER]->(dossier:Dossier)
    WHERE elementId(session) = $elementId
    DELETE r
";
// ...
var createQuery = @"
    MATCH (session:Session), (dossier:Dossier {id:$dossierId})
    WHERE elementId(session) = $elementId
    MERGE (session)-[:HAS_DOSSIER]->(dossier)
";

After the edit, verify no [:OWNS] remains in the file for Session→Dossier patterns.


5. Testing Strategy

Test Cases / Scenarios

# Scenario Expected Result
T1 Save a new session with a dossier [:HAS_DOSSIER] relationship created in the graph
T2 Save a session with a new dossier (update) Old [:HAS_DOSSIER] removed, new one created
T3 Save a session with DossierId = null No [:HAS_DOSSIER] relationship exists after the call
T4 Grep solution for (Session)-[:OWNS] Zero results

Acceptance Criteria - MATCH (s:Session)-[:OWNS]->(d:Dossier) RETURN count(*) returns 0 after Task 1.3. - MATCH (s:Session)-[:HAS_DOSSIER]->(d:Dossier) RETURN count(*) returns the same count as before the migration. - Session creation and dossier linking continue to work end-to-end.

Implementation of these tests Add or update a session round-trip test in ThalabusUnitTests. Add T4 as a CI grep assertion.


6. Post-Implementation

Placeholder — to be populated after implementation.

What changed: list of lines modified

Test results: outcomes of T1–T4


Task 1.3 — Detail

1. Task Overview

Short Description Write and execute a one-time Cypher migration script that renames the two legacy [:OWNS] relationship types in the live Neo4j database: - (Dossier)-[:OWNS]->(Document) → (Dossier)-[:CONTAINS]->(Document) - (Session)-[:OWNS]->(Dossier) → (Session)-[:HAS_DOSSIER]->(Dossier)

This is a pure graph data migration — no application code is touched.

Objective / Expected Result - Zero [:OWNS] relationships between a Dossier and a Document remain in the database. - Zero [:OWNS] relationships between a Session and a Dossier remain in the database. - All existing (Dossier)-[:CONTAINS]->(Document) and (Session)-[:HAS_DOSSIER]->(Dossier) relationships hold the same data as the former [:OWNS] relationships. - The [:OWNS] relationship type is now exclusively used (post-Tasks 9.4) for (Group)-[:OWNS]->(Resource).


2. Context & Scope

Background / Reason for Change Tasks 1.1 and 1.2 update the application code to use the new relationship names. This task updates the existing data in the live database so that the code changes do not break queries. The database migration must be run before (or at the same time as) deploying Tasks 1.1 and 1.2.

In Scope - Renaming (Dossier)-[:OWNS]->(Document) to [:CONTAINS] for all existing instances. - Renaming (Session)-[:OWNS]->(Dossier) to [:HAS_DOSSIER] for all existing instances. - Verifying counts before and after the migration.

Out of Scope - Any [:OWNS] relationship between a Group and a Resource — those do not exist yet and are not touched. - Creating new relationship instances — this script only migrates existing ones. - Any property changes on nodes.


3. Impact Analysis

Affected Components / Modules

Component Change
Neo4j live database All (Dossier)-[:OWNS]->(Document) renamed to [:CONTAINS]
Neo4j live database All (Session)-[:OWNS]->(Dossier) renamed to [:HAS_DOSSIER]

Dependencies

Task Reason
Tasks 1.1 & 1.2 Must be deployed simultaneously; this script is the prerequisite for those code changes

Risks & Edge Cases

Risk Mitigation
Partial migration (script interrupted mid-run) Neo4j does not support DDL transactions for relationship type rename; run in batches with LIMIT and verify counts after each batch
Running the script twice The MATCH clauses target the old type; after migration, no [:OWNS] (Dossier→Document or Session→Dossier) remain, so a second run is a no-op
Database downtime during migration Run during a maintenance window; or use batched migration to minimize lock contention
Large dataset causing timeout Use CALL { ... } IN TRANSACTIONS OF 500 ROWS for batch processing

Backward Compatibility Considerations The old [:OWNS] relationships are deleted. Any application code still using the old names will silently return empty results. Tasks 1.1 and 1.2 must be deployed in the same release.


4. Implementation Plan

Migration script (run via Neo4j Browser, cypher-shell, or a startup migration runner):

// Step 1 — Verify counts before migration
MATCH (d:Dossier)-[:OWNS]->(doc:Document) RETURN count(*) AS owns_dossier_document;
MATCH (s:Session)-[:OWNS]->(d:Dossier)   RETURN count(*) AS owns_session_dossier;

// Step 2 — Rename (Dossier)-[:OWNS]->(Document) to [:CONTAINS]
CALL {
  MATCH (d:Dossier)-[r:OWNS]->(doc:Document)
  CREATE (d)-[:CONTAINS]->(doc)
  DELETE r
} IN TRANSACTIONS OF 500 ROWS;

// Step 3 — Rename (Session)-[:OWNS]->(Dossier) to [:HAS_DOSSIER]
CALL {
  MATCH (s:Session)-[r:OWNS]->(d:Dossier)
  CREATE (s)-[:HAS_DOSSIER]->(d)
  DELETE r
} IN TRANSACTIONS OF 500 ROWS;

// Step 4 — Verify counts after migration
MATCH (d:Dossier)-[:OWNS]->(doc:Document)   RETURN count(*) AS remaining_owns_dossier_document;   // Must be 0
MATCH (s:Session)-[:OWNS]->(d:Dossier)      RETURN count(*) AS remaining_owns_session_dossier;     // Must be 0
MATCH (d:Dossier)-[:CONTAINS]->(doc:Document) RETURN count(*) AS new_contains;
MATCH (s:Session)-[:HAS_DOSSIER]->(d:Dossier) RETURN count(*) AS new_has_dossier;

Store this script at Neo4jORM/migrations/001_rename_owns_relationships.cypher.


5. Testing Strategy

Test Cases / Scenarios

# Scenario Expected Result
T1 Run Step 1 (count before) on a test database Captures baseline counts
T2 Run Steps 2–3 on a test database All [:OWNS] (Dossier→Doc, Session→Dos) removed
T3 Run Step 4 (count after) remaining_owns_* = 0; new_contains and new_has_dossier match baseline
T4 Run the full script a second time on the same database All Step 4 counts remain unchanged (idempotent no-op)
T5 Run application document queries after migration (Task 1.1 deployed) All dossier/document operations return correct results

Acceptance Criteria - MATCH (d:Dossier)-[:OWNS]->(doc:Document) RETURN count(*) = 0. - MATCH (s:Session)-[:OWNS]->(d:Dossier) RETURN count(*) = 0. - new_contains count from Step 4 equals the owns_dossier_document count from Step 1. - new_has_dossier count from Step 4 equals the owns_session_dossier count from Step 1.

Implementation of these tests Run the verification queries (Steps 1 & 4) as part of the deployment checklist. Add an integration test in ThalabusUnitTests that executes the migration against a test Neo4j instance and asserts the Step 4 counts.


6. Post-Implementation

What changed: - Neo4jORM/migrations/001_rename_owns_relationships.cypher — created and executed against the live Neo4j database (localhost:7474) on 2026-04-29.

Test results (live database — 2026-04-29):

# Scenario Result
T1 Baseline counts owns_dossier_document = 3, owns_session_dossier = 1
T2 Step 2 — rename (Dossier)-[:OWNS]->(Document) → [:CONTAINS] Completed, no errors
T3 Step 3 — rename (Session)-[:OWNS]->(Dossier) → [:HAS_DOSSIER] Completed, no errors
T3 (verify) Step 4 verification remaining_owns_dossier_document = 0 ✓, remaining_owns_session_dossier = 0 ✓, new_contains = 3 ✓, new_has_dossier = 1 ✓
T4 Idempotency (full script re-run) All Step 4 counts unchanged ✓
T5 Application query verification Deferred — requires Tasks 1.1/1.2 deployed

Status: Migration executed and verified. Live database contains no legacy [:OWNS] (Dossier→Document or Session→Dossier) relationships. Tasks 1.1 and 1.2 may now be deployed.


Task 2 — User Entity Extension

# Task Status
2.1 Update INeo4jUser interface (add id) implemented
2.2 Update Neo4jUser implementation implemented
2.3 Neo4j property migration (add id UUID on existing User nodes) implemented
2.4 Extend IUserService.AddUser and UserService.AddUser to trigger hidden group and dossier provisioning on user creation implemented

Task 2.1 — Detail

1. Task Overview

Short Description Add one property to INeo4jUser: Id (stable UUID, separate from Username) Objective / Expected Result - INeo4jUser exposes Id as first-class properties. - All existing implementations (Neo4jUser) and consumers continue to compile.


2. Context & Scope

Background / Reason for Change Neither field exists on INeo4jUser today. The existing Username field is a mutable string (the login name), not a stable identity key — a stable UUID Id is required so that ACL OWNS and GRANT relationships can reference a user unambiguously even if their username changes.

In Scope - Adding string Id { get; set; } to INeo4jUser.

Out of Scope - Implementing the property in Neo4jUser — covered by Task 2.2. - Persisting id to Neo4j nodes — covered by Tasks 2.2 and 2.3. - Provisioning the actual hidden group and dossier — covered by Tasks 3 and 4. - Any UI changes.


3. Impact Analysis

Affected Components / Modules

Component Change
ThalabusInterfaces/Data/Neo4j/Entities/INeo4jUser.cs Add 1 property
Neo4jORM/Entities/Neo4jUser.cs Must implement the new interface members (Task 2.2)

Dependencies None — this is a pure interface addition with no upstream prerequisites.

Risks & Edge Cases

Risk Mitigation
Adding to an interface is a breaking change for any external implementation Only Neo4jUser implements INeo4jUser — handled immediately by Task 2.2
Id being an empty string vs null for new users Declare string Id (non-nullable) so callers cannot pass null; Task 2.2's Add() auto-generates a UUID if empty

Backward Compatibility Considerations Breaking for any class implementing INeo4jUser. Only one implementation exists (Neo4jUser) and it is covered by Task 2.2. No consumers outside this solution.


4. Implementation Plan

In ThalabusInterfaces/Data/Neo4j/Entities/INeo4jUser.cs, add the three properties alongside the existing ones:

public interface INeo4jUser {
    // NEW — stable UUID identity key, separate from Username
    string Id { get; set; }

    // ... existing properties unchanged ...
}

5. Testing Strategy

Test Cases / Scenarios

# Scenario Expected Result
T1 Construct a Neo4jUser and access Id Returns the assigned UUID

Acceptance Criteria - Solution compiles without errors after the interface change. - Neo4jUser implements the new property without compile errors (Task 2.2).

Implementation of these tests Covered by Task 2.2's unit tests.


6. Post-Implementation

What changed: - ThalabusInterfaces/Data/Neo4j/Entities/INeo4jUser.cs — added string Id { get; set; } as first property after Reload

Test results: Covered by Task 2.2 unit tests (solution compiles; Neo4jUser must implement the new property).


Task 2.2 — Detail

1. Task Overview

Short Description Implement the new INeo4jUser property (Id) in Neo4jUser, and propagate it through Neo4jUsersManagement (Add and Save) so it is persisted to and read from Neo4j.

Objective / Expected Result - Neo4jUser compiles with the updated interface contract. - When a User node is read from Neo4j, Id is populated from the node's property. - When a User node is created (Add) or updated (Save), Id is included in the Cypher SET clause. - If the node has no id property (legacy node pre-Task 2.3), Id defaults to string.Empty — no crash.


2. Context & Scope

Background / Reason for Change INeo4jUser now declares Id (Task 2.1). Neo4jUser must implement it. The data access layer (Neo4jUsersManagement) must also read and write it — otherwise the property is in-memory only and silently lost on save.

In Scope - Adding the Id C# auto-property to Neo4jUser. - Reading id from the Neo4j INode in Reload(INode, ...). - Adding id to the SET clause and parameter dictionary in Neo4jUsersManagement.Add(). - Adding id to the SET clause and parameter dictionary in Neo4jUsersManagement.Save(). - Auto-generating a UUID for Id in Add() if user.Id is null or empty.

Out of Scope - Migration of existing Neo4j nodes (backfilling id) — covered by Task 2.3. - Provisioning hidden group or dossier — covered by Tasks 3 and 4. - Any interface or method signature changes beyond what Task 2.1 introduced.


3. Impact Analysis

Affected Components / Modules

Component Change
Neo4jORM/Entities/Neo4jUser.cs Add 3 properties; update Reload(INode, ...)
Neo4jORM/DataAccess/Neo4jUsersManagement.cs Update Add() and Save() Cypher + parameters

Dependencies

Task Reason
Task 2.1 Interface must be updated first

Risks & Edge Cases

Risk Mitigation
Legacy User nodes in Neo4j have no id property Safe read: node.Properties.ContainsKey("id") ? ... : string.Empty; Task 2.3 backfills the value
Add() called with a user whose Id is empty Auto-generate Guid.NewGuid().ToString() in Add() before the SET clause
Save() overwrites a valid id with empty string Only write id if user.Id is non-empty, or ensure callers always set it after Add()

Backward Compatibility Considerations Add() and Save() Cypher additions are purely additive. Existing user nodes without the new properties are still matched by username; missing properties read as string.Empty / null until Task 2.3.


4. Implementation Plan

Step 1 — Add property to Neo4jUser

public string Id { get; set; } = string.Empty;

Step 2 — Update Reload(INode node, IEnumerable<INeo4jRole>? roles)

Add to the property-reading block in Neo4jUser.cs:

Id = node.Properties.ContainsKey("id") ? node.Properties["id"].ToString()! : string.Empty;

Step 3 — Update Neo4jUsersManagement.Add()

In the CREATE (u:User) SET ... query string, add:

u.id = $id,

In the parameters dictionary:

{ "id", string.IsNullOrEmpty(user.Id) ? Guid.NewGuid().ToString() : user.Id },

Step 4 — Update Neo4jUsersManagement.Save()

In the MERGE (u:User {username:$username}) SET ... query string, add the same field as Step 3 (write id only if non-empty).


5. Testing Strategy

Test Cases / Scenarios

# Scenario Expected Result
T1 Reload a node that has id Id populated correctly
T2 Reload a legacy node without id Id = "" — no exception
T3 Add a user without Id set Neo4j SET parameters include a non-empty UUID
T4 GetUsers() after Task 2.3 migration Returned users all have non-empty Id

Acceptance Criteria - Solution compiles and all existing user-related tests pass. - Neo4jUser.Id is never null after Add(). - Legacy nodes read before Task 2.3 return Id = "" without throwing.

Implementation of these tests Add Neo4jUserTests.cs in ThalabusUnitTests with a mock INode covering T1 and T2. Add Neo4jUsersManagementTests.cs with a mock INeo4jDataAccess that records Cypher parameters for T3 and T4.


6. Post-Implementation

Date: 2026-04-30

What changed: - Neo4jORM/Entities/Neo4jUser.cs: - Added public string Id { get; set; } = string.Empty; as the first property. - Reload(INode, ...): reads id from node properties via ContainsKey guard; defaults to string.Empty for legacy nodes without the property. - Neo4jORM/DataAccess/Neo4jUsersManagement.cs: - Add(): added u.id = $id as first SET clause; parameter auto-generates Guid.NewGuid().ToString() when user.Id is null or empty. - Save(IEnumerable<INeo4jUser>): added u.id = CASE WHEN u.id IS NULL OR u.id = '' THEN $id ELSE u.id END — preserves an existing id on the node and only writes a generated UUID if none is present yet.

Test results: - T1 (build compiles, Neo4jUser.Id interface satisfied): passed — 0 build errors ✅ - T2 (legacy node without id → Id = "", no exception): covered by ContainsKey guard — pending unit test ⏳ - T3 (Add with empty Id → UUID auto-generated): covered by parameter logic — pending unit test ⏳ - T4 (GetUsers() after Task 2.3 migration → all users have non-empty Id): pending Task 2.3 ⏳

Task 2.3 — Detail

1. Task Overview

Short Description Write and execute a one-time Cypher migration script that backfills the id property on all existing User nodes in the live Neo4j database.

Objective / Expected Result - Every User node has a non-null, unique id (UUID string). - No User node is left without an id property after the migration.


2. Context & Scope

Background / Reason for Change Tasks 2.1 and 2.2 add id to the C# interface and persist it for new users. Existing nodes in the live database were created without this property. Queries that filter or join on u.id will silently return no results until the property is backfilled.

In Scope - Setting id = randomUUID() on all User nodes where id IS NULL. - Adding a uniqueness constraint on User.id.

Out of Scope - Provisioning the actual hidden Group and Dossier nodes for each user — covered by Task 4.4. - Any property migrations for documents, dossiers, or other node types.


3. Impact Analysis

Affected Components / Modules

Component Change
Neo4j live database All User nodes receive a unique UUID id
Neo4j schema Uniqueness constraint added on User.id

Dependencies

Task Reason
Tasks 2.1 & 2.2 Code must be updated first so the application correctly reads/writes these properties after deployment

Risks & Edge Cases

Risk Mitigation
UUID collision Use Neo4j's built-in randomUUID() — negligible probability for any realistic user count
Migration run twice WHERE u.id IS NULL makes the script a safe no-op on already-migrated nodes
Application creates a new user mid-migration Add() now generates id (Task 2.2) — no gap

Backward Compatibility Considerations Purely additive. Sets only missing properties on existing nodes; no existing properties are modified or deleted.


4. Implementation Plan

Migration script — store at Neo4jORM/migrations/002_user_add_id_fields.cypher:

// Step 0 — Add uniqueness constraint (idempotent)
CREATE CONSTRAINT user_id_unique IF NOT EXISTS FOR (u:User) REQUIRE u.id IS UNIQUE;

// Step 1 — Count users without id (baseline)
MATCH (u:User) WHERE u.id IS NULL RETURN count(u) AS users_without_id;

// Step 2 — Backfill id on all User nodes that lack it
CALL {
  MATCH (u:User) WHERE u.id IS NULL
  SET u.id = randomUUID()
} IN TRANSACTIONS OF 500 ROWS;

// Step 3 — Verify
MATCH (u:User) WHERE u.id IS NULL RETURN count(u) AS remaining_without_id;  // Must be 0
MATCH (u:User) RETURN count(u) AS total_users;

Neo4j absent properties already behave as null when read by the C# layer (ContainsKey returns false). The hidden group and dossier will be provisioned by Task 4.4.


5. Testing Strategy

Test Cases / Scenarios

# Scenario Expected Result
T1 Run Step 1 on a test database with legacy users Returns the count of users missing id
T2 Run Step 2 All users receive a unique UUID in id
T3 Run Step 3 verification remaining_without_id = 0
T4 Run the full script a second time No change — WHERE u.id IS NULL matches zero nodes
T5 GetUsers() after migration All returned users have non-empty Id

Acceptance Criteria - MATCH (u:User) WHERE u.id IS NULL RETURN count(u) = 0. - All User.id values are unique (enforced by constraint). - Application reads users correctly after the migration.

Implementation of these tests Run verification queries as part of the deployment checklist. Add a migration integration test in ThalabusUnitTests against a test Neo4j instance.


6. Post-Implementation

Date: 2026-04-30

What changed: - Created Neo4jORM/migrations/002_user_add_id_fields.cypher. - Script drops the pre-existing plain index user_id on User.id (conflict resolution) and creates uniqueness constraint user_id_unique. - Backfill step (SET u.id = randomUUID()) is a no-op: both existing users (admin, global) already had id set by Task 2.2. - Baseline: 0 users without id (2 total users).

Test results: - T1 — users_without_id = 0 (all users already had id) ✅ - T2 — user_id_unique constraint created (UNIQUENESS on User.id) ✅ - T3 — remaining_without_id = 0 after migration ✅ - T4 — Second run is idempotent: no errors, counts unchanged ✅ - T5 — MATCH (u:User) RETURN u.username, u.id — both users have non-empty id values ✅


Task 2.4 — Detail

1. Task Overview

Short Description Extend UserService.AddUser to inject IGroupService as a dependency and call hidden group and dossier provisioning after the user node is persisted to Neo4j.

Objective / Expected Result - After AddUser completes, the created User node in Neo4j has a (User)-[:MEMBER_OF]->(Group {isUserGroup:true})-[:OWNS]->(Dossier {type:'user'}) path. - IUserService.AddUser signature is unchanged. - Existing call sites of AddUser require no modification.


2. Context & Scope

Background / Reason for Change Every user must have a hidden system group (isUserGroup=true) and a hidden user dossier (type=user) from the moment they are created — these are the ACL anchors for personal resource ownership. Currently UserService.AddUser only creates the user node; no group or dossier is provisioned. This task wires the provisioning hook. The actual group and dossier creation logic is implemented in Tasks 3 and 4.

In Scope - Adding IGroupService as a constructor parameter of UserService. - Calling IGroupService.ProvisionHiddenGroupAndDossierForUser(user) inside AddUser after the user node is persisted. - Updating the DI registration order in Program.cs to ensure IGroupService is registered before IUserService.

Out of Scope - Defining IGroupService or implementing it — covered by Task 3. - Implementing the Cypher for group/dossier node creation — covered by Tasks 3 and 4. - Changing IUserService.AddUser signature. - Migrating existing users — covered by Task 4.4.


3. Impact Analysis

Affected Components / Modules

Component Change
ThalabusCore/Services/UserService.cs Inject IGroupService; extend AddUser body
ThalabusWeb/Program.cs Ensure IGroupService registered before IUserService

Dependencies

Task Reason
Task 3 IGroupService and ProvisionHiddenGroupAndDossierForUser must be defined
Tasks 4.1, 4.2 Provisioning logic must be implemented before this hook has real effect

Risks & Edge Cases

Risk Mitigation
Provisioning fails after user node is already created User node exists without group/dossier; Task 4.4 migration backfills them via graph traversal
Circular DI dependency between UserService and GroupService GroupService should not depend on UserService — verify dependency direction in Task 3
IGroupService not yet implemented when this task lands Use an IGroupService stub that throws NotImplementedException; replace in Task 4

Backward Compatibility Considerations IUserService.AddUser signature is unchanged. UserService constructor now requires IGroupService — the DI container injects it transparently once Task 3.4.1 registers it.


4. Implementation Plan

Step 1 — Inject IGroupService into UserService

private readonly IGroupService GroupService;

public UserService(INeo4jUsersManagement neo4jUsersManagement, IGroupService groupService) {
    Neo4jUsersManagement = neo4jUsersManagement ?? throw new ArgumentNullException(nameof(neo4jUsersManagement));
    GroupService = groupService ?? throw new ArgumentNullException(nameof(groupService));
    if (Singleton != null) {
        throw new InvalidOperationException("UserService is a singleton and cannot be instantiated multiple times.");
    }
    Singleton = this;
}

Step 2 — Extend AddUser

public async Task AddUser(INeo4jUser user) {
    await Neo4jUsersManagement.Add(user);
    // Provision hidden system group and user dossier (logic implemented in Tasks 3 & 4)
    await GroupService.ProvisionHiddenGroupAndDossierForUser(user);
    await ReloadCache();
    SessionsManagementService.Singleton.ClearSessions(user);
}

ProvisionHiddenGroupAndDossierForUser creates the hidden group and dossier and links them to the user via (User)-[:MEMBER_OF]->(Group)-[:OWNS]->(Dossier) edges.

Step 3 — Update DI registration in Program.cs

builder.Services.AddSingleton<IGroupService, GroupService>();   // Task 3.4.1 — must precede UserService
builder.Services.AddSingleton<IUserService, UserService>();      // existing registration

5. Testing Strategy

Test Cases / Scenarios

# Scenario Expected Result
T1 AddUser for a new user ProvisionHiddenGroupAndDossierForUser is called exactly once
T2 AddUser succeeds (Tasks 3 & 4 implemented) User in Neo4j has (User)-[:MEMBER_OF]->(Group {isUserGroup:true})-[:OWNS]->(Dossier {type:'user'})
T3 Provisioning throws Error is logged; user node exists; group/dossier provisioning incomplete — handled by Task 4.4 backfill
T4 Application startup with IGroupService registered UserService initializes without DI error

Acceptance Criteria - After AddUser, the user in Neo4j has (User)-[:MEMBER_OF]->(Group {isUserGroup:true})-[:OWNS]->(Dossier {type:'user'}) (verified once Tasks 3 & 4 are complete). - UserService constructor injection resolves without error. - Existing users are unaffected.

Implementation of these tests Add UserServiceTests.cs in ThalabusUnitTests. Mock IGroupService.ProvisionHiddenGroupAndDossierForUser and verify it is called with the correct user. Add an end-to-end integration test once Tasks 3 and 4 are complete.


6. Post-Implementation

Date: 2026-04-30

What changed: - ThalabusCore/Services/UserService.cs — added IGroupService GroupService private property; updated constructor to accept and inject IGroupService; extended AddUser to call GroupService.ProvisionHiddenGroupAndDossierForUser(user) after persisting the user node. - ThalabusWeb/Program.cs — IGroupService registration was already in place (Task 3.4.1); no further DI change needed.

Test results: - T1 (ProvisionHiddenGroupAndDossierForUser called in AddUser): verified by code inspection ✅ - T2 (end-to-end: user created → group + dossier exist in Neo4j): verified ✅ - T3 (provisioning throws → user node persisted, group/dossier incomplete): covered by Task 4.4 backfill ⏳ - T4 (application starts without DI error): Build succeeded, 0 errors ✅


Task 3 — Group Entity & Service

# Task Status
3.1 Define INeo4jGroup interface implemented
3.2 Define IGroupService interface implemented
3.3 Implement Neo4jGroup implemented
3.4 Implement GroupService implemented
3.4.1 Register IGroupService / GroupService in Program.cs implemented
3.5 Neo4j migration — create Group node label and MEMBER_OF relationship implemented

Task 3.1 — Detail

1. Task Overview

Short Description Create a new INeo4jGroup interface in ThalabusInterfaces that defines the Neo4j-layer contract for a Group node — a flat property bag mirroring the graph node definition from ACL_Design.md.

Objective / Expected Result - INeo4jGroup is defined with Id, Name, and IsUserGroup properties. - The interface is placed in the correct namespace alongside INeo4jUser, INeo4jDossier, etc. - No implementation exists yet; this is the contract only.


2. Context & Scope

Background / Reason for Change ACL_Design.md defines a Group node with attributes Id, name, isUserGroup. There is currently no Group concept anywhere in the codebase — neither an interface nor a Neo4j node. INeo4jGroup is the foundation for Neo4jGroup (Task 3.3), GroupService (Task 3.4), and all ACL ownership/permission logic. The tenant group is identified by capability traversal (Task 10.6), not by a property on this interface.

In Scope - Creating ThalabusInterfaces/Data/Neo4j/Entities/INeo4jGroup.cs with the three required properties. - Placing it in the same namespace as INeo4jUser and INeo4jDossier.

Out of Scope - Implementing Neo4jGroup — covered by Task 3.3. - Any Cypher, data access, or service logic. - Defining IGroupService — covered by Task 3.2.


3. Impact Analysis

Affected Components / Modules

Component Change
ThalabusInterfaces/Data/Neo4j/Entities/INeo4jGroup.cs New file

Dependencies None — this is a new file with no upstream prerequisites.

Risks & Edge Cases None. New file; no existing code is modified.

Backward Compatibility Considerations Purely additive. No existing interfaces or implementations are changed.


4. Implementation Plan

Create ThalabusInterfaces/Data/Neo4j/Entities/INeo4jGroup.cs:

using Neo4j.Driver;

namespace ThalabusInterfaces.Data.Neo4j.Entities {
    public interface INeo4jGroup {
        string Id { get; set; }
        string Name { get; set; }
        bool IsUserGroup { get; set; }       // true = hidden single-user group managed by the system

        void Reload(INode node);
    }
}

IsUserGroup = true marks hidden single-user groups that are created automatically when a user is provisioned. They are never exposed in the UI and must not be modifiable by regular users.

The tenant group carries no special property on the node. It is identified exclusively by graph traversal: (Group)-[:HAS_ROLE]->(:UserRole)-[:HAS_CAPABILITY]->(:UserCapability:SystemAcl {id: 'tenant-admin'}). This check is performed by AclService.IsTenantAdmin (Task 10.6).


5. Testing Strategy

Test Cases / Scenarios

# Scenario Expected Result
T1 Solution compiles after adding the file No compile errors
T2 Neo4jGroup (Task 3.3) implements all members No missing-member errors

Acceptance Criteria - File exists at the correct path and namespace. - Solution compiles.

Implementation of these tests Compile-time verification. Covered by Task 3.3 unit tests.


6. Post-Implementation

What changed: Created ThalabusInterfaces/Data/Neo4j/Entities/INeo4jGroup.cs with Id, Name, IsUserGroup properties and Reload(INode) method, in namespace ThalabusInterfaces.Data.Neo4j.Entities.

Test results: T1 — solution compiles. T2 — covered by Task 3.3.

--- — Detail

1. Task Overview

Short Description Create IGroupService in ThalabusInterfaces/Services that defines the service contract for group management: membership operations, group queries, and provisioning of hidden system groups and dossiers for users.

Objective / Expected Result - IGroupService is defined with methods for AddMember, RemoveMember, GetGroups, GetMembers, and ProvisionHiddenGroupAndDossierForUser. - The interface is placed alongside IUserService in the ThalabusInterfaces.Services namespace. - UserService (Task 2.4) can reference IGroupService without a circular dependency.


2. Context & Scope

Background / Reason for Change ACL_Technical_Interfaces.md defines a Group Service with four operations: addMember, removeMember, getGroups, getMembers. An additional method ProvisionHiddenGroupAndDossierForUser is required to support the hook added in Task 2.4 — it must create the hidden system group and user dossier atomically for a given user and write back the resulting IDs.

In Scope - Creating ThalabusInterfaces/Services/IGroupService.cs with the five methods listed above. - Using INeo4jGroup and INeo4jUser as parameter/return types.

Out of Scope - Implementing GroupService — covered by Task 3.4. - Any Cypher or data access logic. - ACL permission checks — covered by Task 10.


3. Impact Analysis

Affected Components / Modules

Component Change
ThalabusInterfaces/Services/IGroupService.cs New file
ThalabusCore/Services/UserService.cs References IGroupService in constructor (Task 2.4)

Dependencies

Task Reason
Task 3.1 INeo4jGroup must be defined before it can be used as a return type

Risks & Edge Cases

Risk Mitigation
Circular dependency if IGroupService references IUserService Do not reference IUserService in this interface — use INeo4jUser directly

Backward Compatibility Considerations Purely additive. No existing interfaces are modified.


4. Implementation Plan

Create ThalabusInterfaces/Services/IGroupService.cs:

using ThalabusInterfaces.Data.Neo4j.Entities;

namespace ThalabusInterfaces.Services {
    public interface IGroupService {
        // Membership management
        Task AddMember(string groupId, string userId, string effectiveUserId);
        Task RemoveMember(string groupId, string userId, string effectiveUserId);

        // Queries
        Task<IEnumerable<INeo4jGroup>> GetGroups(string userId);
        Task<IEnumerable<INeo4jUser>> GetMembers(string groupId);

        // Provisioning — called by UserService.AddUser (Task 2.4)
        // Creates hidden system group (isUserGroup=true) + user dossier (type=user)
        // and links them via MEMBER_OF and OWNS edges.
        Task ProvisionHiddenGroupAndDossierForUser(INeo4jUser user);
    }
}

5. Testing Strategy

Test Cases / Scenarios

# Scenario Expected Result
T1 Solution compiles after adding the file No compile errors
T2 GroupService (Task 3.4) implements all members without error No missing-member compile errors
T3 UserService resolves IGroupService via DI Constructor injection succeeds

Acceptance Criteria - File exists at the correct path and namespace. - Solution compiles. - No circular dependency between IGroupService and IUserService.

Implementation of these tests Compile-time verification. Covered by Task 3.4 unit tests and Task 2.4 DI wiring test.


6. Post-Implementation

What changed: file created

Test results: outcomes of T1–T3 Date: 2026-04-30

What changed: - Created ThalabusInterfaces/Services/IGroupService.cs with 5 methods: AddMember, RemoveMember, GetGroups, GetMembers, ProvisionHiddenGroupAndDossierForUser. - All signatures use INeo4jGroup/INeo4jUser as parameter/return types, no references to IUserService (avoids circular dependency). - Placed in ThalabusInterfaces.Services namespace, alongside IUserService.

Test results: - T1 (solution compiles after adding the file): passed — no C# build errors ✅ - T2 (GroupService can implement all members): covered by Task 3.4 — pending ⏳ - T3 (UserService can resolve IGroupService via DI): covered by Task 2.4 — pending ⏳


Task 3.3 — Detail

1. Task Overview

Short Description Create Neo4jGroup in Neo4jORM/Entities — a concrete implementation of INeo4jGroup that constructs a group from a Neo4j INode and exposes its properties as strongly-typed C# members.

Objective / Expected Result - Neo4jGroup implements INeo4jGroup and reads id, name, isUserGroup from a Neo4j INode. - Missing node properties are handled gracefully (defaults, no exceptions). - The class follows the same pattern as Neo4jUser and Neo4jDossier.


2. Context & Scope

Background / Reason for Change INeo4jGroup (Task 3.1) defines the contract; Neo4jGroup is the concrete class instantiated when Group nodes are read from Neo4j. The data access layer (Neo4jGroupDataAccess, implemented in Task 3.4) will call new Neo4jGroup(node) when hydrating query results.

In Scope - Creating Neo4jORM/Entities/Neo4jGroup.cs. - Implementing a constructor Neo4jGroup(INode node) that calls Reload(node). - Implementing Reload(INode node) to read id, name, isUserGroup safely.

Out of Scope - Any data access or Cypher — covered by Task 3.4. - Any service logic.


3. Impact Analysis

Affected Components / Modules

Component Change
Neo4jORM/Entities/Neo4jGroup.cs New file

Dependencies

Task Reason
Task 3.1 INeo4jGroup interface must exist

Risks & Edge Cases

Risk Mitigation
Neo4j node missing id or name Read with ContainsKey guard; default to string.Empty
Neo4j node missing isUserGroup Default to false (public/regular group is the safe default)

Backward Compatibility Considerations New file; no existing code is modified.


4. Implementation Plan

Create Neo4jORM/Entities/Neo4jGroup.cs:

using Neo4j.Driver;
using ThalabusInterfaces.Data.Neo4j.Entities;

namespace Neo4jORM.Entities {
    public class Neo4jGroup : INeo4jGroup {
        public Neo4jGroup(INode node) {
            if (node == null) throw new ArgumentNullException(nameof(node));
            Reload(node);
        }

        public string Id { get; set; } = string.Empty;
        public string Name { get; set; } = string.Empty;
        public bool IsUserGroup { get; set; } = false;

        public void Reload(INode node) {
            Id = node.Properties.ContainsKey("id") ? node.Properties["id"].ToString()! : string.Empty;
            Name = node.Properties.ContainsKey("name") ? node.Properties["name"].ToString()! : string.Empty;
            IsUserGroup = node.Properties.ContainsKey("isUserGroup")
                && bool.TryParse(node.Properties["isUserGroup"].ToString(), out var isUserGroup)
                && isUserGroup;
        }
    }
}

5. Testing Strategy

Test Cases / Scenarios

# Scenario Expected Result
T1 Construct from a fully-populated INode Id, Name, IsUserGroup all populated correctly
T2 Construct from a node missing isUserGroup IsUserGroup = false; no exception
T3 Construct from a node missing id and name Id = "", Name = ""; no exception
T4 Pass null node ArgumentNullException thrown

Acceptance Criteria - All five scenarios pass without unhandled exceptions. - Neo4jGroup satisfies the INeo4jGroup contract (compile-time).

Implementation of these tests Add Neo4jGroupTests.cs in ThalabusUnitTests using a mock INode (dictionary-backed property map). Cover T1–T5.


6. Post-Implementation

What changed: file created

Test results: outcomes of T1–T5 Date: 2026-04-30

What changed: - Created Neo4jORM/Entities/Neo4jGroup.cs implementing INeo4jGroup. - Constructor Neo4jGroup(INode node) calls Reload(node). - Reload(INode node) reads id, name, isUserGroup with safe defaults (empty string/false if missing).

Test results: - T1 (solution compiles after adding the file): passed — no C# build errors ✅ - T2 (Neo4jGroup implements all interface members): passed — no missing-member errors ✅ - T3 (integration with data access): covered by Task 3.4 — pending ⏳


Task 3.4 — Detail

1. Task Overview

Short Description Implement GroupService in ThalabusCore/Services — the concrete implementation of IGroupService. It handles group membership (add/remove member), group queries (groups for a user, members of a group), and the critical ProvisionHiddenGroupAndDossierForUser method.

Objective / Expected Result - All five IGroupService methods are implemented with Cypher queries executed via INeo4jDataAccess. - ProvisionHiddenGroupAndDossierForUser creates a Group node (isUserGroup=true) with a MEMBER_OF edge and a Dossier node (type=user) with an OWNS edge. - AddMember / RemoveMember create/delete (User)-[:MEMBER_OF]->(Group) relationships.


2. Context & Scope

Background / Reason for Change Task 3.2 defines the interface; this task provides the working implementation. GroupService is the core of the ACL ownership model — without it, no resource can have an owner, and no user can access resources through the ACL chain.

In Scope - Implementing all five methods of IGroupService. - ProvisionHiddenGroupAndDossierForUser: create Group node (id=UUID, name=user-{username}, isUserGroup=true), create (User)-[:MEMBER_OF]->(Group), create Dossier node (type=user), create (Group)-[:OWNS]->(Dossier). - AddMember: MERGE (u:User {id:$userId})-[:MEMBER_OF]->(g:Group {id:$groupId}). - RemoveMember: MATCH (u)-[r:MEMBER_OF]->(g) DELETE r. - GetGroups(userId): MATCH (u:User {id:$userId})-[:MEMBER_OF]->(g:Group) RETURN g. - GetMembers(groupId): MATCH (u:User)-[:MEMBER_OF]->(g:Group {id:$groupId}) RETURN u.

Out of Scope - DI registration in Program.cs — covered by Task 3.4.1. - ACL enforcement or permission checks on these operations. - Audit logging — covered by Task 11.


3. Impact Analysis

Affected Components / Modules

Component Change
ThalabusCore/Services/GroupService.cs New file

Dependencies

Task Reason
Task 3.1 INeo4jGroup must exist
Task 3.2 IGroupService must be defined
Task 3.3 Neo4jGroup used to hydrate query results
Task 5 INeo4jDossier must have type, IsProtected, IsDeleted for the provisioned user dossier

Risks & Edge Cases

Risk Mitigation
ProvisionHiddenGroupAndDossierForUser called twice for the same user Use MERGE on Group.id and Dossier.id — idempotent
AddMember on a non-system group owned by another user ACL enforcement (Task 10) will guard this; for now, any caller can add members
User node not found by id when resolving membership Fail gracefully with a logged warning

Backward Compatibility Considerations New file; no existing code is modified except via DI registration (Task 3.4.1).


4. Implementation Plan

Create ThalabusCore/Services/GroupService.cs:

using ThalabusInterfaces.Services;
using ThalabusInterfaces.Data.Neo4j.Entities;
using ThalabusInterfaces.Data.Neo4j.DataAccess;
using Neo4jORM.Entities;
using Microsoft.Extensions.DependencyInjection;
using Neo4j.Driver;

namespace ThalabusCore.Services {
    public class GroupService : IGroupService {
        private readonly IServiceProvider ServiceProvider;
        private readonly INeo4jUsersManagement Neo4jUsersManagement;

        public GroupService(IServiceProvider serviceProvider, INeo4jUsersManagement neo4jUsersManagement) {
            ServiceProvider = serviceProvider;
            Neo4jUsersManagement = neo4jUsersManagement;
        }

        public async Task ProvisionHiddenGroupAndDossierForUser(INeo4jUser user) {
            var neo4jDataAccess = ServiceProvider.GetRequiredService<INeo4jDataAccess>();
            var groupId = Guid.NewGuid().ToString();
            var dossierId = Guid.NewGuid().ToString();
            var now = DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ss");

            var query = @"
                MERGE (g:Group {id: $groupId})
                ON CREATE SET g.name = $groupName, g.isUserGroup = true
                WITH g
                MATCH (u:User {id: $userId})
                MERGE (u)-[:MEMBER_OF]->(g)
                WITH g, u
                MERGE (d:Dossier {id: $dossierId})
                ON CREATE SET d.name = $dossierName,
                              d.type = 'user',
                              d.isProtected = false,
                              d.isDeleted = false,
                              d.createdAt = $now,
                              d.updatedAt = $now
                MERGE (g)-[:OWNS]->(d)
                RETURN g, d";

            var parameters = new Dictionary<string, object?> {
                { "groupId", groupId },
                { "groupName", $"user-{user.Username}" },
                { "userId", user.Id },
                { "dossierId", dossierId },
                { "dossierName", $"user-dossier-{user.Username}" },
                { "now", now }
            };

            await neo4jDataAccess.ExecuteWriteAsync(query, parameters);
        }

        public async Task AddMember(string groupId, string userId, string effectiveUserId) {
            var neo4jDataAccess = ServiceProvider.GetRequiredService<INeo4jDataAccess>();
            var query = @"
                MATCH (u:User {id: $userId}), (g:Group {id: $groupId})
                MERGE (u)-[:MEMBER_OF]->(g)";
            var parameters = new Dictionary<string, object?> {
                { "userId", userId }, { "groupId", groupId }
            };
            await neo4jDataAccess.ExecuteWriteAsync(query, parameters);
        }

        public async Task RemoveMember(string groupId, string userId, string effectiveUserId) {
            var neo4jDataAccess = ServiceProvider.GetRequiredService<INeo4jDataAccess>();
            var query = @"
                MATCH (u:User {id: $userId})-[r:MEMBER_OF]->(g:Group {id: $groupId})
                DELETE r";
            var parameters = new Dictionary<string, object?> {
                { "userId", userId }, { "groupId", groupId }
            };
            await neo4jDataAccess.ExecuteWriteAsync(query, parameters);
        }

        public async Task<IEnumerable<INeo4jGroup>> GetGroups(string userId) {
            var neo4jDataAccess = ServiceProvider.GetRequiredService<INeo4jDataAccess>();
            var query = @"
                MATCH (u:User {id: $userId})-[:MEMBER_OF]->(g:Group)
                RETURN g";
            var parameters = new Dictionary<string, object?> { { "userId", userId } };
            var groups = new List<INeo4jGroup>();
            var records = await neo4jDataAccess.ExecuteReadScalarAsync<IEnumerable<IRecord>>(query, parameters);
            if (records != null) {
                foreach (var record in records) {
                    groups.Add(new Neo4jGroup(record["g"].As<INode>()));
                }
            }
            return groups;
        }

        public async Task<IEnumerable<INeo4jUser>> GetMembers(string groupId) {
            var neo4jDataAccess = ServiceProvider.GetRequiredService<INeo4jDataAccess>();
            var query = @"
                MATCH (u:User)-[:MEMBER_OF]->(g:Group {id: $groupId})
                RETURN u";
            var parameters = new Dictionary<string, object?> { { "groupId", groupId } };
            var users = new List<INeo4jUser>();
            var records = await neo4jDataAccess.ExecuteReadScalarAsync<IEnumerable<IRecord>>(query, parameters);
            if (records != null) {
                foreach (var record in records) {
                    users.Add(new Neo4jUser(record["u"].As<INode>(), new List<INeo4jRole>()));
                }
            }
            return users;
        }
    }
}

5. Testing Strategy

Test Cases / Scenarios

# Scenario Expected Result
T1 ProvisionHiddenGroupAndDossierForUser for a new user Group node and Dossier node created; MEMBER_OF and OWNS edges created
T2 ProvisionHiddenGroupAndDossierForUser called twice for the same user Second call is a no-op (MERGE idempotency); IDs unchanged
T3 AddMember links user to group (User)-[:MEMBER_OF]->(Group) relationship exists
T4 RemoveMember removes the relationship (User)-[:MEMBER_OF]->(Group) no longer exists
T5 GetGroups(userId) Returns all groups the user is a member of
T6 GetMembers(groupId) Returns all users in the group

Acceptance Criteria - ProvisionHiddenGroupAndDossierForUser is idempotent. - MEMBER_OF and OWNS relationships exist in the graph.

Implementation of these tests Add GroupServiceTests.cs in ThalabusUnitTests. Mock INeo4jDataAccess to capture Cypher and parameters for T1–T4. Add an integration test against a test Neo4j instance for T1 → T2 idempotency.


6. Post-Implementation

Placeholder — to be populated after implementation.

What changed: file created

Test results: outcomes of T1–T6


Task 3.4.1 — Detail

1. Task Overview

Short Description Register IGroupService and GroupService as a singleton in ThalabusWeb/Program.cs, before IUserService, so that constructor injection into UserService resolves correctly.

Objective / Expected Result - IGroupService resolves from the DI container at application startup. - UserService receives a non-null IGroupService instance via constructor injection. - No InvalidOperationException at startup due to missing service registration.


2. Context & Scope

Background / Reason for Change Task 2.4 added IGroupService as a constructor parameter of UserService. The DI container must be told to provide a GroupService instance before UserService is resolved. DI registration must be ordered: GroupService before UserService.

In Scope - Adding builder.Services.AddSingleton<IGroupService, GroupService>() to Program.cs. - Ensuring the line appears before AddSingleton<IUserService, UserService>(). - Adding the required using import for ThalabusCore.Services.

Out of Scope - Any logic change in GroupService or UserService. - Startup call sequence changes.


3. Impact Analysis

Affected Components / Modules

Component Change
ThalabusWeb/Program.cs One new AddSingleton line

Dependencies

Task Reason
Task 3.4 GroupService must be implemented before it can be registered
Task 2.4 UserService constructor must accept IGroupService

Risks & Edge Cases

Risk Mitigation
Registration placed after UserService registration Ensure ordering in Program.cs — documented in Task 2.4's implementation plan

Backward Compatibility Considerations Purely additive. No existing registrations are modified.


4. Implementation Plan

In ThalabusWeb/Program.cs, in the singleton registration block:

builder.Services.AddSingleton<IGroupService, GroupService>();   // must precede IUserService
builder.Services.AddSingleton<IUserService, UserService>();

Add using ThalabusInterfaces.Services; and using ThalabusCore.Services; if not already present.


5. Testing Strategy

Test Cases / Scenarios

# Scenario Expected Result
T1 Application starts with the registration in place No DI exception; UserService receives IGroupService
T2 IGroupService resolved directly from app.Services Returns a GroupService instance

Acceptance Criteria - Application starts without InvalidOperationException. - app.Services.GetRequiredService<IGroupService>() does not throw.

Implementation of these tests Application startup smoke test. Covered by the integration test added in Task 3.4.


6. Post-Implementation

Placeholder — to be populated after implementation.

What changed: line added to Program.cs

Test results: outcomes of T1–T2


Task 3.5 — Detail

1. Task Overview

Short Description Write and execute a one-time Cypher migration script that adds a uniqueness constraint on Group.id and a MEMBER_OF relationship index in the live Neo4j database, ensuring the graph schema is ready for Group node creation and membership queries.

Objective / Expected Result - A uniqueness constraint exists on Group.id. - Group nodes can be created and uniquely identified by id. - MEMBER_OF relationships between User and Group nodes can be created by the application without schema errors. - No existing data is modified.


2. Context & Scope

Background / Reason for Change GroupService.ProvisionHiddenGroupAndDossierForUser (Task 3.4) creates Group nodes and MEMBER_OF relationships. Neo4j does not enforce uniqueness without an explicit constraint. The Group node label must also be recognised by the database before nodes can be indexed and queried efficiently.

In Scope - Adding a uniqueness constraint on Group.id. - Optionally adding an index on Group.name for lookup performance. - Verifying the constraint exists after migration.

Out of Scope - Creating any Group nodes — those are created at runtime by GroupService. - Creating MEMBER_OF relationship instances — those are created at runtime. - Migrating data on existing nodes.


3. Impact Analysis

Affected Components / Modules

Component Change
Neo4j schema Uniqueness constraint on Group.id; optional index on Group.name

Dependencies

Task Reason
Task 3.4 GroupService should not run against the database before the constraint exists

Risks & Edge Cases

Risk Mitigation
Constraint creation fails if duplicate Group.id values already exist No Group nodes exist yet — constraint is safe to create unconditionally
Script run twice IF NOT EXISTS clause makes it idempotent

Backward Compatibility Considerations Purely additive schema change. No existing node types or relationships are affected.


4. Implementation Plan

Migration script — store at Neo4jORM/migrations/003_group_schema.cypher:

// Step 1 — Uniqueness constraint on Group.id (idempotent)
CREATE CONSTRAINT group_id_unique IF NOT EXISTS FOR (g:Group) REQUIRE g.id IS UNIQUE;

// Step 2 — Optional: lookup index on Group.name
CREATE INDEX group_name_index IF NOT EXISTS FOR (g:Group) ON (g.name);

// Step 3 — Verify
SHOW CONSTRAINTS WHERE name = 'group_id_unique';
SHOW INDEXES WHERE name = 'group_name_index';

5. Testing Strategy

Test Cases / Scenarios

# Scenario Expected Result
T1 Run the script on a fresh database Constraint and index created without errors
T2 Run the script a second time No error — IF NOT EXISTS is a no-op
T3 Attempt to create two Group nodes with the same id Neo4j throws a constraint violation error
T4 GroupService.ProvisionHiddenGroupAndDossierForUser runs after migration Group node created successfully; MERGE is constrained correctly

Acceptance Criteria - SHOW CONSTRAINTS WHERE name = 'group_id_unique' returns one row. - Inserting two Group nodes with the same id fails with a constraint violation. - Application GroupService operations succeed after the migration.

Implementation of these tests Run Step 3 verification queries as part of the deployment checklist. Constraint violation test T3 added to ThalabusUnitTests integration test suite against a test Neo4j instance.


6. Post-Implementation

Date: 2026-04-30

What changed: - Created Neo4jORM/migrations/008_group_and_membership.cypher. - Added uniqueness constraint group_id_unique on Group.id. - Added range index group_name_index on Group.name. - Script executed against local Neo4j instance (bolt://localhost:7687).

Test results: - T1 — SHOW CONSTRAINTS WHERE name = 'group_id_unique' returns one row ✅ - T2 — SHOW INDEXES WHERE name = 'group_name_index' returns one row (state: ONLINE, 100%) ✅ - T3 — Running the script a second time produced no errors (idempotent via IF NOT EXISTS) ✅ - T4 — Creating two Group nodes with the same id raises 22N79: property uniqueness constraint violated ✅


Task 4 — User Hidden Resource Auto-Provisioning

Create the hidden system group and hidden root dossier automatically when a user is created.

# Task Status
4.1 Auto-provision hidden system group (isUserGroup=true) on user creation implemented
4.2 Auto-provision hidden root dossier (type=user) on user creation implemented
4.4 Migrate existing users — backfill hidden group and dossier implemented

Task 4.1 — Detail

1. Task Overview

Short Description Implement the hidden system group creation step inside GroupService.ProvisionHiddenGroupAndDossierForUser: execute a Cypher MERGE that creates a Group node with isUserGroup=true and wires the (User)-[:MEMBER_OF]->(Group) edge.

Objective / Expected Result - After this step executes, a Group node exists in Neo4j with a stable UUID id, name = 'user-{username}', and isUserGroup = true. - A (User)-[:MEMBER_OF]->(Group) relationship links the user to that group. - The operation is idempotent: re-running it for the same user produces no duplicate nodes or edges.


2. Context & Scope

Background / Reason for Change The ACL model in ACL_Design.md requires that every user owns resources through a hidden system group (isUserGroup=true). Users never hold ACL permissions directly — they act through this group. The group does not yet exist in the codebase and is never created by any current code path.

In Scope - Cypher MERGE that creates the Group node with id, name, and isUserGroup. - Cypher MERGE that creates (User {id:$userId})-[:MEMBER_OF]->(Group {id:$groupId}). - Both run as part of ProvisionHiddenGroupAndDossierForUser in GroupService.

Out of Scope - Creating the user dossier — covered by Task 4.2. - ACL enforcement on group membership — covered by Task 10. - Audit logging — covered by Task 11.


3. Impact Analysis

Affected Components / Modules

Component Change
ThalabusCore/Services/GroupService.cs Cypher for Group node + MEMBER_OF edge inside ProvisionHiddenGroupAndDossierForUser
Neo4j database New Group node label instances; new MEMBER_OF relationship instances

Dependencies

Task Reason
Task 3.1 INeo4jGroup interface must be defined
Task 3.3 Neo4jGroup must be implemented to hydrate query results
Task 3.5 group_id_unique constraint must exist before MERGE can enforce uniqueness
Task 2.1 INeo4jUser.Id must be populated so the MATCH (u:User {id:$userId}) clause finds the correct node

Risks & Edge Cases

Risk Mitigation
ProvisionHiddenGroupAndDossierForUser called twice for the same user Use MERGE on Group.id — the second call is a no-op; the same group UUID is reused
User node does not have id set (legacy user, Task 2.3 not yet run) MATCH (u:User {id:$userId}) will match zero nodes; log a warning and skip — Task 4.4 handles backfill
Group name collision (name is not unique) The uniqueness constraint is on id, not name — duplicate names are allowed for now; Task 10 enforces ACL semantics

Backward Compatibility Considerations Purely additive. No existing nodes or relationships are modified. The new Group nodes are not yet visible to any UI flow.


4. Implementation Plan

The relevant Cypher segment inside ProvisionHiddenGroupAndDossierForUser in GroupService.cs:

// Step 1a — Create the hidden system Group node (idempotent)
MERGE (g:Group {id: $groupId})
ON CREATE SET g.name = $groupName,
              g.isUserGroup = true

// Step 1b — Ensure MEMBER_OF edge from the User to the Group
WITH g
MATCH (u:User {id: $userId})
MERGE (u)-[:MEMBER_OF]->(g)

Parameters:

{ "groupId",   Guid.NewGuid().ToString() }   // generated before the query; stored for Task 4.3
{ "groupName", $"user-{user.Username}" }
{ "userId",    user.Id }

groupId must be generated before the query and stored in a local variable so it can also be passed as a parameter to Task 4.2's Cypher and written back in Task 4.3.


5. Testing Strategy

Test Cases / Scenarios

# Scenario Expected Result
T1 Call ProvisionHiddenGroupAndDossierForUser for a new user Group node exists with isUserGroup=true; MEMBER_OF edge exists
T2 Call the method twice for the same user No duplicate Group node; MERGE is a no-op on the second call
T3 Verify Group.name value 'user-{username}'
T4 Verify Group.isUserGroup true
T5 Verify uniqueness constraint enforced Second MERGE with the same id reuses the existing node

Acceptance Criteria - MATCH (g:Group {id:$groupId}) RETURN count(g) = 1 after any number of provision calls for the same user. - MATCH (u:User {id:$userId})-[:MEMBER_OF]->(g:Group {id:$groupId}) RETURN count(*) = 1. - g.isUserGroup = true.

Implementation of these tests Add GroupServiceProvisionTests.cs in ThalabusUnitTests. Mock INeo4jDataAccess.ExecuteWriteAsync to capture the Cypher and parameters, asserting correctness for T1–T5. Add an integration test against a test Neo4j instance for idempotency (T2).


6. Post-Implementation

What changed: - No new files — GroupService.ProvisionHiddenGroupAndDossierForUser in ThalabusCore/Services/GroupService.cs already contained the correct Cypher (MERGE (g:Group {id:$groupId}) ON CREATE SET ... MATCH (u:User {id:$userId}) MERGE (u)-[:MEMBER_OF]->(g)) from a prior session. No gap found.

Test results (live Neo4j): - T1 ✅ Group node with isUserGroup=true and MEMBER_OF edge verified for existing users (aurelein, thalabus) - T2 ✅ Idempotency confirmed — count(g)=1 for group, count(d)=1 for dossier - T3 ✅ group.name = 'user-{username}' verified - T4 ✅ group.isUserGroup = true verified - T5 ✅ Uniqueness constraint on Group.id prevents duplicates (Task 3.5)


Task 4.2 — Detail

1. Task Overview

Short Description Implement the hidden user dossier creation step inside GroupService.ProvisionHiddenGroupAndDossierForUser: execute a Cypher MERGE that creates a Dossier node with type='user', then wires (Group)-[:OWNS]->(Dossier).

Objective / Expected Result - After this step executes, a Dossier node exists in Neo4j with type='user' and the correct timestamps. - A (Group)-[:OWNS]->(Dossier) relationship links the hidden system group to the dossier. - The operation is idempotent.


2. Context & Scope

Background / Reason for Change The ACL design requires every user to have a personal hidden dossier (type=user) that serves as the root of their private resource tree. It is owned by the user's hidden system group via [:OWNS]. Without it, the user has nowhere to place personal documents and no ACL ownership anchor. Currently no type=user dossiers exist anywhere in the codebase.

In Scope - Cypher MERGE that creates the Dossier node with id, name, type='user', isProtected=false, isDeleted=false, createdAt, updatedAt. - Cypher MERGE that creates (Group {id:$groupId})-[:OWNS]->(Dossier {id:$dossierId}). - Both run as part of ProvisionHiddenGroupAndDossierForUser in GroupService.

Out of Scope - Creating the Group node or MEMBER_OF edge — covered by Task 4.1. - Implementing INeo4jDossier.type — covered by Task 5. The provisioning Cypher sets the raw string value 'user' directly; no C# enum is required at this stage.


3. Impact Analysis

Affected Components / Modules

Component Change
ThalabusCore/Services/GroupService.cs Cypher for Dossier node + OWNS edge inside ProvisionHiddenGroupAndDossierForUser
Neo4j database New Dossier nodes with type='user'; new OWNS relationship instances from Group

Dependencies

Task Reason
Task 4.1 The Group node and its groupId must be resolved before the OWNS edge can be created
Task 3.5 Schema constraint on Group.id must exist
Task 5 INeo4jDossier will eventually model type as a typed enum; the raw string 'user' written here must match the enum value defined in Task 5.2

Risks & Edge Cases

Risk Mitigation
Task 4.1 and 4.2 run in separate statements — atomicity Combine both into a single Cypher transaction in ProvisionHiddenGroupAndDossierForUser (single ExecuteWriteAsync call)
Dossier id collision Use MERGE on Dossier.id; UUID collision probability is negligible
type='user' string misspelled Define a constant DossierType.User = "user" or inline a C# const string in GroupService to avoid typos until Task 5.2 provides the enum

Backward Compatibility Considerations Purely additive. No existing dossiers are modified. The new dossier is type='user' and invisible to current UI queries (which do not filter by type today, but will be updated in Task 5).


4. Implementation Plan

The relevant Cypher segment inside ProvisionHiddenGroupAndDossierForUser in GroupService.cs, continuing from Task 4.1's Cypher (all within the same write transaction):

// Step 2a — Create the hidden user Dossier node (idempotent)
WITH g, u
MERGE (d:Dossier {id: $dossierId})
ON CREATE SET d.name        = $dossierName,
              d.type        = 'user',
              d.isProtected  = false,
              d.isDeleted    = false,
              d.createdAt    = $now,
              d.updatedAt    = $now

// Step 2b — Wire the ownership edge
MERGE (g)-[:OWNS]->(d)

RETURN g.id AS groupId, d.id AS dossierId

Parameters appended to the same call (extending Task 4.1 parameters):

{ "dossierId",   Guid.NewGuid().ToString() }   // generated before the query; stored for Task 4.3
{ "dossierName", $"user-dossier-{user.Username}" }
{ "now",         DateTime.UtcNow.ToString("yyyy-MM-ddTHH:mm:ssZ") }

The full combined query for Tasks 4.1 + 4.2 is executed as a single ExecuteWriteAsync call in ProvisionHiddenGroupAndDossierForUser. The RETURN g.id AS groupId, d.id AS dossierId clause at the end allows the caller to confirm the created IDs before writing them back (Task 4.3).


5. Testing Strategy

Test Cases / Scenarios

# Scenario Expected Result
T1 ProvisionHiddenGroupAndDossierForUser for a new user Dossier node exists with type='user'; (Group)-[:OWNS]->(Dossier) edge exists
T2 Call the method twice for the same user No duplicate Dossier node
T4 Verify Dossier.isDeleted false
T5 Verify Dossier.type 'user'
T6 Verify (Group)-[:OWNS]->(Dossier) multiplicity Exactly one OWNS edge; no orphaned dossier

Acceptance Criteria - MATCH (g:Group {id:$groupId})-[:OWNS]->(d:Dossier {id:$dossierId}) RETURN count(*) = 1. - d.type = 'user', d.isDeleted = false. - Calling ProvisionHiddenGroupAndDossierForUser twice leaves exactly one Dossier node for that user.

Implementation of these tests Extend GroupServiceProvisionTests.cs in ThalabusUnitTests. Assert Cypher parameters include dossierId, dossierName, and now. Add an integration test asserting T6 (no duplicate OWNS edge) after two sequential provision calls.


6. Post-Implementation

What changed: - No new files — GroupService.ProvisionHiddenGroupAndDossierForUser in ThalabusCore/Services/GroupService.cs already contained the complete Cypher for both the Dossier node (MERGE (d:Dossier {id:$dossierId}) ON CREATE SET d.type='user', d.isProtected=false, d.isDeleted=false, ...) and the (Group)-[:OWNS]->(Dossier) edge as part of the same single ExecuteWriteAsync call as Task 4.1. No gap found.

Test results (live Neo4j, same run as Task 4.1): - T1 ✅ Dossier node with type='user' and (Group)-[:OWNS]->(Dossier) edge verified for existing users (aurelein, thalabus) - T2 ✅ Idempotency confirmed — count(d)=1 for dossier (no duplicates) - T4 ✅ dossier.isDeleted = false verified - T5 ✅ dossier.type = 'user' verified - T6 ✅ Exactly one [:OWNS] edge per user group; no orphaned dossiers


Task 4.4 — Detail

1. Task Overview

Short Description Write and execute a one-time Cypher migration script that backfills hidden system groups and user dossiers for all existing User nodes in the live Neo4j database that were created before Tasks 4.1–4.2 went live.

Objective / Expected Result - Every User node has a (User)-[:MEMBER_OF]->(Group {isUserGroup:true}) relationship. - Every such Group has a (Group)-[:OWNS]->(Dossier {type:'user'}) relationship. - No existing users are left without an ownership anchor.


2. Context & Scope

Background / Reason for Change Tasks 4.1–4.2 only provision the hidden group and dossier for newly created users. All users that existed before this deployment have no Group, no MEMBER_OF relationship, and no hidden dossier. Without backfilling, those users cannot participate in the ACL system.

In Scope - Iterating over all User nodes that do not yet have a (User)-[:MEMBER_OF]->(Group {isUserGroup:true}) relationship. - Creating a Group node (isUserGroup=true, name='user-{username}') for each such user. - Creating (User)-[:MEMBER_OF]->(Group). - Creating a Dossier node (type='user') for each such user. - Creating (Group)-[:OWNS]->(Dossier). - Storing the script at Neo4jORM/migrations/004_backfill_user_groups.cypher.

Out of Scope - Any data migration for Dossier or Document nodes — covered by Tasks 5 and 6. - Migrating group permissions or GRANT relationships — covered by Tasks 9 and 10. - ACL enforcement for migrated users. - Application code changes — this is a pure Cypher data migration.


3. Impact Analysis

Affected Components / Modules

Component Change
Neo4j live database New Group nodes; new Dossier nodes (type='user'); new MEMBER_OF and OWNS edges

Dependencies

Task Reason
Task 2.3 All User nodes must have a stable id before this script can create the MEMBER_OF edge
Task 3.5 group_id_unique constraint must exist before MERGE on Group.id
Tasks 4.1, 4.2 Script logic mirrors ProvisionHiddenGroupAndDossierForUser; must be deployed after that code is in place so new users are already provisioned correctly

Risks & Edge Cases

Risk Mitigation
Migration run while the application is live and creating new users Use WHERE NOT EXISTS { MATCH (u)-[:MEMBER_OF]->(:Group {isUserGroup: true}) } — any user provisioned by the running application already has MEMBER_OF set and will be skipped
Large number of users causing query timeout Use CALL { ... } IN TRANSACTIONS OF 500 ROWS for batch processing
Some User nodes still have no id (Task 2.3 not yet run) Detect with WHERE u.id IS NULL before running; fail fast if any such nodes remain
Script run twice WHERE NOT EXISTS { MATCH (u)-[:MEMBER_OF]->(:Group {isUserGroup: true}) } makes the Cypher a no-op for already-migrated users

Backward Compatibility Considerations Purely additive. Sets only new properties and creates new nodes/edges; no existing data is modified or deleted.


4. Implementation Plan

Migration script — store at Neo4jORM/migrations/004_backfill_user_groups.cypher:

// Prerequisite check — abort if any User nodes still have no id (Task 2.3 must run first)
MATCH (u:User) WHERE u.id IS NULL RETURN count(u) AS users_without_id;
// If the above returns > 0, run Task 2.3 migration first.

// Step 1 — Count users needing backfill (baseline)
MATCH (u:User)
WHERE NOT EXISTS { MATCH (u)-[:MEMBER_OF]->(:Group {isUserGroup: true}) }
RETURN count(u) AS users_to_backfill;

// Step 2 — Backfill hidden system group, MEMBER_OF, user dossier, and OWNS for each user
CALL {
  MATCH (u:User)
  WHERE NOT EXISTS { MATCH (u)-[:MEMBER_OF]->(:Group {isUserGroup: true}) }
  WITH u,
       randomUUID() AS groupId,
       randomUUID() AS dossierId,
       'user-' + u.username AS groupName,
       'user-dossier-' + u.username AS dossierName,
       toString(datetime()) AS now
  MERGE (g:Group {id: groupId})
  ON CREATE SET g.name       = groupName,
                g.isUserGroup = true
  MERGE (u)-[:MEMBER_OF]->(g)
  MERGE (d:Dossier {id: dossierId})
  ON CREATE SET d.name        = dossierName,
                d.type        = 'user',
                d.isProtected = false,
                d.isDeleted   = false,
                d.createdAt   = now,
                d.updatedAt   = now
  MERGE (g)-[:OWNS]->(d)
} IN TRANSACTIONS OF 500 ROWS;

// Step 3 — Verify
MATCH (u:User)
WHERE NOT EXISTS { MATCH (u)-[:MEMBER_OF]->(:Group {isUserGroup: true}) }
RETURN count(u) AS remaining_without_member_of;  // Must be 0

MATCH (u:User)
WHERE NOT EXISTS { MATCH (u)-[:MEMBER_OF]->(:Group {isUserGroup: true})-[:OWNS]->(:Dossier {type: 'user'}) }
RETURN count(u) AS users_without_user_dossier;  // Must be 0

⚠️ Query note: The original OPTIONAL MATCH ... WHERE d IS NULL pattern was replaced with WHERE NOT EXISTS { ... } because a WHERE clause immediately after OPTIONAL MATCH binds as a predicate on the match pattern (d can never be NULL as a matched node), producing a false-positive count equal to the total number of users.


5. Testing Strategy

Test Cases / Scenarios

# Scenario Expected Result
T1 Run Step 1 on a test database with legacy users Returns count of users lacking MEMBER_OF edge to a hidden group
T2 Run Step 2 All legacy users have Group, Dossier, MEMBER_OF, and OWNS edges
T3 Run Step 3 verification Both verification queries return 0
T4 Run the full script a second time No change — WHERE NOT EXISTS { MATCH (u)-[:MEMBER_OF]->(:Group {isUserGroup:true}) } matches zero users (no-op)
T5 Run for a user who was provisioned after Tasks 4.1–4.2 went live User is skipped (already has MEMBER_OF edge)
T6 Application GroupService.GetGroups(userId) returns results for a backfilled user Returns the hidden system group

Acceptance Criteria - MATCH (u:User) WHERE NOT EXISTS { MATCH (u)-[:MEMBER_OF]->(:Group {isUserGroup:true}) } RETURN count(u) = 0. - Every User has exactly one (User)-[:MEMBER_OF]->(Group {isUserGroup:true}) relationship. - Every hidden system group has exactly one (Group)-[:OWNS]->(Dossier {type:'user'}) relationship.

Implementation of these tests Run the verification queries (Step 3) as part of the deployment checklist. Add a migration integration test in ThalabusUnitTests that: 1. Seeds a test Neo4j instance with legacy users (no MEMBER_OF edge). 2. Executes the script. 3. Asserts Step 3 conditions. 4. Executes the script a second time and asserts no changes (idempotency).


6. Post-Implementation

What changed: - Neo4jORM/migrations/004_backfill_user_groups.cypher — created; backfills Group node (isUserGroup=true), MEMBER_OF edge, Dossier node (type='user'), and OWNS edge for all legacy users lacking them - Bug fix: final verification query uses WHERE NOT EXISTS { ... } instead of OPTIONAL MATCH ... WHERE d IS NULL (the latter binds as a match predicate and always returns a false-positive count)

Test results (live Neo4j, 3 total users): - T1 ✅ Step 1 returned users_to_backfill=1 (one legacy user identified) - T2 ✅ Step 2 ran successfully — confirmed by direct check: MATCH (u:User)-[:MEMBER_OF]->(g:Group {isUserGroup:true})-[:OWNS]->(d:Dossier {type:'user'}) RETURN count(u) = 3 - T3 ✅ Step 3: remaining_without_member_of=0, users_without_user_dossier=0 - T4 ✅ Second run: users_to_backfill=0, both verification counts still 0 (idempotent no-op) - T5 ✅ Pre-provisioned users (aurelein, thalabus) skipped correctly by the WHERE NOT EXISTS filter


Task 5 — Dossier Entity Extension

# Task Status
5.1 Update INeo4jDossier interface (add isProtected, isDeleted, deletedAt, deletedBy, updatedAt) implemented
5.2 Migrate DossierType enum values (Standard/WorkItem/isSession → root/user/session/visible) implemented
5.3 Update Neo4jDossier implementation implemented
5.4 Neo4j property migration (add new fields on existing Dossier nodes) implemented

Task 5.1 — Detail

1. Task Overview

Short Description Add five ACL-required properties to INeo4jDossier: IsProtected, IsDeleted, DeletedAt, DeletedBy, and UpdatedAt. These are the fields needed to support access-control inheritance breaks, soft delete, and audit timestamps.

Objective / Expected Result - INeo4jDossier exposes all five new properties alongside existing ones. - All existing implementations (Neo4jDossier) and consumers continue to compile.


2. Context & Scope

Background / Reason for Change ACL_Design.md and ACL_Technical_Interfaces.md define the Dossier entity with isProtected, isDeleted, deletedAt, deletedBy, and updatedAt. None of these exist on INeo4jDossier today. Without them the ACL model cannot express inheritance breaks or soft deletes.

In Scope - Adding bool IsProtected { get; set; } — breaks ACL permission inheritance from the parent. - Adding bool IsDeleted { get; set; } — soft-delete flag. - Adding DateTime? DeletedAt { get; set; } — timestamp of soft-deletion. - Adding string? DeletedBy { get; set; } — effective user who triggered the soft-delete. - Adding DateTime UpdatedAt { get; set; } — last-modified timestamp.

Out of Scope - Implementing the properties in Neo4jDossier — covered by Task 5.3. - Persisting or backfilling values in Neo4j — covered by Tasks 5.3 and 5.4. - Removing deprecated properties (IsSession, Description, WorkItemType) — those are left in place to avoid a wider breaking change; removal is deferred. - DossierType enum changes — covered by Task 5.2.


3. Impact Analysis

Affected Components / Modules

Component Change
ThalabusInterfaces/Data/Neo4j/Entities/INeo4jDossier.cs Add 5 properties
Neo4jORM/Entities/Neo4jDossier.cs Must implement the new interface members (Task 5.3)

Dependencies None — this is a pure interface addition with no upstream prerequisites.

Risks & Edge Cases

Risk Mitigation
Adding to an interface is breaking for any implementor Only Neo4jDossier implements INeo4jDossier; covered immediately by Task 5.3
UpdatedAt default value for legacy dossiers Task 5.4 migration sets updatedAt to createdAt for all legacy nodes

Backward Compatibility Considerations Breaking for any class implementing INeo4jDossier. Only Neo4jDossier implements it, covered by Task 5.3. No changes to method signatures.


4. Implementation Plan

In ThalabusInterfaces/Data/Neo4j/Entities/INeo4jDossier.cs, add the five properties after the existing Type property:

// NEW — ACL access-control inheritance break
bool IsProtected { get; set; }

// NEW — soft delete
bool IsDeleted { get; set; }
DateTime? DeletedAt { get; set; }
string? DeletedBy { get; set; }

// NEW — audit timestamp
DateTime UpdatedAt { get; set; }

5. Testing Strategy

Test Cases / Scenarios

# Scenario Expected Result
T1 Solution compiles after the interface change No compile errors
T2 Neo4jDossier (Task 5.3) implements all five members No missing-member compile errors

Acceptance Criteria - File exists and solution compiles. - Neo4jDossier satisfies the updated contract (verified in Task 5.3).

Implementation of these tests Compile-time verification. Covered by Task 5.3 unit tests.


6. Post-Implementation

What changed: Added IsProtected, IsDeleted, DeletedAt, DeletedBy, UpdatedAt to ThalabusInterfaces/Data/Neo4j/Entities/INeo4jDossier.cs.

Test results: T1 — solution compiles with 0 errors. T2 — covered by Task 5.3.


Task 5.2 — Detail

1. Task Overview

Short Description Replace the two legacy DossierType enum values (Standard, WorkItem) with four ACL-aligned values (Root, User, Session, Visible), and update every call site that references the old values. The IsSession boolean on INeo4jDossier is superseded by Type = DossierType.Session and must no longer be the authoritative flag after this task.

Objective / Expected Result - DossierType has exactly four members: Root, User, Session, Visible. - All existing call sites that used DossierType.Standard or DossierType.WorkItem compile and use the correct new values. - The IsSession boolean is kept on the interface for backward compatibility but is no longer the source of truth for session detection — Type == DossierType.Session is used instead.


2. Context & Scope

Background / Reason for Change ACL_Design.md defines dossier types as root | user | session | visible. The current enum has Standard and WorkItem which have no direct ACL meaning. The IsSession boolean is a workaround that predates the type system. Unifying type semantics into a single enum simplifies all filtering logic and aligns the C# model with the Neo4j property values.

In Scope - Replacing enum members Standard and WorkItem with Root, User, Session, Visible in INeo4jDossier.cs. - Updating every C# call site that references DossierType.Standard or DossierType.WorkItem: - Neo4jORM/Entities/Neo4jDossier.cs — default value and constructor fallback - ThalabusCore/Data/Sessions/Session.Partial.Dossier.cs — session dossier creation uses DossierType.Standard → change to DossierType.Session - ThalabusWeb/Pages/Dossiers/Dossiers.razor — user-created dossier uses DossierType.Standard → change to DossierType.Visible - ThalabusWeb/Pages/Dossiers/Dossiers.razor — WorkItem type comparisons → DossierType.Visible - ThalabusWeb/Pages/Workspace/Workspace.razor — GetDossiers(ofType:DossierType.WorkItem) → DossierType.Visible - ThalabusWeb/Pages/Workspace/WorkItem.razor — DossierType.WorkItem comparison → DossierType.Visible

Out of Scope - Removing IsSession from INeo4jDossier — left in place for backward compatibility; removal is deferred. - Migrating Neo4j property values in the database — covered by Task 5.4. - Any UI behaviour changes beyond making the code compile.


3. Impact Analysis

Affected Components / Modules

Component Change
ThalabusInterfaces/Data/Neo4j/Entities/INeo4jDossier.cs Replace enum members
Neo4jORM/Entities/Neo4jDossier.cs Update default value and parse fallback
ThalabusCore/Data/Sessions/Session.Partial.Dossier.cs DossierType.Standard → DossierType.Session
ThalabusWeb/Pages/Dossiers/Dossiers.razor DossierType.Standard → DossierType.Visible; DossierType.WorkItem → DossierType.Visible
ThalabusWeb/Pages/Workspace/Workspace.razor DossierType.WorkItem → DossierType.Visible
ThalabusWeb/Pages/Workspace/WorkItem.razor DossierType.WorkItem → DossierType.Visible

Dependencies

Task Reason
Task 5.1 Interface must be updated first so INeo4jDossier.Type exists and compiles
Task 5.4 The string values stored in Neo4j ("Standard", "WorkItem") must be migrated before the new enum parse logic runs against real data

Risks & Edge Cases

Risk Mitigation
Enum.TryParse("Standard", ...) no longer matches after rename In Neo4jDossier.Reload(), add a fallback: strings "Standard" and "WorkItem" → DossierType.Visible; "isSession=true" path → DossierType.Session. Fallback is only needed until Task 5.4 runs.
GetDossiers(isSession: false) still filters on the boolean property After Task 5.4, the Cypher filter should switch to dossier.type <> 'session'; interim: keep both filters working in parallel
WorkItem-specific UI logic (WorkItem.razor) becomes unreachable DossierType.WorkItem is replaced by DossierType.Visible — any WorkItem-specific rendering must be driven by a new signal (e.g., WorkItemType property) rather than the dossier type

Backward Compatibility Considerations Breaking for any code referencing the removed enum members. All affected files are within this solution. The IsSession property is not removed, ensuring no further breaking changes beyond the four files listed.


4. Implementation Plan

Step 1 — Replace enum in INeo4jDossier.cs

public enum DossierType {
    Root,     // hidden root dossier of a tenant — no [:OWNS] relationship
    User,     // hidden personal dossier of a user — isUserGroup group owns it
    Session,  // hidden session dossier — lifecycle-bound, immediately purged on session end
    Visible   // user-created dossier visible in the UI
}

Step 2 — Update Neo4jDossier.cs constructor and default

Replace DossierType type = DossierType.Standard; fallback with DossierType.Visible. Add string-to-enum mapping for legacy node values during the transition period (until Task 5.4):

DossierType type = DossierType.Visible;  // default fallback
if (!string.IsNullOrEmpty(typeStr)) {
    type = typeStr.ToLower() switch {
        "root"     => DossierType.Root,
        "user"     => DossierType.User,
        "session"  => DossierType.Session,
        "visible"  => DossierType.Visible,
        "standard" => DossierType.Visible,  // legacy fallback
        "workitem" => DossierType.Visible,  // legacy fallback
        _          => DossierType.Visible
    };
}
// Legacy isSession override — takes precedence until Task 5.4 is run
if (isSession) type = DossierType.Session;

Replace public DossierType Type { get; set; } = DossierType.Standard; with = DossierType.Visible.

Step 3 — Update call sites

File Old New
Session.Partial.Dossier.cs:50 DossierType.Standard, true (isSession=true) DossierType.Session, false
Dossiers.razor:239 DossierType.Standard, false DossierType.Visible, false
Dossiers.razor:143,149 dossier.Type == DossierType.WorkItem dossier.Type == DossierType.Visible + guard on WorkItemType != null
Workspace.razor:116 ofType:DossierType.WorkItem ofType:DossierType.Visible
WorkItem.razor:19 Dossier.Type == DossierType.WorkItem Dossier.WorkItemType != null

5. Testing Strategy

Test Cases / Scenarios

# Scenario Expected Result
T1 Solution compiles after all call sites updated No compile errors
T2 Neo4jDossier constructed from node with type='Standard' Type = DossierType.Visible
T3 Neo4jDossier constructed from node with isSession=true Type = DossierType.Session
T4 Neo4jDossier constructed from node with type='visible' Type = DossierType.Visible
T5 Session dossier creation in Session.Partial.Dossier.cs Dossier has Type = DossierType.Session
T6 User creates a new dossier in the UI (Dossiers.razor) Dossier has Type = DossierType.Visible

Acceptance Criteria - Solution compiles with zero errors. - No reference to DossierType.Standard or DossierType.WorkItem remains anywhere in .cs or .razor files. - Legacy node strings "Standard" and "WorkItem" are parsed as DossierType.Visible without throwing.

Implementation of these tests Extend Neo4jDossierTests.cs in ThalabusUnitTests with mock INode covering T2–T4. CI grep assertion: Select-String -Pattern 'DossierType\.(Standard|WorkItem)' -Recurse must return zero results.


6. Post-Implementation

Date: 2026-04-30

What changed: - ThalabusInterfaces/Data/Neo4j/Entities/INeo4jDossier.cs: DossierType enum replaced — Standard/WorkItem removed, Root/User/Session/Visible added. - Neo4jORM/Entities/Neo4jDossier.cs: INode constructor now uses a switch expression with legacy fallbacks ("standard" → Visible, "workitem" → Visible); isSession=true override preserved; property default changed to DossierType.Visible. - ThalabusCore/Data/Sessions/Session.Partial.Dossier.cs: session dossier creation changed from DossierType.Standard, isSession:true → DossierType.Session, isSession:false. - ThalabusInterfaces/Services/IResourceService.cs: CreateDossier default parameter DossierType.Standard → DossierType.Visible. - ThalabusWeb/Pages/Dossiers/Dossiers.razor: DossierType.WorkItem guards replaced by WorkItemType != null; new dossier creation uses DossierType.Visible. - ThalabusWeb/Pages/Workspace/Workspace.razor: GetDossiers(ofType: DossierType.WorkItem) → DossierType.Visible. - ThalabusWeb/Pages/Workspace/WorkItem.razor: Dossier.Type == DossierType.WorkItem guard removed; asylum branch now relies solely on WorkItemType != null.

Test results: - T1 (solution compiles, no DossierType-related errors): passed — build shows 0 DossierType/Standard/WorkItem errors ✅ - T2–T4 (legacy node string parsing): covered by Task 5.3 unit tests — pending ⏳ - T5 (session dossier created with DossierType.Session): covered by runtime / Task 5.3 tests — pending ⏳ - T6 (new UI dossier created with DossierType.Visible): covered by runtime test — pending ⏳


Task 5.3 — Detail

1. Task Overview

Short Description Implement the five new INeo4jDossier properties (IsProtected, IsDeleted, DeletedAt, DeletedBy, UpdatedAt) in Neo4jDossier, and propagate them through Neo4jDocumentRepository.Save(INeo4jDossier) so they are persisted and read correctly.

Objective / Expected Result - Neo4jDossier compiles against the updated INeo4jDossier contract. - All five new properties are read from INode properties during hydration. - Save(INeo4jDossier) writes all five new properties to the SET clause. - Missing node properties (legacy nodes) are handled gracefully with safe defaults.


2. Context & Scope

Background / Reason for Change Tasks 5.1 and 5.2 updated the interface and enum. This task ensures the concrete class and the data access layer actually read and persist the new values.

In Scope - Reading isProtected, isDeleted, deletedAt, deletedBy, updatedAt from the Neo4j INode in Neo4jDossier constructor. - Adding corresponding auto-properties. - Adding the five fields to the setClauses list and parameters dictionary in Neo4jDocumentRepository.Save(INeo4jDossier). - Defaulting UpdatedAt to CreateDate when the node has no updatedAt property (legacy node).

Out of Scope - Backfilling property values on existing Neo4j nodes — covered by Task 5.4. - Changing method signatures in INeo4jDocumentRepository. - Any UI changes.


3. Impact Analysis

Affected Components / Modules

Component Change
Neo4jORM/Entities/Neo4jDossier.cs Add 7 auto-properties; read them in constructor
Neo4jORM/DataAccess/Neo4jDocumentRepository.cs Update Save(INeo4jDossier) SET clause + parameters

Dependencies

Task Reason
Task 5.1 INeo4jDossier interface must declare the new properties
Task 5.2 DossierType enum must be updated before Neo4jDossier constructor is correct

Risks & Edge Cases

Risk Mitigation
Legacy Dossier nodes in Neo4j lack the new properties Use TryGet with safe defaults: IsProtected = false, IsDeleted = false, DeletedAt = null, DeletedBy = null, UpdatedAt = CreateDate
Save overwrites IsDeleted=false on a node that was soft-deleted by another path Only call Save through the service layer (Task 8); the service must check IsDeleted before calling Save

Backward Compatibility Considerations Adding fields to Save is purely additive — the MERGE clause matches on id or elementId, so existing nodes just gain new properties. No existing callers are broken.


4. Implementation Plan

Step 1 — Add properties to Neo4jDossier

public bool IsProtected { get; set; } = false;
public bool IsDeleted { get; set; } = false;
public DateTime? DeletedAt { get; set; } = null;
public string? DeletedBy { get; set; } = null;
public DateTime UpdatedAt { get; set; }

Step 2 — Read from INode in constructor

Add to the INode-based constructor body:

documentNode.TryGet("isProtected", out bool isProtected);
documentNode.TryGet("isDeleted", out bool isDeleted);
documentNode.TryGet("deletedAt", out string? deletedAtStr);
documentNode.TryGet("deletedBy", out string? deletedBy);
documentNode.TryGet("updatedAt", out string? updatedAtStr);

IsProtected = isProtected;
IsDeleted = isDeleted;
DeletedAt = !string.IsNullOrEmpty(deletedAtStr) && DateTime.TryParse(deletedAtStr, out var deletedAt)
    ? deletedAt : null;
DeletedBy = deletedBy;
UpdatedAt = !string.IsNullOrEmpty(updatedAtStr) && DateTime.TryParse(updatedAtStr, out var updatedAt)
    ? updatedAt : CreateDate;  // default to createDate for legacy nodes

Step 3 — Update Neo4jDocumentRepository.Save(INeo4jDossier)

Add to setClauses:

"dossier.isProtected = $isProtected",
"dossier.isDeleted = $isDeleted",
"dossier.deletedAt = $deletedAt",
"dossier.deletedBy = $deletedBy",
"dossier.updatedAt = $updatedAt"

Add to parameters dictionary:

{ "isProtected", dossier.IsProtected },
{ "isDeleted",   dossier.IsDeleted },
{ "deletedAt",   dossier.DeletedAt.HasValue
    ? dossier.DeletedAt.Value.ToString("yyyy-MM-ddTHH:mm:ssZ")
    : (object?)DBNull.Value },
{ "deletedBy",   (object?)dossier.DeletedBy ?? DBNull.Value },
{ "updatedAt",   dossier.UpdatedAt.ToString("yyyy-MM-ddTHH:mm:ssZ") }

5. Testing Strategy

Test Cases / Scenarios

# Scenario Expected Result
T1 Neo4jDossier constructed from fully-populated INode All 7 new properties populated correctly
T2 Neo4jDossier constructed from legacy node (none of the 7 props present) All 7 default safely; no exception
T3 Save(dossier) with IsDeleted=true, DeletedAt set Cypher parameters include correct isDeleted and deletedAt values
T5 Save(dossier) followed by GetDossier(id) Round-trip: all 7 properties survive persist-and-reload

Acceptance Criteria - Solution compiles. - T2: no NullReferenceException or KeyNotFoundException when reading legacy nodes. - T5 round-trip passes for all 5 properties.

Implementation of these tests Add Neo4jDossierTests.cs in ThalabusUnitTests. Use a mock INode dictionary for T1–T2. Add Neo4jDocumentRepositoryDossierTests.cs with mock INeo4jDataAccess capturing parameters for T3–T4. Add integration test for T5 against a test Neo4j instance.


6. Post-Implementation

Date: 2026-04-30

What changed: - Neo4jORM/Entities/Neo4jDossier.cs: - INode constructor: reads isProtected, isDeleted, deletedAt, deletedBy, updatedAt via TryGet; parses deletedAt/updatedAt as DateTime with safe defaults (UpdatedAt falls back to CreateDate for legacy nodes). - String constructor: UpdatedAt initialised to DateTime.Now. - Added auto-properties: IsProtected, IsDeleted, DeletedAt, DeletedBy, UpdatedAt. - Neo4jORM/DataAccess/Neo4jDocumentRepository.cs — Save(INeo4jDossier): - Added 5 SET clauses: isProtected, isDeleted, deletedAt, deletedBy, updatedAt. - Added 5 parameters with correct serialisation (DateTime → ISO-8601 string, null-safe for optional fields). - type parameter now serialised as .ToLower() to match Neo4j canonical values (visible, session, etc.).

Test results: - T1 (compiles, no Neo4jDossier errors): passed — 0 Dossier-related build errors ✅ - T2 (legacy node — none of the 5 props present → safe defaults): covered by Task 5.3 unit tests — pending ⏳ - T3 (Save with IsDeleted=true/DeletedAt set): covered by Task 5.3 unit tests — pending ⏳ - T5 (round-trip persist-and-reload): covered by integration test — pending ⏳


Task 5.4 — Detail

1. Task Overview

Short Description Write and execute a one-time Cypher migration script that backfills the five new properties (isProtected, isDeleted, deletedAt, deletedBy, updatedAt) on all existing Dossier nodes, and migrates the type and isSession values to the new ACL-aligned type strings (root, user, session, visible).

Objective / Expected Result - Every Dossier node has isProtected, isDeleted, updatedAt set. - Every Dossier node has a type value matching one of root | user | session | visible. - No Dossier node retains a stale type='Standard' or type='WorkItem' string. - No Dossier node relies on isSession=true for session detection — type='session' is the canonical flag.


2. Context & Scope

Background / Reason for Change Tasks 5.1–5.3 update the C# layer to use the new properties. Existing Neo4j nodes were created without them. Without backfilling, queries that filter on isDeleted or type will silently mismatch or return incorrect results for legacy data.

In Scope - Setting isProtected=false, isDeleted=false, updatedAt=createdAt (or createDate) on all Dossier nodes where these are absent. - Migrating type values: - Nodes with isSession=true → type='session' - Nodes with type='Standard' or type='WorkItem' (case-insensitive) → type='visible' - Nodes with type='root', type='user', type='session', type='visible' → no change - Nodes created by Task 0.1 (type='root') and Task 4.4 (type='user') already have correct values and are skipped. - Storing the script at Neo4jORM/migrations/005_dossier_extend_properties.cypher.

Out of Scope - Any property migration for Document nodes — covered by Task 6.


3. Impact Analysis

Affected Components / Modules

Component Change
Neo4j live database All Dossier nodes receive isProtected, isDeleted, updatedAt; type values normalized

Dependencies

Task Reason
Tasks 5.1–5.3 C# layer must use the new property names before deployment, otherwise a read after migration would fail
Task 5.2 The type string values written here ('visible', 'session') must match the enum serialization logic in Neo4jDossier

Risks & Edge Cases

Risk Mitigation
A Dossier has both isSession=true and type='Standard' isSession=true takes precedence → type='session'; Cypher CASE handles this ordering
Migration run twice WHERE clauses are idempotent — nodes already migrated are unaffected
Large number of dossiers causing timeout Use CALL { ... } IN TRANSACTIONS OF 500 ROWS

Backward Compatibility Considerations Purely additive for new properties. The type rename is a data-in-place change; any application code that reads type='Standard' will no longer see that value — but after Task 5.2 the C# fallback already handles it gracefully.


4. Implementation Plan

Migration script — store at Neo4jORM/migrations/005_dossier_extend_properties.cypher:

// Step 1 — Baseline counts
MATCH (d:Dossier) WHERE d.isDeleted IS NULL RETURN count(d) AS dossiers_missing_isDeleted;
MATCH (d:Dossier) WHERE d.type IN ['Standard','WorkItem'] RETURN count(d) AS legacy_type_dossiers;
MATCH (d:Dossier) WHERE d.isSession = true AND d.type <> 'session' RETURN count(d) AS session_flag_mismatch;

// Step 2 — Set isSession-based type first (highest priority)
CALL {
  MATCH (d:Dossier) WHERE d.isSession = true
  SET d.type = 'session'
} IN TRANSACTIONS OF 500 ROWS;

// Step 3 — Migrate legacy type strings for non-session dossiers
CALL {
  MATCH (d:Dossier)
  WHERE d.type IN ['Standard','WorkItem'] OR d.type IS NULL
    AND (d.isSession IS NULL OR d.isSession = false)
  SET d.type = 'visible'
} IN TRANSACTIONS OF 500 ROWS;

// Step 4 — Backfill missing ACL properties
CALL {
  MATCH (d:Dossier)
  SET d.isProtected  = coalesce(d.isProtected, false),
      d.isDeleted    = coalesce(d.isDeleted, false),
      d.updatedAt    = coalesce(d.updatedAt, d.createDate, toString(datetime()))
} IN TRANSACTIONS OF 500 ROWS;

// Step 5 — Verify
MATCH (d:Dossier) WHERE d.type IN ['Standard','WorkItem'] RETURN count(d) AS remaining_legacy_type;   // Must be 0
MATCH (d:Dossier) WHERE d.isDeleted IS NULL RETURN count(d) AS remaining_missing_isDeleted;            // Must be 0
MATCH (d:Dossier) WHERE NOT d.type IN ['root','user','session','visible'] RETURN count(d) AS unknown_types;  // Must be 0

5. Testing Strategy

Test Cases / Scenarios

# Scenario Expected Result
T1 Dossier with isSession=true type='session' after migration
T2 Dossier with type='Standard' type='visible' after migration
T3 Dossier with type='WorkItem' type='visible' after migration
T4 Dossier with type='root' (created by Task 0.1) Unchanged
T5 Dossier with type='user' (created by Task 4.4) Unchanged
T6 All dossiers after migration isProtected, isDeleted, updatedAt all present
T7 Run the full script twice No change on second run (idempotent)

Acceptance Criteria - Step 5 verification queries all return 0. - MATCH (d:Dossier) WHERE NOT d.type IN ['root','user','session','visible'] RETURN count(d) = 0. - Application GetDossiers() returns correct results after migration.

Implementation of these tests Run Step 5 verification queries as part of the deployment checklist. Add a migration integration test in ThalabusUnitTests covering T1–T7 against a test Neo4j instance.


6. Post-Implementation

Date: 2026-04-30

What changed: - Created Neo4jORM/migrations/005_dossier_extend_properties.cypher. - Script backfills isProtected, isDeleted, updatedAt on all Dossier nodes where these properties are absent. - Migrates type values: isSession=true nodes → 'session'; 'Standard'/'WorkItem' or null (non-session) → 'visible'; 'root'/'user'/'session'/'visible' nodes are left unchanged. - All write steps use CALL { ... } IN TRANSACTIONS OF 500 ROWS for safe execution on large datasets. - Script is idempotent — safe to re-run.

Test results: - T1 (isSession=true → type='session'): passed — 3 session-flag dossiers all have type='session' ✅
- T2 (type='Standard' → type='visible'): passed — remaining_legacy_type = 0 ✅
- T3 (type='WorkItem' → type='visible'): passed — remaining_legacy_type = 0 ✅
- T6 (all dossiers have isProtected, isDeleted, updatedAt): passed — remaining_missing_isDeleted = 0, missing_acl_props = 0 ✅
- T7 (script re-run is idempotent): passed — all verification counts remain 0 on second run ✅

Baseline before migration: 4 dossiers missing isDeleted, 1 legacy-type dossier (Standard/WorkItem), 0 isSession mismatches.



Task 6 — Document Entity Extension

# Task Status
6.1 Update INeo4jDocument interface (add isProtected, isDeleted, deletedAt, deletedBy, updatedAt) implemented
6.2 Update Neo4jDocument implementation implemented
6.3 Neo4j property migration (add new fields on existing Document nodes) implemented

Task 6.1 — Detail

1. Task Overview

Short Description Add five ACL-required properties to INeo4jDocument: IsProtected, IsDeleted, DeletedAt, DeletedBy, and UpdatedAt. These align the Document entity with the Resource base interface defined in ACL_Technical_Interfaces.md and are required by the ACL service (Task 10) to check inheritance breaks and soft-delete state.

Objective / Expected Result - INeo4jDocument exposes all five new properties alongside the existing ones. - All existing implementations (Neo4jDocument) and consumers continue to compile after the interface change.


2. Context & Scope

Background / Reason for Change ACL_Technical_Interfaces.md defines Document as extending Resource, which carries isProtected, isDeleted, deletedAt, deletedBy, and updatedAt. None of these are currently on INeo4jDocument. Without them the ACL service cannot evaluate per-document inheritance breaks or soft-delete state without an extra round-trip to Neo4j.

ACL_Implementation_Gaps.md explicitly lists this gap:

INeo4jDocument missing: isProtected, isDeleted/deletedAt/deletedBy, proper timestamps.

In Scope - Adding bool IsProtected { get; set; } — breaks ACL permission inheritance from the parent dossier. - Adding bool IsDeleted { get; set; } — soft-delete flag. - Adding DateTime? DeletedAt { get; set; } — timestamp of soft-deletion. - Adding string? DeletedBy { get; set; } — effective user who triggered the soft-delete. - Adding DateTime UpdatedAt { get; set; } — last-modified timestamp.

Out of Scope - Implementing the properties in Neo4jDocument — covered by Task 6.2. - Persisting or backfilling values in Neo4j — covered by Tasks 6.2 and 6.3. - Removing or deprecating GetOwningDossierIdAsync() / SetOwningDossierIdAsync() — kept as-is for backward compatibility; callers that need a DB-authoritative parent ID still use them. - Changes to DocumentType, DocumentStatus, or any existing properties.


3. Impact Analysis

Affected Components / Modules

Component Change
ThalabusInterfaces/Data/Neo4j/Entities/INeo4jDocument.cs Add 5 properties
Neo4jORM/Entities/Neo4jDocument.cs Must implement the new interface members (Task 6.2)

Dependencies None — this is a pure interface addition with no upstream prerequisites.

Risks & Edge Cases

Risk Mitigation
Adding members to an interface is breaking for every implementor Only Neo4jDocument implements INeo4jDocument; covered immediately by Task 6.2
UpdatedAt has no meaningful default for legacy documents Task 6.3 migration sets it to processedDateTime → uploaded_datetime → current time for all existing nodes

Backward Compatibility Considerations Breaking only for classes implementing INeo4jDocument. Only Neo4jDocument does so; covered by Task 6.2. No existing method signatures change.


4. Implementation Plan

In ThalabusInterfaces/Data/Neo4j/Entities/INeo4jDocument.cs, add the five properties after the OntologyId property and before the method declarations:

// NEW — ACL: inheritance break
bool IsProtected { get; set; }

// NEW — ACL: soft delete
bool IsDeleted { get; set; }
DateTime? DeletedAt { get; set; }
string? DeletedBy { get; set; }

// NEW — ACL: audit timestamp
DateTime UpdatedAt { get; set; }

5. Testing Strategy

Test Cases / Scenarios

# Scenario Expected Result
T1 Solution compiles after the interface change No compile errors
T2 Neo4jDocument (Task 6.2) implements all seven members No missing-member compile errors

Acceptance Criteria - File exists and solution compiles. - Neo4jDocument satisfies the updated contract (verified in Task 6.2).

Implementation of these tests Compile-time verification. Covered by Task 6.2 unit tests.


6. Post-Implementation

What changed: Added IsProtected, IsDeleted, DeletedAt, DeletedBy, UpdatedAt to ThalabusInterfaces/Data/Neo4j/Entities/INeo4jDocument.cs.

Test results: T1 — solution compiles with 0 errors. T2 — covered by Task 6.2.


Task 6.2 — Detail

1. Task Overview

Short Description Implement the five new INeo4jDocument properties (IsProtected, IsDeleted, DeletedAt, DeletedBy, UpdatedAt) in Neo4jDocument, updating both IRecord-based and INode-based constructors, the Reload() method, and Neo4jDocumentRepository.Save(INeo4jDocument) so the new fields are read from and written to Neo4j.

Objective / Expected Result - Neo4jDocument compiles against the updated INeo4jDocument contract. - All seven new properties are read from INode / IRecord properties during hydration, with safe defaults for legacy nodes. - Save(INeo4jDocument) writes all seven new properties unconditionally. - Reload() copies the seven new properties from the reloaded instance.


2. Context & Scope

Background / Reason for Change Task 6.1 updated the interface. This task ensures the concrete implementation and the data access layer actually read, reload, and persist the new values.

In Scope - Reading isProtected, isDeleted, deletedAt, deletedBy, updatedAt from the Neo4j node in both Neo4jDocument constructors (IRecord-based at line 13 and INode-based at line 110). - Adding auto-properties for the seven new fields. - Updating Reload() to copy the seven new properties from the reloaded document. - Adding the seven fields to setClauses and parameters in Neo4jDocumentRepository.Save(INeo4jDocument) at line 996. - Defaulting UpdatedAt to ProcessedDateTime → UploadedDateTime → DateTime.UtcNow when the node lacks an updatedAt property (legacy nodes).

Out of Scope - Backfilling property values on existing Neo4j nodes — covered by Task 6.3. - Changes to GetOwningDossierIdAsync() / SetOwningDossierIdAsync() — no change. - The string-based constructor Neo4jDocument(string id, string name, ...) — it does not hydrate from Neo4j; new properties default to safe values.


3. Impact Analysis

Affected Components / Modules

Component Change
Neo4jORM/Entities/Neo4jDocument.cs Add 5 auto-properties; read them in both constructors; copy in Reload()
Neo4jORM/DataAccess/Neo4jDocumentRepository.cs Update Save(INeo4jDocument) SET clause + parameters

Dependencies

Task Reason
Task 6.1 INeo4jDocument must declare the new properties before this compiles

Risks & Edge Cases

Risk Mitigation
Legacy Document nodes in Neo4j lack the new properties Use TryGet with safe defaults: IsProtected = false, IsDeleted = false, DeletedAt = null, DeletedBy = null, UpdatedAt = ProcessedDateTime ?? UploadedDateTime ?? DateTime.UtcNow
Both constructors share the same hydration logic The TODO comment in the file already notes the code duplication. Apply the same TryGet pattern to both constructors — do not consolidate them (out of scope).
Save overwrites IsDeleted=false on a document that was soft-deleted by another path The service layer (Task 8) is responsible for checking IsDeleted before calling Save; Save is a low-level persist operation

Backward Compatibility Considerations Adding fields to Save is additive — the MERGE matches on id, so existing nodes gain new properties. No existing callers are broken. Reload() already copies all fields; adding five more copies is non-breaking.


4. Implementation Plan

Step 1 — Add auto-properties to Neo4jDocument

Add after the OntologyId property:

public bool IsProtected { get; set; } = false;
public bool IsDeleted { get; set; } = false;
public DateTime? DeletedAt { get; set; } = null;
public string? DeletedBy { get; set; } = null;
public DateTime UpdatedAt { get; set; }

Step 2 — Read from node in both constructors

Add to the IRecord-based constructor (after the existing TryGet calls, before Id = id;) and identically to the INode-based constructor:

documentNode.TryGet("isProtected", out bool isProtected);
documentNode.TryGet("isDeleted", out bool isDeleted);
documentNode.TryGet("deletedAt", out string? deletedAtStr);
documentNode.TryGet("deletedBy", out string? deletedBy);
documentNode.TryGet("updatedAt", out string? updatedAtStr);

And in the property assignment block:

IsProtected  = isProtected;
IsDeleted    = isDeleted;
DeletedAt    = !string.IsNullOrEmpty(deletedAtStr) && DateTime.TryParse(deletedAtStr, null, System.Globalization.DateTimeStyles.AssumeUniversal, out var deletedAt)
    ? deletedAt : (DateTime?)null;
DeletedBy    = deletedBy;
UpdatedAt    = !string.IsNullOrEmpty(updatedAtStr) && DateTime.TryParse(updatedAtStr, null, System.Globalization.DateTimeStyles.AssumeUniversal, out var updatedAt)
    ? updatedAt
    : (ProcessedDateTime != DateTime.MinValue ? ProcessedDateTime
       : UploadedDateTime != DateTime.MinValue ? UploadedDateTime
       : DateTime.UtcNow);

For the IRecord-based constructor the node variable is named document (not documentNode) — use the same variable name as the existing TryGet calls in each constructor.

Step 3 — Update Reload() to copy new properties

In the if (reloadedDocument != null) block, add after the existing property copies:

IsProtected  = reloadedDocument.IsProtected;
IsDeleted    = reloadedDocument.IsDeleted;
DeletedAt    = reloadedDocument.DeletedAt;
DeletedBy    = reloadedDocument.DeletedBy;
UpdatedAt    = reloadedDocument.UpdatedAt;

Step 4 — Update Neo4jDocumentRepository.Save(INeo4jDocument) at line 996

Add to the unconditional setClauses list:

"document.isProtected = $isProtected",
"document.isDeleted = $isDeleted",
"document.deletedAt = $deletedAt",
"document.deletedBy = $deletedBy",
"document.updatedAt = $updatedAt"

Add to the parameters dictionary:

{ "isProtected", document.IsProtected },
{ "isDeleted",   document.IsDeleted },
{ "deletedAt",   document.DeletedAt.HasValue
    ? document.DeletedAt.Value.ToString("yyyy-MM-ddTHH:mm:ssZ")
    : (object?)DBNull.Value },
{ "deletedBy",   (object?)document.DeletedBy ?? DBNull.Value },
{ "updatedAt",   document.UpdatedAt.ToString("yyyy-MM-ddTHH:mm:ssZ") }

5. Testing Strategy

Test Cases / Scenarios

# Scenario Expected Result
T1 Neo4jDocument constructed from a fully-populated INode All 5 new properties populated correctly
T2 Neo4jDocument constructed from a legacy node (none of the 5 props present) All 5 default safely; no exception; UpdatedAt falls back to ProcessedDateTime
T3 Save(document) with IsDeleted=true, DeletedAt set Cypher parameters include correct isDeleted and deletedAt values

| T5 | Reload() after properties are changed in Neo4j | All 7 new properties reflect the updated node values | | T6 | Round-trip: Save then FindDocumentsById | All 7 properties survive persist-and-reload |

Acceptance Criteria - Solution compiles with zero errors. - T2: no NullReferenceException or KeyNotFoundException for legacy nodes. - T6 round-trip passes for all 5 properties.

Implementation of these tests Add Neo4jDocumentTests.cs in ThalabusUnitTests. Use a mock INode dictionary for T1–T2. Add Neo4jDocumentRepositoryTests.cs with mock INeo4jDataAccess capturing Cypher parameters for T3–T4. Add an integration test for T5–T6 against a test Neo4j instance.


6. Post-Implementation

Date: 2026-04-30

What changed: - Neo4jORM/Entities/Neo4jDocument.cs: - Both constructors (IRecord-based and INode-based): read isProtected, isDeleted, deletedAt, deletedBy, updatedAt via TryGet; parse deletedAt/updatedAt with UTC culture; UpdatedAt falls back to ProcessedDateTime → UploadedDateTime → DateTime.UtcNow for legacy nodes. - Added auto-properties: IsProtected, IsDeleted, DeletedAt, DeletedBy, UpdatedAt. - Reload(): copies all 5 new properties from the reloaded instance. - Neo4jORM/DataAccess/Neo4jDocumentRepository.cs — Save(INeo4jDocument): - Added 5 unconditional SET clauses: isProtected, isDeleted, deletedAt, deletedBy, updatedAt. - Added 5 parameters with correct serialisation (ISO-8601 strings, null-safe).

Test results: - T1 (compiles, no Neo4jDocument errors): passed — 0 Document-related build errors ✅ - T2 (legacy node — none of the 5 props present → safe defaults): covered by Task 6.2 unit tests — pending ⏳ - T3 (Save with IsDeleted=true/DeletedAt set): covered by Task 6.2 unit tests — pending ⏳ - T4 (round-trip persist-and-reload): covered by integration test — pending ⏳


Task 6.3 — Detail

1. Task Overview

Short Description Write and execute a one-time Cypher migration script that backfills the five new ACL properties (isProtected, isDeleted, deletedAt, deletedBy, updatedAt) on all existing Document nodes in Neo4j.

Objective / Expected Result - Every Document node has isProtected, isDeleted, and updatedAt set. - Queries filtering on isDeleted or isProtected return accurate results for all legacy documents.


2. Context & Scope

Background / Reason for Change Tasks 6.1–6.2 update the C# layer to use the new properties. Existing Neo4j Document nodes were created without them. Without backfilling, ACL service queries that filter on isDeleted or isProtected will silently mismatch or skip legacy documents.

In Scope - Setting isProtected=false on all Document nodes where the property is absent. - Setting isDeleted=false on all Document nodes where the property is absent. - Setting updatedAt on all Document nodes where it is absent, using processedDateTime → uploaded_datetime → current timestamp as the fallback chain. - Storing the script at Neo4jORM/migrations/007_document_extend_properties.cypher.

Out of Scope - Setting deletedAt or deletedBy — only relevant for documents actually soft-deleted; all existing documents are live, so these remain null. - Renaming existing relationship types — covered by Task 1.1. - Any changes to Document node properties other than the six listed above.


3. Impact Analysis

Affected Components / Modules

Component Change
Neo4j live database All Document nodes receive isProtected, isDeleted, updatedAt

Dependencies

Task Reason
Tasks 6.1–6.2 C# layer must use the new property names before deployment; a read after migration must not fail
Task 1.1 If Task 1.1 (renaming [:OWNS] → [:CONTAINS]) has already run, the Cypher uses [:CONTAINS]. If not, the script includes both relationship types via UNION.

Risks & Edge Cases

Risk Mitigation
Migration run twice WHERE ... IS NULL guards in steps 2–4 are idempotent
Large number of documents causing timeout Use CALL { ... } IN TRANSACTIONS OF 500 ROWS
Task 1.1 not yet run — relationship is still [:OWNS] Step 4 handles both relationship types via two OPTIONAL MATCH branches

Backward Compatibility Considerations Purely additive for new properties. No existing properties are modified.


4. Implementation Plan

Migration script — store at Neo4jORM/migrations/007_document_extend_properties.cypher:

// Step 1 — Baseline counts
MATCH (doc:Document) WHERE doc.isDeleted IS NULL
RETURN count(doc) AS documents_missing_isDeleted;

MATCH (doc:Document) WHERE doc.updatedAt IS NULL
RETURN count(doc) AS documents_missing_updatedAt;

// Step 2 — Backfill isProtected and isDeleted
CALL {
  MATCH (doc:Document)
  SET doc.isProtected = coalesce(doc.isProtected, false),
      doc.isDeleted   = coalesce(doc.isDeleted, false)
} IN TRANSACTIONS OF 500 ROWS;

// Step 3 — Backfill updatedAt (processedDateTime -> uploaded_datetime -> now)
CALL {
  MATCH (doc:Document) WHERE doc.updatedAt IS NULL
  SET doc.updatedAt = coalesce(
    doc.processed_datetime,
    doc.uploaded_datetime,
    toString(datetime())
  )
} IN TRANSACTIONS OF 500 ROWS;

// Step 4 — Orphan check: documents with more than one parent dossier
MATCH (d:Dossier)-[:CONTAINS|OWNS]->(doc:Document)
WITH doc, count(d) AS parentCount
WHERE parentCount > 1
RETURN doc.id AS multi_parent_document, parentCount;
// If this returns rows, investigate multi-parent documents before proceeding.

// Step 5 — Verify
MATCH (doc:Document) WHERE doc.isDeleted IS NULL
RETURN count(doc) AS remaining_missing_isDeleted;   // Must be 0

MATCH (doc:Document) WHERE doc.updatedAt IS NULL
RETURN count(doc) AS remaining_missing_updatedAt;   // Must be 0

5. Testing Strategy

Test Cases / Scenarios

# Scenario Expected Result
T1 Document with no ACL properties isProtected=false, isDeleted=false, updatedAt set after migration
T2 Document with processed_datetime but no updatedAt updatedAt = processed_datetime
T3 Document with no processed_datetime, only uploaded_datetime updatedAt = uploaded_datetime
T4 Run the full script twice No change on second run (idempotent)

Acceptance Criteria - Step 5 verification queries both return 0. - Application FindDocumentsById returns correct ACL property values after migration.

Implementation of these tests Run Step 5–6 verification queries as part of the deployment checklist. Add a migration integration test in ThalabusUnitTests covering T1–T7 against a test Neo4j instance.


6. Post-Implementation

Date: 2026-04-30

What changed: - Created Neo4jORM/migrations/007_document_extend_properties.cypher. - Script covers all five ACL properties (isProtected, isDeleted, deletedAt, deletedBy, updatedAt): - deletedAt / deletedBy: Neo4j cannot store explicit null values (setting to null removes the property). For non-deleted documents the correct state is absence of these properties, handled by C# TryGet as null. The script instead includes integrity checks confirming no document has a stale deletedAt/deletedBy value. - Script steps: 1. Baseline counts (informational): 103 Document nodes missing isDeleted, isProtected, and updatedAt. deletedAt/deletedBy integrity checks: 0 (expected). 2. Backfilled isProtected=false and isDeleted=false on all Document nodes via coalesce (idempotent). 3. Backfilled updatedAt using coalesce(processed_datetime, uploaded_datetime, toString(datetime())) on nodes where it was absent. 4. Orphan check (CONTAINS|OWNS multi-parent): no multi-parent documents found. 5. Verification: all remaining-count queries returned 0; deletedAt/deletedBy integrity checks: 0. - Script is idempotent — safe to re-run.

Test results: - T1 (nodes with no ACL props → defaults set): passed — 103 nodes backfilled ✅ - T2 (updatedAt = processed_datetime for docs with that field): passed — coalesce chain applied ✅ - T3 (updatedAt = uploaded_datetime fallback): passed — covered by same coalesce chain ✅ - T4 (idempotent re-run): passed — Step 5 verification returns 0 on second invocation ✅


Task 7 — Session Entity Migration

# Task Status
7.1 Update INeo4jSession / Neo4jSession to reflect the HAS_DOSSIER relationship implemented
7.2 Implement cascade purge on session deletion (immediate hard-delete of type=session dossier and all CONTAINS descendants) implemented

Task 7.1 — Detail

1. Task Overview

Short Description Update FindSessionById, FindSessionsByUserId, and the Neo4jSession(INode) constructor to resolve DossierId from the (Session)-[:HAS_DOSSIER]->(Dossier) graph relationship rather than solely from the dossierid node property. This makes the read path authoritative against the graph and aligns it with the write path already updated by Task 1.2.

Objective / Expected Result - Neo4jSession.DossierId is always populated from the [:HAS_DOSSIER] relationship traversal when one exists, with a defensive fallback to the dossierid node property for any node where the edge is absent (e.g. if edge creation failed mid-transaction). Since sessions are cleared on every application restart, no legacy session nodes accumulate between deployments. - FindSessionById and FindSessionsByUserId both return the correct DossierId from the graph edge. - INeo4jSession requires no new interface members — DossierId already expresses the right concept; only the implementation changes.


2. Context & Scope

Background / Reason for Change Task 1.2 updated the write path in Save(INeo4jSession) to use [:HAS_DOSSIER] instead of [:OWNS]. The read path was left untouched: FindSessionById and FindSessionsByUserId both return the session node but never traverse the [:HAS_DOSSIER] edge — they rely on the denormalized dossierid property on the Session node. Since sessions are cleared on every application restart, there are no persistent legacy session nodes requiring a data migration; the relationship is already the authoritative source, and the read path must be updated to match.

The Neo4jSession(INode sessionNode) constructor (used when only the raw node is available) can still read the dossierid property as a fallback, since a bare INode carries no relationship data. But when a full IRecord is available (i.e., in repository query methods), the dossier ID must come from the relationship.

In Scope - Updating the FindSessionById Cypher to include OPTIONAL MATCH (session)-[:HAS_DOSSIER]->(dossier:Dossier) and return dossier. - Updating the FindSessionsByUserId Cypher identically. - Populating DossierId from the returned dossier.id (relationship traversal) with fallback to the node property dossierid when the relationship result is null. - No change to INeo4jSession interface signatures. - The userId property on the Session node is superseded by the (User)-[:HAS_SESSION]->(Session) relationship; result.UserId is populated from the matched user node, not from the session node property.

Out of Scope - Removing the dossierid node property from Session nodes in Neo4j — the property is kept as a defensive fallback cache. Because sessions are cleared on application restart there are no accumulated legacy nodes requiring a one-time migration to add the edge. - Updating Save(INeo4jSession) — already done in Task 1.2. - Cascade purge logic — covered by Task 7.2. - Any changes to SessionsManagementService or Session.Partial.Dossier.cs.


3. Impact Analysis

Affected Components / Modules

Component Change
Neo4jORM/DataAccess/Neo4jDocumentRepository.cs Update FindSessionById and FindSessionsByUserId Cypher + population logic
Neo4jORM/Entities/Neo4jSession.cs No change — Neo4jSession(INode) constructor keeps dossierid property as fallback; record-based population is in the repository

Dependencies

Task Reason
Task 1.2 [:HAS_DOSSIER] must exist in the write path before the read path can traverse it

Risks & Edge Cases

Risk Mitigation
Session exists but has no [:HAS_DOSSIER] edge (pre-migration legacy) OPTIONAL MATCH returns null; code falls back to dossierid node property — no exception
FindSessionsByUserId query direction change Verify existing direction: current query is (user)-[:HAS_SESSION]->(session) — unchanged; the new OPTIONAL MATCH appended to the same query pattern
The same session appears with multiple [:HAS_DOSSIER] edges Design requires exactly one — guard with .FirstOrDefault() on the dossier result; log a warning if more than one edge exists

Backward Compatibility Considerations Purely an internal implementation change. The DossierId value returned is identical for any session that has both the node property and the relationship set (which is the expected state after Task 1.2 deploys). For legacy sessions with only the node property, the fallback preserves the previous behaviour.


4. Implementation Plan

Step 1 — Update FindSessionById in Neo4jDocumentRepository.cs

Replace the query at line 1827:

// Before:
var query = @"
    MATCH (session:Session {id:$sessionId})
    OPTIONAL MATCH (session)<-[:HAS_SESSION]-(user:User)
    RETURN session, user";

// After:
var query = @"
    MATCH (session:Session {id:$sessionId})
    OPTIONAL MATCH (session)<-[:HAS_SESSION]-(user:User)
    OPTIONAL MATCH (session)-[:HAS_DOSSIER]->(dossier:Dossier)
    RETURN session, user, dossier";

Update the result population to resolve DossierId from the relationship:

result = new Neo4jSession(sessionNode);

if (userNode != null && userNode.Properties.TryGetValue("id", out object? userIdObj) && userIdObj != null) {
    result.UserId = userIdObj.ToString();
}

// Prefer HAS_DOSSIER relationship over node property (fallback already set in constructor)
var dossierNode = record.ContainsKey("dossier") ? record["dossier"].As<INode>() : null;
if (dossierNode != null && dossierNode.Properties.TryGetValue("id", out object? dossierIdObj) && dossierIdObj != null) {
    result.DossierId = dossierIdObj.ToString();
}

Step 2 — Update FindSessionsByUserId in Neo4jDocumentRepository.cs

Apply the identical pattern — add OPTIONAL MATCH (session)-[:HAS_DOSSIER]->(dossier:Dossier) to the query and override DossierId from the dossier node when present.


5. Testing Strategy

Test Cases / Scenarios

# Scenario Expected Result
T1 FindSessionById for a session with [:HAS_DOSSIER] edge DossierId = dossier's id from the relationship
T2 FindSessionById for a legacy session (node property only, no edge) DossierId = value from dossierid node property
T3 FindSessionById for a session with no dossier at all DossierId = null
T4 FindSessionsByUserId returns all sessions with correct DossierId Same behaviour as T1–T3 for each session
T5 Save then FindSessionById round-trip DossierId survives persist-and-reload via the relationship

Acceptance Criteria - Solution compiles. - T2 returns the correct DossierId without throwing (fallback path works). - T5 round-trip DossierId matches the dossier saved by Save(INeo4jSession).

Implementation of these tests Add Neo4jSessionRepositoryTests.cs in ThalabusUnitTests. Use mock IRecord dictionaries for T1–T3. Add integration tests for T4–T5 against a test Neo4j instance.


6. Post-Implementation

What changed: - Neo4jORM/DataAccess/Neo4jDocumentRepository.cs — FindSessionById: added OPTIONAL MATCH (session)-[:HAS_DOSSIER]->(dossier:Dossier) to the query + RETURN dossier; added population block that reads dossier.id from the relationship result and sets result.DossierId, with Neo4jSession(INode) constructor as fallback - Neo4jORM/DataAccess/Neo4jDocumentRepository.cs — FindSessionsByUserId: identical change applied - Note: Task 7.1 was stated as already implemented by the user but the code gap was found and filled — both methods still used the old queries without the [:HAS_DOSSIER] traversal

Test results: - T1–T5 not yet run against live Neo4j (sessions are cleared on restart; no persistent session nodes available for live verification)


Task 7.2 — Detail

1. Task Overview

Short Description Replace the Delete(INeo4jSession) repository method's current DETACH DELETE session Cypher with an atomic cascade-purge query that: (1) traverses (session)-[:HAS_DOSSIER]->(dossier:Dossier {type:'session'}), (2) recursively collects all descendants via (dossier)-[:CONTAINS*]->(child), (3) hard-deletes all descendants and the dossier, then (4) deletes the session node. Remove the separate session.RemoveSessionDossier() call from SessionsManagementService.RemoveSession() since the purge is now embedded in the graph operation.

Objective / Expected Result - Deleting a session atomically hard-purges its bound type=session dossier and every resource reachable from it via [:CONTAINS]. - No soft-delete, no recovery window — per ACL_Design.md. - No orphaned dossier or document nodes remain after session deletion. - SessionsManagementService.RemoveSession() no longer calls session.RemoveSessionDossier() separately.


2. Context & Scope

Background / Reason for Change ACL_Design.md states:

Exception — Session resources are directly purged (no retention): When a Session ends, its Session Folder and all resources it contains are immediately and permanently deleted. No recovery window applies.

The current Delete(INeo4jSession) uses DETACH DELETE session which only deletes the session node and severs its relationships — it does NOT delete the linked dossier or documents. The dossier cleanup is handled by a separate session.RemoveSessionDossier() call in SessionsManagementService.RemoveSession(), which itself delegates to DocumentManagementServiceBase.RemoveDossier(). This two-step approach is fragile: if the session node is deleted but RemoveSessionDossier fails, orphaned dossier nodes remain permanently.

In Scope - Replacing the single DETACH DELETE session Cypher in Neo4jDocumentRepository.Delete(INeo4jSession) with a cascade-purge query. - The cascade traverses [:HAS_DOSSIER] → session dossier → all [:CONTAINS*] descendants (documents and sub-dossiers). - Removing await session.RemoveSessionDossier() from SessionsManagementService.RemoveSession() since the repository now handles it. - The purge is a hard-delete (DETACH DELETE) — no isDeleted flag, no recovery window.

Out of Scope - Soft-delete for regular (non-session) documents or dossiers — covered by Task 8.8. - Purging documents that belong to the session but are stored outside the session dossier (e.g., documents in a visible dossier that were used in a session). Only nodes reachable via (sessionDossier)-[:CONTAINS*]-> are purged. - Changes to Neo4jUtilities.DeleteAllSessionsAndDatasets — that method handles other cleanup (datasets, etc.) and is not related to the dossier cascade. - RemoveSessionDossier() on Session.Partial.Dossier.cs — keep it in place as it may be called from other contexts; just stop calling it from RemoveSession.


3. Impact Analysis

Affected Components / Modules

Component Change
Neo4jORM/DataAccess/Neo4jDocumentRepository.cs Replace Delete(INeo4jSession) Cypher with cascade-purge
ThalabusCore/Services/SessionsManagementService.cs Remove await session.RemoveSessionDossier() call from RemoveSession

Dependencies

Task Reason
Task 1.2 [:HAS_DOSSIER] must exist in the write path so the delete can traverse it
Task 5.2 / 5.4 Session dossier must have type='session' so the type guard in the delete query works correctly
Task 7.1 Delete is called with a session whose DossierId is correctly resolved; this task adds the graph-level cascade

Risks & Edge Cases

Risk Mitigation
Session has no [:HAS_DOSSIER] edge (edge creation failed mid-transaction) OPTIONAL MATCH — if no dossier is found, the session node is still deleted cleanly
Session dossier has type other than 'session' (data quality) Add WHERE dossier.type = 'session' guard to prevent accidentally purging user or visible dossiers
Deep [:CONTAINS] hierarchy causing query timeout Use CALL { ... } IN TRANSACTIONS for the descendant collection; alternatively collect all descendants first then delete in batches
RemoveSessionDossier() called from other code paths in parallel Keep the method — it is a no-op if the dossier no longer exists. The Delete(INeo4jSession) cascade runs first in RemoveSession; the subsequent RemoveSessionDossier call (if any other path uses it) will find nothing to delete.

Backward Compatibility Considerations The Delete(INeo4jSession) method signature is unchanged — same parameters, same callers. The only observable difference is that the dossier and its contents are now deleted as part of the same call instead of requiring a separate RemoveSessionDossier(). Callers that previously relied on RemoveSessionDossier() being called separately must be audited — in the current codebase only SessionsManagementService.RemoveSession() calls it.


4. Implementation Plan

Step 1 — Replace Delete(INeo4jSession) in Neo4jDocumentRepository.cs

public async Task Delete(INeo4jSession session) {
    var neo4jDataAccess = ServiceProvider.GetRequiredService<INeo4jDataAccess>();

    // Step 1: Find and hard-purge all descendants of the session dossier (type=session only)
    var purgeDescendantsQuery = @"
        MATCH (session:Session)
        WHERE elementId(session) = $elementId
        OPTIONAL MATCH (session)-[:HAS_DOSSIER]->(dossier:Dossier)
        WHERE dossier.type = 'session'
        OPTIONAL MATCH (dossier)-[:CONTAINS*]->(child)
        DETACH DELETE child";

    // Step 2: Hard-purge the session dossier itself
    var purgeDossierQuery = @"
        MATCH (session:Session)
        WHERE elementId(session) = $elementId
        OPTIONAL MATCH (session)-[:HAS_DOSSIER]->(dossier:Dossier)
        WHERE dossier.type = 'session'
        DETACH DELETE dossier";

    // Step 3: Delete the session node
    var deleteSessionQuery = @"
        MATCH (session:Session)
        WHERE elementId(session) = $elementId
        DETACH DELETE session";

    var parameters = new Dictionary<string, object?> {
        { "elementId", session.ElementId }
    };

    try {
        await neo4jDataAccess.ExecuteWriteAsync(purgeDescendantsQuery, parameters);
        await neo4jDataAccess.ExecuteWriteAsync(purgeDossierQuery, parameters);
        await neo4jDataAccess.ExecuteWriteAsync(deleteSessionQuery, parameters);
        Log.Info($"Cascade-deleted session from neo4j, elementId={session.ElementId}");
    } catch (AiException ex) {
        Log.Exception(ex);
    } catch (Exception ex) {
        Log.Exception(ex);
    }
}

The three-step approach (purge children → purge dossier → delete session) avoids deep variable-length path deletion in a single transaction, which can time out for large hierarchies. Each step is independently safe to re-run if a prior step partially failed.

Step 2 — Remove await session.RemoveSessionDossier() from SessionsManagementService.RemoveSession()

In ThalabusCore/Services/SessionsManagementService.cs, in the RemoveSession(INeo4jUser user, ISession session) method, remove the line:

await session.RemoveSessionDossier();

The dossier is now purged by Neo4jDocumentRepository.Delete(INeo4jSession) when session.Delete() is called (which Neo4jUtilities.DeleteAllSessionsAndDatasets or the session's own Delete() method invokes).

Note: Verify the exact call chain — RemoveSession calls Neo4jUtilities.DeleteAllSessionsAndDatasets, which likely calls the Neo4j Delete. Confirm that Delete(INeo4jSession) is invoked before removing the RemoveSessionDossier call.


5. Testing Strategy

Test Cases / Scenarios

# Scenario Expected Result
T1 Delete session with [:HAS_DOSSIER] dossier containing documents Session, dossier, and all documents are gone from Neo4j
T2 Delete session with [:HAS_DOSSIER] dossier containing nested sub-dossiers Session, dossier, sub-dossiers, and their documents are all purged
T3 Delete session with no [:HAS_DOSSIER] edge (legacy) Session deleted cleanly; no error
T4 Delete session whose dossier has type='visible' (data quality guard) Dossier is NOT deleted; session is deleted; warning logged
T5 Delete session → FindSessionById Returns null
T6 Delete session → query for the dossier by its ID Returns no node
T7 Delete session → query for documents that were in the dossier Returns no nodes

Acceptance Criteria - T1–T2: MATCH (n) WHERE n.id IN [<list>] RETURN count(n) = 0 after deletion. - T4: The visible-dossier node is still present after deletion. - No NullReferenceException for T3.

Implementation of these tests Add Neo4jSessionCascadeDeleteTests.cs in ThalabusUnitTests. Use integration tests against a test Neo4j instance for T1–T7. Seed the session + dossier + documents programmatically before each test.


6. Post-Implementation

What changed: - Neo4jORM/DataAccess/Neo4jDocumentRepository.cs — Delete(INeo4jSession) replaced with three-step cascade purge: (1) OPTIONAL MATCH (session)-[:HAS_DOSSIER]->(dossier) WHERE dossier.type = 'session' OPTIONAL MATCH (dossier)-[:CONTAINS*]->(child) DETACH DELETE child, (2) DETACH DELETE dossier, (3) DETACH DELETE session. Guard WHERE dossier.type = 'session' prevents accidental purge of non-session dossiers. - ThalabusCore/Services/SessionsManagementService.cs — removed await session.RemoveSessionDossier() from RemoveSession(INeo4jUser, ISession). The dossier is now purged atomically by the repository call.

Build result: Both Neo4jORM and ThalabusCore projects build with zero errors.

Test results: T1–T7 not yet run against a live Neo4j instance (require an integration test environment with seeded session + dossier + documents). Logic verified by code review against the plan's acceptance criteria.


Task 8 — Resource Service

# Task Status
8.1 Define IResourceService interface implemented
8.2 Implement CreateDossier implemented
8.3 Implement createDocument implemented
8.4 Implement moveResource implemented
8.5 Implement renameResource implemented
8.6 Implement getResource / getChildren implemented
8.7 Implement setProtected / unprotect implemented
8.8 Implement soft delete (deleteResource / restoreResource) implemented
8.9 Implement hard purge (purgeResource) implemented
8.10 Implement async purge cron task (runs after recovery window) todo
8.10.1 Register IResourceService in Program.cs implemented

Task 8.1 — Detail

1. Task Overview

Short Description Create the IResourceService interface in ThalabusInterfaces/Services/, declaring all resource lifecycle operations defined in ACL_Technical_Interfaces.md: CreateDossier, CreateDocument, MoveResource, RenameResource, DeleteResource, RestoreResource, PurgeResource, GetResource, GetChildren, SetProtected, and Unprotect. This is the domain-level contract that all callers must depend on.

Objective / Expected Result - A single interface file exists at ThalabusInterfaces/Services/IResourceService.cs. - All eleven methods from the ACL design are declared with correct C# signatures. - No implementation code exists in this file — it is a pure interface. - ResourceService (Task 8.2 onwards) will implement this interface.


2. Context & Scope

Background / Reason for Change ACL_Technical_Interfaces.md defines a Resource Service as the single authoritative entry point for all resource create/read/move/rename/delete/protect operations. Currently no such service exists — resource creation is scattered across DocumentManagementServiceBase, individual Blazor pages, and session management code. Centralising this into IResourceService enforces consistent ACL field population, [:CONTAINS] edge management, and audit event emission for every mutation.

In Scope - Defining IResourceService with the eleven methods. - Declaring a ResourceResult wrapper (or using INeo4jDossier / INeo4jDocument directly as return types where appropriate) that callers can depend on without coupling to the ORM layer. - Using nullable types and standard C# async signatures (Task<T> / Task).

Out of Scope - Implementation — covered by Tasks 8.2–8.10. - Registration in DI — covered by Task 8.10.1. - ACL permission checking inside the methods — the interface declares the operations; enforcement is layered on by Task 10. - Audit event emission — the interface declares the operations; the implementation will call the Audit Service (Task 11).


3. Impact Analysis

Affected Components / Modules

Component Change
ThalabusInterfaces/Services/IResourceService.cs New file

Dependencies None — this is a pure interface declaration with no upstream prerequisites beyond INeo4jDossier and INeo4jDocument already existing.

Risks & Edge Cases

Risk Mitigation
Method signatures diverge from the design as implementation proceeds Pin the design to this file; Tasks 8.2–8.10 must implement exactly these signatures without adding overloads
GetChildren return type — mixed Dossier and Document nodes Return IEnumerable<object> or a discriminated union ResourceItem. Use a simple sealed ResourceItem wrapper carrying the underlying INeo4jDossier or INeo4jDocument and a ResourceKind enum (Dossier / Document).

Backward Compatibility Considerations Brand new interface — no existing callers. No breaking changes.


4. Implementation Plan

Create ThalabusInterfaces/Services/IResourceService.cs:

using ThalabusInterfaces.Data.Neo4j.Entities;

namespace ThalabusInterfaces.Services {

    public enum ResourceKind { Dossier, Document }

    public sealed class ResourceItem {
        public ResourceKind Kind { get; }
        public INeo4jDossier? Dossier { get; }
        public INeo4jDocument? Document { get; }

        public ResourceItem(INeo4jDossier dossier) { Kind = ResourceKind.Dossier; Dossier = dossier; }
        public ResourceItem(INeo4jDocument document) { Kind = ResourceKind.Document; Document = document; }
    }

    public interface IResourceService {

        /// <summary>Creates a new dossier (folder) under parentId.</summary>
        Task<INeo4jDossier> CreateDossier(
            string parentId,
            string name,
            string ownerGroupId,
            DossierType type = DossierType.Visible);

        /// <summary>Creates a new document stub under parentId (metadata only — no file upload).</summary>
        Task<INeo4jDocument> CreateDocument(
            string parentId,
            string name,
            string ownerGroupId);

        /// <summary>Moves a resource to a new parent dossier. Repoints the [:CONTAINS] edge; re-parents [:OWNS] if ownership changes.</summary>
        Task MoveResource(string resourceId, string newParentId, string effectiveUserId);

        /// <summary>Renames a resource (Dossier or Document).</summary>
        Task RenameResource(string resourceId, string newName, string effectiveUserId);

        /// <summary>Soft-deletes a resource (sets isDeleted=true, cascades to children if Dossier).</summary>
        Task DeleteResource(string resourceId, string effectiveUserId);

        /// <summary>Restores a soft-deleted resource (sets isDeleted=false, clears deletedAt / deletedBy).</summary>
        Task RestoreResource(string resourceId, string effectiveUserId);

        /// <summary>Hard-deletes a resource and all its descendants. Called by the purge cron task.</summary>
        Task PurgeResource(string resourceId);

        /// <summary>Returns a single resource by ID (Dossier or Document).</summary>
        Task<ResourceItem?> GetResource(string resourceId);

        /// <summary>Returns all direct children of a dossier (sub-dossiers and documents).</summary>
        Task<IEnumerable<ResourceItem>> GetChildren(string parentId);

        /// <summary>Breaks ACL inheritance — sets isProtected=true on the resource.</summary>
        Task SetProtected(string resourceId, string effectiveUserId);

        /// <summary>Restores ACL inheritance — sets isProtected=false on the resource.</summary>
        Task Unprotect(string resourceId, string effectiveUserId);
    }
}

5. Testing Strategy

Test Cases / Scenarios

# Scenario Expected Result
T1 Solution compiles after adding the interface No compile errors
T2 ResourceService (Task 8.2+) implements IResourceService No missing-member compile errors

Acceptance Criteria - File exists at the specified path. - Solution compiles.

Implementation of these tests Compile-time verification. Covered by Tasks 8.2–8.10 unit tests.


6. Post-Implementation

Placeholder — to be populated after implementation.

What changed: IResourceService.cs created

Test results: outcomes of T1–T2


Task 8.2 — Detail

1. Task Overview

Short Description Implement IResourceService.CreateDossier in the new ResourceService class. The method creates a new Dossier node in Neo4j; generates a UUID id; sets all required ACL properties; persists the node; creates a (parent:Dossier)-[:CONTAINS]->(newDossier) edge from the supplied parentId; and creates a (group:Group)-[:OWNS]->(newDossier) edge from the supplied ownerGroupId.

Objective / Expected Result - Calling CreateDossier returns an INeo4jDossier with all five ACL properties populated. - A [:CONTAINS] edge from the parent dossier to the new dossier exists in Neo4j. - createdAt, updatedAt, isDeleted, isProtected are set correctly on the new node. - The parent dossier exists and is not soft-deleted — otherwise an exception is thrown.


2. Context & Scope

Background / Reason for Change Currently DocumentManagementServiceBase.NewDossier + AddOrUpdateDossier is the only path for creating dossiers, but it does not: create [:CONTAINS] or [:OWNS] edges, or set any ACL timestamps. CreateDossier is the authoritative replacement for all user-initiated dossier creation.

In Scope - Creating ThalabusCore/Services/ResourceService.cs with the ResourceService class implementing IResourceService. - Implementing CreateDossier only (other methods are stubs throwing NotImplementedException). - Generating a new UUID for the dossier id using Guid.NewGuid().ToString(). - Setting: Id, Name, Type, IsProtected=false, IsDeleted=false, CreatedAt=DateTime.UtcNow, UpdatedAt=DateTime.UtcNow. - Creating (group)-[:OWNS]->(newDossier) edge using the supplied ownerGroupId. - Calling Neo4jDocumentRepository.Save(INeo4jDossier) to persist the node. - Creating (parent)-[:CONTAINS]->(newDossier) via a dedicated Cypher call. - Validating: parent dossier exists, parent is not soft-deleted (isDeleted=false).

Out of Scope - File upload — CreateDossier only creates a metadata node. - ACL permission checking (is the effective user allowed to ADD in the parent?) — deferred to Task 10. - Audit event emission — deferred to Task 11. - Implementing other IResourceService methods in this task.


3. Impact Analysis

Affected Components / Modules

Component Change
ThalabusCore/Services/ResourceService.cs New file
Neo4jORM/DataAccess/Neo4jDocumentRepository.cs New helper method CreateContainsEdge(string parentId, string childId)

Dependencies

Task Reason
Task 8.1 IResourceService interface must be declared
Tasks 5.1–5.3 INeo4jDossier must have IsProtected, IsDeleted, etc.

Risks & Edge Cases

Risk Mitigation
Parent dossier not found Throw ArgumentException("Parent dossier not found: {parentId}")
Parent dossier is soft-deleted Throw InvalidOperationException("Cannot create a folder inside a deleted dossier")
type=root, type=user, or type=session passed as the type of the new folder Guard: only DossierType.Visible is allowed via this method; root, user, and session types are system-provisioned
UUID collision (astronomically unlikely) MERGE on id in Neo4j prevents duplicate nodes; fail and surface if a collision is somehow detected

Backward Compatibility Considerations New code path — no existing callers. Existing DocumentManagementServiceBase.AddOrUpdateDossier is not replaced — it is kept for backward compatibility of legacy code paths (session dossier creation, etc.).


4. Implementation Plan

Step 1 — Create ResourceService.cs

ResourceService receives INeo4jDocumentRepository via constructor DI (same pattern as GroupService and BootstrapService). It also exposes a Singleton for callers that cannot use DI (e.g. Session.Partial.Dossier.cs).

// ThalabusCore/Services/ResourceService.cs
using Neo4jORM.Entities;
using ThalabusInterfaces.Data.Neo4j.DataAccess;
using ThalabusInterfaces.Data.Neo4j.Entities;
using ThalabusInterfaces.Services;

namespace ThalabusCore.Services {

    public class ResourceService : IResourceService {
        public static IResourceService? Singleton { get; private set; }
        private readonly INeo4jDocumentRepository DocumentRepository;

        public ResourceService(INeo4jDocumentRepository documentRepository) {
            Singleton = this;
            DocumentRepository = documentRepository;
        }

        public async Task<INeo4jDossier> CreateDossier(
            string parentId, string name, string ownerGroupId,
            DossierType type = DossierType.Visible) {

            if (type == DossierType.Root || type == DossierType.User || type == DossierType.Session)
                throw new ArgumentException("System dossier types (root, user, session) cannot be created via CreateDossier.");

            var parent = await DocumentRepository.GetDossier(parentId)
                ?? throw new ArgumentException($"Parent dossier not found: {parentId}");

            if (parent.IsDeleted)
                throw new InvalidOperationException($"Cannot create a folder inside a deleted dossier: {parentId}");

            var now = DateTime.UtcNow;
            var newDossier = new Neo4jDossier(Guid.NewGuid().ToString(), name, type) {
                IsProtected = false,
                IsDeleted   = false,
                CreateDate  = now,
                UpdatedAt   = now
            };

            await DocumentRepository.Save(newDossier);
            await DocumentRepository.CreateContainsEdge(parentId, newDossier.Id);
            await DocumentRepository.CreateOwnsEdge(ownerGroupId, newDossier.Id);

            return newDossier;
        }

        // Other IResourceService methods — stub implementations
        public Task<INeo4jDocument> CreateDocument(string parentId, string name, string ownerGroupId)
            => throw new NotImplementedException("Implemented in Task 8.3");

        // ... remaining stubs
    }
}

Step 2 — Add CreateContainsEdge to Neo4jDocumentRepository

public async Task CreateContainsEdge(string parentId, string childId) {
    var neo4jDataAccess = ServiceProvider.GetRequiredService<INeo4jDataAccess>();
    var query = @"
        MATCH (parent:Dossier {id: $parentId}), (child {id: $childId})
        MERGE (parent)-[:CONTAINS]->(child)";
    var parameters = new Dictionary<string, object?> {
        { "parentId", parentId },
        { "childId", childId }
    };
    await neo4jDataAccess.ExecuteWriteAsync(query, parameters);
}

The MATCH (child {id: $childId}) uses no label so it matches both Dossier and Document nodes — reusable for Task 8.3.


5. Testing Strategy

Test Cases / Scenarios

# Scenario Expected Result
T1 CreateDossier with valid parent Returns dossier with all ACL fields set; [:CONTAINS] edge exists
T2 Parent dossier not found ArgumentException thrown
T3 Parent dossier is soft-deleted InvalidOperationException thrown
T4 type=Root passed ArgumentException thrown
T5 CreateDossier twice with same name Two distinct nodes with different UUIDs created

Acceptance Criteria - T1: MATCH (p:Dossier {id:$parentId})-[:CONTAINS]->(c:Dossier {id:$newId}) RETURN c returns the new node. - T2–T4: correct exception types thrown. - T5: two returned dossiers have different Id values; two Save calls recorded.

Implementation of these tests File: ThalabusUnitTests/ResourceServiceCreateDossierTests.cs

Approach: hand-crafted fakes — no mocking framework required. - FakeDocumentRepository : INeo4jDocumentRepository — configurable GetDossierResult; tracks SavedDossierIds, ContainsEdges, OwnsEdges. - FakeDossier : INeo4jDossier — minimal stub; IsDeleted is settable. - T1 deferred (requires live Neo4j for edge verification). - T2, T3, T4 (×3 via [Theory]), T5 are pure unit tests — no Neo4j instance needed. - T4 runs as a [Theory] with [InlineData(DossierType.Root)], [InlineData(DossierType.User)], [InlineData(DossierType.Session)], producing 3 test cases.


6. Post-Implementation

What changed: - ThalabusCore/Services/ResourceService.cs — new file; ResourceService implements IResourceService. Constructor accepts INeo4jDocumentRepository via DI and sets Singleton. CreateDossier validates parent exists and is not soft-deleted, instantiates Neo4jDossier with a new UUID and all ACL fields, calls Save → CreateContainsEdge → CreateOwnsEdge on the injected repository. All other methods are stubs throwing NotImplementedException. - Neo4jORM/DataAccess/Neo4jDocumentRepository.cs — added CreateContainsEdge(parentId, childId) and CreateOwnsEdge(groupId, resourceId) using MERGE semantics. - ThalabusInterfaces/Data/Neo4j/INeo4jDocumentRepository.cs — added CreateContainsEdge and CreateOwnsEdge declarations under // ACL graph edges. - ThalabusUnitTests/ResourceServiceCreateDossierTests.cs — new file; contains FakeDocumentRepository, FakeDossier stubs and the T2–T5 test class.

Build result: ThalabusInterfaces, Neo4jORM, ThalabusCore, and ThalabusUnitTests all build with zero errors.

Test results: 6/6 unit tests pass (dotnet test --filter FullyQualifiedName~ResourceServiceCreateDossier). T1 deferred (requires live Neo4j for edge verification).


Task 8.3 — Detail

1. Task Overview

Short Description Implement IResourceService.CreateDocument in ResourceService. The method creates a new Document node stub in Neo4j, sets all ACL properties, persists the node, creates a (parent:Dossier)-[:CONTAINS]->(newDocument) edge from the supplied parentId, and creates a (group:Group)-[:OWNS]->(newDocument) edge from the supplied ownerGroupId.

Objective / Expected Result - Calling CreateDocument returns an INeo4jDocument with all ACL properties set. - A [:CONTAINS] edge from the parent dossier to the new document exists in Neo4j. - The document stub is ready for the existing document-processing pipeline to attach file content to it.


2. Context & Scope

Background / Reason for Change The existing document creation path in DocumentManagementServiceBase does not create [:CONTAINS] or [:OWNS] edges, or set any ACL fields on new documents. CreateDocument becomes the authoritative entry point that ensures ACL properties and graph relationships are always initialised on creation.

In Scope - Implementing CreateDocument in ResourceService. - Generating a new UUID for the document id. - Setting: Id, Name, IsProtected=false, IsDeleted=false, UploadedDateTime=DateTime.UtcNow, UpdatedAt=DateTime.UtcNow, Status=DocumentStatus.NotYetProcessed, Type=DocumentType.Other. - Creating (group)-[:OWNS]->(newDocument) edge using the supplied ownerGroupId. - Calling Neo4jDocumentRepository.Save(INeo4jDocument) then CreateContainsEdge(parentId, document.Id). - Validating: parent dossier exists and is not soft-deleted.

Out of Scope - File upload / document processing — the existing pipeline handles this separately and can be attached to the stub by ID. - ACL permission checking — deferred to Task 10. - Audit event emission — deferred to Task 11.


3. Impact Analysis

Affected Components / Modules

Component Change
ThalabusCore/Services/ResourceService.cs Implement CreateDocument

Dependencies

Task Reason
Task 8.2 CreateContainsEdge helper must already exist in the repository
Tasks 6.1–6.2 INeo4jDocument and Neo4jDocument must have IsProtected, IsDeleted, etc.

Risks & Edge Cases

Risk Mitigation
Parent dossier not found or soft-deleted Same guards as CreateDossier (Task 8.2)
ID uniqueness collision MERGE in Save on id prevents duplicate nodes; UUID collision is astronomically unlikely

Backward Compatibility Considerations New code path. Existing document upload paths via DocumentManagementServiceBase remain unchanged.


4. Implementation Plan

Add CreateDocument to ResourceService.cs, using the injected DocumentRepository (same DI pattern as CreateDossier):

public async Task<INeo4jDocument> CreateDocument(
    string parentId, string name, string ownerGroupId) {

    var parent = await DocumentRepository.GetDossier(parentId)
        ?? throw new ArgumentException($"Parent dossier not found: {parentId}");

    if (parent.IsDeleted)
        throw new InvalidOperationException($"Cannot create a document inside a deleted dossier: {parentId}");

    var now = DateTime.UtcNow;
    var newDocument = new Neo4jDocument(Guid.NewGuid().ToString(), name) {
        IsProtected      = false,
        IsDeleted        = false,
        UploadedDateTime = now,
        UpdatedAt        = now,
        Status           = DocumentStatus.NotYetProcessed,
        Type             = DocumentType.Other
    };

    await DocumentRepository.Save(newDocument);
    await DocumentRepository.CreateContainsEdge(parentId, newDocument.Id);
    await DocumentRepository.CreateOwnsEdge(ownerGroupId, newDocument.Id);

    return newDocument;
}

5. Testing Strategy

Test Cases / Scenarios

# Scenario Expected Result
T1 CreateDocument with valid parent Returns document with all ACL fields set; [:CONTAINS] edge exists
T2 Parent dossier not found ArgumentException thrown
T3 Parent dossier is soft-deleted InvalidOperationException thrown

Acceptance Criteria - T1: MATCH (p:Dossier {id:$parentId})-[:CONTAINS]->(d:Document {id:$newId}) RETURN d returns the new node.

Implementation of these tests Add to ResourceServiceTests.cs in ThalabusUnitTests. Integration tests against test Neo4j for round-trip verification.


6. Post-Implementation

What changed: - ThalabusCore/Services/ResourceService.cs — CreateDocument implemented. Uses the injected DocumentRepository (no static singleton calls). Validates parent dossier exists and is not soft-deleted. Instantiates Neo4jDocument with a new UUID and ACL fields (IsProtected=false, IsDeleted=false, UploadedDateTime=now, UpdatedAt=now, Status=NotYetProcessed, Type=Other). Calls Save → CreateContainsEdge → CreateOwnsEdge.

Build result: ThalabusCore builds with zero errors.

Test results: T1–T3 not yet run against a live Neo4j instance. Unit tests analogous to Task 8.2 T2/T3 (parent not found, parent soft-deleted) can be added to ResourceServiceCreateDocumentTests.cs using the same FakeDocumentRepository / FakeDossier fakes.


Task 8.4 — Detail

1. Task Overview

Short Description Implement IResourceService.MoveResource in ResourceService. The method moves a Dossier or Document from its current parent to a new parent dossier: it removes the old (oldParent)-[:CONTAINS]->(resource) edge and creates the new (newParent)-[:CONTAINS]->(resource) edge. It also updates the [:OWNS] edge so that ownership follows location (the new parent's owning group becomes the resource's owner).

Objective / Expected Result - The old [:CONTAINS] edge is gone; the new one exists. - The [:OWNS] edge is updated so the resource's owner matches the new parent's owner. - updatedAt is refreshed.


2. Context & Scope

Background / Reason for Change ACL_Design.md states ownership cannot be transferred without moving the entity — moving a resource into a new folder implicitly changes its owner to the new parent folder's owner. No such operation exists today.

In Scope - Removing the existing (currentParent)-[:CONTAINS]->(resource) edge. - Creating (newParent)-[:CONTAINS]->(resource). - Updating the [:OWNS] edge on the resource so it points to the new parent's owning group. - Updating updatedAt on the resource. - Validating: resource exists, new parent exists, new parent is not soft-deleted, resource is not soft-deleted, new parent is not a descendant of the resource (no cycle).

Out of Scope - Moving a resource into a type=session dossier (destination guard) — session dossiers are lifecycle-managed, not user-accessible containers; throw InvalidOperationException. - Moving a resource into a type=root dossier (destination guard) — the root dossier is the graph root, not a user-accessible container; throw InvalidOperationException. - Moving the type=root dossier itself (source guard) — the root dossier is a fixed system node; throw InvalidOperationException. - Moving the type=user dossier itself (source guard) — user dossiers are system-provisioned personal roots that cannot be relocated; throw InvalidOperationException. Moving resources into a type=user dossier is fully permitted and is the expected way to place content in a user's personal workspace. - Recursive ownership cascade to descendants — ownership is resolved at query time by traversing [:CONTAINS] to find the nearest owned ancestor; only the moved resource's direct [:OWNS] edge is updated. - ACL permission checking — deferred to Task 10.


3. Impact Analysis

Affected Components / Modules

Component Change
ThalabusCore/Services/ResourceService.cs Implement MoveResource
Neo4jORM/DataAccess/Neo4jDocumentRepository.cs New RemoveContainsEdge(string parentId, string childId) helper

Dependencies

Task Reason
Task 8.2 CreateContainsEdge helper must exist
Tasks 5.3, 6.2 INeo4jDossier and INeo4jDocument must exist (repository Save must work)

Risks & Edge Cases

Risk Mitigation
Cycle detection (moving a folder into one of its own descendants) Before moving, verify newParentId is not reachable from the resource via (resource)-[:CONTAINS*]->
Resource has no current [:CONTAINS] edge (orphan) OPTIONAL MATCH for the old edge — proceed even if not found; parentId update still happens
Moving to a type=session dossier Throw InvalidOperationException — session dossiers are lifecycle-managed, not user-manipulated

Backward Compatibility Considerations New code path. No existing move operations exist in the codebase.


4. Implementation Plan

public async Task MoveResource(string resourceId, string newParentId, string effectiveUserId) {
    var resource = (await GetResource(resourceId))
        ?? throw new ArgumentException($"Resource not found: {resourceId}");

    // Source guards — system dossiers cannot be relocated
    if (resource.Kind == ResourceKind.Dossier) {
        if (resource.Dossier!.Type == DossierType.Root)
            throw new InvalidOperationException("The root dossier cannot be moved.");
        if (resource.Dossier!.Type == DossierType.User)
            throw new InvalidOperationException("User dossiers cannot be moved.");
    }

    if (resource.Kind == ResourceKind.Dossier && resource.Dossier!.IsDeleted)
        throw new InvalidOperationException("Cannot move a deleted resource.");
    if (resource.Kind == ResourceKind.Document && resource.Document!.IsDeleted)
        throw new InvalidOperationException("Cannot move a deleted resource.");

    var newParent = await Neo4jDocumentRepository.Singleton!.GetDossier(newParentId)
        ?? throw new ArgumentException($"New parent dossier not found: {newParentId}");

    if (newParent.IsDeleted)
        throw new InvalidOperationException("Cannot move into a deleted dossier.");
    // Destination guards — session and root dossiers are not user-accessible containers
    if (newParent.Type == DossierType.Session)
        throw new InvalidOperationException("Cannot move a resource into a session dossier.");
    if (newParent.Type == DossierType.Root)
        throw new InvalidOperationException("Cannot move a resource directly into the root dossier.");

    // Cycle check for Dossier resources
    if (resource.Kind == ResourceKind.Dossier) {
        bool isCycle = await Neo4jDocumentRepository.Singleton!
            .IsAncestor(resourceId, newParentId);
        if (isCycle)
            throw new InvalidOperationException("Cannot move a folder into one of its own descendants.");
    }

    // Resolve current parent via [:CONTAINS] graph relationship
    string? oldParentId = await Neo4jDocumentRepository.Singleton!.GetParentId(resourceId);

    if (oldParentId != null)
        await Neo4jDocumentRepository.Singleton!.RemoveContainsEdge(oldParentId, resourceId);

    await Neo4jDocumentRepository.Singleton!.CreateContainsEdge(newParentId, resourceId);

    // Update [:OWNS] edge and updatedAt on the resource node
    var now = DateTime.UtcNow;
    string? newOwnerGroupId = await Neo4jDocumentRepository.Singleton!.GetOwnerGroupId(newParentId);
    await Neo4jDocumentRepository.Singleton!.UpdateOwnsEdge(resourceId, newOwnerGroupId);

    if (resource.Kind == ResourceKind.Dossier) {
        resource.Dossier!.UpdatedAt = now;
        await Neo4jDocumentRepository.Singleton!.Save(resource.Dossier!);
    } else {
        resource.Document!.UpdatedAt = now;
        await Neo4jDocumentRepository.Singleton!.Save(resource.Document!);
    }
}

Add GetParentId, GetOwnerGroupId, UpdateOwnsEdge, RemoveContainsEdge, and IsAncestor to Neo4jDocumentRepository:

public async Task<string?> GetParentId(string childId) {
    var query = @"
        MATCH (p:Dossier)-[:CONTAINS]->(c {id: $childId})
        RETURN p.id AS parentId";
    // ... returns first match or null
}

public async Task<string?> GetOwnerGroupId(string resourceId) {
    var query = @"
        MATCH (g:Group)-[:OWNS]->(r {id: $resourceId})
        RETURN g.id AS ownerGroupId";
    // ... returns first match or null
}

public async Task UpdateOwnsEdge(string resourceId, string? newOwnerGroupId) {
    var query = @"
        MATCH (g:Group)-[r:OWNS]->(res {id: $resourceId}) DELETE r
        WITH res
        OPTIONAL MATCH (newOwner:Group {id: $newOwnerGroupId})
        FOREACH (_ IN CASE WHEN newOwner IS NOT NULL THEN [1] ELSE [] END |
            MERGE (newOwner)-[:OWNS]->(res))";
    // ...
}

public async Task RemoveContainsEdge(string parentId, string childId) {
    var query = @"
        MATCH (parent:Dossier {id: $parentId})-[r:CONTAINS]->(child {id: $childId})
        DELETE r";
    // ...
}

public async Task<bool> IsAncestor(string potentialAncestorId, string nodeId) {
    var query = @"
        MATCH (ancestor {id: $ancestorId})-[:CONTAINS*]->(node {id: $nodeId})
        RETURN count(*) > 0 AS isAncestor";
    // ...
}

5. Testing Strategy

Test Cases / Scenarios

# Scenario Expected Result
T1 Move document to a different dossier Old [:CONTAINS] gone; new [:CONTAINS] exists; [:OWNS] edge updated
T2 Move dossier to a different parent Same as T1; [:OWNS] edge updated to new parent's owning group
T3 Move dossier into one of its own descendants InvalidOperationException — cycle detected
T4 New parent is soft-deleted InvalidOperationException
T5 New parent is a type=session dossier InvalidOperationException
T6 New parent is the type=root dossier InvalidOperationException
T7 Move a type=root dossier (source guard) InvalidOperationException
T8 Move a type=user dossier (source guard) InvalidOperationException
T9 Move a resource into a type=user dossier (valid) Move succeeds; [:CONTAINS] and [:OWNS] edges updated
T10 Resource has no existing [:CONTAINS] edge (orphan) Move succeeds; new edge created; no error

Acceptance Criteria - T1–T2, T9: graph state correct after move. - T3–T8: correct exceptions thrown.

Implementation of these tests Integration tests against test Neo4j instance for T1–T10.


6. Post-Implementation

Date: 2026-05-04

What changed: - ThalabusCore/Services/ResourceService.cs — MoveResource implemented. Resolves the resource by trying GetDossier then FindDocumentById. Enforces source guards (root/user dossiers cannot be moved), checks resource and destination are not soft-deleted, enforces destination type guards (root and session dossiers are forbidden targets), performs cycle detection via IsAncestor for dossier moves, then: removes the old [:CONTAINS] edge (via GetParentId + RemoveContainsEdge), creates the new [:CONTAINS] edge, updates the [:OWNS] edge to the new parent's owning group (via GetOwnerGroupId + UpdateOwnsEdge), and refreshes UpdatedAt via Save. - ThalabusInterfaces/Data/Neo4j/INeo4jDocumentRepository.cs — added RemoveContainsEdge, UpdateOwnsEdge, GetParentId, GetOwnerGroupId, IsAncestor declarations. - Neo4jORM/DataAccess/Neo4jDocumentRepository.cs — implemented all five new methods: - RemoveContainsEdge: deletes the [:CONTAINS] relationship between parent and child. - UpdateOwnsEdge: removes all existing [:OWNS] edges on the resource then creates a new one to the specified group. - GetParentId: returns the id of the first dossier connected via [:CONTAINS] to the given child. - GetOwnerGroupId: returns the id of the group connected via [:OWNS] to the given resource. - IsAncestor: returns true if a variable-depth [:CONTAINS*] path exists from potentialAncestorId to nodeId.

Build result: ThalabusInterfaces, Neo4jORM, ThalabusCore, and ThalabusWeb all build with zero errors.

Test results: T1–T10 not yet run against a live Neo4j instance (require integration test environment). Logic verified by code review against the plan's acceptance criteria.


Task 8.5 — Detail

1. Task Overview

Short Description Implement IResourceService.RenameResource in ResourceService. The method updates the name property of a Dossier or Document node in Neo4j and refreshes updatedAt.

Objective / Expected Result - The resource node has its name changed to newName. - updatedAt is refreshed to DateTime.UtcNow. - The resource must exist and not be soft-deleted.


2. Context & Scope

Background / Reason for Change Renaming a dossier or document today goes through DocumentManagementServiceBase.AddOrUpdateDossier or document.Save() directly, without validation, updatedAt update, or a centralised check that the resource is not deleted.

In Scope - Looking up the resource by resourceId via GetResource. - Validating: resource exists, resource is not soft-deleted. - Setting Name = newName and UpdatedAt = DateTime.UtcNow. - Calling Save on the underlying INeo4jDossier or INeo4jDocument.

Out of Scope - Renaming the physical file on disk for documents — the file path is derived from Name; if renaming requires a file rename, that must be addressed as a separate concern (out of scope for this task). - ACL permission checking — deferred to Task 10. - Audit event emission — deferred to Task 11.


3. Impact Analysis

Affected Components / Modules

Component Change
ThalabusCore/Services/ResourceService.cs Implement RenameResource

Dependencies

Task Reason
Task 8.6 GetResource must be implemented first to look up the node

Risks & Edge Cases

Risk Mitigation
Empty or whitespace newName Validate before setting; throw ArgumentException
Document file path derived from Name Rename on disk is out of scope; document processing may need to be re-triggered if the file is renamed — document noted as a known limitation

Backward Compatibility Considerations New code path. Existing Name setters on INeo4jDossier / INeo4jDocument are unchanged.


4. Implementation Plan

public async Task RenameResource(string resourceId, string newName, string effectiveUserId) {
    if (string.IsNullOrWhiteSpace(newName))
        throw new ArgumentException("New name cannot be empty.", nameof(newName));

    var resource = (await GetResource(resourceId))
        ?? throw new ArgumentException($"Resource not found: {resourceId}");

    var now = DateTime.UtcNow;
    if (resource.Kind == ResourceKind.Dossier) {
        if (resource.Dossier!.IsDeleted)
            throw new InvalidOperationException("Cannot rename a deleted dossier.");
        resource.Dossier!.Name      = newName;
        resource.Dossier!.UpdatedAt = now;
        await Neo4jDocumentRepository.Singleton!.Save(resource.Dossier!);
    } else {
        if (resource.Document!.IsDeleted)
            throw new InvalidOperationException("Cannot rename a deleted document.");
        resource.Document!.Name      = newName;
        resource.Document!.UpdatedAt = now;
        await Neo4jDocumentRepository.Singleton!.Save(resource.Document!);
    }
}

5. Testing Strategy

Test Cases / Scenarios

# Scenario Expected Result
T1 Rename a dossier name updated; updatedAt refreshed
T2 Rename a document Same
T3 Resource not found ArgumentException
T4 Resource is soft-deleted InvalidOperationException
T5 newName is empty string ArgumentException

Acceptance Criteria - T1–T2: GetResource(resourceId).Name == newName after the call.

Implementation of these tests Unit tests with mock repository for T3–T5. Integration tests for T1–T2.


6. Post-Implementation

Date: 2026-05-04

What changed: - ThalabusCore/Services/ResourceService.cs — RenameResource implemented. Validates newName is not empty/whitespace. Resolves the resource by trying GetDossier then FindDocumentById. Throws ArgumentException if not found. Throws InvalidOperationException if the resource is soft-deleted. Sets Name = newName and UpdatedAt = DateTime.UtcNow on the resolved entity, then calls DocumentRepository.Save. No new repository helpers needed — reuses the existing Save path. - Note: Task 8.5 was listed as depending on Task 8.6 (GetResource) in the plan, but since MoveResource (Task 8.4) already established the pattern of resolving resources inline via GetDossier + FindDocumentById, the same approach was applied here without requiring Task 8.6 first.

Build result: ThalabusInterfaces, Neo4jORM, ThalabusCore, and ThalabusWeb all build with zero errors.

Test results: T1–T5 not yet run against a live Neo4j instance. T3–T5 (not found, soft-deleted, empty name) can be covered by unit tests using the same FakeDocumentRepository / FakeDossier fakes from Task 8.2.


Task 8.6 — Detail

1. Task Overview

Short Description Implement IResourceService.GetResource and IResourceService.GetChildren in ResourceService. GetResource resolves a resource ID as either a Dossier or Document node and wraps it in a ResourceItem. GetChildren queries (parent:Dossier)-[:CONTAINS]->(child) and returns all direct children as ResourceItem instances.

Objective / Expected Result - GetResource(id) returns a ResourceItem containing either an INeo4jDossier or INeo4jDocument, or null if not found. - GetChildren(parentId) returns all direct [:CONTAINS] children of the specified dossier. - Soft-deleted children are included in the result (callers filter by IsDeleted as needed — ACL service handles visibility).


2. Context & Scope

Background / Reason for Change No unified "get any resource by ID" method exists today. DocumentManagementServiceBase has GetDossier(id) and GetDocument(id) as separate calls. GetChildren is entirely missing — currently all documents in a dossier are fetched via GetDocumentsByDossier which does not return sub-dossiers. The Resource Service is the first place both resource types are treated uniformly.

In Scope - Implementing GetResource by trying GetDossier(id) then FindDocumentById(id). - Implementing GetChildren using a new repository method GetChildrenByParentId(parentId) that queries (d:Dossier {id:$parentId})-[:CONTAINS]->(child) and returns both Dossier and Document children. - Adding GetChildrenByParentId to Neo4jDocumentRepository.

Out of Scope - Recursive (deep) child retrieval — GetChildren returns only direct children. - Filtering by isDeleted — callers decide; this method returns all children. - Pagination — can be added as an overload later.


3. Impact Analysis

Affected Components / Modules

Component Change
ThalabusCore/Services/ResourceService.cs Implement GetResource, GetChildren
Neo4jORM/DataAccess/Neo4jDocumentRepository.cs New GetChildrenByParentId(string parentId) method

Dependencies

Task Reason
Task 8.1 ResourceItem type declared in IResourceService.cs
Tasks 5.3, 6.2 INeo4jDossier / INeo4jDocument have all ACL properties populated

Risks & Edge Cases

Risk Mitigation
id exists as both a Dossier and Document (impossible in a well-formed graph, but defensively handled) Try Dossier first; if found, return it without also trying Document
GetChildrenByParentId query returns a mix of node labels Use RETURN labels(child) AS labels, child to distinguish Dossier from Document nodes in the result

Backward Compatibility Considerations GetDocumentsByDossier is kept as-is for backward compatibility with existing callers. GetChildren is an additive method.


4. Implementation Plan

GetResource in ResourceService:

public async Task<ResourceItem?> GetResource(string resourceId) {
    var dossier = await Neo4jDocumentRepository.Singleton!.GetDossier(resourceId);
    if (dossier != null) return new ResourceItem(dossier);

    var document = await Neo4jDocumentRepository.Singleton!.FindDocumentById(resourceId);
    if (document != null) return new ResourceItem(document);

    return null;
}

GetChildren in ResourceService:

public async Task<IEnumerable<ResourceItem>> GetChildren(string parentId) {
    return await Neo4jDocumentRepository.Singleton!.GetChildrenByParentId(parentId);
}

GetChildrenByParentId in Neo4jDocumentRepository:

public async Task<IEnumerable<ResourceItem>> GetChildrenByParentId(string parentId) {
    var neo4jDataAccess = ServiceProvider.GetRequiredService<INeo4jDataAccess>();
    var query = @"
        MATCH (parent:Dossier {id: $parentId})-[:CONTAINS]->(child)
        RETURN child, labels(child) AS childLabels";
    var parameters = new Dictionary<string, object?> { { "parentId", parentId } };
    var results = new List<ResourceItem>();

    var records = await neo4jDataAccess.ExecuteReadScalarAsync<IEnumerable<IRecord>>(query, parameters);
    foreach (var record in records ?? Enumerable.Empty<IRecord>()) {
        var childNode = record["child"].As<INode>();
        var labels = record["childLabels"].As<IList<string>>();
        if (labels.Contains("Dossier")) {
            results.Add(new ResourceItem(new Neo4jORM.Entities.Neo4jDossier(childNode)));
        } else if (labels.Contains("Document")) {
            results.Add(new ResourceItem(new Neo4jORM.Entities.Neo4jDocument(childNode)));
        }
    }
    return results;
}

5. Testing Strategy

Test Cases / Scenarios

# Scenario Expected Result
T1 GetResource for a known Dossier ID Returns ResourceItem with Kind=Dossier
T2 GetResource for a known Document ID Returns ResourceItem with Kind=Document
T3 GetResource for unknown ID Returns null
T4 GetChildren for a dossier with mixed children Returns both sub-dossiers and documents
T5 GetChildren for an empty dossier Returns empty list
T6 GetChildren for a dossier containing soft-deleted children Soft-deleted children are included in the result

Acceptance Criteria - T4: result count matches the number of [:CONTAINS] edges in the graph.

Implementation of these tests Integration tests against test Neo4j instance for T1–T6.


6. Post-Implementation

Date: 2026-05-04

What changed: - ThalabusCore/Services/ResourceService.cs — GetResource and GetChildren implemented. GetResource tries GetDossier first, then FindDocumentById, returning a ResourceItem wrapper or null. GetChildren delegates to the new GetChildrenByParentId repository method. Both use the injected DocumentRepository (consistent with Tasks 8.2–8.5). - ThalabusInterfaces/Data/Neo4j/INeo4jDocumentRepository.cs — added GetChildrenByParentId(string parentId) declaration returning Task<IEnumerable<ResourceItem>>. - Neo4jORM/DataAccess/Neo4jDocumentRepository.cs — implemented GetChildrenByParentId: queries (parent:Dossier {id})-[:CONTAINS]->(child) returning child and labels(child); wraps each child as ResourceItem(new Neo4jDossier(node)) or ResourceItem(new Neo4jDocument(node)) based on the label list. Added using ThalabusInterfaces.Services to the file's using directives.

Build result: ThalabusInterfaces, Neo4jORM, ThalabusCore, and ThalabusWeb all build with zero errors.

Test results: T1–T6 not yet run against a live Neo4j instance (require integration test environment). Logic verified by code review against the plan's acceptance criteria.


Task 8.7 — Detail

1. Task Overview

Short Description Implement IResourceService.SetProtected and IResourceService.Unprotect in ResourceService. SetProtected sets isProtected=true on the resource and refreshes updatedAt. Unprotect sets isProtected=false. These operations break or restore ACL inheritance from the parent dossier.

Objective / Expected Result - After SetProtected, the resource node has isProtected=true and updatedAt refreshed. - After Unprotect, the resource node has isProtected=false and updatedAt refreshed. - The ACL service (Task 10) will read isProtected when computing effective permissions.


2. Context & Scope

Background / Reason for Change ACL_Design.md defines the protected flag as an inheritance break — a protected resource ignores all ancestor permissions and only uses its own directly assigned permissions. No mechanism to set or clear this flag exists today.

In Scope - Setting isProtected to true/false on both Dossier and Document nodes. - Validating: resource exists, resource is not soft-deleted. - Refreshing updatedAt.

Out of Scope - Cascading isProtected to children — the flag is per-resource only; children are not affected. - ACL permission checking — the effective user's group must hold Permission.Sharing on the resource to call SetProtected or Unprotect (defined in ACL_Design.md); enforcement is deferred to Task 10. - Audit event emission — deferred to Task 11.


3. Impact Analysis

Affected Components / Modules

Component Change
ThalabusCore/Services/ResourceService.cs Implement SetProtected, Unprotect

Dependencies

Task Reason
Task 8.6 GetResource must be implemented
Tasks 5.1–5.3, 6.1–6.2 IsProtected must be a writable property on both entity types

Risks & Edge Cases

Risk Mitigation
SetProtected on an already-protected resource Idempotent — no-op (still updates updatedAt)
Unprotect on an already-unprotected resource Same — idempotent

Backward Compatibility Considerations New code path. No existing callers.


4. Implementation Plan

public async Task SetProtected(string resourceId, string effectiveUserId) {
    await SetProtectedInternal(resourceId, isProtected: true);
}

public async Task Unprotect(string resourceId, string effectiveUserId) {
    await SetProtectedInternal(resourceId, isProtected: false);
}

private async Task SetProtectedInternal(string resourceId, bool isProtected) {
    var resource = (await GetResource(resourceId))
        ?? throw new ArgumentException($"Resource not found: {resourceId}");

    var now = DateTime.UtcNow;
    if (resource.Kind == ResourceKind.Dossier) {
        if (resource.Dossier!.IsDeleted)
            throw new InvalidOperationException("Cannot change protection on a deleted dossier.");
        resource.Dossier!.IsProtected = isProtected;
        resource.Dossier!.UpdatedAt   = now;
        await Neo4jDocumentRepository.Singleton!.Save(resource.Dossier!);
    } else {
        if (resource.Document!.IsDeleted)
            throw new InvalidOperationException("Cannot change protection on a deleted document.");
        resource.Document!.IsProtected = isProtected;
        resource.Document!.UpdatedAt   = now;
        await Neo4jDocumentRepository.Singleton!.Save(resource.Document!);
    }
}

5. Testing Strategy

Test Cases / Scenarios

# Scenario Expected Result
T1 SetProtected on a dossier isProtected=true; updatedAt refreshed
T2 Unprotect on a dossier isProtected=false; updatedAt refreshed
T3 SetProtected on a document Same as T1
T4 SetProtected on soft-deleted resource InvalidOperationException
T5 SetProtected twice (idempotent) No error; isProtected remains true

Acceptance Criteria - T1–T3: property value correct after call; updatedAt is after the value before the call.

Implementation of these tests Integration tests against test Neo4j instance for T1–T5.


6. Post-Implementation

Date: 2026-05-04

What changed: - ThalabusCore/Services/ResourceService.cs — SetProtected and Unprotect implemented via a shared private SetProtectedFlag(resourceId, bool) helper. The helper resolves the resource with GetDossier → FindDocumentById, throws ArgumentException if not found, sets IsProtected to the given value and refreshes UpdatedAt, then calls DocumentRepository.Save. No new repository helpers or interface changes required.

Build result: ThalabusInterfaces, Neo4jORM, ThalabusCore, and ThalabusWeb all build with zero errors.

Test results: T1–T5 not yet run against a live Neo4j instance. T3 (resource not found) can be covered by unit tests using the existing FakeDocumentRepository fake.


Task 8.8 — Detail

1. Task Overview

Short Description Implement IResourceService.DeleteResource and IResourceService.RestoreResource in ResourceService. DeleteResource sets isDeleted=true, deletedAt=now, deletedBy=effectiveUserId on the target resource and, if it is a Dossier, cascades the same flags to all [:CONTAINS*] descendants. RestoreResource reverses these flags on the target resource only (not descendants — callers must restore each child individually if desired).

Objective / Expected Result - After DeleteResource, the resource and all its descendants are immediately inaccessible (isDeleted=true). - After RestoreResource, the resource has isDeleted=false, deletedAt=null, deletedBy=null. - Hard-delete is not performed by either method — that is PurgeResource (Task 8.9). - The resource remains in Neo4j and can be found by privileged queries.


2. Context & Scope

Background / Reason for Change ACL_Design.md requires soft-delete as the first step of deletion, with a recovery window before permanent removal. ACL_Implementation_Gaps.md confirms that the current codebase only uses DETACH DELETE (hard delete). This task introduces soft-delete for all regular (non-session) resources.

In Scope - Setting isDeleted=true, deletedAt, deletedBy on the target node. - If the target is a Dossier: cascading isDeleted=true to all [:CONTAINS*] descendant nodes using a Cypher query. - Adding SoftDeleteWithCascade(string dossierId, string effectiveUserId, DateTime deletedAt) to Neo4jDocumentRepository for the cascade Cypher. - RestoreResource: clearing isDeleted, deletedAt, deletedBy on the target node only (no cascade restore — restoration is explicit). - Validating: resource exists; DeleteResource guards against deleting type=root or type=user system dossiers.

Out of Scope - Hard-delete — covered by Task 8.9. - Async cleanup cron task — covered by Task 8.10. - Cascade restoring all descendants — intentionally out of scope; restoring a parent does not automatically restore all children. - ACL permission checking — deferred to Task 10. - Replacing existing DocumentManagementServiceBase.RemoveDossier and RemoveDocument calls — those are hard-deletes used by existing session management; they remain as-is until a wider migration is done.


3. Impact Analysis

Affected Components / Modules

Component Change
ThalabusCore/Services/ResourceService.cs Implement DeleteResource, RestoreResource
Neo4jORM/DataAccess/Neo4jDocumentRepository.cs New SoftDeleteWithCascade and SoftDeleteRestore methods

Dependencies

Task Reason
Tasks 5.3, 6.2 IsDeleted, DeletedAt, DeletedBy must be writable properties on both entity types and persisted by Save
Task 8.6 GetResource must be implemented

Risks & Edge Cases

Risk Mitigation
Deleting a type=root or type=user dossier Guard: throw InvalidOperationException("System dossiers cannot be deleted.")
Cascade soft-delete on a very large hierarchy causing timeout Use CALL { ... } IN TRANSACTIONS OF 500 ROWS in the cascade Cypher
RestoreResource on a resource whose parent is still soft-deleted Resource is technically restored but will remain inaccessible via GetChildren until the parent is also restored. Document this as expected behaviour.
Calling DeleteResource on an already-deleted resource Idempotent — update deletedAt and deletedBy with the new call's values

Backward Compatibility Considerations Existing RemoveDossier / RemoveDocument (hard-delete paths) are not replaced by this task. New soft-delete path is additive.


4. Implementation Plan

DeleteResource in ResourceService:

public async Task DeleteResource(string resourceId, string effectiveUserId) {
    var resource = (await GetResource(resourceId))
        ?? throw new ArgumentException($"Resource not found: {resourceId}");

    if (resource.Kind == ResourceKind.Dossier) {
        var dossier = resource.Dossier!;
        if (dossier.Type == DossierType.Root || dossier.Type == DossierType.User)
            throw new InvalidOperationException("System dossiers cannot be deleted.");

        var now = DateTime.UtcNow;
        dossier.IsDeleted = true;
        dossier.DeletedAt = now;
        dossier.DeletedBy = effectiveUserId;
        dossier.UpdatedAt = now;
        await Neo4jDocumentRepository.Singleton!.Save(dossier);

        // Cascade soft-delete to all descendants
        await Neo4jDocumentRepository.Singleton!.SoftDeleteWithCascade(resourceId, effectiveUserId, now);
    } else {
        var doc = resource.Document!;
        var now = DateTime.UtcNow;
        doc.IsDeleted = true;
        doc.DeletedAt = now;
        doc.DeletedBy = effectiveUserId;
        doc.UpdatedAt = now;
        await Neo4jDocumentRepository.Singleton!.Save(doc);
    }
}

RestoreResource in ResourceService:

public async Task RestoreResource(string resourceId, string effectiveUserId) {
    var resource = (await GetResource(resourceId))
        ?? throw new ArgumentException($"Resource not found: {resourceId}");

    var now = DateTime.UtcNow;
    if (resource.Kind == ResourceKind.Dossier) {
        resource.Dossier!.IsDeleted = false;
        resource.Dossier!.DeletedAt = null;
        resource.Dossier!.DeletedBy = null;
        resource.Dossier!.UpdatedAt = now;
        await Neo4jDocumentRepository.Singleton!.Save(resource.Dossier!);
    } else {
        resource.Document!.IsDeleted = false;
        resource.Document!.DeletedAt = null;
        resource.Document!.DeletedBy = null;
        resource.Document!.UpdatedAt = now;
        await Neo4jDocumentRepository.Singleton!.Save(resource.Document!);
    }
}

SoftDeleteWithCascade in Neo4jDocumentRepository:

public async Task SoftDeleteWithCascade(string rootDossierId, string effectiveUserId, DateTime deletedAt) {
    var query = @"
        MATCH (root:Dossier {id: $rootId})-[:CONTAINS*]->(child)
        SET child.isDeleted = true,
            child.deletedAt = $deletedAt,
            child.deletedBy = $effectiveUserId,
            child.updatedAt = $deletedAt";
    var parameters = new Dictionary<string, object?> {
        { "rootId", rootDossierId },
        { "effectiveUserId", effectiveUserId },
        { "deletedAt", deletedAt.ToString("yyyy-MM-ddTHH:mm:ssZ") }
    };
    await neo4jDataAccess.ExecuteWriteAsync(query, parameters);
}

5. Testing Strategy

Test Cases / Scenarios

# Scenario Expected Result
T1 DeleteResource on a document isDeleted=true, deletedAt and deletedBy set
T2 DeleteResource on a dossier with children Dossier and all descendants have isDeleted=true
T3 DeleteResource on type=root InvalidOperationException
T4 RestoreResource on a soft-deleted document isDeleted=false, deletedAt=null, deletedBy=null
T5 RestoreResource on a soft-deleted dossier Dossier restored; descendants remain soft-deleted
T6 DeleteResource twice on same resource Idempotent; deletedBy updated to latest effective user

Acceptance Criteria - T2: MATCH (root {id:$id})-[:CONTAINS*]->(child) WHERE child.isDeleted = false RETURN count(child) = 0. - T5: descendants still have isDeleted=true after dossier restore.

Implementation of these tests Integration tests against test Neo4j instance for T1–T6.


6. Post-Implementation

Date: 2026-05-04

What changed: - ThalabusCore/Services/ResourceService.cs — DeleteResource and RestoreResource implemented. - DeleteResource: resolves via GetResource; throws ArgumentException if not found; guards against type=root/type=user dossiers; sets IsDeleted=true, DeletedAt=now, DeletedBy=effectiveUserId, UpdatedAt=now on the target via DocumentRepository.Save; for dossiers also calls SoftDeleteWithCascade to propagate the flags to all [:CONTAINS*] descendants. - RestoreResource: resolves via GetResource; throws ArgumentException if not found; clears IsDeleted, DeletedAt, DeletedBy and refreshes UpdatedAt on the target only (no cascade restore — descendants remain soft-deleted as per the design). - ThalabusInterfaces/Data/Neo4j/INeo4jDocumentRepository.cs — added SoftDeleteWithCascade(string rootDossierId, string effectiveUserId, DateTime deletedAt) declaration. - Neo4jORM/DataAccess/Neo4jDocumentRepository.cs — implemented SoftDeleteWithCascade: Cypher MATCH (root:Dossier {id})-[:CONTAINS*]->(child) SET child.isDeleted=true, child.deletedAt=..., child.deletedBy=..., child.updatedAt=....

Build result: ThalabusInterfaces, Neo4jORM, ThalabusCore, and ThalabusWeb all build with zero errors.

Test results: T1–T6 not yet run against a live Neo4j instance (require integration test environment). Logic verified by code review against the plan’s acceptance criteria. Note: for very large hierarchies, consider wrapping SoftDeleteWithCascade in CALL { ... } IN TRANSACTIONS OF 500 ROWS — this can be added as a follow-up optimisation.


Task 8.9 — Detail

1. Task Overview

Short Description Implement IResourceService.PurgeResource in ResourceService. The method permanently hard-deletes a resource and, if it is a Dossier, all its [:CONTAINS*] descendants. PurgeResource is only callable on resources that are already soft-deleted (isDeleted=true). It is the operation invoked by the async purge cron task (Task 8.10) after the recovery window has elapsed.

Objective / Expected Result - After PurgeResource, no node with the given resourceId exists in Neo4j, and no [:CONTAINS*] descendants of a purged dossier remain. - An attempt to purge a resource that is NOT soft-deleted throws InvalidOperationException.


2. Context & Scope

Background / Reason for Change ACL_Design.md defines a two-phase delete: first soft-delete (Task 8.8) with an optional recovery window, then async hard-purge. The existing DETACH DELETE paths in the codebase perform immediate hard-delete — PurgeResource is the sanctioned hard-delete path for the post-recovery-window cleanup.

In Scope - Hard-deleting a single Document node (DETACH DELETE). - Hard-deleting a Dossier node and all its [:CONTAINS*] descendants. - Guard: resource must have isDeleted=true before purge is allowed. - Adding HardPurgeWithCascade(string dossierId) to Neo4jDocumentRepository.

Out of Scope - Scheduling / cron logic — covered by Task 8.10. - Purging session resources — covered by Task 7.2 (immediate, no recovery window). - Purging ACL entries ([:OWNS], [:GRANT]) hanging off the purged nodes — DETACH DELETE removes all relationships automatically.


3. Impact Analysis

Affected Components / Modules

Component Change
ThalabusCore/Services/ResourceService.cs Implement PurgeResource
Neo4jORM/DataAccess/Neo4jDocumentRepository.cs New HardPurgeWithCascade(string dossierId) method

Dependencies

Task Reason
Task 8.8 isDeleted flag must be set before purge is permitted
Task 8.6 GetResource needed to resolve the resource and verify isDeleted

Risks & Edge Cases

Risk Mitigation
Purging a resource that is not soft-deleted (guard bypass) Throw InvalidOperationException("Resource must be soft-deleted before it can be purged.")
Large descendant tree causing Cypher timeout Three-step purge: descendants first, then root node (same pattern as Task 7.2)
Purging a resource that has already been purged (called twice) GetResource returns null — throw ArgumentException("Resource not found")

Backward Compatibility Considerations Existing Delete(INeo4jDossier) (used by DocumentManagementServiceBase.RemoveDossier) is kept as-is for the session management code paths. PurgeResource is a new, guarded alternative.


4. Implementation Plan

public async Task PurgeResource(string resourceId) {
    var resource = (await GetResource(resourceId))
        ?? throw new ArgumentException($"Resource not found: {resourceId}");

    bool isDeleted = resource.Kind == ResourceKind.Dossier
        ? resource.Dossier!.IsDeleted
        : resource.Document!.IsDeleted;

    if (!isDeleted)
        throw new InvalidOperationException(
            "Resource must be soft-deleted before it can be purged.");

    if (resource.Kind == ResourceKind.Dossier) {
        await Neo4jDocumentRepository.Singleton!.HardPurgeWithCascade(resourceId);
    } else {
        await Neo4jDocumentRepository.Singleton!.DeleteDocumentById(resourceId);
    }
}

HardPurgeWithCascade in Neo4jDocumentRepository:

public async Task HardPurgeWithCascade(string dossierId) {
    // Step 1: purge all descendants
    var purgeDescendants = @"
        MATCH (root:Dossier {id: $id})-[:CONTAINS*]->(child)
        DETACH DELETE child";
    // Step 2: purge the root dossier
    var purgeRoot = @"
        MATCH (root:Dossier {id: $id})
        DETACH DELETE root";
    var parameters = new Dictionary<string, object?> { { "id", dossierId } };
    await neo4jDataAccess.ExecuteWriteAsync(purgeDescendants, parameters);
    await neo4jDataAccess.ExecuteWriteAsync(purgeRoot, parameters);
}

5. Testing Strategy

Test Cases / Scenarios

# Scenario Expected Result
T1 PurgeResource on a soft-deleted document Document node gone from Neo4j
T2 PurgeResource on a soft-deleted dossier with children Dossier and all descendants gone
T3 PurgeResource on a resource that is NOT soft-deleted InvalidOperationException
T4 PurgeResource called twice Second call: ArgumentException (resource not found)

Acceptance Criteria - T1–T2: MATCH (n {id:$id}) RETURN count(n) = 0 after purge. - T2: descendants query also returns 0.

Implementation of these tests Integration tests against test Neo4j instance for T1–T4.


6. Post-Implementation

Date: 2026-05-04

What changed: - ThalabusCore/Services/ResourceService.cs — PurgeResource implemented. - Resolves the resource via GetResource; throws ArgumentException if not found. - Reads IsDeleted from the resolved resource; throws InvalidOperationException("Resource must be soft-deleted before it can be purged.") if the flag is false. - For a Dossier: calls DocumentRepository.HardPurgeWithCascade(resourceId) — two-step Cypher purge (descendants first, then the root node). - For a Document: calls DocumentRepository.DeleteDocumentById(resourceId) (pre-existing method). - ThalabusInterfaces/Data/Neo4j/INeo4jDocumentRepository.cs — added HardPurgeWithCascade(string dossierId) declaration. - Neo4jORM/DataAccess/Neo4jDocumentRepository.cs — implemented HardPurgeWithCascade: two sequential ExecuteWriteAsync calls — first MATCH (root:Dossier {id})-[:CONTAINS*]->(child) DETACH DELETE child, then MATCH (root:Dossier {id}) DETACH DELETE root.

Build result: ThalabusInterfaces, Neo4jORM, ThalabusCore, and ThalabusWeb all build with zero errors.

Test results: T1–T4 not yet run against a live Neo4j instance (require integration test environment). Logic verified by code review against the plan's acceptance criteria.


Task 8.10 — Detail

1. Task Overview

Short Description Implement an async background cron task (ResourcePurgeJob) that periodically queries for all soft-deleted resources whose deletedAt timestamp has exceeded the configured recovery window, and calls PurgeResource on each. The job runs in the background as a hosted service registered in Program.cs.

Objective / Expected Result - Resources soft-deleted more than RecoveryWindowDays ago are automatically hard-purged without any manual intervention. - The job runs on a configurable interval (default: once per day). - The recovery window duration is configurable via appsettings.json. - No session-type resources are touched (they are purged immediately in Task 7.2 and will not have isDeleted=true at the time the cron runs).


2. Context & Scope

Background / Reason for Change ACL_Design.md states: "Run the cleanup asynchronously once the recovery window has passed (system cron task)." The recovery window gives privileged users (DOCUMENT SUPER ADMIN) a chance to restore accidentally deleted resources. After it elapses, the cleanup is automatic.

In Scope - Creating ThalabusCore/Services/ResourcePurgeJob.cs implementing IHostedService (or BackgroundService). - Querying Neo4j for all Dossier and Document nodes with isDeleted=true AND deletedAt < (now - recoveryWindowDays). - Calling IResourceService.PurgeResource for each result. - Reading RecoveryWindowDays and PurgeIntervalHours from IConfiguration (with sensible defaults). - Logging each purge action and any errors.

Out of Scope - UI to configure the recovery window — configuration file only. - Per-resource recovery window overrides — all resources share the global window. - Purging session resources — those are hard-deleted immediately in Task 7.2. - Purging type=root or type=user dossiers — they are never soft-deleted; the query will never find them.


3. Impact Analysis

Affected Components / Modules

Component Change
ThalabusCore/Services/ResourcePurgeJob.cs New file
ThalabusWeb/Program.cs Register ResourcePurgeJob as hosted service (Task 8.10.1)
Neo4jORM/DataAccess/Neo4jDocumentRepository.cs New GetResourcesPendingPurge(DateTime olderThan) query method
appsettings.json (or environment config) New section ResourcePurge with RecoveryWindowDays and PurgeIntervalHours

Dependencies

Task Reason
Task 8.9 PurgeResource must be implemented before the cron can call it
Task 8.1 IResourceService must be injectable

Risks & Edge Cases

Risk Mitigation
Cron runs while the application is under heavy load Use a configurable interval; log start/end times; skip a cycle if the previous one is still running (via a SemaphoreSlim)
A resource is restored during the purge window (race condition) PurgeResource checks isDeleted=true before deleting; if restored in the meantime, GetResource will return the node with isDeleted=false and PurgeResource will throw InvalidOperationException — catch and log, continue with next resource
RecoveryWindowDays = 0 (immediate purge) Allowed but flagged as a warning in startup logs — any soft-deleted resource is immediately eligible for purge

Backward Compatibility Considerations New hosted service. No existing code is affected. If ResourcePurge section is absent from config, defaults apply (7-day window, 24-hour interval).


4. Implementation Plan

GetResourcesPendingPurge in Neo4jDocumentRepository:

public async Task<IEnumerable<string>> GetResourcesPendingPurge(DateTime olderThan) {
    var query = @"
        MATCH (n) WHERE (n:Dossier OR n:Document)
          AND n.isDeleted = true
          AND n.deletedAt < $olderThan
        RETURN n.id AS resourceId";
    var parameters = new Dictionary<string, object?> {
        { "olderThan", olderThan.ToString("yyyy-MM-ddTHH:mm:ssZ") }
    };
    // ... execute and collect resourceId strings
}

ResourcePurgeJob.cs:

public class ResourcePurgeJob : BackgroundService {
    private readonly IResourceService ResourceService;
    private readonly IConfiguration Configuration;
    private readonly SemaphoreSlim PurgeLock = new SemaphoreSlim(1, 1);

    public ResourcePurgeJob(IResourceService resourceService, IConfiguration configuration) {
        ResourceService = resourceService;
        Configuration = configuration;
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken) {
        int intervalHours = Configuration.GetValue("ResourcePurge:PurgeIntervalHours", 24);
        while (!stoppingToken.IsCancellationRequested) {
            await Task.Delay(TimeSpan.FromHours(intervalHours), stoppingToken);
            await RunPurgeCycle();
        }
    }

    private async Task RunPurgeCycle() {
        if (!await PurgeLock.WaitAsync(0)) return; // skip if already running
        try {
            int windowDays = Configuration.GetValue("ResourcePurge:RecoveryWindowDays", 7);
            var cutoff = DateTime.UtcNow.AddDays(-windowDays);
            var ids = await Neo4jDocumentRepository.Singleton!.GetResourcesPendingPurge(cutoff);
            foreach (var id in ids) {
                try {
                    await ResourceService.PurgeResource(id);
                    Log.Info($"[ResourcePurgeJob] Purged resource {id}");
                } catch (Exception ex) {
                    Log.Warning($"[ResourcePurgeJob] Skipped resource {id}: {ex.Message}");
                }
            }
        } finally {
            PurgeLock.Release();
        }
    }
}

appsettings.json section:

"ResourcePurge": {
  "RecoveryWindowDays": 7,
  "PurgeIntervalHours": 24
}

5. Testing Strategy

Test Cases / Scenarios

# Scenario Expected Result
T1 Resource soft-deleted more than RecoveryWindowDays ago Purged in the next cron cycle
T2 Resource soft-deleted within RecoveryWindowDays Not purged in the cron cycle
T3 Resource restored before cron runs PurgeResource throws InvalidOperationException; job logs warning and continues
T4 Cron called while previous cycle still running Second invocation is skipped via SemaphoreSlim
T5 RecoveryWindowDays = 0 All soft-deleted resources are immediately eligible for purge

Acceptance Criteria - T1: resource nodes absent from Neo4j after purge cycle. - T3: job does not crash; continues to purge remaining eligible resources. - T4: no duplicate purge attempts.

Implementation of these tests Unit tests with mock IResourceService and mock time for T1–T5. Integration test with a short recovery window (1 minute) against test Neo4j for end-to-end verification.


6. Post-Implementation

Date: 2026-05-05

Status: Skipped by user decision. The async purge cron (ResourcePurgeJob) will not be implemented at this stage. Hard-purge remains a manual operation via IResourceService.PurgeResource (Task 8.9). This task can be revisited when automated cleanup becomes a priority.


Task 8.10.1 — Detail

1. Task Overview

Short Description Register IResourceService (→ ResourceService) as a singleton and ResourcePurgeJob as a hosted service in ThalabusWeb/Program.cs, so both are available to the DI container and the purge job starts automatically with the application.

Objective / Expected Result - IResourceService is resolvable from the DI container throughout the application. - ResourcePurgeJob starts when the application starts and is cancelled gracefully on shutdown.


2. Context & Scope

In Scope - Adding builder.Services.AddSingleton<IResourceService, ResourceService>(); in Program.cs. - Adding builder.Services.AddHostedService<ResourcePurgeJob>(); in Program.cs.

Out of Scope - Any other DI registration changes. - Configuration validation at startup (recovery window values are read lazily by the job).


3. Impact Analysis

Affected Components / Modules

Component Change
ThalabusWeb/Program.cs Two new service registrations

Dependencies

Task Reason
Task 8.1 IResourceService must be declared
Task 8.10 ResourcePurgeJob must exist

Risks & Edge Cases

Risk Mitigation
ResourcePurgeJob starts before Neo4j is ready The job's first run is delayed by PurgeIntervalHours — Neo4j is typically ready well before then. For safety, wrap the first RunPurgeCycle in a try-catch that logs and continues.

Backward Compatibility Considerations Additive — two new registrations. No existing registrations are changed.


4. Implementation Plan

In ThalabusWeb/Program.cs, in the singleton services block, add:

builder.Services.AddSingleton<IResourceService, ResourceService>();
builder.Services.AddHostedService<ResourcePurgeJob>();

Also add the required using directives:

using ThalabusCore.Services;   // ResourceService, ResourcePurgeJob

5. Testing Strategy

Test Cases / Scenarios

# Scenario Expected Result
T1 Application starts No DI resolution exceptions for IResourceService or ResourcePurgeJob
T2 Inject IResourceService in a Blazor page Resolves correctly

Acceptance Criteria - Application starts without exceptions. - IResourceService resolves in the DI container.

Implementation of these tests Integration / startup test verifying DI container resolution.


6. Post-Implementation

Date: 2026-05-05

What changed: - ThalabusWeb/Program.cs — builder.Services.AddSingleton<IResourceService, ResourceService>(); was already present at line 86 (added during a prior session alongside INeo4jDocumentRepository). No code change was required. - ResourcePurgeJob hosted service registration is omitted because Task 8.10 was skipped.

Build result: No change needed — IResourceService registration was already in place and the project builds clean.

Test results: T1 verified — application starts without DI resolution exceptions for IResourceService. T2 (inject in a Blazor page) not explicitly tested but resolution is guaranteed by the singleton registration.


Task 9 — ACL Entities

# Task Status
9.1 Define Permission type implemented
9.2 Define RoleType enum + Role interface implemented
9.3 Define ACLEntry interface implemented
9.4 Neo4j migration — create OWNS (Group→Resource) relationship todo

Task 9.1 — Detail

1. Task Overview

Short Description Create a C# Permission enum in a new ThalabusInterfaces/ACL/ namespace, representing the six resource-level permissions defined in ACL_Design.md: Read, Sharing, Delete, Add, Update, and Ownership. This is a pure type declaration with no runtime logic.

Objective / Expected Result - A file ThalabusInterfaces/ACL/Permission.cs exists with the Permission enum. - The six values map exactly to the ACL design specification. - All downstream ACL types (IRole, IAclEntry) and the ACL service (Task 10) compile against this enum.


2. Context & Scope

Background / Reason for Change ACL_Design.md defines six resource-level permissions (READ, SHARING, DELETE, ADD, UPDATE, OWNERSHIP) that control what operations a group can perform on a dossier or document. The existing INeo4jCapability system is an application-level feature-flag mechanism and serves a completely different purpose — it is not replaced by this enum. The Permission enum is a new, separate concept dedicated to resource ACL.

The permissions are referenced in ACLEntry (the assignment of a role/permissions to a group on a resource), in IRole (fixed permission sets per role type), and in IACLService.CheckAccess (the runtime gate).

In Scope - Defining the Permission enum with six values in namespace ThalabusInterfaces.ACL. - Creating the directory ThalabusInterfaces/ACL/ if it does not exist. - Using C# PascalCase names (Read, Sharing, Delete, Add, Update, Ownership) for the enum values, consistent with C# conventions while mapping directly to the design's uppercase names.

Out of Scope - Any runtime logic, helper methods, or extension methods on Permission. - Changes to the existing INeo4jCapability / INeo4jRole system — these coexist unchanged. - Implementing any service that uses Permission — that is Task 10.


3. Impact Analysis

Affected Components / Modules

Component Change
ThalabusInterfaces/ACL/Permission.cs New file

Dependencies None — this is a standalone type declaration.

Risks & Edge Cases

Risk Mitigation
Name clash with existing System.Security.Permissions namespace Use fully qualified namespace ThalabusInterfaces.ACL to avoid any ambiguity
Delete enum member conflicts with System.Data.DeleteRule or similar via using directives in consuming files Fully qualify as ThalabusInterfaces.ACL.Permission.Delete where needed

Backward Compatibility Considerations New file, no existing callers. No breaking changes.


4. Implementation Plan

Create ThalabusInterfaces/ACL/Permission.cs:

namespace ThalabusInterfaces.ACL {

    /// <summary>
    /// Resource-level ACL permissions as defined in ACL_Design.md.
    /// These are distinct from application-level capabilities (INeo4jCapability).
    /// </summary>
    public enum Permission {
        /// <summary>View / open a document or folder.</summary>
        Read,
        /// <summary>Manage permissions on a document or folder (sharing).</summary>
        Sharing,
        /// <summary>Delete a document or folder.</summary>
        Delete,
        /// <summary>Add documents or sub-folders inside a folder.</summary>
        Add,
        /// <summary>Rename a document or folder.</summary>
        Update,
        /// <summary>Transfer ownership of a folder by moving it.</summary>
        Ownership
    }
}

5. Testing Strategy

Test Cases / Scenarios

# Scenario Expected Result
T1 Solution compiles after adding the file No compile errors
T2 IRole and IAclEntry (Tasks 9.2, 9.3) reference Permission No missing-type compile errors

Acceptance Criteria - File exists; solution compiles cleanly.

Implementation of these tests Compile-time verification at Tasks 9.2 and 9.3.


6. Post-Implementation

Placeholder — to be populated after implementation.

What changed: Permission.cs created

Test results: outcomes of T1–T2


Task 9.2 — Detail

1. Task Overview

Short Description Create ThalabusInterfaces/ACL/IRole.cs containing the RoleType enum, the IRole interface, and the companion static AclRoles class that exposes the five pre-defined role instances with their canonical permission sets as specified in ACL_Design.md. Keeping RoleType and IRole in the same file avoids a one-type-per-file split for two types that are tightly coupled by design.

Objective / Expected Result - RoleType enum with five values exists in ThalabusInterfaces/ACL/IRole.cs. - IRole is declared with Name (RoleType) and Permissions (IReadOnlyList<Permission>). - AclRoles exposes five static readonly instances: SuperAdmin, Owner, Admin, Manager, Viewer, each with the correct permission set. - Callers can look up the canonical role for a RoleType value via AclRoles.For(RoleType roleType). - IAclEntry.Role (Task 9.3) and the ACL service (Task 10) compile against this file.


2. Context & Scope

Background / Reason for Change Roles in the ACL design are not ad-hoc — they are a fixed, closed set with pre-defined permissions. RoleType is solely an input discriminator for IRole; it has no independent consumers. Co-locating both in IRole.cs reflects this coupling and removes unnecessary file navigation. The existing INeo4jRole is a Neo4j node whose name is a free-form user-defined string — a completely different concept (application feature access); the ACL RoleType is a closed system-defined set and must not be conflated with it.

Roles from ACL_Design.md:

Role Permissions
SUPER_ADMIN Read, Sharing, Delete, Add, Update, Ownership
OWNER Read, Sharing, Delete, Add, Update, Ownership
ADMIN Read, Sharing, Delete, Add, Update
MANAGER Read, Sharing, Add, Update
VIEWER Read

In Scope - Defining RoleType enum (5 values, PascalCase) in ThalabusInterfaces/ACL/IRole.cs. - Defining IRole interface in the same file. - Defining AclRoles static class in the same file with five pre-built instances and a For(RoleType) lookup. - Internal AclRole sealed record implementing IRole.

Out of Scope - A standalone RoleType.cs file — RoleType lives in IRole.cs only. - Allowing callers to create custom roles — roles are a closed system-defined set. - Storing IRole as a Neo4j node — roles are never persisted in the graph. SuperAdmin identity is determined by capability traversal ((Group)-[:HAS_ROLE]->(:UserRole)-[:HAS_CAPABILITY]->(:UserCapability:SystemAcl {id: 'tenant-admin'})), not by any property or ACL Role abstraction. - Any changes to the existing INeo4jRole system.


3. Impact Analysis

Affected Components / Modules

Component Change
ThalabusInterfaces/ACL/IRole.cs New file (RoleType enum + IRole interface + AclRoles class + AclRole record)

Dependencies

Task Reason
Task 9.1 Permission enum must be declared

Risks & Edge Cases

Risk Mitigation
AclRoles.For called with an unrecognised RoleType value Throw ArgumentOutOfRangeException — no fallback; the caller must pass a valid enum value

Backward Compatibility Considerations New file. No existing callers.


4. Implementation Plan

Create ThalabusInterfaces/ACL/IRole.cs:

namespace ThalabusInterfaces.ACL {

    /// <summary>
    /// Fixed ACL role types as defined in ACL_Design.md.
    /// Each role has a pre-defined set of Permissions.
    /// These are distinct from application-level Neo4j roles (INeo4jRole).
    /// Serialised as a string on Neo4j [:GRANT] relationship properties.
    /// </summary>
    public enum RoleType {
        /// <summary>Full access to all resources in the tenant; bypasses isProtected.</summary>
        SuperAdmin,
        /// <summary>Full control over owned resources.</summary>
        Owner,
        /// <summary>Read, Sharing, Delete, Add, Update — no Ownership transfer.</summary>
        Admin,
        /// <summary>Read, Sharing, Add, Update — no Delete or Ownership.</summary>
        Manager,
        /// <summary>Read only.</summary>
        Viewer
    }

    public interface IRole {
        RoleType Name { get; }
        IReadOnlyList<Permission> Permissions { get; }
    }

    /// <summary>
    /// Pre-defined canonical roles with their permission sets.
    /// Roles are a closed system-defined set and cannot be customised.
    /// </summary>
    public static class AclRoles {

        private sealed record AclRole(RoleType Name, IReadOnlyList<Permission> Permissions) : IRole;

        public static readonly IRole SuperAdmin = new AclRole(
            RoleType.SuperAdmin,
            new[] { Permission.Read, Permission.Sharing, Permission.Delete,
                    Permission.Add, Permission.Update, Permission.Ownership });

        public static readonly IRole Owner = new AclRole(
            RoleType.Owner,
            new[] { Permission.Read, Permission.Sharing, Permission.Delete,
                    Permission.Add, Permission.Update, Permission.Ownership });

        public static readonly IRole Admin = new AclRole(
            RoleType.Admin,
            new[] { Permission.Read, Permission.Sharing, Permission.Delete,
                    Permission.Add, Permission.Update });

        public static readonly IRole Manager = new AclRole(
            RoleType.Manager,
            new[] { Permission.Read, Permission.Sharing,
                    Permission.Add, Permission.Update });

        public static readonly IRole Viewer = new AclRole(
            RoleType.Viewer,
            new[] { Permission.Read });

        public static IRole For(RoleType roleType) => roleType switch {
            RoleType.SuperAdmin => SuperAdmin,
            RoleType.Owner      => Owner,
            RoleType.Admin      => Admin,
            RoleType.Manager    => Manager,
            RoleType.Viewer     => Viewer,
            _ => throw new ArgumentOutOfRangeException(nameof(roleType), roleType, "Unknown RoleType")
        };
    }
}

5. Testing Strategy

Test Cases / Scenarios

# Scenario Expected Result
T1 Enum.Parse<RoleType>("SuperAdmin", ignoreCase: true) Returns RoleType.SuperAdmin
T2 RoleType.Viewer.ToString() Returns "Viewer"
T3 AclRoles.Owner.Permissions Contains all 6 permissions
T4 AclRoles.Viewer.Permissions Contains only Permission.Read
T5 AclRoles.Admin.Permissions Does NOT contain Permission.Ownership
T6 AclRoles.Manager.Permissions Does NOT contain Permission.Delete or Permission.Ownership
T7 AclRoles.For(RoleType.Viewer) Returns the same instance as AclRoles.Viewer
T8 AclRoles.For((RoleType)999) Throws ArgumentOutOfRangeException

Acceptance Criteria - T1–T8 pass. - AclRoles.SuperAdmin.Permissions == AclRoles.Owner.Permissions (same set, different role name — design intent).

Implementation of these tests Unit tests in ThalabusUnitTests/ACL/AclRolesTests.cs (covers both RoleType serialisation and AclRoles permission sets).


6. Post-Implementation

Placeholder — to be populated after implementation.

What changed: IRole.cs created with RoleType enum, IRole interface, and AclRoles static class

Test results: outcomes of T1–T8


Task 9.3 — Detail

1. Task Overview

Short Description Create an IAclEntry interface in ThalabusInterfaces/ACL/ representing a single ACL grant: the assignment of a Role (with optional permission overrides) to a Group (identified by GroupId string) on a specific resource. This is the in-memory contract that the ACL service reads when evaluating access. A companion AclEntry record provides the default implementation.

Objective / Expected Result - IAclEntry is declared with ResourceId, GroupId, Role, PermissionOverrides?, and UpdatedAt. - An AclEntry record implementing IAclEntry is available for construction. - The ACL service (Task 10) uses IEnumerable<IAclEntry> as the return type of GetAcl(resourceId).


2. Context & Scope

Background / Reason for Change ACL_Technical_Interfaces.md defines the following contract (C# equivalent):

public interface IAclEntry {
    string ResourceId { get; }
    string GroupId { get; }                        // always a Group ID — users are never granted directly
    IRole Role { get; }
    IReadOnlyList<Permission>? PermissionOverrides { get; } // null = use role defaults
    DateTime UpdatedAt { get; }
}

Role is a C# abstraction only — it is never stored in the Neo4j graph. When an IAclEntry with Role = Manager is persisted, the ACL service creates one [:GRANT:<PERMISSION>] typed relationship per effective permission (e.g. [:GRANT:READ], [:GRANT:SHARING], [:GRANT:ADD], [:GRANT:UPDATE]). When read back, the permission set is reconstructed from the typed relationships and matched to the closest canonical role via ResolveRoleFromPermissions. The permissions override allows granting a group more or fewer permissions than the standard role provides.

In Scope - Defining IAclEntry in ThalabusInterfaces/ACL/IAclEntry.cs. - Defining AclEntry record (implementing IAclEntry) for construction by the repository layer. - PermissionOverrides is nullable — null means "use the role's canonical permissions"; a non-null list replaces them entirely. - Documenting the effective permissions logic: PermissionOverrides ?? Role.Permissions.

Out of Scope - Neo4j persistence of IAclEntry (reading/writing [:GRANT] relationships) — covered by Task 10.7 (GetAcl, SetAcl, AddPermission, RemovePermission). No prior migration script is required; [:GRANT] relationships are created at runtime. - Validation that the group exists in Neo4j — performed at write time by the ACL service. - Multi-resource or wildcard ACL entries — not supported in this design.


3. Impact Analysis

Affected Components / Modules

Component Change
ThalabusInterfaces/ACL/IAclEntry.cs New file (IAclEntry interface + AclEntry record)

Dependencies

Task Reason
Task 9.1 Permission enum must be declared
Task 9.2 RoleType enum and IRole must be declared

Risks & Edge Cases

Risk Mitigation
PermissionOverrides is an empty list [] vs null An empty list means "no permissions granted" (effectively deny); null means "use role defaults". Both are valid; the ACL service must distinguish them.
One group has multiple [:GRANT:<PERMISSION>] relationships to the same resource This is expected — one relationship per permission (e.g. [:GRANT:READ], [:GRANT:SHARING]). AddPermission uses APOC apoc.merge.relationship to ensure idempotency: calling it twice for the same (group, resource, permission) updates the existing relationship in place.

Backward Compatibility Considerations New file. No existing callers.


4. Implementation Plan

Create ThalabusInterfaces/ACL/IAclEntry.cs:

namespace ThalabusInterfaces.ACL {

    /// <summary>
    /// A single ACL entry — assigns a Role (with optional permission overrides) to a Group on a specific resource.
    /// Role is a C# abstraction only: in Neo4j, each effective permission is stored as a separate typed relationship
    /// (e.g. [:GRANT:READ], [:GRANT:SHARING]) carrying only grantedAt and grantedBy as metadata.
    /// </summary>
    public interface IAclEntry {
        /// <summary>The ID of the resource (Dossier or Document) this entry applies to.</summary>
        string ResourceId { get; }

            /// <summary>
        /// The ID of the Group that is granted this entry.
        /// Always a Group ID — users are never granted directly; they act through group membership.
        /// </summary>
        string GroupId { get; }

        /// <summary>The role assigned to the group on this resource.</summary>
        IRole Role { get; }

        /// <summary>
        /// Optional explicit permission list that overrides the role's canonical permissions.
        /// Null means the role's default permissions apply.
        /// An empty list means no permissions are granted (effectively deny).
        /// </summary>
        IReadOnlyList<Permission>? PermissionOverrides { get; }

        /// <summary>Effective permissions: overrides if present, otherwise role's canonical set.</summary>
        IReadOnlyList<Permission> EffectivePermissions =>
            PermissionOverrides ?? Role.Permissions;

        DateTime UpdatedAt { get; }

        /// <summary>
        /// The ID of the effective user (user or system principal) who created this grant.
        /// Corresponds to the grantedBy property on the [:GRANT] relationship in Neo4j
        /// as specified in ACL_Design.md.
        /// </summary>
        string? GrantedBy { get; }
    }

    /// <summary>Default implementation of IAclEntry for use by the repository layer.</summary>
    public sealed record AclEntry(
        string ResourceId,
        string GroupId,
        IRole Role,
        IReadOnlyList<Permission>? PermissionOverrides,
        DateTime UpdatedAt,
        string? GrantedBy = null
    ) : IAclEntry;
}

5. Testing Strategy

Test Cases / Scenarios

# Scenario Expected Result
T1 AclEntry with PermissionOverrides = null EffectivePermissions == Role.Permissions
T2 AclEntry with PermissionOverrides = [Permission.Read] EffectivePermissions == [Permission.Read] (overrides role)
T3 AclEntry with PermissionOverrides = [] (empty) EffectivePermissions is empty — effectively no access
T4 Two AclEntry instances with same values Record equality returns true

Acceptance Criteria - T1–T4 pass; solution compiles.

Implementation of these tests Unit tests in ThalabusUnitTests/ACL/AclEntryTests.cs.


6. Post-Implementation

Placeholder — to be populated after implementation.

What changed: IAclEntry.cs created with AclEntry record

Test results: outcomes of T1–T4


Task 9.4 — Detail

1. Task Overview

Short Description Write and execute a Cypher migration script that creates (Group)-[:OWNS]->(Dossier) edges in Neo4j for all existing user-provisioned dossiers that do not yet have an [:OWNS] edge. Ownership for user dossiers is derived by traversing (u:User)-[:MEMBER_OF]->(g:Group {isUserGroup:true})-[:OWNS]->(d:Dossier {type:'user'}) edges provisioned by Task 4. The ACL service (Task 10) will traverse the resulting edges when evaluating ownership.

Objective / Expected Result - For every user Dossier (type = user) linked to a Group via (u:User)-[:MEMBER_OF]->(g:Group {isUserGroup:true}), a (g:Group)-[:OWNS]->(dossier) edge exists. - Dossiers/documents without derivable ownership remain without an [:OWNS] edge — they are treated as "unowned" (accessible only by SUPER_ADMIN). - The migration is idempotent. - Script stored at Neo4jORM/migrations/008_acl_owns_relationship.cypher.


2. Context & Scope

Background / Reason for Change The [:OWNS] relationship was freed up for (Group)-[:OWNS]->(Resource) semantics in Task 1. Tasks 4.1–4.2 provision user groups and dossiers, creating [:OWNS] edges at provisioning time. This migration backfills [:OWNS] edges for user dossiers that were provisioned before Task 4's implementation — deriving ownership by traversing (u:User)-[:MEMBER_OF]->(g:Group {isUserGroup:true}) to the existing dossier linked under that group.

In Scope - Creating (g:Group {isUserGroup:true})-[:OWNS]->(d:Dossier {type:'user'}) for all Group/Dossier pairs reachable via (u:User)-[:MEMBER_OF]->(g) where the [:OWNS] edge does not yet exist. - Creating a Neo4j index on [:OWNS] for efficient ownership lookups. - Surfacing resources where a Group has a MEMBER_OF from a user but no [:OWNS]->(Dossier {type:'user'}) edge (data quality check).

Out of Scope - Assigning ownership to non-user dossiers or documents — that requires user/admin decisions and is out of scope. - Handling the (Group)-[:OWNS]->(Group) or (Group)-[:OWNS]->(User) variants — [:OWNS] is exclusively between a Group and a Dossier/Document resource.


3. Impact Analysis

Affected Components / Modules

Component Change
Neo4j live database [:OWNS] edges created for resources identified via (User)-[:MEMBER_OF]->(Group) traversal; index on [:OWNS]

Dependencies

Task Reason
Task 1 [:OWNS] must have been freed from its old semantics (Dossier→Document, Session→Dossier) before this migration can safely use the relationship name for Group→Resource
Task 3.3 Group nodes must exist in Neo4j before their IDs can be used as relationship endpoints
Task 4.4 All users must have MEMBER_OF edges to their hidden Group (by backfill or fresh provisioning)

Risks & Edge Cases

Risk Mitigation
A resource already has an [:OWNS] edge from a previous partial run MERGE is idempotent — no duplicate edges
Large number of resources causing timeout Use CALL { ... } IN TRANSACTIONS OF 500 ROWS

Backward Compatibility Considerations Purely additive. No existing queries use (Group)-[:OWNS]->(Resource) — the relationship is new.


4. Implementation Plan

Migration script — store at Neo4jORM/migrations/008_acl_owns_relationship.cypher:

// Step 1 — Baseline counts
MATCH (u:User)-[:MEMBER_OF]->(g:Group {isUserGroup: true})
RETURN count(DISTINCT u) AS users_with_provisioned_groups;

MATCH (g:Group)-[:OWNS]->(r) RETURN count(*) AS existing_owns_edges;

// Step 2 — Orphan check: Groups with MEMBER_OF but no OWNS→Dossier
MATCH (u:User)-[:MEMBER_OF]->(g:Group {isUserGroup: true})
WHERE NOT EXISTS { MATCH (g)-[:OWNS]->(:Dossier {type: 'user'}) }
RETURN u.id AS user_id, g.id AS group_id;
// Investigate any rows returned before proceeding.

// Step 3 — Create OWNS edges for user-provisioned dossiers
CALL {
  MATCH (u:User)-[:MEMBER_OF]->(g:Group {isUserGroup: true})
  MATCH (g)-[:OWNS]->(d:Dossier {type: 'user'})
  WHERE NOT EXISTS { MATCH (g)-[:OWNS]->(d) }
  MERGE (g)-[:OWNS]->(d)
} IN TRANSACTIONS OF 500 ROWS;

// Step 4 — Create index for efficient ownership lookups
CREATE INDEX acl_owns_group IF NOT EXISTS
FOR ()-[r:OWNS]-()
ON (r);
// Note: Neo4j 5.x supports relationship indexes; adjust syntax for Neo4j 4.x if needed.

// Step 5 — Verify
MATCH (g:Group)-[:OWNS]->(r) RETURN count(*) AS total_owns_edges;

// Dossiers with type=user that still have no [:OWNS] edge
MATCH (d:Dossier {type: 'user'}) WHERE NOT EXISTS { MATCH (g:Group)-[:OWNS]->(d) }
RETURN count(d) AS user_dossiers_still_missing_owns;  // Should be 0 (excluding acknowledged orphans)

5. Testing Strategy

Test Cases / Scenarios

# Scenario Expected Result
T1 User with MEMBER_OF edge to Group and Group's dossier already provisioned [:OWNS] edge confirmed or created
T2 User dossier already has [:OWNS] edge MERGE is idempotent — no duplicate edge
T3 Run migration twice Idempotent — no duplicate edges
T4 ACL service can traverse (Group)-[:OWNS]->(Resource) after migration Ownership query returns correct group

Acceptance Criteria - Step 5 verification query: user_dossiers_still_missing_owns = 0 (excluding acknowledged orphans from Step 2). - Step 2 orphan queries reviewed and either return 0 rows or each orphan is documented.

Implementation of these tests Run Step 2 and 6 verification queries as part of deployment checklist. Integration test in ThalabusUnitTests covering T1–T5 against a test Neo4j instance.


6. Post-Implementation

Placeholder — to be populated after implementation.

What changed: edge counts before/after; orphan report

Test results: outcomes of T1–T6


Task 10 — ACL Service

# Task Status
10.1 Define IAclService interface implemented
10.2 Implement CheckAccess implemented
10.3 Implement GetEffectivePermissions implemented
10.4 Implement permission inheritance traversal (child inherits from parent via CONTAINS) implemented
10.5 Implement isProtected inheritance-break logic implemented
10.6 Implement SUPER_ADMIN bypass logic implemented
10.7 Implement GetAcl / SetAcl / AddPermission / RemovePermission implemented
10.7.1 Register IAclService in Program.cs implemented

Task 10.1 — Detail

1. Task Overview

Short Description Create the IAclService interface in ThalabusInterfaces/Services/ declaring the full ACL service contract: access checking, effective permission resolution, and ACL entry management.

Objective / Expected Result - IAclService exists with six async methods matching ACL_Technical_Interfaces.md. - All methods are Task-returning so callers can await the Neo4j I/O that backs each operation. - AclService (Tasks 10.2–10.7) and any test mocks implement this interface.


2. Context & Scope

Background / Reason for Change The ACL service is the single gateway through which all access decisions flow. Defining the interface first lets all other components (Resource Service, Blazor UI, test doubles) depend on the abstraction rather than the concrete implementation. It also establishes the method signatures before the more complex traversal logic is written.

In Scope - Declaring the six methods below in ThalabusInterfaces/Services/IAclService.cs. - Using async-compatible return types (Task<bool>, Task<IReadOnlyList<Permission>>, etc.) throughout. - XML documentation comments for each method.

Out of Scope - Any implementation — covered by Tasks 10.2–10.7. - Defining the ACL domain types (Permission, IAclEntry) — these are already defined in Tasks 9.1–9.3. - ACL enforcement at existing call sites — covered by Task 12.


3. Impact Analysis

Affected Components / Modules

Component Change
ThalabusInterfaces/Services/IAclService.cs New file

Dependencies

Task Reason
Task 9.1 Permission enum must be declared
Task 9.3 IAclEntry must be declared

Risks & Edge Cases None — pure interface declaration.

Backward Compatibility Considerations New file; no existing callers. No breaking changes.


4. Implementation Plan

Create ThalabusInterfaces/Services/IAclService.cs:

using ThalabusInterfaces.ACL;

namespace ThalabusInterfaces.Services {

    /// <summary>
    /// Gate for all resource-level access decisions.
    /// All operations that read or mutate a resource must call CheckAccess before proceeding.
    /// </summary>
    public interface IAclService {

        /// <summary>
        /// Returns true if the user holds the specified permission on the resource.
        /// Evaluates inherited permissions (via CONTAINS ancestry) and SUPER_ADMIN bypass.
        /// </summary>
        Task<bool> CheckAccess(string userId, string resourceId, Permission permission);

        /// <summary>
        /// Returns the full set of permissions the user holds on the resource
        /// after evaluating inheritance, isProtected barriers, and SUPER_ADMIN bypass.
        /// </summary>
        Task<IReadOnlyList<Permission>> GetEffectivePermissions(string userId, string resourceId);

        /// <summary>
        /// Returns all ACL entries (GRANT relationships) directly assigned to the resource.
        /// Does not include inherited entries from ancestor resources.
        /// </summary>
        Task<IEnumerable<IAclEntry>> GetAcl(string resourceId);

        /// <summary>
        /// Replaces ALL ACL entries on the resource with the provided set.
        /// Existing GRANT relationships are deleted and re-created atomically.
        /// Requires the caller to have already checked SHARING permission.
        /// </summary>
        Task SetAcl(string resourceId, IEnumerable<IAclEntry> entries, string effectiveUserId);

        /// <summary>
        /// Adds or updates a single GRANT relationship for the given entry's subject on the resource.
        /// Uses MERGE — if a grant for the same (group, resource) already exists, it is updated.
        /// Requires the caller to have already checked SHARING permission.
        /// </summary>
        Task AddPermission(IAclEntry entry, string effectiveUserId);

        /// <summary>
        /// Removes the GRANT relationship between the specified group and the resource.
        /// No-op if no grant exists for the group.
        /// Requires the caller to have already checked SHARING permission.
        /// </summary>
        Task RemovePermission(string resourceId, string groupId, string effectiveUserId);
    }
}

5. Testing Strategy

Test Cases / Scenarios

# Scenario Expected Result
T1 Solution compiles with all 9.x types in place No compile errors
T2 A mock IAclService can be registered in DI for unit tests No DI or reflection errors

Acceptance Criteria - File exists; solution compiles cleanly.

Implementation of these tests Compile-time only at this task. Full behavioural tests added in Tasks 10.2–10.7.


6. Post-Implementation

Placeholder — to be populated after implementation.

What changed: IAclService.cs created

Test results: compile-time verification


Task 10.2 — Detail

1. Task Overview

Short Description Create AclService in ThalabusCore/Services/ and implement CheckAccess(userId, resourceId, permission) — the primary ACL gate. The method resolves the user's effective permissions on the given resource (delegating to GetEffectivePermissions) and returns whether the requested permission is present.

Objective / Expected Result - CheckAccess returns true if the user holds the requested permission on the resource (directly or by inheritance or via SUPER_ADMIN bypass); false otherwise. - SUPER_ADMIN users always get true. - Non-existent users or resources return false (no exception).


2. Context & Scope

Background / Reason for Change CheckAccess is the single boolean gate that all resource operations will call (Task 12). It must be fast (index-backed Neo4j queries), deterministic, and cover the three permission resolution paths: direct grant, inherited grant, and SUPER_ADMIN bypass.

In Scope - Declaring the AclService class implementing IAclService. - Constructor taking IServiceProvider (to resolve INeo4jDataAccess) and IGroupService. - CheckAccess implemented as: get effective permissions → return permissions.Contains(permission). - The class file at ThalabusCore/Services/AclService.cs (shared by Tasks 10.2–10.7).

Out of Scope - Implementing GetEffectivePermissions logic (Task 10.3). - Implementing SUPER_ADMIN bypass detection (Task 10.6) — both are called from CheckAccess but implemented in their own tasks. - Stub the unimplemented methods with throw new NotImplementedException() at this stage.


3. Impact Analysis

Affected Components / Modules

Component Change
ThalabusCore/Services/AclService.cs New file; CheckAccess implemented; all other methods stubbed

Dependencies

Task Reason
Task 10.1 IAclService interface must exist
Task 9.1 Permission enum
Task 3.2 IGroupService.GetGroups(userId) used to get user's groups

Risks & Edge Cases

Risk Mitigation
userId maps to no groups in Neo4j GetGroups returns empty; effective permissions is empty; CheckAccess returns false
resourceId not found GetAcl returns empty set; effective permissions is empty; CheckAccess returns false

Backward Compatibility Considerations New file. No existing code is modified.


4. Implementation Plan

Create ThalabusCore/Services/AclService.cs with the following initial structure:

using ThalabusInterfaces.Services;
using ThalabusInterfaces.ACL;
using ThalabusInterfaces.Data.Neo4j.DataAccess;
using Microsoft.Extensions.DependencyInjection;

namespace ThalabusCore.Services {
    public class AclService : IAclService {

        private readonly IServiceProvider ServiceProvider;
        private readonly IGroupService GroupService;

        public AclService(IServiceProvider serviceProvider, IGroupService groupService) {
            ServiceProvider = serviceProvider;
            GroupService = groupService;
        }

        // Task 10.2
        public async Task<bool> CheckAccess(string userId, string resourceId, Permission permission) {
            var effectivePermissions = await GetEffectivePermissions(userId, resourceId);
            return effectivePermissions.Contains(permission);
        }

        // Task 10.3 — implemented in Task 10.3
        public Task<IReadOnlyList<Permission>> GetEffectivePermissions(string userId, string resourceId)
            => throw new NotImplementedException();

        // Task 10.7 — implemented in Task 10.7
        public Task<IEnumerable<IAclEntry>> GetAcl(string resourceId)
            => throw new NotImplementedException();

        public Task SetAcl(string resourceId, IEnumerable<IAclEntry> entries, string effectiveUserId)
            => throw new NotImplementedException();

        public Task AddPermission(IAclEntry entry, string effectiveUserId)
            => throw new NotImplementedException();

        public Task RemovePermission(string resourceId, string groupId, string effectiveUserId)
            => throw new NotImplementedException();
    }
}

5. Testing Strategy

Test Cases / Scenarios

# Scenario Expected Result
T1 CheckAccess with mocked GetEffectivePermissions returning [Read], requesting Read Returns true
T2 CheckAccess with mocked GetEffectivePermissions returning [Read], requesting Delete Returns false
T3 CheckAccess with mocked GetEffectivePermissions returning [] (empty) Returns false for any permission

Acceptance Criteria - T1–T3 pass. - CheckAccess never throws for valid inputs.

Implementation of these tests Unit tests in ThalabusUnitTests/ACL/AclServiceCheckAccessTests.cs. Mock IGroupService and IAclService.GetEffectivePermissions (partial mock or separate test helper).


6. Post-Implementation

Date: 2026-05-05

What changed: - ThalabusCore/Services/AclService.cs — new file created. - AclService implements IAclService; exposes public static IAclService? Singleton. - Constructor injects IServiceProvider (for future Neo4j access) and IGroupService. - CheckAccess implemented: delegates to GetEffectivePermissions and returns permissions.Contains(permission). - All other interface methods (GetEffectivePermissions, GetAcl, SetAcl, AddPermission, RemovePermission) stubbed with throw new NotImplementedException("Implemented in Task 10.x") as placeholders for Tasks 10.3 and 10.7. - ThalabusWeb/Program.cs — added builder.Services.AddSingleton<IAclService, AclService>(); after IResourceService registration (must follow IGroupService).

Build result: ThalabusInterfaces, Neo4jORM, ThalabusCore, and ThalabusWeb all build with zero errors.

Test results: T1–T3 not yet run — CheckAccess delegates to GetEffectivePermissions which is not yet implemented (Task 10.3). Unit tests against a mock IAclService will be written once Task 10.3 is complete.


Task 10.3 — Detail

1. Task Overview

Short Description Implement GetEffectivePermissions(userId, resourceId) in AclService. This method collects all permissions the user holds on the given resource by: (1) resolving the user's groups, (2) applying SUPER_ADMIN bypass, (3) fetching the resource's own direct grants, (4) walking the [:CONTAINS] ancestor chain to collect inherited grants (with isProtected barriers and short-circuit), and (5) returning the union of all collected permissions.

Objective / Expected Result - Returns IReadOnlyList<Permission> containing every permission the user holds on the resource. - SUPER_ADMIN users receive all six permissions regardless of actual grants. - Protected resources use only their own direct grants (no ancestor traversal). - Non-protected resources inherit from ancestors up to (and including) the first protected ancestor.


2. Context & Scope

Background / Reason for Change GetEffectivePermissions is the core of the ACL engine. It encapsulates the full resolution logic described in ACL_Design.md and is called by both CheckAccess (for a binary yes/no) and any permission-enumeration UI that needs to show which actions are available to the user.

The resolution order is: 1. SUPER_ADMIN bypass → return all permissions immediately (Task 10.6) 2. Fetch direct grants on the resource for any of the user's groups 3. If resource is NOT isProtected: walk ancestor chain (Task 10.4), stopping at the first protected ancestor (inclusive) (Task 10.5) 4. Union all collected permission sets → deduplicate → return

In Scope - Implementing GetEffectivePermissions in AclService. - Delegating to IsTenantAdmin(groupIds) (Task 10.6) as a first check. - Delegating to GetDirectGrants(resourceId, groupIds) (internal helper) for step 2. - Delegating to GetInheritedPermissions(resourceId, groupIds) (Task 10.4) for step 3. - Checking isProtected on the resource node before invoking ancestor traversal (Task 10.5).

Out of Scope - The ancestor traversal itself — implemented in Task 10.4. - isProtected barrier logic in the traversal — implemented in Task 10.5. - SUPER_ADMIN detection — implemented in Task 10.6. - Caching effective permissions — not in scope for this plan; can be added later.


3. Impact Analysis

Affected Components / Modules

Component Change
ThalabusCore/Services/AclService.cs Replace GetEffectivePermissions stub

Dependencies

Task Reason
Task 9.3 IAclEntry.EffectivePermissions provides the per-entry computed set
Task 10.6 IsTenantAdmin(groupIds) must be callable
Task 10.4 GetInheritedPermissions must be callable
Task 10.5 IsResourceProtected(resourceId) must be callable
Task 10.7 GetDirectGrants(resourceId, groupIds) uses the same Cypher pattern as GetAcl
Task 3.2 IGroupService.GetGroups(userId)

Risks & Edge Cases

Risk Mitigation
User belongs to many groups — Cypher IN list becomes large Neo4j handles IN efficiently with indexed Group IDs; no pagination needed at this scale
Resource has no direct grants and no protected ancestor — full tree walk Bounded by tree depth; acceptable for typical dossier structures
Permission duplicates in the union (same permission from two different groups) Use Distinct() before returning

Backward Compatibility Considerations Changes are within the new AclService class; no existing code is modified.


4. Implementation Plan

Replace the GetEffectivePermissions stub in AclService with:

public async Task<IReadOnlyList<Permission>> GetEffectivePermissions(
    string userId, string resourceId) {

    // Step 1: resolve user's groups
    var groups = (await GroupService.GetGroups(userId)).ToList();
    var groupIds = groups.Select(g => g.Id).ToList();

    // Step 2: SUPER_ADMIN bypass (Task 10.6)
    if (await IsTenantAdmin(groupIds))
        return AclRoles.SuperAdmin.Permissions;

    // Step 3: direct grants on this resource for any of the user's groups
    var directEntries = await GetDirectGrants(resourceId, groupIds);
    var permissions = directEntries
        .SelectMany(e => e.EffectivePermissions)
        .ToHashSet();

    // Step 4: inherited grants (skipped if resource is protected — Task 10.5)
    var isProtected = await IsResourceProtected(resourceId);
    if (!isProtected) {
        var inherited = await GetInheritedPermissions(resourceId, groupIds);
        foreach (var p in inherited) permissions.Add(p);
    }

    return permissions.ToList();
}

Add private helper GetDirectGrants(string resourceId, IList<string> groupIds):

private async Task<IEnumerable<IAclEntry>> GetDirectGrants(
    string resourceId, IList<string> groupIds) {

    var neo4j = ServiceProvider.GetRequiredService<INeo4jDataAccess>();
    var query = @"
        MATCH (g:Group)-[grant]->(r {id: $resourceId})
        WHERE g.id IN $groupIds
          AND type(grant) STARTS WITH 'GRANT:'
        RETURN g.id AS groupId,
               type(grant) AS grantType,
               grant.grantedAt AS grantedAt,
               grant.grantedBy AS grantedBy";

    var parameters = new Dictionary<string, object?> {
        { "resourceId", resourceId },
        { "groupIds", groupIds }
    };

    var records = await neo4j.ExecuteReadVectorAsync(query, parameters);
    return MapToAclEntries(records, resourceId);
}

private IEnumerable<IAclEntry> MapToAclEntries(
    IEnumerable<IDictionary<string, object?>> records, string resourceId) {
    // Group rows by GroupId — multiple rows per group are expected (one per [:GRANT:<PERMISSION>] relationship).
    return records
        .GroupBy(r => r["groupId"]?.ToString() ?? string.Empty)
        .Select(g => {
            var permissions = g
                .Select(r => r["grantType"]?.ToString()?.Replace("GRANT:", string.Empty))
                .Where(p => !string.IsNullOrEmpty(p))
                .Select(p => Enum.Parse<Permission>(p!, ignoreCase: true))
                .ToList();
            // Use the most recent grantedAt across all relationships as UpdatedAt.
            var updatedAt = g
                .Select(r => r["grantedAt"] is string s
                    ? DateTime.Parse(s, null, System.Globalization.DateTimeStyles.RoundtripKind)
                    : DateTime.MinValue)
                .Max();
            var grantedBy = g.First().ContainsKey("grantedBy")
                ? g.First()["grantedBy"]?.ToString()
                : null;
            // Infer canonical role from the permission set; fall back to PermissionOverrides
            // if no canonical role matches exactly.
            var (role, overrides) = ResolveRoleFromPermissions(permissions);
            return (IAclEntry)new AclEntry(
                ResourceId:          resourceId,
                GroupId:             g.Key,
                Role:                role,
                PermissionOverrides: overrides,
                UpdatedAt:           updatedAt,
                GrantedBy:           grantedBy
            );
        });
}

/// <summary>
/// Attempts to match a permission set to a canonical ACL role.
/// If the set matches a role exactly, returns (role, null) — no overrides needed.
/// Otherwise returns (AclRoles.Viewer, permissions) so EffectivePermissions remains correct.
/// </summary>
private static (IRole Role, IReadOnlyList<Permission>? Overrides)
    ResolveRoleFromPermissions(IReadOnlyList<Permission> permissions) {
    foreach (var candidate in new[] {
        AclRoles.SuperAdmin, AclRoles.Owner, AclRoles.Admin, AclRoles.Manager, AclRoles.Viewer })
    {
        if (candidate.Permissions.Count == permissions.Count
            && candidate.Permissions.All(p => permissions.Contains(p)))
            return (candidate, null);
    }
    // Custom permission set — use Viewer as the base role; PermissionOverrides carries the effective set.
    return (AclRoles.Viewer, permissions);
}

Add private helper IsResourceProtected(string resourceId):

private async Task<bool> IsResourceProtected(string resourceId) {
    var neo4j = ServiceProvider.GetRequiredService<INeo4jDataAccess>();
    var query = @"
        MATCH (r {id: $resourceId})
        RETURN coalesce(r.isProtected, false) AS isProtected";
    var parameters = new Dictionary<string, object?> { { "resourceId", resourceId } };
    var result = await neo4j.ExecuteReadScalarAsync<bool>(query, parameters);
    return result;
}

5. Testing Strategy

Test Cases / Scenarios

# Scenario Expected Result
T1 User in group with Owner grant on resource (not protected) All 6 permissions returned
T2 User in group with Viewer grant on resource Only Read returned
T3 User in group with Manager grant + resource is protected Only Manager permissions (no ancestors consulted)
T4 User in group with Viewer grant on resource; parent has Admin grant for same group Union: Read (direct) + Admin permissions = [Read, Sharing, Delete, Add, Update]
T5 User has no groups Returns []
T6 Resource does not exist Returns []
T7 SUPER_ADMIN user Returns all 6 permissions regardless of resource state

Acceptance Criteria - T1–T7 pass. - Distinct() ensures no duplicate permissions in the return value.

Implementation of these tests Unit tests in ThalabusUnitTests/ACL/AclServiceEffectivePermissionsTests.cs. Mock IGroupService and INeo4jDataAccess to inject controlled responses.


6. Post-Implementation

Date: 2026-05-05

What changed: - ThalabusCore/Services/AclService.cs — GetEffectivePermissions fully implemented plus all private helpers required by Task 10.3: - GetEffectivePermissions: resolves user groups, delegates to IsTenantAdmin (stub), collects GetDirectGrants, checks IsResourceProtected, conditionally calls GetInheritedPermissions (stub), returns deduplicated union as IReadOnlyList<Permission>. - GetDirectGrants: Cypher MATCH (g:Group)-[grant]->(r {id}) WHERE g.id IN $groupIds AND type(grant) STARTS WITH 'GRANT:' — returns one row per [:GRANT:<PERMISSION>] relationship; rows are grouped by groupId and mapped via MapToAclEntries. - MapToAclEntries: groups rows by groupId, parses grantType suffix into Permission enum values, picks the latest grantedAt as UpdatedAt, calls ResolveRoleFromPermissions to pick the canonical role (or fall back to Viewer + PermissionOverrides). - ResolveRoleFromPermissions: matches permission set against all five canonical roles in descending privilege order; returns (role, null) on exact match, (Viewer, customSet) otherwise. - IsResourceProtected: MATCH (r {id}) RETURN coalesce(r.isProtected, false) — single scalar read. - IsTenantAdmin (Task 10.6 stub): always returns false. - GetInheritedPermissions (Task 10.4 stub): always returns Enumerable.Empty<Permission>(). - Added using Microsoft.Extensions.DependencyInjection; and using ThalabusInterfaces.Data.Neo4j.DataAccess; to using directives.

Build result: ThalabusInterfaces, Neo4jORM, ThalabusCore, and ThalabusWeb all build with zero errors.

Test results: T1–T7 not yet run against a live Neo4j instance. Logic verified by code review against the plan. Note: SUPER_ADMIN bypass (T7) and permission inheritance (T4) will return incorrect results until Tasks 10.6 and 10.4 are implemented respectively.


Task 10.4 — Detail

1. Task Overview

Short Description Implement GetInheritedPermissions(resourceId, groupIds) — a private helper in AclService that walks the [:CONTAINS] ancestor chain from the given resource toward the root, collecting all ACL grants for the specified groups at each ancestor level. The walk terminates at the first isProtected ancestor node (inclusive — that ancestor's grants are collected but no further ancestors are consulted).

Objective / Expected Result - Returns the union of all permissions inherited from ancestor resources. - Respects the isProtected barrier: stops at the nearest protected ancestor (inclusive). - Returns an empty collection if the resource is the root or has no ancestor grants.


2. Context & Scope

Background / Reason for Change ACL_Design.md specifies that sub-folders and documents inherit permissions from their parent folder. This traversal is the mechanism behind that inheritance: rather than copying grants onto every child at write time (fan-out), permissions are accumulated at read time by walking up the containment tree. The isProtected barrier ensures that confidential sections of the tree do not inadvertently expose their ancestors' grants to viewers below the barrier.

In Scope - Implementing GetInheritedPermissions as a private async Task<IEnumerable<Permission>> in AclService. - Using a single Cypher query to retrieve all ancestors ordered by proximity (nearest first), then iterating in C# to apply the isProtected barrier and collect grants. - Each ancestor's grants are fetched with GetDirectGrants(ancestorId, groupIds).

Out of Scope - isProtected on the resource itself — handled by GetEffectivePermissions (Task 10.3) before calling this method. - Caching or memoisation of ancestor chains — out of scope.


3. Impact Analysis

Affected Components / Modules

Component Change
ThalabusCore/Services/AclService.cs New private method GetInheritedPermissions

Dependencies

Task Reason
Task 10.3 GetDirectGrants helper must exist (also from 10.3)
Task 10.5 isProtected barrier applied here
Task 1 [:CONTAINS] must be the active containment relationship (former [:OWNS] for Dossier→Document)

Risks & Edge Cases

Risk Mitigation
Cycle in the containment graph (should not happen, but possible due to data error) Cypher variable-length path will raise a stack-overflow risk; set a max depth of 50 on the Cypher query
Root dossier has no [:OWNS] relationship Cypher collects grants via [:GRANT] only; no [:OWNS] edge for root causes no extra grant match → correct
Very deep folder hierarchy (100+ levels) Max depth cap of 50 is enforced in Cypher pattern

Backward Compatibility Considerations Private helper; no existing callers outside AclService.


4. Implementation Plan

Add the following private method to AclService:

private async Task<IEnumerable<Permission>> GetInheritedPermissions(
    string resourceId, IList<string> groupIds) {

    // Step 1: Get all ancestors ordered by proximity (nearest first),
    //         with a maximum depth cap of 50 to guard against pathological data.
    var neo4j = ServiceProvider.GetRequiredService<INeo4jDataAccess>();
    var ancestorQuery = @"
        MATCH path = (r {id: $resourceId})<-[:CONTAINS*1..50]-(ancestor)
        RETURN ancestor.id AS ancestorId,
               coalesce(ancestor.isProtected, false) AS isProtected,
               length(path) AS depth
        ORDER BY depth ASC";

    var ancestorParams = new Dictionary<string, object?> { { "resourceId", resourceId } };
    var ancestorRecords = await neo4j.ExecuteReadVectorAsync(ancestorQuery, ancestorParams);

    // Step 2: Walk ancestors nearest-first; collect grants; stop at first protected ancestor.
    var inherited = new HashSet<Permission>();
    foreach (var record in ancestorRecords) {
        var ancestorId  = record["ancestorId"]?.ToString() ?? string.Empty;
        var isProtected = record["isProtected"] is bool b && b;

        var ancestorGrants = await GetDirectGrants(ancestorId, groupIds);
        foreach (var entry in ancestorGrants)
            foreach (var p in entry.EffectivePermissions)
                inherited.Add(p);

        if (isProtected) break; // inclusive barrier: include this ancestor's grants then stop
    }

    return inherited;
}

5. Testing Strategy

Test Cases / Scenarios

# Scenario Expected Result
T1 Resource with unprotected parent that has Admin grant for group Parent's permissions collected
T2 Resource with three ancestor levels, all unprotected All three levels' grants are unioned
T3 Resource with grandparent that is protected Nearest unprotected parent's grants collected; protected grandparent's grants collected (inclusive); great-grandparent NOT included
T4 Resource with no ancestors (root or direct child of root with no grants) Returns empty collection
T5 Depth exceeds 50 levels Ancestors beyond depth 50 are not traversed (Cypher limit enforces this)

Acceptance Criteria - T1–T5 pass. - The isProtected barrier is inclusive (the protected ancestor's own grants are included but nothing above it).

Implementation of these tests Unit tests in ThalabusUnitTests/ACL/AclServiceInheritanceTests.cs. Mock INeo4jDataAccess to return controlled ancestor records and grants.


6. Post-Implementation

Date: 2026-05-05

What changed: - ThalabusCore/Services/AclService.cs — replaced the GetInheritedPermissions stub with a full implementation: - Cypher: MATCH path = (r {id: $resourceId})<-[:CONTAINS*1..50]-(ancestor) RETURN ancestor.id, coalesce(ancestor.isProtected, false), length(path) ORDER BY depth ASC — fetches all ancestors up to depth 50, nearest first. - C# loop: calls GetDirectGrants(ancestorId, groupIds) for each ancestor and unions all EffectivePermissions into a HashSet<Permission>. - isProtected barrier: after collecting the current ancestor’s grants, if ancestor.isProtected == true, breaks out of the loop (inclusive — the protected ancestor’s own grants are included but nothing above it). - Depth cap of 50 in the Cypher pattern guards against pathological data or accidental cycles.

Build result: ThalabusInterfaces, Neo4jORM, ThalabusCore, and ThalabusWeb all build with zero errors.

Test results: T1–T5 not yet run against a live Neo4j instance. Logic verified by code review against the plan. Note: IsTenantAdmin (Task 10.6) remains a stub returning false, so the SUPER_ADMIN bypass path through inheritance is not yet active.


Task 10.5 — Detail

1. Task Overview

Short Description Document and verify the isProtected inheritance-break logic across both GetEffectivePermissions (Task 10.3) and GetInheritedPermissions (Task 10.4). Confirm that the two-level semantics are correctly implemented: (a) a protected resource ignores all ancestor grants and uses only its own; (b) a protected ancestor during traversal stops propagation of grants from further ancestors while including its own.

Objective / Expected Result - No code changes beyond what Tasks 10.3 and 10.4 already produce. - The logic is verified end-to-end through dedicated integration test scenarios. - isProtected semantics are confirmed to match ACL_Design.md.


2. Context & Scope

Background / Reason for Change The isProtected flag is the ACL design's "override feature" — it allows a folder or document to be sealed against inheritance from ancestors. This is used for confidential items inside shared folders. Getting this logic exactly right is critical: an off-by-one error in the traversal cut-off point could either leak permissions (under-protection) or wrongly deny access (over-protection). A dedicated task ensures this logic is explicitly tested, not just incidentally covered.

In Scope - Validating the two distinct isProtected scenarios: 1. The target resource itself is isProtected = true → GetEffectivePermissions must NOT call GetInheritedPermissions; only direct grants on the target resource apply. 2. An ancestor in the traversal chain has isProtected = true → GetInheritedPermissions includes that ancestor's grants but does NOT continue to ancestors above it. - Integration tests covering both scenarios with a real or mocked Neo4j graph.

Out of Scope - Changing how isProtected is set or cleared on nodes — covered by Task 8.7 (SetProtected/Unprotect). - Cascading isProtected to children — the flag is per-resource; children are not affected. - Requiring SHARING permission to set isProtected — already specified in Task 8.7 and documented in ACL_Design.md.


3. Impact Analysis

Affected Components / Modules

Component Change
ThalabusCore/Services/AclService.cs No new code — logic verified through tests
ThalabusUnitTests/ACL/AclServiceProtectionTests.cs New test file

Dependencies

Task Reason
Task 10.3 GetEffectivePermissions with IsResourceProtected check
Task 10.4 GetInheritedPermissions with isProtected barrier

Risks & Edge Cases

Risk Mitigation
Entire path to root is protected nodes Traversal stops at the first protected ancestor (inclusive); only that ancestor's grants propagate downward
Resource is protected AND has no direct grants User gets no permissions (empty set). SUPER_ADMIN bypass still applies (Task 10.6).
isProtected flag not present on old nodes (before migration) coalesce(r.isProtected, false) in all Cypher queries defaults to false — safe fallback

Backward Compatibility Considerations No code changes. Existing resources without isProtected default to false via Cypher coalesce.


4. Implementation Plan

No code to add — logic is already in place from Tasks 10.3 and 10.4. This task consists of:

  1. Review that GetEffectivePermissions calls IsResourceProtected(resourceId) and skips GetInheritedPermissions when the result is true.
  2. Review that GetInheritedPermissions breaks after collecting grants from a protected ancestor.
  3. Add dedicated integration tests (see Testing Strategy below).

Verify the control flow:

GetEffectivePermissions(userId, resourceId):
  └─ IsResourceProtected(resourceId) → true?
       YES: return directGrants only (no inheritance)
       NO:
         └─ GetInheritedPermissions(resourceId, groupIds):
              for each ancestor (nearest first):
                collect grants
                if ancestor.isProtected → break (inclusive)

5. Testing Strategy

Test Cases / Scenarios

# Scenario Expected Result
T1 Target resource is isProtected = true; parent has broad grants Only target's own grants returned; parent's grants are NOT inherited
T2 Target resource is NOT protected; grandparent is protected Grants from: target (direct) + parent (not protected) + grandparent (protected, inclusive); great-grandparent NOT included
T3 Target resource is NOT protected; NO ancestor is protected All ancestor grants all the way to root are included
T4 Protected resource with no direct grants Returns []; SUPER_ADMIN bypass is the only way in
T5 SUPER_ADMIN accesses a protected resource with no direct grants Returns all 6 permissions

Acceptance Criteria - T1: parent grants are absent from the result. - T2: grandparent's grants are present; great-grandparent's grants are absent. - T4: empty result confirmed. - T5: all 6 permissions confirmed.

Implementation of these tests Integration tests in ThalabusUnitTests/ACL/AclServiceProtectionTests.cs using a mock Neo4j graph or a test-container Neo4j instance.


6. Post-Implementation

What changed: No new code beyond Tasks 10.3/10.4. Verified in ThalabusCore/Services/AclService.cs: GetEffectivePermissions skips GetInheritedPermissions when IsResourceProtected(resourceId) is true; GetInheritedPermissions breaks the ancestor loop immediately after collecting a protected ancestor's grants (inclusive barrier).

Test results: Control-flow verified by code review against T1–T5; dedicated integration tests in AclServiceProtectionTests.cs were not found in the repository as of this review — recommended as a follow-up if not already tracked elsewhere.


Task 10.6 — Detail

1. Task Overview

Short Description Implement IsTenantAdmin(groupIds) — a private helper in AclService that returns true if any of the supplied group IDs corresponds to a group that holds the tenant-admin capability via graph traversal. This check is the SUPER_ADMIN bypass gate used by GetEffectivePermissions and indirectly by CheckAccess.

Objective / Expected Result - IsTenantAdmin returns true if any group in groupIds is connected to a Capability {id: 'tenant-admin'} node via (Group)-[:HAS_ROLE]->(:UserRole)-[:HAS_CAPABILITY]->(:UserCapability:SystemAcl {id: 'tenant-admin'}). - GetEffectivePermissions calls this as its first check and returns all permissions immediately if true. - The bypass applies regardless of isProtected on any resource and regardless of any [:GRANT] relationships.


2. Context & Scope

Background / Reason for Change ACL_Design.md states: "The DOCUMENT SUPER ADMIN role grants access to all documents and folders in the tenant, whether the entity is protected or not." SuperAdmin is identified by capability traversal — the Group node has no tenantAdmin property. Exactly one tenant group exists per tenant; it is provisioned at bootstrap (Task 0.2) by creating the (Group)-[:HAS_ROLE]->(:UserRole)-[:HAS_CAPABILITY]->(:UserCapability:SystemAcl {id: 'tenant-admin'}) chain and has no [:GRANT:<PERMISSION>] relationships on any resource. Separating the check into its own helper keeps GetEffectivePermissions readable and makes the bypass easy to mock in tests.

In Scope - Implementing IsTenantAdmin(IList<string> groupIds) as a private async Task<bool> method in AclService. - One Cypher query: traverse (Group)-[:HAS_ROLE]->(:UserRole)-[:HAS_CAPABILITY]->(:UserCapability:SystemAcl {id: 'tenant-admin'}) filtering by groupIds. - Calling this at the top of GetEffectivePermissions (already scaffolded in Task 10.3).

Out of Scope - Provisioning the tenant group — done at bootstrap (Task 0.2). - Creating or managing [:GRANT] relationships for the tenant group — by design none exist. - Restricting which resources a SuperAdmin can act on — by design they bypass all resource-level checks.


3. Impact Analysis

Affected Components / Modules

Component Change
ThalabusCore/Services/AclService.cs New private method IsTenantAdmin

Dependencies

Task Reason
Task 0.2 tenant group capability chain must be provisioned before IsTenantAdmin can return true

Risks & Edge Cases

Risk Mitigation
groupIds is empty Short-circuit before the Cypher call → false; correct
tenant group capability chain accidentally removed All super-admin users lose full access. This is a data-integrity concern; the audit log (Task 11) tracks group modifications.

Backward Compatibility Considerations Private method; no external callers.


4. Implementation Plan

Add the following private method to AclService:

private async Task<bool> IsTenantAdmin(IList<string> groupIds) {
    if (groupIds.Count == 0) return false;

    var neo4j = ServiceProvider.GetRequiredService<INeo4jDataAccess>();
    // SuperAdmin is identified by capability traversal, not a node property.
    // A group holding the 'tenant-admin' capability bypasses all resource permission checks.
    var query = @"
        MATCH (g:Group)-[:HAS_ROLE]->(:UserRole)-[:HAS_CAPABILITY]->(:UserCapability:SystemAcl {id: 'tenant-admin'})
        WHERE g.id IN $groupIds
        RETURN count(*) > 0 AS isTenantAdmin";

    var parameters = new Dictionary<string, object?> { { "groupIds", groupIds } };
    var result = await neo4j.ExecuteReadScalarAsync<bool>(query, parameters);
    return result;
}

5. Testing Strategy

Test Cases / Scenarios

# Scenario Expected Result
T1 Group node connected to Capability {id: 'tenant-admin'} IsTenantAdmin returns true
T2 Group node has no [:HAS_ROLE] relationships (or chain does not reach tenant-admin) IsTenantAdmin returns false
T3 groupIds is empty IsTenantAdmin returns false (short-circuit before Cypher)
T4 CheckAccess for a SuperAdmin user on a protected resource with no direct grants Returns true
T5 GetEffectivePermissions for a SuperAdmin user Returns all 6 permissions without Neo4j resource lookup

Acceptance Criteria - T1–T5 pass. - IsTenantAdmin never inspects [:GRANT] relationships — confirmed by query review.

Implementation of these tests Unit tests in ThalabusUnitTests/ACL/AclServiceSuperAdminTests.cs. Mock INeo4jDataAccess to return controlled results. Integration test with test-container Neo4j confirms correct behaviour for T1 and T2.


6. Post-Implementation

What changed: IsTenantAdmin is implemented as INeo4jAclRepository.IsTenantAdmin(groupIds) (queried via the Neo4jAclRepository data-access layer rather than inline INeo4jDataAccess calls in AclService), and is invoked from AclService.GetEffectivePermissions as the first check, returning AclRoles.Owner.Permissions immediately on a match and logging SuperAdminAccessUsed.

Test results: Behavior confirmed via code review of AclService.cs and Neo4jAclRepository.cs; no dedicated AclServiceSuperAdminTests.cs file was found in the repository as of this review — recommended as a follow-up if not already tracked elsewhere.


Task 10.7 — Detail

1. Task Overview

Short Description Implement the four ACL management methods in AclService: GetAcl, SetAcl, AddPermission, and RemovePermission. These methods read and write [:GRANT] relationships between Group nodes and resource nodes (Dossier/Document), translating between the IAclEntry domain type and the [:GRANT] Neo4j relationship property schema.

Objective / Expected Result - GetAcl(resourceId) returns all IAclEntry instances directly assigned to the resource. - AddPermission(entry) upserts a single [:GRANT] relationship (MERGE semantics — one grant per group+resource pair). - RemovePermission(resourceId, subject) deletes the [:GRANT] relationship for the given group. - SetAcl(resourceId, entries) atomically replaces ALL grants on the resource with the provided set. - Callers are expected to have verified SHARING permission before invoking these methods (enforced at the call sites in Task 12).


2. Context & Scope

Background / Reason for Change GetEffectivePermissions (Task 10.3) fetches direct grants as part of permission resolution using an internal helper. GetAcl exposes the same data to external callers (e.g. a sharing UI that needs to list who has access). AddPermission, RemovePermission, and SetAcl are the mutation operations used when an admin grants or revokes access.

In Scope - GetAcl(resourceId): query all (g:Group)-[grant]->(r {id: $resourceId}) where type(grant) STARTS WITH 'GRANT:' and map to IEnumerable<IAclEntry>. Each row includes type(grant) (e.g. GRANT:READ) — the permission is encoded in the relationship type, not a property. - AddPermission(entry): create one [:GRANT:<PERMISSION>] relationship per effective permission in the entry (e.g. [:GRANT:READ], [:GRANT:SHARING]). APOC apoc.merge.relationship is used for dynamic relationship types. Each relationship stores only grantedAt and grantedBy as metadata — role is a C# abstraction and is not persisted in the graph. - RemovePermission(resourceId, subject): MATCH and DELETE all [:GRANT:*] relationships between the group and the resource. - SetAcl(resourceId, entries): in a single Cypher write, delete all existing grants on the resource then create new ones for each entry. If entries is empty, all grants are revoked. - Mapping IAclEntry ↔ Neo4j typed-relationship schema (serialize Permission[] as uppercase strings; reconstruct IRole via ResolveRoleFromPermissions).

Out of Scope - Permission pre-checks (SHARING required) — the caller is responsible; these methods are the write primitives. - Propagating grant changes to descendants (fan-out) — the design uses read-time traversal, so no fan-out is needed. - Audit logging of grant changes — covered by Task 11.4. - Ownership transfer — [:OWNS] relationship is managed by ResourceService.MoveResource, not by AclService.


3. Impact Analysis

Affected Components / Modules

Component Change
ThalabusCore/Services/AclService.cs Replace four NotImplementedException stubs

Dependencies

Task Reason
Task 9.3 IAclEntry, AclEntry used for mapping
Task 9.2 AclRoles.For(roleType) used to reconstruct IRole from stored string
Task 9.3 IAclEntry and AclEntry used for mapping results
Task 10.3 MapToAclEntry helper already introduced there; reused here

Risks & Edge Cases

Risk Mitigation
SetAcl with empty entries list — should revoke all access Delete-then-skip-insert branch; verified in tests
AddPermission called twice for the same (group, resource) MERGE is idempotent; second call updates properties in place
RemovePermission on a non-existent grant MATCH+DELETE finds nothing; Cypher is a no-op; no exception
SetAcl partial failure (delete succeeded, inserts fail halfway) Wrap in a single Cypher write transaction via ExecuteWriteAsync; Neo4j rolls back atomically on error
Resource node labels differ (Dossier vs Document) — Cypher must match both Use generic {id: $resourceId} without a label constraint in GRANT queries; both node types share the id property

Backward Compatibility Considerations Replaces NotImplementedException stubs. No existing production callers yet (Task 12 wires call sites).


4. Implementation Plan

Replace the four stub methods in AclService with the following:

GetAcl

public async Task<IEnumerable<IAclEntry>> GetAcl(string resourceId) {
    var neo4j = ServiceProvider.GetRequiredService<INeo4jDataAccess>();
    var query = @"
        MATCH (g:Group)-[grant]->(r {id: $resourceId})
        WHERE type(grant) STARTS WITH 'GRANT:'
        RETURN g.id AS groupId,
               type(grant) AS grantType,
               grant.grantedAt AS grantedAt,
               grant.grantedBy AS grantedBy";
    var parameters = new Dictionary<string, object?> { { "resourceId", resourceId } };
    var records = await neo4j.ExecuteReadVectorAsync(query, parameters);
    return MapToAclEntries(records, resourceId);
}

AddPermission

public async Task AddPermission(IAclEntry entry, string effectiveUserId) {
    var neo4j = ServiceProvider.GetRequiredService<INeo4jDataAccess>();
    // Each effective permission is stored as a separate [:GRANT:<PERMISSION>] relationship
    // (e.g. [:GRANT:READ], [:GRANT:SHARING]).  APOC is required for dynamic rel types.
    var query = @"
        MATCH (g:Group {id: $groupId}), (r {id: $resourceId})
        UNWIND $permissions AS perm
        CALL apoc.merge.relationship(g, 'GRANT:' + perm, {},
            {grantedAt: $grantedAt, grantedBy: $grantedBy}, r, {})
        YIELD rel
        RETURN count(rel)";

    var parameters = new Dictionary<string, object?> {
        { "groupId",     entry.GroupId },
        { "resourceId",  entry.ResourceId },
        { "permissions", entry.EffectivePermissions
                             .Select(p => p.ToString().ToUpperInvariant())
                             .ToArray() },
        { "grantedAt",   entry.UpdatedAt.ToString("o") },
        { "grantedBy",   entry.GrantedBy ?? effectiveUserId }
    };
    await neo4j.ExecuteWriteAsync(query, parameters);
}

RemovePermission

public async Task RemovePermission(string resourceId, string groupId, string effectiveUserId) {
    var neo4j = ServiceProvider.GetRequiredService<INeo4jDataAccess>();
    var query = @"
        MATCH (g:Group {id: $groupId})-[r]->(res {id: $resourceId})
        WHERE type(r) STARTS WITH 'GRANT:'
        DELETE r";
    var parameters = new Dictionary<string, object?> {
        { "groupId",    groupId },
        { "resourceId", resourceId }
    };
    await neo4j.ExecuteWriteAsync(query, parameters);
}

SetAcl

public async Task SetAcl(string resourceId, IEnumerable<IAclEntry> entries, string effectiveUserId) {
    var entryList = entries.ToList();
    var neo4j = ServiceProvider.GetRequiredService<INeo4jDataAccess>();

    // Build per-permission rows so we can UNWIND them in a single Cypher statement.
    var grantRows = entryList
        .SelectMany(entry => entry.EffectivePermissions.Select(p => new Dictionary<string, object?> {
            { "groupId",    entry.GroupId },
            { "permission", p.ToString().ToUpperInvariant() },
            { "grantedAt",  entry.UpdatedAt.ToString("o") },
            { "grantedBy",  entry.GrantedBy ?? effectiveUserId }
        }))
        .ToArray();

    // Atomic: delete all existing GRANT relationships on the resource and
    // re-create the new set in a single write transaction via APOC merge.
    var atomicQuery = @"
        MATCH (res {id: $resourceId})
        OPTIONAL MATCH (g_old:Group)-[r_old]->(res)
        WHERE type(r_old) STARTS WITH 'GRANT:'
        DELETE r_old
        WITH res
        UNWIND $grantRows AS row
        MATCH (g:Group {id: row.groupId})
        CALL apoc.merge.relationship(g, 'GRANT:' + row.permission, {},
            {grantedAt: row.grantedAt, grantedBy: row.grantedBy}, res, {})
        YIELD rel
        RETURN count(rel)";

    var parameters = new Dictionary<string, object?> {
        { "resourceId", resourceId },
        { "grantRows",  grantRows }
    };
    await neo4j.ExecuteWriteAsync(atomicQuery, parameters);
}

Note: The delete and re-create steps execute inside a single Neo4j write transaction. No intermediate state is observable between grant revocation and re-creation. If entries is empty, all existing grants are deleted and UNWIND over an empty list is a no-op — the resource ends up with no grants.


5. Testing Strategy

Test Cases / Scenarios

# Scenario Expected Result
T1 AddPermission for group G on resource R with role Manager One [:GRANT:<PERMISSION>] relationship per Manager permission exists, each with grantedAt and grantedBy
T2 AddPermission called twice for same (G, R) with same role Existing [:GRANT:<PERMISSION>] relationships are merged in place; grantedAt and grantedBy updated
T3 RemovePermission for group G on resource R [:GRANT] relationship no longer exists
T4 RemovePermission when no grant exists No error; no-op
T5 GetAcl(resourceId) returns all direct grants Each returned IAclEntry has correct GroupId, Role (inferred from permissions), EffectivePermissions
T6 SetAcl with two entries (e.g. Manager + Viewer) Existing grants deleted; new [:GRANT:<PERMISSION>] typed relationships created — one per effective permission per entry
T7 SetAcl with empty list All existing grants deleted; resource has no grants
T8 GetAcl after SetAcl Returns exactly the entries passed to SetAcl
T9 AddPermission with PermissionOverrides = [] (empty list) Grant created; EffectivePermissions is empty (deny all)

Acceptance Criteria - T1–T9 pass. - SetAcl is atomic — no partial state visible between delete and re-create within a single session.

Implementation of these tests Integration tests in ThalabusUnitTests/ACL/AclServiceGrantTests.cs against a test-container Neo4j instance (or mocked INeo4jDataAccess for unit tests). End-to-end scenario: AddPermission → GetAcl → RemovePermission → GetAcl (expect empty).


6. Post-Implementation

Date: 2026-05-05

What changed: - ThalabusCore/Services/AclService.cs — replaced all four NotImplementedException stubs: - GetAcl: MATCH (g:Group)-[grant]->(r {id}) WHERE type(grant) STARTS WITH 'GRANT:' RETURN groupId, grantType, grantedAt, grantedBy — reuses MapToAclEntries for mapping. - AddPermission: UNWINDs the effective permissions array and calls apoc.merge.relationship(g, 'GRANT:' + perm, {}, {grantedAt, grantedBy}, r, {}) — one typed relationship per permission; MERGE is idempotent. - RemovePermission: MATCH (g:Group {id})-[r]->(res {id}) WHERE type(r) STARTS WITH 'GRANT:' DELETE r — no-op when no grant exists. - SetAcl: single Cypher write transaction — OPTIONAL MATCH + DELETE all existing GRANT rels, then UNWIND $grantRows + apoc.merge.relationship to re-create the new set. If entries is empty the UNWIND is a no-op (all grants revoked atomically).

Build result: Zero CS errors. ThalabusWeb build clean.

Test results: T1–T9 not yet run against a live Neo4j instance. Logic verified by code review against plan. APOC availability required at runtime.


Task 10.7.1 — Detail

1. Task Overview

Short Description Register IAclService and AclService as a singleton in ThalabusWeb/Program.cs so that AclService can be injected into all call sites that enforce ACL (Task 12) and into any Blazor components that need to query effective permissions.

Objective / Expected Result - IAclService resolves from the DI container at application startup. - No InvalidOperationException at startup. - Registration is placed after IGroupService (Task 3.4.1) since AclService depends on IGroupService.


2. Context & Scope

Background / Reason for Change AclService depends on IGroupService (for GetGroups), which must already be registered. The registration order in Program.cs must respect this dependency chain.

In Scope - Adding builder.Services.AddSingleton<IAclService, AclService>() to Program.cs. - Ensuring placement after IGroupService registration.

Out of Scope - Any logic change in AclService.


3. Impact Analysis

Affected Components / Modules

Component Change
ThalabusWeb/Program.cs One new AddSingleton line

Dependencies

Task Reason
Task 10.2–10.7 AclService must be fully implemented
Task 3.4.1 IGroupService must be registered first

Risks & Edge Cases

Risk Mitigation
IAclService registered before IGroupService DI throws at resolution time; fix by ordering the lines correctly

Backward Compatibility Considerations Purely additive. No existing registrations are modified.


4. Implementation Plan

In ThalabusWeb/Program.cs, after the IGroupService registration line:

builder.Services.AddSingleton<IGroupService, GroupService>();   // Task 3.4.1
builder.Services.AddSingleton<IAclService, AclService>();       // Task 10.7.1 — must follow IGroupService

Add using ThalabusInterfaces.Services; and using ThalabusCore.Services; if not already present.


5. Testing Strategy

Test Cases / Scenarios

# Scenario Expected Result
T1 Application starts No DI exception
T2 app.Services.GetRequiredService<IAclService>() Returns AclService instance

Acceptance Criteria - Application starts without InvalidOperationException.

Implementation of these tests Application startup smoke test. Covered by the integration tests in Task 10.7.


6. Post-Implementation

Date: 2026-05-05

What changed: - ThalabusWeb/Program.cs line 87: builder.Services.AddSingleton<IAclService, AclService>(); — already added during Task 10.2. Registration placed after IGroupService to satisfy the constructor dependency.

Build result: Zero CS errors.

Test results: Application starts without InvalidOperationException. AclService.Singleton is set on first resolution. Startup smoke test passed.


Task 11 — Audit Service

# Task Status
11.1 Define AuditEventType enum implemented
11.2 Define IAuditService interface implemented
11.3 Implement AuditService.Log implemented
11.4 Wire audit calls into Resource Service, ACL Service, and Group Service implemented

Task 11.1 — Detail

1. Task Overview

Short Description Create a AuditEventType enum in ThalabusInterfaces/ACL/ declaring all 16 business-level audit event types defined in ACL_Technical_Interfaces.md. This is a pure type declaration with no runtime logic.

Objective / Expected Result - A file ThalabusInterfaces/ACL/AuditEventType.cs exists with all 16 PascalCase enum values. - IAuditService (Task 11.2) compiles against this enum.


2. Context & Scope

Background / Reason for Change ACL_Implementation_Gaps.md confirms that no structured audit trail exists today — only a developer-diagnostic Log.cs utility. The ACL design requires a queryable, typed audit trail of business events (permission grants, resource mutations, access decisions). AuditEventType is the closed set of event discriminators for this trail.

In Scope - Defining the AuditEventType enum with 16 values in namespace ThalabusInterfaces.ACL. - Using PascalCase names per C# conventions (e.g. ResourceCreated maps to 'RESOURCE_CREATED' in the TypeScript design).

Out of Scope - Any runtime logic, helper methods, or extension methods on AuditEventType. - Implementing the service that emits these events — covered by Task 11.3.


3. Impact Analysis

Affected Components / Modules

Component Change
ThalabusInterfaces/ACL/AuditEventType.cs New file

Dependencies None — standalone type declaration.

Risks & Edge Cases

Risk Mitigation
Future need to add new event types New values can be appended without breaking existing stored strings; no migration needed
Stored string value (e.g. "ResourceCreated") must round-trip via Enum.Parse Enum.Parse<AuditEventType>(str, ignoreCase: true) handles all 16 values; verified in Task 11.3 tests

Backward Compatibility Considerations New file. No existing callers.


4. Implementation Plan

Create ThalabusInterfaces/ACL/AuditEventType.cs:

namespace ThalabusInterfaces.ACL {

    /// <summary>
    /// Discriminator for all structured business-level audit events.
    /// Stored as a string on the AuditEvent Neo4j node (e.g. "ResourceCreated").
    /// Maps to the TypeScript AuditEventType union defined in ACL_Technical_Interfaces.md.
    /// </summary>
    public enum AuditEventType {
        /// <summary>A new Dossier or Document was created.</summary>
        ResourceCreated,
        /// <summary>A resource was moved to a different parent dossier.</summary>
        ResourceMoved,
        /// <summary>A resource was renamed.</summary>
        ResourceRenamed,
        /// <summary>A resource was soft-deleted.</summary>
        ResourceDeleted,
        /// <summary>A soft-deleted resource was restored.</summary>
        ResourceRestored,
        /// <summary>One or more [:GRANT:&lt;PERMISSION&gt;] typed relationships were created (group received permissions on a resource).</summary>
        PermissionGranted,
        /// <summary>One or more [:GRANT:&lt;PERMISSION&gt;] typed relationships were deleted (group's permissions on a resource were revoked).</summary>
        PermissionRevoked,
        /// <summary>A resource's isProtected flag was set to true.</summary>
        ResourceProtected,
        /// <summary>A resource's isProtected flag was cleared.</summary>
        ResourceUnprotected,
        /// <summary>The ownership ([:OWNS] edge) of a resource changed (e.g. via MoveResource into a differently-owned dossier).</summary>
        OwnershipChanged,
        /// <summary>A bulk ACL replacement (SetAcl) was performed on a resource.</summary>
        SharingActionPerformed,
        /// <summary>CheckAccess returned false for a user on a resource.</summary>
        AccessDenied,
        /// <summary>CheckAccess returned true for a user on a resource.</summary>
        AccessGranted,
        /// <summary>A SUPER_ADMIN bypassed isProtected or ACL enforcement on a resource.</summary>
        SuperAdminAccessUsed,
        /// <summary>A user was added to or removed from a group.</summary>
        GroupMembershipChanged,
        /// <summary>A user was removed from the tenant (and their hidden system group was dissolved).</summary>
        UserRemovedFromTenant
    }
}

5. Testing Strategy

Test Cases / Scenarios

# Scenario Expected Result
T1 Solution compiles after adding the file No compile errors
T2 Enum.Parse<AuditEventType>("ResourceCreated", ignoreCase: true) Returns AuditEventType.ResourceCreated
T3 AuditEventType.PermissionGranted.ToString() Returns "PermissionGranted"
T4 Enum.GetValues<AuditEventType>().Length Returns 16

Acceptance Criteria - T1–T4 pass; all 16 values present and round-trip through string serialisation.

Implementation of these tests Unit tests in ThalabusUnitTests/ACL/AuditEventTypeTests.cs.


6. Post-Implementation

Placeholder — to be populated after implementation.

What changed: AuditEventType.cs created

Test results: outcomes of T1–T4


Task 11.2 — Detail

1. Task Overview

Short Description Create the IAuditService interface in ThalabusInterfaces/Services/ declaring the single audit operation: emitting a structured audit event. Events are written to the existing Thalabus Log system using a dedicated AUDIT container (log file). They are not stored in Neo4j.

Objective / Expected Result - IAuditService exists with one method: Log(...). - All callers in ResourceService, AclService, and GroupService (Task 11.4) depend on this abstraction. - Audit events are findable in thalabus_AUDIT.log by grepping for the structured key [NAMESPACE.AuditEventType].


2. Context & Scope

Background / Reason for Change Audit events must be stored alongside other system logs and be retrievable by a structured key without introducing new infrastructure (no new database, no new store). The existing Log utility already writes rotating log files per containerName; using containerName = "AUDIT" produces a dedicated thalabus_AUDIT.log file that can be searched independently of the diagnostic log.

Namespace key scheme

Each log entry is prefixed with [NAMESPACE.AuditEventType], making it grep-searchable:

Namespace AuditEventType values
RESOURCE_LIFECYCLE ResourceCreated, ResourceMoved, ResourceRenamed, ResourceDeleted, ResourceRestored, ResourceProtected, ResourceUnprotected, OwnershipChanged
RESOURCE_PERMISSION PermissionGranted, PermissionRevoked, SharingActionPerformed
ACCESS_CONTROL AccessDenied, AccessGranted, SuperAdminAccessUsed
USER_MANAGEMENT GroupMembershipChanged, UserRemovedFromTenant

Example log entry produced in thalabus_AUDIT.log:

INFO      | 2026-04-27 10:23:45.123 | AUDIT | ... | [RESOURCE_PERMISSION.PermissionGranted] id=550e8400-... effectiveUserId=usr-1 resourceId=res-42 targetId=grp-7 metadata={"role":"OWNER"}

To find all permission events: grep "\[RESOURCE_PERMISSION\." thalabus_AUDIT.log To find a specific resource: grep "resourceId=res-42" thalabus_AUDIT.log

In Scope - Declaring IAuditService in ThalabusInterfaces/Services/IAuditService.cs with a single Log(...) method. - OccurredAt and a UUID id are assigned internally by the service.

Out of Scope - GetEvents / GetEventsByActor — log files are not a queryable store; retrieval is via external tools (grep, log aggregators). These methods are intentionally omitted. - Defining AuditEventType — covered by Task 11.1.


3. Impact Analysis

Affected Components / Modules

Component Change
ThalabusInterfaces/Services/IAuditService.cs New file

Dependencies

Task Reason
Task 11.1 AuditEventType enum must be declared

Risks & Edge Cases

Risk Mitigation
Log called from a high-frequency code path (e.g. CheckAccess) AccessGranted / AccessDenied events emitted selectively (see Task 11.4); not on every call
Log failure must not break the calling operation AuditService.Log catches all exceptions internally and falls back to DiagLog.Warning

Backward Compatibility Considerations New file. No existing callers.


4. Implementation Plan

Create ThalabusInterfaces/Services/IAuditService.cs:

using ThalabusInterfaces.ACL;

namespace ThalabusInterfaces.Services {

    /// <summary>
    /// Business-level audit trail service.
    /// Writes structured audit events to the system log file (AUDIT container).
    /// Events are findable by grepping for [NAMESPACE.AuditEventType] in thalabus_AUDIT.log.
    /// Distinct from the diagnostic Log utility — audit events represent business actions,
    /// not developer/operations diagnostics.
    /// </summary>
    public interface IAuditService {

        /// <summary>
        /// Records a new audit event. Assigns a UUID id and OccurredAt timestamp internally.
        /// Fire-and-forget: exceptions are swallowed inside the implementation.
        /// The call does not block on I/O (log write is synchronous but very fast).
        /// </summary>
        /// <param name="type">The type of business event — determines the log namespace key.</param>
        /// <param name="effectiveUserId">The user who performed the action.</param>
        /// <param name="resourceId">The primary resource involved (optional).</param>
        /// <param name="targetId">A secondary target, e.g. new parent dossier or group ID (optional).</param>
        /// <param name="metadata">Additional context key-value pairs (optional; values must be JSON-serialisable).</param>
        Task Log(
            AuditEventType type,
            string effectiveUserId,
            string? resourceId = null,
            string? targetId = null,
            IReadOnlyDictionary<string, object?>? metadata = null);
    }
}

5. Testing Strategy

Test Cases / Scenarios

# Scenario Expected Result
T1 Solution compiles after adding the file No compile errors
T2 AuditService implements IAuditService (Task 11.3) No missing-member errors
T3 Mock IAuditService resolves in DI container No DI resolution exceptions

Acceptance Criteria - T1–T3 pass; solution compiles.

Implementation of these tests Compile-time verification at Task 11.3. DI smoke test at Task 11.4.


6. Post-Implementation

Date: 2026-05-06

What changed: - ThalabusInterfaces/Services/IAuditService.cs — new file created. - Single method Task Log(AuditEventType eventType, string effectiveUserId, string? resourceId = null, string? targetId = null, Dictionary<string, object?>? metadata = null). - All parameters beyond eventType and effectiveUserId are optional — callers only supply what is relevant for the event type. - XML doc on the method and on the interface itself describing the log destination, namespace-key scheme, and the swallowed-failure contract.

Build result: ThalabusInterfaces, Neo4jORM, ThalabusCore, and ThalabusWeb all build with zero CS errors.

Test results: T1 (compile) — passed. T2 and T3 will be verified once AuditService is implemented in Task 11.3.


Task 11.3 — Detail

1. Task Overview

Short Description Create AuditService in ThalabusCore/Services/ and implement Log — the method that writes a structured audit entry to the existing Thalabus Log system using containerName = "AUDIT". This produces a dedicated thalabus_AUDIT.log file alongside the other system logs. No Neo4j writes; no new infrastructure.

Objective / Expected Result - A call to Log(type, effectiveUserId, ...) writes one line to thalabus_AUDIT.log in the format [NAMESPACE.AuditEventType] id=... effectiveUserId=... resourceId=... targetId=... metadata={...}. - The NAMESPACE is determined by the event type (see Task 11.2 namespace key table). - Failures are swallowed; the calling operation always continues normally.


2. Context & Scope

Background / Reason for Change Audit events must live alongside other system logs and be retrievable by a structured key. The existing Log utility supports a containerName parameter that routes entries to a dedicated file (thalabus_AUDIT.log), satisfying both requirements without new infrastructure.

In Scope - AuditService class in ThalabusCore/Services/AuditService.cs implementing IAuditService. - No constructor parameters — AuditService has no external dependencies (the Log utility is a static class). - Log(...) builds the structured message string, then calls DiagLog.Info(message, containerName: "AUDIT"). - The static ThalabusInterfaces.Data.Logging.Log class is aliased as DiagLog inside the file to avoid name collision with the IAuditService.Log(...) method. - AuditEventType → namespace mapping is defined as a static dictionary inside AuditService.

Out of Scope - Any Neo4j interaction — no INeo4jDataAccess dependency. - GetEvents / GetEventsByActor — log files are not a queryable store; these methods are not part of IAuditService (see Task 11.2).


3. Impact Analysis

Affected Components / Modules

Component Change
ThalabusCore/Services/AuditService.cs New file; full Log implementation

Dependencies

Task Reason
Task 11.2 IAuditService interface must be declared
Task 11.1 AuditEventType enum (for namespace mapping) must be declared

Risks & Edge Cases

Risk Mitigation
DiagLog.Info throws (e.g. disk full) Wrap the entire body in try/catch; swallow silently — the Log utility itself already swallows internally, but a double guard costs nothing
Metadata contains a non-serialisable type JsonSerializer.Serialize throws; caught by the outer try/catch; event is silently dropped
Name collision between static Log class and IAuditService.Log(...) method Resolved by aliasing the static class: using DiagLog = ThalabusInterfaces.Data.Logging.Log;

Backward Compatibility Considerations New file. No existing callers.


4. Implementation Plan

Create ThalabusCore/Services/AuditService.cs:

using System.Text.Json;
using ThalabusInterfaces.ACL;
using ThalabusInterfaces.Services;
using DiagLog = ThalabusInterfaces.Data.Logging.Log;

namespace ThalabusCore.Services {

    public class AuditService : IAuditService {

        private static readonly IReadOnlyDictionary<AuditEventType, string> NamespaceMap =
            new Dictionary<AuditEventType, string> {
                { AuditEventType.ResourceCreated,         "RESOURCE_LIFECYCLE" },
                { AuditEventType.ResourceMoved,           "RESOURCE_LIFECYCLE" },
                { AuditEventType.ResourceRenamed,         "RESOURCE_LIFECYCLE" },
                { AuditEventType.ResourceDeleted,         "RESOURCE_LIFECYCLE" },
                { AuditEventType.ResourceRestored,        "RESOURCE_LIFECYCLE" },
                { AuditEventType.ResourceProtected,       "RESOURCE_LIFECYCLE" },
                { AuditEventType.ResourceUnprotected,     "RESOURCE_LIFECYCLE" },
                { AuditEventType.OwnershipChanged,        "RESOURCE_LIFECYCLE" },
                { AuditEventType.PermissionGranted,       "RESOURCE_PERMISSION" },
                { AuditEventType.PermissionRevoked,       "RESOURCE_PERMISSION" },
                { AuditEventType.SharingActionPerformed,  "RESOURCE_PERMISSION" },
                { AuditEventType.AccessDenied,            "ACCESS_CONTROL" },
                { AuditEventType.AccessGranted,           "ACCESS_CONTROL" },
                { AuditEventType.SuperAdminAccessUsed,    "ACCESS_CONTROL" },
                { AuditEventType.GroupMembershipChanged,  "USER_MANAGEMENT" },
                { AuditEventType.UserRemovedFromTenant,   "USER_MANAGEMENT" },
            };

        public Task Log(
            AuditEventType type,
            string effectiveUserId,
            string? resourceId = null,
            string? targetId = null,
            IReadOnlyDictionary<string, object?>? metadata = null) {
            try {
                var ns = NamespaceMap.TryGetValue(type, out var n) ? n : "AUDIT";
                var id = Guid.NewGuid().ToString();
                var sb = new System.Text.StringBuilder();
                sb.Append($"[{ns}.{type}] id={id} effectiveUserId={effectiveUserId}");
                if (resourceId != null) sb.Append($" resourceId={resourceId}");
                if (targetId != null)   sb.Append($" targetId={targetId}");
                if (metadata != null)   sb.Append($" metadata={JsonSerializer.Serialize(metadata)}");
                DiagLog.Info(sb.ToString(), containerName: "AUDIT");
            } catch {
                // Swallow silently — audit failures must never interrupt the calling operation.
            }
            return Task.CompletedTask;
        }
    }
}

5. Testing Strategy

Test Cases / Scenarios

# Scenario Expected Result
T1 Log(PermissionGranted, effectiveUserId, resourceId, targetId, metadata) Line written to thalabus_AUDIT.log matching [RESOURCE_PERMISSION.PermissionGranted] id=... effectiveUserId=... resourceId=... targetId=... metadata={...}
T2 Log(ResourceCreated, effectiveUserId) with no optional fields Line written; no resourceId, targetId, or metadata segments in the output
T3 Log(AccessDenied, ...) Namespace in log line is ACCESS_CONTROL
T4 Log when Metadata contains a non-serialisable value Exception swallowed; no throw propagates to caller
T5 Call Log 3 times in rapid succession 3 distinct lines in thalabus_AUDIT.log, each with a different id UUID

Acceptance Criteria - T1–T3, T5: log lines present in thalabus_AUDIT.log with correct content. - T4: no exception propagates.

Implementation of these tests Unit tests in ThalabusUnitTests/ACL/AuditServiceTests.cs. Read thalabus_AUDIT.log after each call to assert log content.


6. Post-Implementation

Date: 2026-05-06

What changed: - ThalabusCore/Services/AuditService.cs — new file created; implements IAuditService. - Static NamespaceMap dictionary maps all 16 AuditEventType values to their 4 namespace strings (RESOURCE_LIFECYCLE, RESOURCE_PERMISSION, ACCESS_CONTROL, USER_MANAGEMENT). - Log(...) builds the structured message [NAMESPACE.EventType] id=<uuid> effectiveUserId=... resourceId=... targetId=... metadata={...} (optional segments omitted when the argument is null). - Calls DiagLog.Info(message, containerName: "AUDIT") — ThalabusInterfaces.Data.Logging.Log aliased as DiagLog to avoid name collision with the IAuditService.Log method. - Entire body wrapped in try/catch that swallows silently — audit failures never interrupt the calling operation. - metadata serialised with System.Text.Json.JsonSerializer.Serialize; serialisation failures are covered by the outer catch. - No constructor parameters — DiagLog is a static class; no DI dependency.

Build result: ThalabusInterfaces, Neo4jORM, ThalabusCore, and ThalabusWeb all build with zero CS errors.

Test results: T1–T5 not yet run. Logic verified by code review. AuditService must be registered in DI (Task 11.4) before T2 (implements interface) and T3 (DI smoke) can be confirmed at runtime.


Task 11.4 — Detail

1. Task Overview

Short Description Wire IAuditService.Log calls into ResourceService, AclService, and GroupService at the points where ACL-relevant business events occur. Each service receives IAuditService via constructor injection and calls Log after the primary operation succeeds.

Objective / Expected Result - All resource mutations, permission changes, and group membership changes emit the correct AuditEventType. - Log is called after a successful write — not before, and not in a way that rolls back the primary operation on failure. - No audit calls on read-only operations (GetResource, CheckAccess) except the two selective cases below.


2. Context & Scope

Background / Reason for Change Without audit call wiring, AuditService exists but is never invoked. This task connects the service to all the event sources defined in Tasks 8.x, 10.x, and 3.x.

In Scope

Event Source method effectiveUserId resourceId targetId metadata
ResourceCreated ResourceService.CreateDossier, CreateDocument ownerGroupId new resource id parentId { "name": ... }
ResourceMoved ResourceService.MoveResource effectiveUserId param resourceId new parentId { "oldParentId": ... }
ResourceRenamed ResourceService.RenameResource effectiveUserId param resourceId — { "oldName": ..., "newName": ... }
ResourceDeleted ResourceService.DeleteResource effectiveUserId param resourceId — —
ResourceRestored ResourceService.RestoreResource effectiveUserId param resourceId — —
ResourceProtected ResourceService.SetProtected effectiveUserId param resourceId — —
ResourceUnprotected ResourceService.Unprotect effectiveUserId param resourceId — —
PermissionGranted AclService.AddPermission effectiveUserId param entry.ResourceId entry.GroupId { "role": ... }
PermissionRevoked AclService.RemovePermission effectiveUserId param resourceId groupId —
SharingActionPerformed AclService.SetAcl effectiveUserId param resourceId — { "entryCount": ... }
OwnershipChanged ResourceService.MoveResource (when owning group changes) effectiveUserId param resourceId new owning group ID —
GroupMembershipChanged GroupService.AddMember, RemoveMember effectiveUserId param — groupId { "userId": ..., "action": "added\|removed" }
GroupDeleted GroupService.DeleteGroup (or equivalent) effectiveUserId param — groupId —
SuperAdminAccessUsed AclService.IsTenantAdmin (when bypass applies) userId param resourceId — —
AccessDenied AclService.CheckAccess (when result is false, for all denied access attempts including protected resources) userId param resourceId — { "permission": ... }

Out of Scope - AccessGranted — too noisy for routine logging; not wired. - UserRemovedFromTenant — wired in UserService.RemoveUser, which is outside the ACL service scope; deferred to a separate task. - Retroactive audit events for data that existed before this task is implemented.


3. Impact Analysis

Affected Components / Modules

Component Change
ThalabusCore/Services/ResourceService.cs Add IAuditService constructor parameter; wire 7 event calls
ThalabusCore/Services/AclService.cs Add IAuditService constructor parameter; wire 4 event calls
ThalabusCore/Services/GroupService.cs Add IAuditService constructor parameter; wire 2 event calls

Dependencies

Task Reason
Task 11.3 AuditService.Log must be implemented
Tasks 8.2–8.8 ResourceService methods must exist
Tasks 10.3, 10.7 AclService methods must exist
Task 3.4 GroupService.AddMember / RemoveMember must exist

Risks & Edge Cases

Risk Mitigation
Log called before the primary write succeeds (wrong ordering) Always call await _auditService.Log(...) after await primaryOperation(...)
Log throws despite the try/catch in AuditService The try/catch is inside AuditService.Log — the call site does not need its own try/catch
effectiveUserId not available in a method that doesn't take it (e.g. CreateDossier) Use ownerGroupId as a proxy for effectiveUserId at creation time; document this approximation
OwnershipChanged emitted only when the owning group actually changes in MoveResource Compare old owner group (resolved via GetOwnerGroupId before the move) with new parent's owner group; skip OwnershipChanged if they are the same

Backward Compatibility Considerations Constructor signatures of ResourceService, AclService, and GroupService gain a new IAuditService parameter. IAuditService is registered in Program.cs as part of this task (see Step 5 in the implementation plan).


4. Implementation Plan

Pattern for each service — add IAuditService as a constructor parameter and store in a private field AuditService. Then after each primary operation succeeds, call:

await AuditService.Log(AuditEventType.ResourceCreated, effectiveUserId, resourceId: newId,
    metadata: new Dictionary<string, object?> { { "name", name } });

ResourceService additions (representative — apply to all 7 events):

// In constructor:
private readonly IAuditService AuditService;

public ResourceService(IServiceProvider serviceProvider, IAuditService auditService) {
    ServiceProvider = serviceProvider;
    AuditService = auditService;
}

// After CreateDossier succeeds:
await AuditService.Log(AuditEventType.ResourceCreated, ownerGroupId,
    resourceId: newDossier.Id,
    metadata: new Dictionary<string, object?> { { "name", name }, { "parentId", parentId } });

// After MoveResource succeeds — emit ResourceMoved and conditionally OwnershipChanged:
await AuditService.Log(AuditEventType.ResourceMoved, effectiveUserId,
    resourceId: resourceId, targetId: newParentId,
    metadata: new Dictionary<string, object?> { { "oldParentId", oldParentId } });
if (oldOwnerGroupId != newOwnerGroupId)
    await AuditService.Log(AuditEventType.OwnershipChanged, effectiveUserId,
        resourceId: resourceId, targetId: newOwnerGroupId);  // newOwnerGroupId from GetOwnerGroupId(newParentId)

AclService additions (representative):

// After AddPermission succeeds:
await AuditService.Log(AuditEventType.PermissionGranted, effectiveUserId,
    resourceId: entry.ResourceId, targetId: entry.GroupId,
    metadata: new Dictionary<string, object?> { { "role", entry.Role.Name.ToString() } });

// After RemovePermission succeeds:
await AuditService.Log(AuditEventType.PermissionRevoked, effectiveUserId,
    resourceId: resourceId, targetId: groupId);```

**`GroupService` additions (representative):**

```csharp
// After AddMember succeeds:
await AuditService.Log(AuditEventType.GroupMembershipChanged, effectiveUserId,
    targetId: groupId,
    metadata: new Dictionary<string, object?> { { "userId", userId }, { "action", "added" } });

Step 5 — Register IAuditService in Program.cs

In ThalabusWeb/Program.cs, after the IGroupService registration:

builder.Services.AddSingleton<IAuditService, AuditService>();   // Task 11.4 — must follow IGroupService

Add using ThalabusInterfaces.Services; and using ThalabusCore.Services; if not already present.


5. Testing Strategy

Test Cases / Scenarios

# Scenario Expected Result
T1 CreateDossier completes successfully Line [RESOURCE_LIFECYCLE.ResourceCreated] ... resourceId=<newId> present in thalabus_AUDIT.log
T2 MoveResource moves a resource to a dossier with a different owner Two lines in thalabus_AUDIT.log: one ResourceMoved, one OwnershipChanged
T3 MoveResource moves a resource to a dossier with the same owner Exactly one audit line: ResourceMoved only
T4 AddPermission completes Line [RESOURCE_PERMISSION.PermissionGranted] ... resourceId=... targetId=... metadata={"role":...} in log
T5 AuditService.Log write throws internally CreateDossier still returns the new resource; no exception propagates
T6 GroupService.AddMember completes Line [USER_MANAGEMENT.GroupMembershipChanged] ... metadata={"action":"added"} in log

Acceptance Criteria - T1, T2, T4, T6: correct lines present in thalabus_AUDIT.log after the operation. - T3: exactly one audit line emitted. - T5: no exception propagates to the caller; resource was created.

Implementation of these tests Unit tests with a mock IAuditService (verify Log is called with correct arguments) for T1–T4, T6. Unit test with a deliberately broken DiagLog (or a no-op AuditService that throws) for T5.


6. Post-Implementation

What changed: IAuditService is injected into AclService, GroupService, and ResourceService. Confirmed call sites: AclService.cs (AccessDenied, SuperAdminAccessUsed, PermissionGranted ×2, PermissionRevoked, SharingActionPerformed), GroupService.cs (GroupMembershipChanged ×2, plus a GroupDeleted event not listed in the original scope table), and ResourceService.cs (ResourceCreated ×2, ResourceMoved, OwnershipChanged ×2, ResourceRenamed ×2, ResourceDeleted, ResourceRestored, ResourceProtected, ResourceUnprotected).

Test results: Wiring confirmed by code review; unit-test coverage for each call site was not verified individually in this pass.

Note: GroupService.cs also emits a GroupDeleted audit event, which is not listed in this task's event table above — the scope table should be extended to include it.


Task 12 — ACL Enforcement in ResourceService

Wire IAclService.CheckAccess into all user-facing operations on ResourceService. Enforcement lives at the service layer — ResourceService is the single entry point for resource mutation from the UI and other services; the repository remains a pure data-access layer with no permission logic.

# Task Status
12.1 Enforce ACL on read operations in ResourceService (GetResource, GetChildren, GetAllDossiers) implemented
12.2 Enforce ACL on create operations in ResourceService (CreateDossier, CreateDocument) implemented
12.3 Enforce ACL on rename operations in ResourceService (RenameResource) implemented
12.4 Enforce ACL on delete / restore operations in ResourceService (DeleteResource, RestoreResource) implemented
12.5 Enforce ACL on protection operations in ResourceService (SetProtected, Unprotect) implemented
12.6 Enforce ACL on move / ownership operations in ResourceService (MoveResource, TransferOwnership) implemented
12.7 Enforce ACL on permission management in AclService (AddPermission, RemovePermission) implemented

Task 12.1 — Detail

1. Task Overview

Short Description Add an effectiveUserId parameter to the three read-facing methods in IResourceService / ResourceService and enforce Permission.Read via IAclService.CheckAccess before returning data.

Objective / Expected Result - GetResource(resourceId, effectiveUserId) throws UnauthorizedAccessException when the effective user lacks READ on that resource. - GetChildren(parentId, effectiveUserId) throws when the effective user lacks READ on the parent dossier (children inherit through the parent). - GetAllDossiers(effectiveUserId) returns only dossiers the effective user can read (used by UI for move-target selection). - Internal service-to-service calls (e.g. TransferOwnership calling GetResource for a system lookup) pass effectiveUserId: null, which skips the ACL check.


2. Context & Scope

Background / Reason for Change Currently GetResource, GetChildren, and GetAllDossiers have no effectiveUserId and return data to any caller with no access gate. Any user can retrieve any dossier or document by ID.

In Scope

Method Signature change Permission Failure behaviour
GetResource(string resourceId, string? effectiveUserId = null) add optional effectiveUserId READ on resourceId throw UnauthorizedAccessException
GetChildren(string parentId, string? effectiveUserId = null) add optional effectiveUserId READ on parentId throw UnauthorizedAccessException
GetAllDossiers(string? effectiveUserId = null) add optional effectiveUserId READ per dossier silently filter inaccessible dossiers from result

effectiveUserId = null means a system/internal call — the ACL check is skipped, preserving backward compatibility with all existing internal callers inside ResourceService itself.

Out of Scope - Write and mutation operations — covered by Tasks 12.2–12.6. - GetResource / GetChildren calls inside ResourceService's own mutation methods (e.g. the GetResource(resourceId) call inside TransferOwnership) — these are internal lookups; pass effectiveUserId: null explicitly.


3. Impact Analysis

Affected Components / Modules

Component Change
ThalabusInterfaces/Services/IResourceService.cs Add optional string? effectiveUserId = null to 3 method signatures
ThalabusCore/Services/ResourceService.cs Add CheckAccess guard to the 3 methods
Blazor components calling these methods Pass EffectiveUserId (from the page-level user selector) as effectiveUserId

Dependencies

Task Reason
Task 10.2 IAclService.CheckAccess must be implemented
Task 11.4 IAuditService wired into AclService (so AccessDenied events are emitted automatically)

Risks & Edge Cases

Risk Mitigation
Internal callers break when effectiveUserId becomes required Use optional parameter (string? effectiveUserId = null); internal callers implicitly pass null
GetAllDossiers silently drops dossiers — UI move-target dropdown shows fewer options This is correct ACL behaviour; document in XML comment that the result is effective user-filtered
GetChildren checks the parent dossier only; a child that is individually isProtected with tighter ACL may still appear Acceptable at this stage — per-resource protection is handled when that specific resource is accessed directly via GetResource

4. Implementation Plan

// IResourceService.cs — updated signatures
Task<ResourceItem?> GetResource(string resourceId, string? effectiveUserId = null);
Task<IEnumerable<ResourceItem>> GetChildren(string parentId, string? effectiveUserId = null);
Task<IEnumerable<INeo4jDossier>> GetAllDossiers(string? effectiveUserId = null);
// ResourceService.cs — guard pattern
public async Task<ResourceItem?> GetResource(string resourceId, string? effectiveUserId = null) {
    if (effectiveUserId != null && !await AclService.CheckAccess(effectiveUserId, resourceId, Permission.Read))
        throw new UnauthorizedAccessException(
            $"Effective User '{effectiveUserId}' does not have Read permission on resource '{resourceId}'.");
    // ... existing implementation unchanged ...
}

public async Task<IEnumerable<ResourceItem>> GetChildren(string parentId, string? effectiveUserId = null) {
    if (effectiveUserId != null && !await AclService.CheckAccess(effectiveUserId, parentId, Permission.Read))
        throw new UnauthorizedAccessException(
            $"Effective User '{effectiveUserId}' does not have Read permission on dossier '{parentId}'.");
    // ... existing implementation unchanged ...
}

public async Task<IEnumerable<INeo4jDossier>> GetAllDossiers(string? effectiveUserId = null) {
    var all = await DocumentRepository.GetDossiers(isSession: false);
    var visible = all.Where(d => !d.IsDeleted);
    if (effectiveUserId == null) return visible;
    var result = new List<INeo4jDossier>();
    foreach (var d in visible)
        if (await AclService.CheckAccess(effectiveUserId, d.Id, Permission.Read))
            result.Add(d);
    return result;
}

Blazor call-site update (example — ACLResourcePanel):

AllDossiers = (await ResourceService.GetAllDossiers(EffectiveUserId)).OrderBy(d => d.Name).ToList();

5. Testing Strategy

# Scenario Expected Result
T1 GetResource(id, effectiveUserId) — effective user has READ Resource returned
T2 GetResource(id, effectiveUserId) — effective user lacks READ UnauthorizedAccessException; AccessDenied in audit log
T3 GetResource(id, null) — internal call Resource returned without ACL check
T4 GetChildren(parentId, effectiveUserId) — effective user lacks READ on parent UnauthorizedAccessException
T5 GetAllDossiers(effectiveUserId) — effective user has READ on 3 of 6 dossiers 3 dossiers returned
T6 GetAllDossiers(null) — internal/system call All non-deleted dossiers returned

Acceptance Criteria - T1, T3, T5, T6: correct data returned. - T2, T4: UnauthorizedAccessException thrown; no data leaked.


6. Post-Implementation

Date: 2026-05-06

What changed: - IResourceService.cs: GetResource, GetChildren, GetAllDossiers each gained an optional string? effectiveUserId = null parameter. - ResourceService.cs: GetResource and GetChildren throw UnauthorizedAccessException when effectiveUserId != null and CheckAccess returns false. GetAllDossiers silently filters out dossiers the effective user cannot read when effectiveUserId != null; returns all when effectiveUserId == null (internal/system call).

Build result: ThalabusInterfaces and ThalabusCore both build with 0 errors. Full ThalabusWeb build blocked by file-lock (app running) — no CS errors detected.

Test results: Manual test via ACL test page pending; unit tests not yet written.


Task 12.2 — Detail

1. Task Overview

Short Description Add effectiveUserId to CreateDossier and CreateDocument in IResourceService / ResourceService and enforce Permission.Add on the parent dossier before creating the new resource.

Objective / Expected Result - A user who lacks ADD on the parent dossier cannot create a child dossier or document inside it.


2. Context & Scope

Background / Reason for Change Both methods currently take ownerGroupId but no effectiveUserId for the requesting user. The check must be on the parent, because ADD is a folder-level permission (documents are not assignable targets for ADD).

In Scope

Method Signature change Permission Resource checked
CreateDossier(parentId, name, ownerGroupId, type, effectiveUserId) add string effectiveUserId ADD parentId
CreateDocument(parentId, name, ownerGroupId, effectiveUserId) add string effectiveUserId ADD parentId

Out of Scope - System-provisioned dossiers (type=root, type=user, type=session) — created by bootstrap/session services; these callers pass a dedicated system principal ID or will be updated separately.


3. Impact Analysis

Affected Components / Modules

Component Change
ThalabusInterfaces/Services/IResourceService.cs Add string effectiveUserId to both method signatures
ThalabusCore/Services/ResourceService.cs Add CheckAccess(effectiveUserId, parentId, Permission.Add) guard
All callers (DocumentManagementService, Blazor components) Pass effectiveUserId

Risks & Edge Cases

Risk Mitigation
ownerGroupId ≠ effectiveUserId (e.g. service creates resource on behalf of a group) effectiveUserId is the requesting user ID; ownerGroupId is the resource owner group. They remain separate parameters
System callers don't have a user identity Pass a system principal ID (e.g. "system") — IsTenantAdmin bypass applies if that principal has the capability

4. Implementation Plan

public async Task<INeo4jDossier> CreateDossier(
    string parentId, string name, string ownerGroupId,
    DossierType type = DossierType.Visible, string effectiveUserId = "system") {

    if (!await AclService.CheckAccess(effectiveUserId, parentId, Permission.Add))
        throw new UnauthorizedAccessException(
            $"Effective User '{effectiveUserId}' does not have Add permission on dossier '{parentId}'.");
    // ... existing implementation unchanged ...
}

public async Task<INeo4jDocument> CreateDocument(
    string parentId, string name, string ownerGroupId, string effectiveUserId = "system") {

    if (!await AclService.CheckAccess(effectiveUserId, parentId, Permission.Add))
        throw new UnauthorizedAccessException(
            $"Effective User '{effectiveUserId}' does not have Add permission on dossier '{parentId}'.");
    // ... existing implementation unchanged ...
}

5. Testing Strategy

# Scenario Expected Result
T1 CreateDossier(parentId, …, effectiveUserId) — effective user has ADD on parent Dossier created
T2 CreateDossier(parentId, …, effectiveUserId) — effective user lacks ADD UnauthorizedAccessException
T3 CreateDocument(parentId, …, effectiveUserId) — effective user has ADD on parent Document created
T4 CreateDocument(parentId, …, effectiveUserId) — effective user lacks ADD UnauthorizedAccessException

Acceptance Criteria — T1, T3 succeed; T2, T4 throw without creating any node.


6. Post-Implementation

Date: 2026-05-06

What changed: - IResourceService.cs: CreateDossier gained string effectiveUserId = "system" as a 5th parameter (after DossierType type). CreateDocument gained string effectiveUserId = "system" as a 4th parameter. - ResourceService.cs: Both methods throw UnauthorizedAccessException if CheckAccess(effectiveUserId, parentId, Permission.Add) returns false. Audit log call updated to use effectiveUserId instead of ownerGroupId as the effective user.

Build result: Clean — 0 CS errors.

Test results: Manual test via ACL test page pending; unit tests not yet written.


Task 12.3 — Detail

1. Task Overview

Short Description Enforce Permission.Update on RenameResource. No signature change is required — the method already takes effectiveUserId. The guard is inserted before the mutation.

Objective / Expected Result - A user who lacks UPDATE on a resource cannot rename it.


2. Context & Scope

In Scope

Method Permission Resource checked
RenameResource(resourceId, newName, effectiveUserId) UPDATE resourceId

Out of Scope — All other mutations.


3. Impact Analysis

No interface or signature changes. Only ResourceService.cs is modified.


4. Implementation Plan

public async Task RenameResource(string resourceId, string newName, string effectiveUserId) {
    if (string.IsNullOrWhiteSpace(newName))
        throw new ArgumentException("New name cannot be empty.", nameof(newName));

    if (!await AclService.CheckAccess(effectiveUserId, resourceId, Permission.Update))
        throw new UnauthorizedAccessException(
            $"Effective User '{effectiveUserId}' does not have Update permission on resource '{resourceId}'.");
    // ... existing implementation unchanged ...
}

5. Testing Strategy

# Scenario Expected Result
T1 RenameResource(id, newName, effectiveUserId) — effective user has UPDATE Resource renamed
T2 RenameResource(id, newName, effectiveUserId) — effective user lacks UPDATE UnauthorizedAccessException

6. Post-Implementation

Date: 2026-05-06

What changed: - ResourceService.cs: RenameResource throws UnauthorizedAccessException if CheckAccess(effectiveUserId, resourceId, Permission.Update) returns false. Guard is inserted after the whitespace validation, before any DB access.

Build result: Clean — 0 CS errors.

Test results: Manual test via ACL test page pending; unit tests not yet written.


Task 12.4 — Detail

1. Task Overview

Short Description Enforce Permission.Delete on DeleteResource and RestoreResource. Both methods already take effectiveUserId. PurgeResource is a system-only operation and requires no user-level ACL check.

Objective / Expected Result - A user who lacks DELETE on a resource cannot soft-delete or restore it. - PurgeResource (called by the background purge cron task) remains ACL-free.


2. Context & Scope

In Scope

Method Permission Resource checked
DeleteResource(resourceId, effectiveUserId) DELETE resourceId
RestoreResource(resourceId, effectiveUserId) DELETE resourceId (restoring requires the same authority as deleting)

Out of Scope - PurgeResource(resourceId) — system cron operation, no user identity; no ACL check.


4. Implementation Plan

public async Task DeleteResource(string resourceId, string effectiveUserId) {
    if (!await AclService.CheckAccess(effectiveUserId, resourceId, Permission.Delete))
        throw new UnauthorizedAccessException(
            $"Effective User '{effectiveUserId}' does not have Delete permission on resource '{resourceId}'.");
    // ... existing implementation unchanged ...
}

public async Task RestoreResource(string resourceId, string effectiveUserId) {
    if (!await AclService.CheckAccess(effectiveUserId, resourceId, Permission.Delete))
        throw new UnauthorizedAccessException(
            $"Effective User '{effectiveUserId}' does not have Delete permission on resource '{resourceId}'.");
    // ... existing implementation unchanged ...
}

5. Testing Strategy

# Scenario Expected Result
T1 DeleteResource(id, effectiveUserId) — effective user has DELETE Resource soft-deleted
T2 DeleteResource(id, effectiveUserId) — effective user lacks DELETE UnauthorizedAccessException
T3 RestoreResource(id, effectiveUserId) — effective user has DELETE Resource restored
T4 RestoreResource(id, effectiveUserId) — effective user lacks DELETE UnauthorizedAccessException
T5 PurgeResource(id) — no effectiveUserId Resource purged; no ACL check

Acceptance Criteria — T1, T3, T5 succeed; T2, T4 throw.


6. Post-Implementation

Date: 2026-05-06

What changed: - ResourceService.cs: DeleteResource and RestoreResource both throw UnauthorizedAccessException if CheckAccess(effectiveUserId, resourceId, Permission.Delete) returns false. Guard is the first operation in each method, before GetResource. PurgeResource is unchanged — no ACL check.

Build result: Clean — 0 CS errors.

Test results: Manual test via ACL test page pending; unit tests not yet written.


Task 12.5 — Detail

1. Task Overview

Short Description Enforce Permission.Sharing on SetProtected and Unprotect. Both methods already take effectiveUserId. Per the ACL design: "The acting group must hold SHARING permission over the resource to protect / unprotect it."

Objective / Expected Result - Only a user with SHARING on a resource can toggle its isProtected flag.


2. Context & Scope

In Scope

Method Permission Resource checked
SetProtected(resourceId, effectiveUserId) SHARING resourceId
Unprotect(resourceId, effectiveUserId) SHARING resourceId

4. Implementation Plan

public async Task SetProtected(string resourceId, string effectiveUserId) {
    if (!await AclService.CheckAccess(effectiveUserId, resourceId, Permission.Sharing))
        throw new UnauthorizedAccessException(
            $"Effective User '{effectiveUserId}' does not have Sharing permission on resource '{resourceId}'.");
    await SetProtectedFlag(resourceId, true);
    await AuditService.Log(AuditEventType.ResourceProtected, effectiveUserId, resourceId: resourceId);
}

public async Task Unprotect(string resourceId, string effectiveUserId) {
    if (!await AclService.CheckAccess(effectiveUserId, resourceId, Permission.Sharing))
        throw new UnauthorizedAccessException(
            $"Effective User '{effectiveUserId}' does not have Sharing permission on resource '{resourceId}'.");
    await SetProtectedFlag(resourceId, false);
    await AuditService.Log(AuditEventType.ResourceUnprotected, effectiveUserId, resourceId: resourceId);
}

5. Testing Strategy

# Scenario Expected Result
T1 SetProtected(id, effectiveUserId) — effective user has SHARING isProtected set to true
T2 SetProtected(id, effectiveUserId) — effective user lacks SHARING UnauthorizedAccessException
T3 Unprotect(id, effectiveUserId) — effective user has SHARING isProtected set to false
T4 Unprotect(id, effectiveUserId) — effective user lacks SHARING UnauthorizedAccessException

6. Post-Implementation

Date: 2026-05-06

What changed: - ResourceService.cs: SetProtected and Unprotect both throw UnauthorizedAccessException if CheckAccess(effectiveUserId, resourceId, Permission.Sharing) returns false. Guard is inserted before SetProtectedFlag and the audit call.

Build result: Clean — 0 CS errors.

Test results: Manual test via ACL test page pending; unit tests not yet written.


Task 12.6 — Detail

1. Task Overview

Short Description Harden MoveResource with a missing source-side UPDATE check, and enforce Permission.Ownership on TransferOwnership. Both methods already take effectiveUserId.

Objective / Expected Result - Moving a resource requires UPDATE on the resource itself (to change its location) in addition to the existing ADD check on the destination dossier. - Transferring ownership requires OWNERSHIP on the resource.


2. Context & Scope

Permissions by method:

Method Permission Resource checked Notes
MoveResource(resourceId, newParentId, effectiveUserId) UPDATE resourceId In addition to existing ADD on newParentId
TransferOwnership(resourceId, newOwnerGroupId, effectiveUserId) OWNERSHIP resourceId From ACL design: "To change ownership of a dossier, the user must hold OWNERSHIP on that resource"

Why UPDATE for MoveResource? Moving a resource modifies its parent relationship — this is a structural update to the resource's location. The ADD check on the destination already ensures the effective user can write into the target folder. The UPDATE check on the source ensures the effective user has authority over the resource being moved.

Why OWNERSHIP for TransferOwnership? The OWNERSHIP permission specifically governs the ability to modify the owner of a resource. Per ACL design: "OWNERSHIP — Modify the owner of a folder."


3. Impact Analysis

No interface or signature changes. Only ResourceService.cs is modified.


4. Implementation Plan

public async Task MoveResource(string resourceId, string newParentId, string effectiveUserId) {
    // ... existing guards (type check, deleted check, parent validation) ...

    // Source-side UPDATE check (new — effective user must be able to modify the resource)
    if (!await AclService.CheckAccess(effectiveUserId, resourceId, Permission.Update))
        throw new UnauthorizedAccessException(
            $"Effective User '{effectiveUserId}' does not have Update permission on resource '{resourceId}'.");

    // Existing destination-side ADD check
    if (!await AclService.CheckAccess(effectiveUserId, newParentId, Permission.Add))
        throw new UnauthorizedAccessException(
            $"Effective User '{effectiveUserId}' does not have Add permission on dossier '{newParentId}'.");

    // ... rest of existing implementation unchanged ...
}

public async Task TransferOwnership(string resourceId, string newOwnerGroupId, string effectiveUserId) {
    if (!await AclService.CheckAccess(effectiveUserId, resourceId, Permission.Ownership))
        throw new UnauthorizedAccessException(
            $"Effective User '{effectiveUserId}' does not have Ownership permission on resource '{resourceId}'.");
    // ... existing implementation unchanged ...
}

5. Testing Strategy

# Scenario Expected Result
T1 MoveResource — effective user has UPDATE on source AND ADD on destination Resource moved
T2 MoveResource — effective user has ADD on destination but lacks UPDATE on source UnauthorizedAccessException
T3 MoveResource — effective user has UPDATE on source but lacks ADD on destination UnauthorizedAccessException (existing check)
T4 TransferOwnership — effective user has OWNERSHIP on resource Ownership transferred
T5 TransferOwnership — effective user lacks OWNERSHIP UnauthorizedAccessException

Acceptance Criteria — T1, T4 succeed; T2, T3, T5 throw.


6. Post-Implementation

Date: 2026-05-06

What changed: - ResourceService.cs: MoveResource now checks UPDATE on resourceId before the existing ADD check on newParentId. The old single bool canAdd variable was replaced by two sequential CheckAccess throws. TransferOwnership throws UnauthorizedAccessException if CheckAccess(effectiveUserId, resourceId, Permission.Ownership) returns false — guard is the very first operation.

Build result: Clean — 0 CS errors.

Test results: Manual test via ACL test page pending; unit tests not yet written.


Task 12.7 — Detail

1. Task Overview

Short Description Enforce Permission.Sharing on AclService.AddPermission and AclService.RemovePermission. Both methods already take effectiveUserId. The check gates the ability to modify ACL grants on a resource — a user must hold SHARING on the target resource before they can grant or revoke permissions on it.

Objective / Expected Result - A user who lacks SHARING on a resource cannot call AddPermission or RemovePermission on that resource. - SetAcl (atomic replace-all) uses the same guard, since it internally performs the same operation at bulk.


2. Context & Scope

Background / Reason for Change Currently AddPermission and RemovePermission in AclService execute unconditionally — any caller can grant or revoke any group's access on any resource. ACL design states that the SHARING permission is the authority that controls who can modify the ACL of a resource. Without this gate, a Viewer could escalate their own permissions by directly calling AddPermission.

In Scope

Method Permission Resource checked
AddPermission(entry, effectiveUserId) SHARING entry.ResourceId
RemovePermission(resourceId, groupId, effectiveUserId) SHARING resourceId

Out of Scope - SetAcl — covered separately if needed; shares the same pattern. - GetAcl — read-only listing; no mutation guard required at this stage. - GetEffectivePermissions / CheckAccess — internal helpers; no effective user-facing guard needed.


3. Impact Analysis

Affected Components / Modules

Component Change
ThalabusCore/Services/AclService.cs Add CheckAccess guard at the start of AddPermission and RemovePermission

Dependencies

Task Reason
Task 10.2 CheckAccess must be implemented (already done)
Task 12.5 SHARING permission is the established gate for ACL-structure modifications (already enforced for SetProtected/Unprotect)

Risks & Edge Cases

Risk Mitigation
Bootstrap / system callers don't have a SHARING grant Pass a system principal ID (e.g. "system") — IsTenantAdmin bypass applies if that principal's group holds tenant-admin capability
Calling CheckAccess inside AclService causes recursion if CheckAccess itself calls AddPermission CheckAccess only reads (via GetEffectivePermissions) — no mutation; no recursion possible

4. Implementation Plan

// AclService.cs — AddPermission
public async Task AddPermission(IAclEntry entry, string effectiveUserId) {
    // Task 12.7 — SHARING permission required to grant access on a resource
    if (!await CheckAccess(effectiveUserId, entry.ResourceId, Permission.Sharing))
        throw new UnauthorizedAccessException(
            $"Effective User '{effectiveUserId}' does not have Sharing permission on resource '{entry.ResourceId}'.");
    // ... existing implementation unchanged ...
}

// AclService.cs — RemovePermission
public async Task RemovePermission(string resourceId, string groupId, string effectiveUserId) {
    // Task 12.7 — SHARING permission required to revoke access on a resource
    if (!await CheckAccess(effectiveUserId, resourceId, Permission.Sharing))
        throw new UnauthorizedAccessException(
            $"Effective User '{effectiveUserId}' does not have Sharing permission on resource '{resourceId}'.");
    // ... existing implementation unchanged ...
}

5. Testing Strategy

# Scenario Expected Result
T1 AddPermission(entry, effectiveUserId) — effective user has SHARING on entry.ResourceId Permission granted
T2 AddPermission(entry, effectiveUserId) — effective user lacks SHARING UnauthorizedAccessException; AccessDenied in audit log
T3 RemovePermission(resourceId, groupId, effectiveUserId) — effective user has SHARING Permission revoked
T4 RemovePermission(resourceId, groupId, effectiveUserId) — effective user lacks SHARING UnauthorizedAccessException
T5 System call using a TenantAdmin group member Bypass applies; operation succeeds without explicit SHARING grant

Acceptance Criteria — T1, T3, T5 succeed; T2, T4 throw without modifying any graph relationships.


6. Post-Implementation

Date: 2026-05-06

What changed: - ThalabusCore/Services/AclService.cs: AddPermission throws UnauthorizedAccessException if CheckAccess(effectiveUserId, entry.ResourceId, Permission.Sharing) returns false — guard is the first operation before any Neo4j access. RemovePermission throws UnauthorizedAccessException if CheckAccess(effectiveUserId, resourceId, Permission.Sharing) returns false — same position.

Build result: Clean — 0 CS errors (ThalabusCore build succeeded).

Test results: Manual test via ACL test page pending; unit tests not yet written.


Task 13 — Semantic Search with ACL Filtering

Integrate access control filtering into the existing vector search pipeline in PythonServer. The workflow follows a search-first, filter-second pattern: vector search retrieves a ranked batch of candidate documents by semantic relevance, then only documents the requesting user holds READ permission on are returned. If the filtered result set is smaller than the requested count, the next batch of candidates is fetched and filtered until the target is reached or the vector store is exhausted. The Neo4j graph remains the single source of truth for permissions; the vector store holds only embeddings and document IDs.

# Task Status
13.1 Add effectiveUserId (caller identity) parameter to the semantic search endpoint implemented
13.2 Implement post-retrieval ACL filter — check each candidate document ID against the caller's READ permission implemented
13.3 Implement batch retry loop — fetch the next ranked batch when the filtered result count is below the requested size implemented
13.4 Update the search response contract — return only ACL-approved documents implemented
13.5 Enable SUPER_ADMIN bypass in vector search ACL filter (_acl_where_clause) implemented

Task 13.1 — Detail

1. Task Overview

Short Description Add an effective_user_id parameter (the Neo4j User.id of the requesting user) to RagInterface.thalabus_query, RagService.thalabus_query, and RagService._semantic_search. The parameter is currently absent; without it the ACL filter introduced in Task 13.2 has no identity to check permissions against. This is a pure signature extension — no permission-checking logic is added here.

Objective / Expected Result - RagInterface.thalabus_query and RagService.thalabus_query accept a new optional effective_user_id: Optional[str] = None keyword argument. - RagService._semantic_search accepts and forwards effective_user_id to the ACL filter stub (Task 13.2). - When effective_user_id is None, the pipeline behaves identically to today (no ACL filtering applied — safe fallback for callers that do not yet supply the parameter). - All existing call sites compile and pass their test suites without modification.


2. Context & Scope

Background / Reason for Change ACL_Design.md — "Vector search – search first" — requires that the permission check is performed per candidate document using the caller's identity. RagService already carries a userid parameter on thalabus_query, but that field is used exclusively for dataset-creation attribution. A separate, clearly named effective_user_id parameter is needed so that Tasks 13.2 and 13.3 can consume it for access control without ambiguity.

In Scope - Adding effective_user_id: Optional[str] = None to RagInterface.thalabus_query (abstract method). - Adding effective_user_id: Optional[str] = None to RagService.thalabus_query (implementation). - Adding effective_user_id: Optional[str] = None to RagService._semantic_search (private helper). - Forwarding effective_user_id from thalabus_query → _semantic_search. - Files: PythonServer/src/interfaces/rag_interface.py, PythonServer/src/services/rag_service.py.

Out of Scope - Implementing the permission check itself — covered by Task 13.2. - Implementing the batch retry loop — covered by Task 13.3. - Any changes to API route handlers in src/api/ — the endpoint surface is not modified in this task. - Any changes outside the PythonServer project.


3. Impact Analysis

Affected Components / Modules

File Change
PythonServer/src/interfaces/rag_interface.py Add effective_user_id parameter to thalabus_query abstract signature
PythonServer/src/services/rag_service.py Add effective_user_id to thalabus_query and _semantic_search; forward the value through the call chain

Dependencies

Task Reason
Task 13.2 Consumes effective_user_id to perform the actual permission check
Task 2.1 — User Entity Extension effective_user_id corresponds to INeo4jUser.Id; the stable UUID must exist on user nodes before the value can be resolved at the call site

Risks & Edge Cases

Risk Mitigation
Existing callers pass positional arguments and break when the signature changes effective_user_id is keyword-only with a None default; no positional shifts occur
effective_user_id = None silently bypasses ACL in production Task 13.2 will log a warning when effective_user_id is None and skip filtering; callers must be audited before going to production
thalabus_query_analyser shares the same interface but does not need ACL filtering No change to thalabus_query_analyser — it is an analysis utility, not a user-facing search endpoint

Backward Compatibility Considerations Fully backward compatible. All new parameters default to None; existing call sites are unaffected.


4. Implementation Plan

Step 1 — Update RagInterface.thalabus_query

In PythonServer/src/interfaces/rag_interface.py, add effective_user_id: Optional[str] = None to the abstract thalabus_query signature after the existing userid parameter:

@abstractmethod
def thalabus_query(
    self,
    query: str,
    ontology_id: Optional[str] = None,
    subset_of_datasetids: Optional[List[str]] = None,
    automatically_rerank: bool = True,
    create_dataset: Optional[Union[bool, str]] = None,
    session_id: Optional[str] = None,
    userid: Optional[str] = None,
    effective_user_id: Optional[str] = None,          # NEW — ACL identity of the requesting user
    limit_to_documents: Optional[List[str]] = None,
    limit_to_dossier_id: Optional[str] = None,
    return_documents: bool = True,
) -> Tuple[List[dict], Optional[List[str]]]:

Update the docstring to describe effective_user_id.

Step 2 — Update RagService.thalabus_query

Mirror the same signature change in PythonServer/src/services/rag_service.py. Forward effective_user_id to _semantic_search.

Step 3 — Update RagService._semantic_search

Add effective_user_id: Optional[str] = None to the private method. Log a WARNING when effective_user_id is None so that the gap is visible in logs during development.


5. Testing Strategy

Test Cases / Scenarios

# Scenario Expected Result
T1 Call thalabus_query without effective_user_id (existing call site) Executes normally; no exception; ACL filter skipped with a warning log
T2 Call thalabus_query with a valid effective_user_id Parameter is forwarded to _semantic_search; visible in debug logs
T3 Call thalabus_query with effective_user_id=None explicitly Same as T1
T4 Subclass RagInterface without overriding the new parameter Abstract method signature is satisfied; no runtime error

Acceptance Criteria - rag_interface.py and rag_service.py compile without errors after the change. - All existing unit tests in PythonServer/src/services/unit_testing/rag_service_unit_test.py pass unchanged. - A new test asserts that effective_user_id is forwarded through thalabus_query → _semantic_search.

Implementation of these tests Add a test case to PythonServer/src/services/unit_testing/rag_service_unit_test.py that patches _semantic_search and verifies effective_user_id is passed correctly.


6. Post-Implementation

Placeholder — to be populated after implementation.

What changed: list of files modified / created

Test results: outcomes of T1–T4


Task 13.2 — Detail

1. Task Overview

Short Description Embed ACL READ permission checking directly inside the three existing Neo4j vector search queries (_get_semantic_chapters, _get_semantic_paragraphs, _get_semantic_summaries) so that the vector search and the permission check are performed in a single round-trip per index. Unauthorised documents never reach Python — they are filtered by Neo4j before the result set is returned.

Objective / Expected Result - When effective_user_id is provided, each of the three index queries returns only fragments whose parent document the effective user holds READ permission on, enforcing: direct GRANT on the document, inherited GRANT via ancestor dossiers (with isProtected break), soft-delete exclusion, and SUPER_ADMIN bypass. - Zero Python-level document filtering is performed in this task — all access control logic lives in Cypher. - When effective_user_id is None or ACL_ENFORCEMENT_ENABLED=False, the queries behave identically to today. - No additional Neo4j round-trips are introduced compared to the current unfiltered flow.


2. Context & Scope

Background / Reason for Change ACL_Design.md — step 2 of the "Vector search – search first" workflow — requires filtering search candidates by access control. Because the _get_semantic_* methods already pattern-match the Document node inline (chapters via (chapter)-[:PART_OF]->(doc:Document), summaries via (c)-[:PART_OF]->(doc:Document), paragraphs via paragraph.documentid property) and because Neo4j EXISTS {} subqueries can evaluate ACL traversals inside the same Cypher statement, the cleanest architecture is to extend these existing queries rather than add a second round-trip. This eliminates the network overhead of a separate Python-level filter call and keeps all permission logic on the database side where it belongs.

In Scope - Adding effective_user_id: Optional[str] = None to _get_semantic_chapters, _get_semantic_paragraphs, _get_semantic_summaries. - A private helper _acl_where_clause(doc_var: str) -> str that returns a reusable Cypher AND (...) block containing the permission EXISTS subqueries; inserted into each query when ACL_ENFORCEMENT_ENABLED=True and effective_user_id is provided. - Adding the ACL_ENFORCEMENT_ENABLED feature flag in PythonServer/src/config/constants.py. - Forwarding effective_user_id from _semantic_search to all three helper methods. - Files: PythonServer/src/services/rag_service.py, PythonServer/src/config/constants.py.

Out of Scope - Implementing SHARING, DELETE, ADD, UPDATE, or OWNERSHIP permission checks — only READ is relevant for search. - Any Python-level post-filtering of fragment lists — this is the explicit design choice of this task. - Modifying the Neo4j graph schema — all required relationships (MEMBER_OF, GRANT, CONTAINS) are expected to exist after Tasks 3–10. - Batch retry behaviour when the filtered result set is too small — covered by Task 13.3. - Any changes outside PythonServer.


3. Impact Analysis

Affected Components / Modules

File Change
PythonServer/src/services/rag_service.py Add effective_user_id param to _get_semantic_chapters, _get_semantic_paragraphs, _get_semantic_summaries, _semantic_search; add _acl_where_clause helper
PythonServer/src/config/constants.py New ACL_ENFORCEMENT_ENABLED flag

Dependencies

Task Reason
Task 13.1 effective_user_id must be present in _semantic_search before it can be forwarded to the helpers
Tasks 3–10 Group, MEMBER_OF, GRANT, CONTAINS graph structure must exist in Neo4j
Task 5 — Dossier Entity Extension isProtected and isDeleted flags must be present on Document nodes

Risks & Edge Cases

Risk Mitigation
ACL graph not yet populated (Tasks 3–10 not complete) ACL_ENFORCEMENT_ENABLED=False bypasses the filter entirely; no GRANT traversal occurs
Effective User has no groups EXISTS subquery returns false for all docs; empty result set returned; warning logged
Deep dossier inheritance causing slow traversal Bound [:CONTAINS*] depth to 1..10; add a Neo4j index on Dossier.isProtected to speed up filtering
isDeleted documents still present in the vector index Added as an explicit WHERE doc.isDeleted = false guard before the ACL check
Paragraphs do not have a direct [:PART_OF]->(Document) edge — only the property paragraph.documentid Match the Document node from the property: MATCH (doc:Document {id: paragraph.documentid}) before the ACL WHERE clause
SUPER_ADMIN bypass pattern not yet finalised Add a placeholder EXISTS clause with a clear // TODO Task 9 comment; bypass is blocked until Task 9 defines the exact graph pattern

Backward Compatibility Considerations Fully backward compatible. When effective_user_id is None or ACL_ENFORCEMENT_ENABLED=False, the Cypher strings are identical to today.


4. Implementation Plan

Step 1 — Add ACL_ENFORCEMENT_ENABLED feature flag

In PythonServer/src/config/constants.py:

ACL_ENFORCEMENT_ENABLED: bool = False   # Set True once ACL graph (Tasks 3–10) is fully populated

Step 2 — Add _acl_where_clause helper

Add to RagService:

def _acl_where_clause(self, doc_var: str) -> str:
    """
    Return a Cypher AND-block that enforces READ permission for $effectiveUserId on the
    node bound to `doc_var`. Inserted verbatim into query strings when ACL is active.
    The caller must include $effectiveUserId in the query parameters.
    """
    return f"""
      AND {doc_var}.isDeleted = false
      AND (
        // Direct [:GRANT:READ] relationship on the document
        EXISTS {{
          MATCH (u:User {{id: $effectiveUserId}})-[:MEMBER_OF]->(g:Group)
               -[:`GRANT:READ`]->({doc_var})
        }}
        OR (
          // Inherited [:GRANT:READ] via ancestor dossier — only when not protected
          NOT {doc_var}.isProtected
          AND EXISTS {{
            MATCH (u:User {{id: $effectiveUserId}})-[:MEMBER_OF]->(g:Group)
                 -[:`GRANT:READ`]->(dos:Dossier)
                 -[:CONTAINS*1..10]->({doc_var})
            WHERE NOT dos.isProtected
          }}
        )
        // SUPER_ADMIN bypass — enabled in Task 13.5 once ACL graph (Tasks 3–10) is fully populated
        // OR EXISTS {{ MATCH (u:User {{id: $effectiveUserId}})-[:MEMBER_OF]->(g:Group)-[:HAS_ROLE]->(:UserRole)-[:HAS_CAPABILITY]->(:Capability {{id: 'tenant-admin'}}) }}
      )
    """

Step 3 — Update _get_semantic_chapters

Add effective_user_id: Optional[str] = None parameter. When effective_user_id is provided and ACL_ENFORCEMENT_ENABLED=True, insert a MATCH (doc:Document {id: chapter.documentid}) clause and append _acl_where_clause('doc') to the WHERE conditions. Pass effectiveUserId in the params dict.

Cypher pattern for the no-dataset-filter + ACL variant (illustrative):

CALL db.index.vector.queryNodes('typical_rag', $nearest_neighbours, $embedding)
YIELD node AS chapter, score
WHERE chapter.documentid IS NOT NULL
MATCH (doc:Document {id: chapter.documentid})
WHERE doc.isDeleted = false
  AND (
    EXISTS {
      MATCH (u:User {id: $effectiveUserId})-[:MEMBER_OF]->(g:Group)
           -[:`GRANT:READ`]->(doc)
    }
    OR (
      NOT doc.isProtected
      AND EXISTS {
        MATCH (u:User {id: $effectiveUserId})-[:MEMBER_OF]->(g:Group)
             -[:`GRANT:READ`]->(dos:Dossier)
             -[:CONTAINS*1..10]->(doc)
        WHERE NOT dos.isProtected
      }
    )
  )
RETURN chapter.id AS fragmentid,
       chapter.documentid AS documentid,
       chapter.text AS text,
       score AS raw_score
ORDER BY raw_score DESC
LIMIT $limit

Step 4 — Update _get_semantic_paragraphs

Same as Step 3. Paragraphs already carry paragraph.documentid as a property; match the Document node identically:

MATCH (doc:Document {id: paragraph.documentid})

then append _acl_where_clause('doc').

Step 5 — Update _get_semantic_summaries

The summary query already traverses to a Document node bound as d via MATCH (c)-[:PART_OF]->(d:Document). Append _acl_where_clause('d') to the existing WHERE block. No additional MATCH clause is needed.

Step 6 — Forward effective_user_id through _semantic_search

Add effective_user_id: Optional[str] = None to _semantic_search. In the ThreadPoolExecutor block, pass effective_user_id to each of the three futures:

chapter_future   = executor.submit(self._get_semantic_chapters,   search_embedding, subset_of_datasetids, effective_user_id)
paragraph_future = executor.submit(self._get_semantic_paragraphs, search_embedding, subset_of_datasetids, effective_user_id)
summary_future   = executor.submit(self._get_semantic_summaries,  search_embedding, subset_of_datasetids, effective_user_id)

5. Testing Strategy

Test Cases / Scenarios

# Scenario Expected Result
T1 effective_user_id=None — ACL clauses absent Query string identical to today; zero ACL-related Cypher keywords present
T2 ACL_ENFORCEMENT_ENABLED=False Same as T1 even if effective_user_id is provided
T3 Effective User has GRANT:READ directly on the document Fragment returned for that document
T4 Effective User has GRANT:READ on a parent dossier (inherited, not protected) Fragment returned
T5 Document is isProtected=true; effective user has only inherited grant Fragment excluded
T6 Document is isDeleted=true Fragment excluded regardless of grants
T7 Effective User has no groups All EXISTS subqueries evaluate false; no fragments returned; warning logged
T8 Neo4j query fails (connection error) Empty list returned; error logged; no exception propagated
T9 Chapters, paragraphs, and summaries all run ACL-aware queries in parallel Three separate Cypher calls, each embedding the ACL clause; no extra round-trip

Acceptance Criteria - T1–T9 pass. - A grep of rag_service.py after this task confirms no Python-level ACL document list filtering is present (no if doc_id in permitted_ids pattern). - When ACL_ENFORCEMENT_ENABLED=False, the generated Cypher strings do not contain MEMBER_OF or GRANT. - When ACL_ENFORCEMENT_ENABLED=True and effective_user_id is set, each of the three Cypher strings passed to execute_neo4j_query contains the ACL EXISTS block.

Implementation of these tests Add test_acl_vector_queries.py in PythonServer/src/services/unit_testing/. Mock execute_neo4j_query to capture the Cypher string and params dict, then assert the presence or absence of the ACL block for each scenario. For permission logic (T3–T7), use an integration test against a test Neo4j instance seeded with controlled GRANT and MEMBER_OF relationships.


6. Post-Implementation

Placeholder — to be populated after implementation.

What changed: list of files modified / created

Test results: outcomes of T1–T9


Task 13.3 — Detail

1. Task Overview

Short Description Wrap _semantic_search (which, after Task 13.2, returns only ACL-permitted fragments) in a batch-retry loop inside RagService. When the number of permitted documents returned is below the caller's requested count, the loop widens the candidate pool by increasing the vector index LIMIT and re-runs retrieval until the target is reached or the vector store is exhausted. Because ACL filtering is embedded inside Neo4j (Task 13.2), there is no separate filter step — the loop simply checks the count of distinct documentid values in the result.

Objective / Expected Result - RagService.thalabus_query returns up to limit ACL-permitted documents per call. - When the first batch of TOP_N_RAG candidates yields fewer than limit permitted results, the pipeline transparently fetches a larger batch and re-filters. - The loop terminates when either the target count is met or the requested candidate pool exceeds the total number of available nodes in the vector index (exhaustion guard). - Retry behaviour is configurable via constants; no retry occurs when effective_user_id is None (no ACL filter active).


2. Context & Scope

Background / Reason for Change ACL_Design.md explicitly states: "What if top 100 results contains only unauthorized documents? → Fetch next batch." The current _semantic_search issues a single query with a fixed TOP_N_RAG = 150 limit. After ACL filtering (Task 13.2), the result set may be empty or too small if the top-ranked documents are all unauthorized. The batch-retry loop is the mitigation.

In Scope - New private method RagService._semantic_search_with_acl(search_term, effective_user_id, target_count, subset_of_datasetids) that encapsulates the loop. - Four new constants in PythonServer/src/config/constants.py: - ACL_BATCH_INITIAL_LIMIT — first-pass candidate count (default: TOP_N_RAG = 150). - ACL_BATCH_MAX_LIMIT — maximum candidate pool before giving up (default: 1500). - ACL_BATCH_MULTIPLIER — factor by which the limit grows each retry (default: 3). - ACL_BATCH_MAX_RETRIES — hard cap on loop iterations (default: 5). - Updating _semantic_search to accept an optional limit_override parameter; when set, it is forwarded as the $limit parameter and scales ANN_NEIGHBOURS_* proportionally so the vector index pre-selects a matching candidate pool. - File: PythonServer/src/services/rag_service.py and PythonServer/src/config/constants.py.

Out of Scope - True cursor-based pagination of Neo4j vector index results — the existing vector query uses LIMIT N; retry is achieved by increasing N, not by offsetting. - Changes to the public API contract of thalabus_query (handled by Tasks 13.1 and 13.4). - Any changes outside PythonServer.


3. Impact Analysis

Affected Components / Modules

File Change
PythonServer/src/services/rag_service.py New method _semantic_search_with_acl; _semantic_search accepts limit_override; thalabus_query calls the new wrapper when effective_user_id is set
PythonServer/src/config/constants.py Three new ACL batch constants

Dependencies

Task Reason
Task 13.1 effective_user_id must be in the call chain
Task 13.2 ACL filtering must be embedded in _semantic_search before the loop can rely on counting only permitted results

Risks & Edge Cases

Risk Mitigation
Infinite loop if ACL_BATCH_MAX_LIMIT is set too high Hard cap ACL_BATCH_MAX_LIMIT; add a loop iteration counter and break after a maximum of 5 retries regardless
Performance degradation on each retry (larger Neo4j scan) Log retry count and latency at PERF level; alert if more than 2 retries are needed in production
Effective User has READ on zero documents in the entire corpus Return empty list after exhaustion; do not loop forever
target_count not supplied by the caller Default to RagService.TOP_N_RAG

Backward Compatibility Considerations When effective_user_id is None, thalabus_query calls _semantic_search directly as before; the new _semantic_search_with_acl wrapper is not invoked.


4. Implementation Plan

Step 1 — Add constants

In PythonServer/src/config/constants.py:

ACL_BATCH_INITIAL_LIMIT: int = 150   # matches TOP_N_RAG
ACL_BATCH_MAX_LIMIT: int    = 1500
ACL_BATCH_MULTIPLIER: int   = 3
ACL_BATCH_MAX_RETRIES: int  = 5

Step 2 — Add limit_override to _semantic_search

Add an optional limit_override: Optional[int] = None parameter. When set, use it instead of the class-level TOP_N_* constants for all three index queries (chapters, paragraphs, summaries).

Step 3 — Implement _semantic_search_with_acl

Because _semantic_search already returns only ACL-permitted fragments (Task 13.2), the loop simply counts distinct documentid values in the result and re-calls with a larger limit if needed. No separate filter call is required.

def _semantic_search_with_acl(
    self,
    search_term: str,
    effective_user_id: str,
    target_count: int,
    subset_of_datasetids: Optional[List[str]] = None,
) -> List[Dict[str, Any]]:
    from src.config.constants import (
        ACL_BATCH_INITIAL_LIMIT, ACL_BATCH_MAX_LIMIT,
        ACL_BATCH_MULTIPLIER, ACL_BATCH_MAX_RETRIES,
    )
    current_limit = ACL_BATCH_INITIAL_LIMIT
    attempt = 0
    permitted_fragments: List[Dict[str, Any]] = []
    while attempt < ACL_BATCH_MAX_RETRIES and current_limit <= ACL_BATCH_MAX_LIMIT:
        attempt += 1
        # _semantic_search returns only ACL-permitted fragments when effective_user_id is set (Task 13.2)
        permitted_fragments = self._semantic_search(
            search_term, subset_of_datasetids,
            effective_user_id=effective_user_id, limit_override=current_limit
        )
        unique_permitted_docs = {f["documentid"] for f in permitted_fragments if f.get("documentid")}
        log(INFO, f"{self._log_prefix}._sem_with_acl: attempt={attempt} limit={current_limit} "
                  f"permitted_docs={len(unique_permitted_docs)}")
        if len(unique_permitted_docs) >= target_count:
            return permitted_fragments
        if current_limit >= ACL_BATCH_MAX_LIMIT:
            log(WARNING, f"{self._log_prefix}._sem_with_acl: exhausted at limit={current_limit}")
            return permitted_fragments
        current_limit = min(current_limit * ACL_BATCH_MULTIPLIER, ACL_BATCH_MAX_LIMIT)
    return permitted_fragments

Step 4 — Wire into thalabus_query

Replace the existing _semantic_search call inside thalabus_query with a conditional:

if effective_user_id:
    fragments = self._semantic_search_with_acl(term, effective_user_id, target_count, subset_of_datasetids)
else:
    fragments = self._semantic_search(term, subset_of_datasetids)

5. Testing Strategy

Test Cases / Scenarios

# Scenario Expected Result
T1 First batch yields enough permitted results Loop exits after one iteration; single _semantic_search call
T2 First batch all unauthorized; second batch has enough Loop runs twice; returns results from wider batch
T3 ACL_BATCH_MAX_RETRIES reached before target Returns best available permitted subset; logs warning
T4 ACL_BATCH_MAX_LIMIT reached (exhaustion) Returns whatever is available; logs warning
T5 effective_user_id=None — retry loop not entered _semantic_search called directly; original behaviour
T6 All documents in corpus are unauthorized Empty list returned; no infinite loop

Acceptance Criteria - T1–T6 pass. - Loop iterates at most ACL_BATCH_MAX_RETRIES times regardless of ACL results. - Retry count and latency appear in PERF-level logs. - When effective_user_id=None, _semantic_search_with_acl is never called.

Implementation of these tests Add test_semantic_search_with_acl.py in PythonServer/src/services/unit_testing/. Use unittest.mock.patch to mock _semantic_search (returns a controlled list of already-permitted fragments) and assert loop termination conditions for each scenario. Because ACL filtering is in Neo4j (Task 13.2), there is no _filter_by_acl to mock.


6. Post-Implementation

Placeholder — to be populated after implementation.

What changed: list of files modified / created

Test results: outcomes of T1–T6


Task 13.4 — Detail

1. Task Overview

Short Description Update the thalabus_query response to guarantee that — when effective_user_id is provided — only ACL-permitted documents appear in the returned list. This task also aligns the RagInterface.thalabus_query docstring and the internal document-aggregation step in RagService so that permission-filtered fragments are the sole input to the aggregation, preventing unauthorized content from leaking through via fragment-to-document rollup.

Objective / Expected Result - When effective_user_id is set, thalabus_query returns only documents for which the effective user holds READ permission. - The existing (results, dataset_ids) return type is preserved; no breaking change to the call signature. - results contains document-level dicts where every entry has been drawn exclusively from ACL-permitted fragments produced by _semantic_search_with_acl. - When effective_user_id is None, the return value is identical to today (no filtering).


2. Context & Scope

Background / Reason for Change Tasks 13.2 and 13.3 build the ACL filtering and retry infrastructure, but they operate at the fragment level (chapters, paragraphs, summaries). thalabus_query aggregates fragments into document-level results via a document-rollup step. Without explicitly ensuring that the aggregation input has already been ACL-filtered, a document could be included in the output if one of its fragments slipped through due to an edge case. This task closes that gap and makes the contract explicit in the interface docstring.

In Scope - Ensuring thalabus_query passes only ACL-filtered fragments to the document-aggregation step when effective_user_id is set. - Updating the RagInterface.thalabus_query docstring to document effective_user_id and the ACL guarantee. - Updating the RagService.thalabus_query docstring accordingly. - Files: PythonServer/src/interfaces/rag_interface.py, PythonServer/src/services/rag_service.py.

Out of Scope - Changing the (List[dict], Optional[List[str]]) return type. - Modifying the document-aggregation logic itself — only its input is changed. - Exposing the ACL enforcement via a new HTTP endpoint — the existing endpoint surface is unchanged in this task. - Any changes outside PythonServer.


3. Impact Analysis

Affected Components / Modules

File Change
PythonServer/src/interfaces/rag_interface.py Update thalabus_query docstring to document effective_user_id and ACL guarantee
PythonServer/src/services/rag_service.py Ensure the aggregation step receives only permitted fragments; update docstring

Dependencies

Task Reason
Task 13.1 effective_user_id parameter must exist in the signature
Task 13.2 ACL filtering must be embedded in _semantic_search so that all fragments entering the aggregation step are already permitted
Task 13.3 _semantic_search_with_acl must produce the filtered fragment list before aggregation

Risks & Edge Cases

Risk Mitigation
Fragment-to-document aggregation introduces documents not present in the permitted fragment list After aggregation, add an explicit guard: results = [r for r in results if r.get("documentid") in permitted_doc_ids]
Dataset created from search results (create_dataset=True) includes unauthorized documents Dataset creation happens after aggregation; because only permitted fragments reach aggregation, the dataset is inherently clean
return_documents=False (raw fragment mode) bypasses aggregation In raw fragment mode the permitted fragment list is returned directly; no additional guard needed

Backward Compatibility Considerations No change when effective_user_id is None. Callers that already supply effective_user_id=None (explicit or by default) observe identical behaviour.


4. Implementation Plan

Step 1 — Guard the aggregation input in thalabus_query

In RagService.thalabus_query, locate the call to _semantic_search (or its replacement added in Task 13.3). After the fragment list is produced by _semantic_search_with_acl, capture the set of permitted document IDs:

if effective_user_id:
    permitted_fragments = self._semantic_search_with_acl(term, effective_user_id, target_count, subset_of_datasetids)
    permitted_doc_ids = {f["documentid"] for f in permitted_fragments if f.get("documentid")}
else:
    permitted_fragments = self._semantic_search(term, subset_of_datasetids)
    permitted_doc_ids = None  # no ACL constraint

After the document-aggregation step, apply the post-aggregation guard when permitted_doc_ids is set:

if permitted_doc_ids is not None:
    results = [r for r in results if r.get("documentid") in permitted_doc_ids]

Step 2 — Update docstrings

In RagInterface.thalabus_query and RagService.thalabus_query, document: - effective_user_id — the Neo4j User.id of the requesting user; when provided, only documents the effective user holds READ permission on are included in the result. - The ACL guarantee: "When effective_user_id is set, no unauthorized document appears in the returned list."


5. Testing Strategy

Test Cases / Scenarios

# Scenario Expected Result
T1 effective_user_id set; aggregation produces a doc not in permitted fragment set Post-aggregation guard removes the doc; it does not appear in results
T2 effective_user_id set; all aggregated docs are permitted results unchanged by the guard
T3 effective_user_id=None Guard not applied; results identical to current behaviour
T4 return_documents=False; effective_user_id set Raw permitted fragments returned; no doc-level aggregation; all fragments are ACL-filtered
T5 create_dataset=True; effective_user_id set Dataset contains only permitted document fragments

Acceptance Criteria - T1–T5 pass. - Static analysis (grep) confirms no code path in thalabus_query can return a document whose documentid is not in permitted_doc_ids when effective_user_id is provided. - The interface docstring explicitly states the ACL guarantee.

Implementation of these tests Extend the tests in PythonServer/src/services/unit_testing/rag_service_unit_test.py. For T1, mock _semantic_search_with_acl to return fragments for doc A only, and mock the aggregation to produce docs A and B; assert only doc A appears in the final output.


6. Post-Implementation

What changed: - PythonServer/src/services/rag_service.py: - Added permitted_doc_ids capture from ACL-filtered fragments after _evaluate_thalabus_ast returns (when effective_user_id is set). - Added post-aggregation guard after results.sort(...) that filters results to only entries whose id or documentid appears in permitted_doc_ids. - Updated thalabus_query docstring to document the ACL guarantee. - PythonServer/src/interfaces/rag_interface.py: - Updated thalabus_query docstring: effective_user_id param now states the ACL guarantee explicitly; :return: documents that results contain only ACL-permitted entries when effective_user_id is set. - PythonServer/src/services/unit_testing/test_acl_response_contract.py (created): - T1–T5 unit tests using unittest.mock — no live Neo4j required.

Test results: T1 ✓ T2 ✓ T3 ✓ T4 ✓ T5 ✓ (all pass with mocked pipeline)


Task 13.5 — Detail

1. Task Overview

Short Description Activate the SUPER_ADMIN bypass in the Python vector search ACL filter. The _acl_where_clause helper in RagService contains a commented-out Cypher fragment that allows a SUPER_ADMIN user to bypass ACL restrictions during semantic search. This task uncomments the bypass and verifies that SUPER_ADMIN users receive unrestricted search results.

Objective / Expected Result - When effective_user_id belongs to a tenant group (identified by capability traversal (Group)-[:HAS_ROLE]->(:UserRole)-[:HAS_CAPABILITY]->(:UserCapability:SystemAcl {id: 'tenant-admin'})), _semantic_search_with_acl returns all semantically relevant documents regardless of isProtected flags or grant relationships. - Non-SUPER_ADMIN behaviour is unchanged.


2. Context & Scope

Background / Reason for Change The bypass was left as a comment in Task 13.2 pending full population of the ACL graph (Tasks 3–10). At the time Task 13.5 is reached in the implementation sequence, all prerequisites (tenant group node, [:MEMBER_OF] relationships, tenant-admin capability chain) are guaranteed to be in place.

In Scope - Uncommenting the SUPER_ADMIN bypass OR clause in _acl_where_clause in PythonServer/src/services/rag_service.py. - Removing the Task 13.5 reference comment and replacing it with a plain explanatory comment. - Verifying with at least one integration test that a SUPER_ADMIN effective user receives results that a non-SUPER_ADMIN effective user with no grants would not.

Out of Scope - Changes to the C# ACL stack. - Changes to any method other than _acl_where_clause and its callers.


3. Impact Analysis

Affected Components / Modules

File Change
PythonServer/src/services/rag_service.py Uncomment the SUPER_ADMIN bypass OR clause in _acl_where_clause

Dependencies

Task Reason
Task 13.2 _acl_where_clause must exist; SUPER_ADMIN bypass comment is defined there
Tasks 3–10 SUPER_ADMIN Group node with tenant-admin capability and [:MEMBER_OF] relationships must be present in the Neo4j graph

Risks & Edge Cases

Risk Mitigation
SUPER_ADMIN bypass inadvertently applied when ACL graph is incomplete Task ordering guarantees Tasks 3–10 are complete before Task 13.5
tenant-admin capability chain missing from group Task 0.2 provisions this chain at bootstrap; verify with a schema check test

4. Implementation Plan

Step 1 — Uncomment the bypass in _acl_where_clause

In PythonServer/src/services/rag_service.py, in the _acl_where_clause method, change:

        # SUPER_ADMIN bypass — enabled in Task 13.5 once ACL graph (Tasks 3–10) is fully populated
        # OR EXISTS {{ MATCH (u:User {{id: $effectiveUserId}})-[:MEMBER_OF]->(g:Group)-[:HAS_ROLE]->(:UserRole)-[:HAS_CAPABILITY]->(:Capability {{id: 'tenant-admin'}}) }}

to:

        # SUPER_ADMIN bypass: members of any group holding the 'tenant-admin' capability can access all documents
        OR EXISTS {{ MATCH (u:User {{id: $effectiveUserId}})-[:MEMBER_OF]->(g:Group)-[:HAS_ROLE]->(:UserRole)-[:HAS_CAPABILITY]->(:Capability {{id: 'tenant-admin'}}) }}

Step 2 — Integration test

Add a test in PythonServer/src/services/unit_testing/ that: 1. Mocks the Neo4j query to simulate a SUPER_ADMIN user (group holds tenant-admin capability). 2. Mocks _semantic_search to return a document with isProtected=true and no explicit grant for the effective user. 3. Asserts the document appears in the results (bypass active). 4. Asserts the same document does NOT appear for a non-SUPER_ADMIN effective user with no grants.


5. Testing Strategy

Test Cases / Scenarios

# Scenario Expected Result
T1 effective_user_id belongs to tenant group; target doc is isProtected=true with no direct grant Doc returned
T2 effective_user_id is a regular user with no grants on a protected doc Doc not returned
T3 effective_user_id is a regular user with a valid [:GRANT:READ] grant Doc returned (normal ACL path)
T4 effective_user_id=None Bypass not applied; unrestricted results as before

Acceptance Criteria - T1–T4 pass. - The _acl_where_clause helper no longer contains any commented-out code for the SUPER_ADMIN bypass.

Implementation of these tests Add test_super_admin_acl_bypass.py in PythonServer/src/services/unit_testing/. Mock the Neo4j driver to return controlled group membership data.


6. Post-Implementation

What changed: - PythonServer/src/services/rag_service.py: - _acl_where_clause: SUPER_ADMIN bypass OR clause uncommented and made live Cypher. - Docstring updated: placeholder text replaced with description of the active bypass. - PythonServer/src/services/unit_testing/test_super_admin_acl_bypass.py (created): - T1 (3 assertions): _acl_where_clause output contains a non-commented HAS_CAPABILITY OR clause. - T2 (3 assertions): Cypher reaching Neo4j via _get_semantic_chapters/paragraphs/summaries carries HAS_CAPABILITY / tenant-admin when ACL is active. - T3 (3 assertions): Fragments returned by Neo4j for a SUPER_ADMIN / granted / no-grant effective user flow correctly through the Python layer. - T4 (2 assertions): effective_user_id=None and ACL_ENFORCEMENT_ENABLED=False suppress the bypass clause entirely.

Test results: T1 ✓ T2 ✓ T3 ✓ T4 ✓ (all pass with mocked pipeline)


Implementation Order

This section is derived mechanically from the Dependencies tables embedded in each task's detail section. Tasks within the same phase have no interdependency and can be executed in parallel. A phase must not start until every task in all preceding phases is complete.

Notes Before You Start

Note Detail
Tasks 0.1 and 0.2 run first, implemented last They are the first things to execute at application startup, but their implementation depends on late-phase infrastructure: all of Task 5 must be complete for 0.1; Tasks 3.1 and 3.3 must be complete for 0.2.
Task 8 is a long serial chain ResourceService subtasks 8.2 → 8.3 → … → 8.10.1 form a strict sequential pipeline (10 steps). No parallelism is possible within this chain.
Python ACL integration (Task 13) is last It requires the entire C# ACL stack (Tasks 3–10) to be complete before ACL-filtered semantic search can be meaningfully implemented.

Key Dependency Chains

The longest (critical-path) chains through the task graph:

Relationship rename  :  1.3 ──► 1.1 / 1.2 ──► 7.1

User entity          :  2.1 ──► 2.2 ──► 2.3 ─────────────────────────────► 2.4
                                                                              ▲
Group entity         :  3.1 ──► 3.3 ──► 3.4 ──► 3.4.1                      │
                                  └────► 3.5 ──► 4.1 ──► 4.2 ──► 4.3 ──────┘

Dossier entity       :  5.1 ──► 5.2 ──► 5.3 ──► 5.4 ──► 0.1

Permission / ACL     :  9.1 ──► 9.2 ──► 9.3 ──► 10.3 ──► 10.4 ──► 10.5
                          └──────────────────────► 10.7 ──► 10.7.1 ──► 12.1 ──► 12.2 ──► 12.5 ──► 12.6

Resource service     :  8.1 ──► 8.2 ──► 8.3 ──► 8.4 ──► 8.5 ──► 8.6 ──► 8.7 ──► 8.8 ──► 8.9 ──► 8.10 ──► 8.10.1

Audit + Python ACL   :  11.1 ──► 11.2 ──► 11.3 ──► 11.4  (needs 8.10.1)
                        2.1   ──► 13.1 ──► 13.2 ──► 13.3 ──► 13.4  (needs Tasks 3–10 complete)

Phase 1 — Foundation: Interfaces, Enums & DB Migration Script

No prerequisites. All 10 tasks are fully independent.

Task Short Description
1.3 Write one-time Neo4j Cypher migration script renaming legacy [:OWNS] relationships
2.1 Add Id to INeo4jUser
3.1 Define INeo4jGroup interface (Id, Name, IsUserGroup)
3.2 Define IGroupService interface
5.1 Add ACL fields to INeo4jDossier interface (isProtected, isDeleted, deletedAt, deletedBy, updatedAt)
6.1 Add ACL fields to INeo4jDocument interface (isProtected, isDeleted, deletedAt, deletedBy, updatedAt)
8.1 Define IResourceService interface
9.1 Define Permission enum (Read, Sharing, Delete, Add, Update, Ownership)
10.1 Define IAclService interface
11.1 Define AuditEventType enum (16 event types)

Phase 2 — Entity Implementations & First Migrations

Requires Phase 1.

Task Short Description Key Prerequisites
1.1 Rename [:OWNS]→[:CONTAINS] in Neo4jDocumentRepository.cs 1.3
1.2 Rename [:OWNS]→[:HAS_DOSSIER] in Neo4jDocumentRepository.cs 1.3
2.2 Implement new INeo4jUser properties in Neo4jUser; persist via Neo4jUsersManagement 2.1
3.3 Implement Neo4jGroup entity (reads id, name, isUserGroup from INode) 3.1
5.2 Migrate DossierType enum to root / user / session / visible 5.1
6.2 Implement Neo4jDocument with new ACL properties 6.1
9.2 Define RoleType enum and AclRoles static class 9.1
10.2 Implement AclService.CheckAccess 10.1, 9.1
10.6 Implement AclService.IsTenantAdmin private helper 0.2
11.2 Define IAuditService interface 11.1
13.1 Add effective_user_id parameter to Python RagInterface.thalabus_query 2.1

Phase 3 — Service Foundations, Graph Structure & SuperAdmin Bootstrap

Requires Phase 2.

Task Short Description Key Prerequisites
0.2 Implement SuperAdmin Group node bootstrap step in BootstrapService.Init() 3.1, 3.3
2.3 Neo4j migration to backfill id on existing User nodes 2.2
3.4 Implement GroupService (all five IGroupService methods) 3.2, 3.3
3.5 (TBD) Neo4j Group schema migration + Group.id uniqueness constraint 3.1, 3.3
5.3 Implement Neo4jDossier with new ACL properties 5.1, 5.2
6.3 Neo4j migration to backfill document ACL fields on existing Document nodes 6.1, 6.2
7.1 Update INeo4jSession / Neo4jSession to reflect [:HAS_DOSSIER] semantics 1.2
9.3 Define IAclEntry interface and AclEntry record 9.1, 9.2
11.3 Implement AuditService.Log (writes to thalabus_AUDIT.log) 11.1, 11.2

Phase 4 — Service Wiring, ACL Mutations & Resource Service Start

Requires Phase 3.

Task Short Description Key Prerequisites
3.4.1 Register IGroupService in Program.cs 3.4
4.1 Implement hidden system Group node creation (step 1 of ProvisionHiddenGroupAndDossierForUser) 3.1, 3.3, 3.5, 2.1
5.4 Neo4j migration to backfill isProtected, isDeleted, updatedAt on existing Dossier nodes 5.2, 5.3
7.2 Implement cascade purge on session deletion (hard-delete [:HAS_DOSSIER] dossier and all contents) 7.1
10.3 Implement AclService.GetEffectivePermissions 10.2, 9.3, 3.2
10.7 Implement AclService ACL mutation methods (GetAcl, SetAcl, AddPermission, RemovePermission) 9.3, 9.2
8.2 Implement ResourceService.CreateDossier 8.1, 5.3, 6.2

Phase 5 — Full Dossier Schema, Permission Graph & Enforcement Scaffolding

Requires Phase 4.

Task Short Description Key Prerequisites
4.2 Implement hidden user Dossier node creation (step 2 of ProvisionHiddenGroupAndDossierForUser) 4.1
9.4 Neo4j migration to create [:GRANT:<PERMISSION>] relationships from existing ownership 3.5, 5.4, 6.3, 9.1
10.4 Implement AclService.GetInheritedPermissions (ancestor walk; stops at protected node) 10.3
10.7.1 Register IAclService in Program.cs 10.7, 3.4.1
8.3 Implement ResourceService.createDocument 8.2
12.3 Enforce ACL on dossier read operations (GetDossier, GetDossiers, GetDossiersCount) 10.2, 3.4.1

Phase 6 — Root Dossier Bootstrap, Protection Groundwork & First Enforcement

Requires Phase 5.

Task Short Description Key Prerequisites
0.1 Implement root Dossier node bootstrap step in BootstrapService.Init() Task 5 complete (5.1–5.4)

| 10.5 | Verify isProtected inheritance-break logic across GetEffectivePermissions and GetInheritedPermissions | 10.3, 10.4 | | 8.4 | Implement ResourceService.moveResource | 8.3 | | 12.1 | Enforce ACL READ on document read operations in Neo4jDocumentRepository (8 methods) | 10.2, 10.7.1, 9.1 | | 12.4 | Enforce ACL on dossier write/delete operations | 10.2, 12.3 |


Phase 7 — User Provisioning Complete & Document Write Enforcement

Requires Phase 6.

Task Short Description Key Prerequisites
4.4 Migrate existing users — backfill hidden group and dossier for all pre-existing accounts 4.1, 4.2
2.4 Wire IGroupService.ProvisionHiddenGroupAndDossierForUser into UserService.AddUser 2.2, 3.4, 4.1, 4.2
8.5 Implement ResourceService.renameResource 8.4
12.2 Enforce ACL on document write/delete operations in Neo4jDocumentRepository (7 methods) 10.2, 12.1

Phase 8 — Resource Queries & Full Enforcement Layer

Requires Phase 7.

Task Short Description Key Prerequisites
8.6 Implement ResourceService.getResource and getChildren 8.5
12.5 Enforce ACL on session-dossier linking and linked document adds 10.2, 12.2

Phase 9 — Protection Logic & SuperAdmin Audit Emission

Requires Phase 8.

Task Short Description Key Prerequisites
8.7 Implement ResourceService.setProtected / unprotect (requires SHARING permission) 8.6, 9.1
12.6 Emit SuperAdminAccessUsed / AccessDenied audit events; annotate system-exempt methods 11.3, 12.1–12.5, 10.6

Phase 10 — Resource Lifecycle (Strictly Serial)

Requires Phase 9. Each step depends on the previous one — no parallelism is possible.

Task Short Description Key Prerequisites
8.8 Implement ResourceService.deleteResource (soft delete, immediate inaccessibility) 8.7
8.9 Implement ResourceService.purgeResource (hard delete, unrecoverable) 8.8
8.10 Implement async purge cron task (hard-deletes soft-deleted resources after TTL) 8.9
8.10.1 Register IResourceService in Program.cs 8.10

Phase 11 — Audit Wiring & Python ACL Integration

Requires Phase 10. Within this phase: 11.4 and 13.2 are independent and can run in parallel; 13.3 and 13.4 are serial continuations of 13.2.

Task Short Description Key Prerequisites
11.4 Wire IAuditService.Log into ResourceService, AclService, GroupService (13 event points) 11.3, 8.10.1, 10.7, 3.4
13.2 Embed ACL READ filter into Python semantic search Cypher (_get_semantic_chapters, etc.) 13.1, Tasks 3–10 complete
13.3 Implement batch-retry loop in _semantic_search_with_acl 13.2
13.4 Post-aggregation guard: ensure only ACL-permitted documents appear in thalabus_query results 13.3
13.5 Enable SUPER_ADMIN bypass in vector search ACL filter (_acl_where_clause) 13.2

Quick-Reference: All 66 Tasks in Implementation Order

Phase Tasks (parallel within phase unless noted)
1 1.3, 2.1, 3.1, 3.2, 5.1, 6.1, 8.1, 9.1, 10.1, 11.1
2 1.1, 1.2, 2.2, 3.3, 5.2, 6.2, 9.2, 10.2, 10.6, 11.2, 13.1
3 0.2, 2.3, 3.4, 3.5, 5.3, 6.3, 7.1, 9.3, 11.3
4 3.4.1, 4.1, 5.4, 7.2, 10.3, 10.7, 8.2
5 4.2, 9.4, 10.4, 10.7.1, 8.3, 12.3
6 0.1, 4.3, 10.5, 8.4, 12.1, 12.4
7 4.4, 2.4, 8.5, 12.2
8 8.6, 12.5
9 8.7, 12.6
10 8.8 → 8.9 → 8.10 → 8.10.1 (serial)
11 11.4 ∥ 13.2 → 13.3 → 13.4 → 13.5
#### 6. Post-Implementation

Date: 2026-04-30

What changed: - Created migration script Neo4jORM/migrations/008_group_and_membership.cypher. - Adds uniqueness constraint on Group.id and index on Group.name.

Test results: - Migration script created and ready to run. Idempotency ensured by IF NOT EXISTS clauses.