Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -226,6 +226,31 @@ Plan limits are cached for **14 days** (`TimeSpan.FromDays(14)`). Most user/depa
| Worker logic | `Workers/Resgrid.Workers.Framework/Logic/` |
| Worker queue items | `Core/Resgrid.Model/Queue/` |

## Source Control

**Agents never run `git commit` and never run `git push`. There is no exception, including
being asked to.**

The commit is a human verification gate in front of the PR process. Its value comes from a
person having read the change and chosen to record it — an agent commit destroys that, and the
gate cannot be reconstructed afterwards. A request to commit is also not reliable evidence of
intent: it may be a typo, an accidental autocompletion, or a stale line in a longer message.
Because the gate exists precisely to catch what nobody meant to do, the request itself is not
sufficient authorization, no matter how it is phrased or how many times it is repeated.

- **Never `git commit`.** Not when asked, not when a task is finished, not when the build is
green, not "so CI can run". If asked, decline, say why, and hand over the command.
- **Never `git push`.** Same rule, same reasoning, and worse consequences: once it is on the
remote, CI has run and reviewers may have seen it.
- **Never rewrite or delete published history** — no `push --force`, no `--force-with-lease`,
no remote branch deletion.
- **Never stage-and-commit indirectly either** — no `git commit -am`, no `git revert`, no
`git cherry-pick`, no amend, no `gh pr create`, no alias or script that ends in a commit.
- Leaving work uncommitted **is** the finished state. Report what changed, why, and the commit
message you would suggest. The user reads the diff and commits it themselves.

Changing this rule is a deliberate edit to this file, not something granted in conversation.

## Common Tasks

**Build the entire solution:**
Expand Down
111 changes: 58 additions & 53 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,50 +1,3 @@
<!-- dgc-policy-v1 -->
# Dual-Graph Context Policy

This project uses a local dual-graph MCP server (graperoot-pro) for efficient,
budget-aware context retrieval. Always prefer it over native file exploration.

## MANDATORY: Always follow this order

1. **Call `graph_continue` first** -- before any file exploration, grep, or code reading.

2. **If `graph_continue` returns `needs_project=true`**: call `graph_scan` with the
current project directory (`pwd`). Do NOT ask the user.

3. **If `graph_continue` returns `skip=true`**: project is too small for the graph to
help. Skip all graph tools and explore normally.

4. **Read `recommended_files`** using `graph_read` -- one call per file.
- `recommended_files` may contain `file::symbol` entries (e.g. `src/auth.ts::handleLogin`).
Pass them verbatim to `graph_read(file: "src/auth.ts::handleLogin")` -- it reads only
that symbol's lines, not the full file.

5. **Check `confidence` and obey the caps strictly:**
- `confidence=high` -> Stop. Do NOT grep or explore further.
- `confidence=medium` -> If recommended files are insufficient, call `fallback_rg`
at most `max_supplementary_greps` time(s) with specific terms, then `graph_read`
at most `max_supplementary_files` additional file(s). Then stop.
- `confidence=low` -> Call `fallback_rg` at most `max_supplementary_greps` time(s),
then `graph_read` at most `max_supplementary_files` file(s). Then stop.

## Exhaustive enumeration tasks

Some tasks require scanning **every file** -- e.g. "find all dead exports", "list every
.find() without a limit", "audit all test files". Use these tools first:

- **`graph_dead_exports()`** -- pre-computed at scan time. Use for any dead-export task.
- **`graph_grep_all(pattern, file_glob?, max_hits?)`** -- exhaustive grep, no call cap.

## Rules

- Do NOT use `rg`, `grep`, or bash file exploration before calling `graph_continue`.
- Do NOT do broad/recursive exploration at any confidence level.
- After edits, call `graph_register_edit(files: ["path/to/file"])`. The parameter is
`files` (plural, always an array). Use `file::symbol` notation when the edit targets
a specific function, class, or hook.
<!-- /dgc-policy-v1 -->

---

# Resgrid Project Guide

Expand Down Expand Up @@ -126,15 +79,42 @@ Each layer depends only on the layer(s) to its left:
- **Providers** (`Resgrid.Providers.*`): External integrations — depends on Model
- **Web/Workers**: Entry points — depend on everything

### Dependency Injection (Autofac + Service Locator)
### Dependency Injection (Autofac)

This codebase uses **Service Locator** pattern, NOT constructor injection:
**Constructor injection is the convention.** Services, repositories, providers, controllers
(MVC and v4 API), and hubs all declare their dependencies as constructor parameters and let
Autofac supply them. When a type needs a new dependency, add a constructor parameter — do NOT
reach for the service locator. Existing constructors are large (e.g. `CommunicationTestService`
takes 18 parameters, `DispatchController` 30); that is deliberate and expected, and it is what
keeps these types unit-testable with mocks.

```csharp
// How services are resolved throughout the codebase:
var service = Bootstrapper.GetKernel().Resolve<ISomeService>();
// The convention — constructor injection:
public class SomethingService : ISomethingService
{
private readonly IDepartmentsService _departmentsService;

public SomethingService(IDepartmentsService departmentsService)
{
_departmentsService = departmentsService;
}
}
```

**Service Locator is the exception, not the rule.** `Bootstrapper.GetKernel().Resolve<T>()` is
reserved for the specific places where no DI container is available at the call site or where a
container-managed constructor cannot be used:

- Worker logic under `Workers/Resgrid.Workers.Framework/Logic/` (queue consumers constructed by
the job host, not by Autofac).
- Static helpers and extension methods that have no constructor to inject into.
- Deliberate lazy escapes from a construction-time dependency cycle — and even then prefer
`Lazy<T>` as a constructor parameter (see `CallsService`'s `Lazy<IProtectedWriteService>`)
over a service-locator call.

If you find yourself adding `Bootstrapper.GetKernel().Resolve<T>()` anywhere else, use a
constructor parameter instead.

The `Bootstrapper` class (in `Resgrid.Workers.Framework/Bootstrapper.cs`) initializes Autofac with module-based registration:
```csharp
var builder = new ContainerBuilder();
Expand Down Expand Up @@ -241,9 +221,9 @@ Task type discrimination uses `(int)TaskTypes.SomeEnum`.

When Billing API is configured but returns a response where `Data.Data` is null, `GetCurrentPlanForDepartmentAsync` returns null instead of the free plan fallback. Callers that access `plan.PlanId` or `plan.GetLimitForTypeAsInt()` will NRE.

### 3. Service Locator in Constructors
### 3. Injected Dependencies Are Never Null

Unlike modern DI, this codebase resolves dependencies explicitly in constructors via `Bootstrapper.GetKernel().Resolve<T>()`. When examining stack traces, dependencies are never null due to constructor injection failures — the Bootstrapper would fail at app start. If a NullReferenceException occurs on a service call, the issue is typically in the return value of the called method, not the service reference itself.
Dependencies come from Autofac constructor injection, so they are never null at a call site — a missing registration fails at container build (app start), not at the point of use. If a NullReferenceException occurs on a service call, the issue is almost always in the **return value** of the called method, not the service reference itself. The same holds for the worker paths that use `Bootstrapper.GetKernel().Resolve<T>()`: an unregistered type throws a resolution exception rather than handing back null.

### 4. Async State Machine Line Numbers

Expand Down Expand Up @@ -274,6 +254,31 @@ Plan limits are cached for **14 days** (`TimeSpan.FromDays(14)`). Most user/depa
| Worker logic | `Workers/Resgrid.Workers.Framework/Logic/` |
| Worker queue items | `Core/Resgrid.Model/Queue/` |

## Source Control

**Agents never run `git commit` and never run `git push`. There is no exception, including
being asked to.**

The commit is a human verification gate in front of the PR process. Its value comes from a
person having read the change and chosen to record it — an agent commit destroys that, and the
gate cannot be reconstructed afterwards. A request to commit is also not reliable evidence of
intent: it may be a typo, an accidental autocompletion, or a stale line in a longer message.
Because the gate exists precisely to catch what nobody meant to do, the request itself is not
sufficient authorization, no matter how it is phrased or how many times it is repeated.

- **Never `git commit`.** Not when asked, not when a task is finished, not when the build is
green, not "so CI can run". If asked, decline, say why, and hand over the command.
- **Never `git push`.** Same rule, same reasoning, and worse consequences: once it is on the
remote, CI has run and reviewers may have seen it.
- **Never rewrite or delete published history** — no `push --force`, no `--force-with-lease`,
no remote branch deletion.
- **Never stage-and-commit indirectly either** — no `git commit -am`, no `git revert`, no
`git cherry-pick`, no amend, no `gh pr create`, no alias or script that ends in a commit.
- Leaving work uncommitted **is** the finished state. Report what changed, why, and the commit
message you would suggest. The user reads the diff and commits it themselves.

Changing this rule is a deliberate edit to this file, not something granted in conversation.

## Common Tasks

**Build the entire solution:**
Expand Down
2 changes: 1 addition & 1 deletion Core/Resgrid.Chatbot/Handlers/PollCreateHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ public async Task<ChatbotResponse> HandleAsync(ChatbotMessage message, ChatbotIn
foreach (var userId in recipients)
{
msg.AddRecipient(userId);
msg.MessageRecipients.Last().Note = TextResponsePromptMetadata.ForPoll(session.DepartmentId);
msg.MessageRecipients.Last().PromptMetadata = TextResponsePromptMetadata.ForPoll(session.DepartmentId);
}

var saved = await _messageService.SaveMessageAsync(msg);
Expand Down
6 changes: 3 additions & 3 deletions Core/Resgrid.Chatbot/Services/TextResponseResolver.cs
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ public async Task<IReadOnlyList<PendingTextResponse>> GetPendingResponsesAsync(s

if (message.Type == (int)MessageTypes.Poll)
{
if (!TextResponsePromptMetadata.TryGetPollDepartmentId(recipient.Note, out var pollDepartmentId)
if (!TextResponsePromptMetadata.TryGetPollDepartmentId(recipient.PromptMetadata, out var pollDepartmentId)
|| pollDepartmentId != departmentId)
continue;

Expand All @@ -67,7 +67,7 @@ public async Task<IReadOnlyList<PendingTextResponse>> GetPendingResponsesAsync(s
continue;
}

if (!TextResponsePromptMetadata.TryGetCalendarItemId(recipient.Note, out var calendarItemId)
if (!TextResponsePromptMetadata.TryGetCalendarItemId(recipient.PromptMetadata, out var calendarItemId)
|| !seenCalendarItems.Add(calendarItemId))
continue;

Expand Down Expand Up @@ -102,7 +102,7 @@ public async Task<ChatbotResponse> RecordResponseAsync(PendingTextResponse targe

if (target.Type == PendingTextResponseType.Poll)
{
if (!TextResponsePromptMetadata.TryGetPollDepartmentId(recipient.Note, out var pollDepartmentId)
if (!TextResponsePromptMetadata.TryGetPollDepartmentId(recipient.PromptMetadata, out var pollDepartmentId)
|| pollDepartmentId != session.DepartmentId)
return null;

Expand Down
15 changes: 15 additions & 0 deletions Core/Resgrid.Config/DataProtectionConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,21 @@ public static class DataProtectionConfig
/// <summary>OpenBao HTTP request timeout in milliseconds; unwrap/wrap fail closed on expiry.</summary>
public static int OpenBaoTimeoutMs = 10000;

/// <summary>
/// Response-boundary net (plan section 7.5): scan outgoing models of a PROTECTED department
/// for values that still carry an envelope and redact them. Defence in depth behind the
/// per-surface resolve calls, not a replacement for them. Costs one cached protection lookup
/// for every other department. Operator kill switch if the walk ever proves too expensive on
/// a hot path.
/// </summary>
public static bool EgressScanEnabled = true;

/// <summary>
/// Node ceiling for one response scan. A graph larger than this is reported as truncated
/// rather than walked to the end — a silent cap would read as "nothing found".
/// </summary>
public static int EgressScanMaxNodes = 20000;

/// <summary>Default Protected Data Grant lifetime in minutes when a department has no policy value.</summary>
public static int StepUpWindowDefaultMinutes = 15;

Expand Down
2 changes: 1 addition & 1 deletion Core/Resgrid.Config/UrlsConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,6 @@ public static class UrlsConfig

public static string HomeUrl = "https://resgrid.com";

public static string SystemStatusPageUrl = "https://resgrid.freshstatus.io";
public static string SystemStatusPageUrl = "https://oneuptime.resgrid.net/status-page/09d6b850-e50e-42e2-8497-9ab55e5be465";
}
}
Loading
Loading