Sheepit.Sdk
0.1.2
dotnet add package Sheepit.Sdk --version 0.1.2
NuGet\Install-Package Sheepit.Sdk -Version 0.1.2
<PackageReference Include="Sheepit.Sdk" Version="0.1.2" />
<PackageVersion Include="Sheepit.Sdk" Version="0.1.2" />
<PackageReference Include="Sheepit.Sdk" />
paket add Sheepit.Sdk --version 0.1.2
#r "nuget: Sheepit.Sdk, 0.1.2"
#:package Sheepit.Sdk@0.1.2
#addin nuget:?package=Sheepit.Sdk&version=0.1.2
#tool nuget:?package=Sheepit.Sdk&version=0.1.2
Sheepit.Sdk — local flag evaluation for .NET
Preview (0.x). This package is the flag evaluator only. It has no HTTP client, cache or event pipeline yet — see What is not here yet. The API may change before 1.0.
Sheepit.Sdk evaluates Sheepit feature flags in your process. You fetch the
ruleset from GET /v1/flags/ruleset; this package turns that document plus a user into a flag value.
Evaluation does no I/O, so one fetch serves any number of evaluations and a flag check never waits on
the network.
Its behaviour is not a reimplementation from prose. Bucketing and rule evaluation are checked against the language-neutral Sheepit conformance corpus — the same vectors Sheepit's own TypeScript bucketing and evaluation code is held to — so the same user lands in the same rollout bucket in .NET as on the server.
Install
dotnet add package Sheepit.Sdk --version 0.1.2
Requirements
- net8.0 or net10.0.
- Zero NuGet dependencies. Everything the package uses (including
System.Text.Json) ships in the shared framework. The package's own test suite fails if a dependency is ever added. - A secret API key (
lp_sec_…) to read the ruleset. Server-side only — never ship it to a browser or mobile client. Publishable (lp_pub_…) and dev (lp_dev_…) keys are refused by that endpoint.
Usage
The package gives you the evaluator and the types; fetching and mapping the ruleset is yours, and it is short. Both samples below are compiled by the package's test suite, and the first is executed against a canned ruleset, so they match the shipped API:
using System.Net.Http.Headers;
using System.Text.Json;
using Sheepit.Sdk;
public sealed class SheepitFlags
{
private readonly Dictionary<string, RulesetFlag> _flags;
private SheepitFlags(Dictionary<string, RulesetFlag> flags) => _flags = flags;
// GET v1/flags/ruleset with a SECRET key (lp_sec_…). Server-side only.
// http.BaseAddress must END IN "/": the path below is relative, so a base such as
// https://sheepit.example.com/api/ keeps its /api/ prefix.
public static async Task<SheepitFlags> FetchAsync(HttpClient http, string secretKey)
{
using var request = new HttpRequestMessage(HttpMethod.Get, "v1/flags/ruleset");
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", secretKey);
using HttpResponseMessage response = await http.SendAsync(request);
response.EnsureSuccessStatusCode();
using JsonDocument doc = JsonDocument.Parse(await response.Content.ReadAsStringAsync());
JsonElement data = doc.RootElement.GetProperty("data");
// Refuse a snapshot this package was not built to bucket.
int version = data.GetProperty("bucketing_version").GetInt32();
if (version != Bucketing.Version)
throw new NotSupportedException($"ruleset bucketing_version {version}, SDK implements {Bucketing.Version}");
return new SheepitFlags(data.GetProperty("flags").EnumerateArray()
.Select(ToFlag)
.ToDictionary(f => f.Key));
}
public object? Evaluate(
string flagKey,
string? userId,
string? deviceId,
IReadOnlyDictionary<string, object?> context,
object? callerDefault)
{
// No identity -> the caller's default. Never skip the rollout instead.
string? identity = Bucketing.Identity(userId, deviceId);
if (identity is null) return callerDefault;
_flags.TryGetValue(flagKey, out RulesetFlag? flag);
return RulesetEvaluator.Evaluate(flag, identity, context, callerDefault).Value;
}
private static RulesetFlag ToFlag(JsonElement f) => new(
f.GetProperty("key").GetString()!,
f.GetProperty("value_type").GetString()!,
JsValue.FromJson(f.GetProperty("default_value")),
f.GetProperty("killed").GetBoolean(),
[.. f.GetProperty("rules").EnumerateArray().Select(r => new RulesetRule(
r.GetProperty("id").GetString()!,
r.GetProperty("sort_order").GetDouble(),
Conditions(r),
r.TryGetProperty("value", out JsonElement v) ? JsValue.FromJson(v) : null,
r.TryGetProperty("enabled", out JsonElement en) && en.ValueKind != JsonValueKind.Null
? en.GetBoolean()
: null))],
[.. f.GetProperty("rollouts").EnumerateArray().Select(r => new RulesetRollout(
r.GetProperty("id").GetString()!,
r.GetProperty("current_pct").GetDouble(),
Conditions(r),
r.TryGetProperty("value", out JsonElement v) ? JsValue.FromJson(v) : null,
// Absent status = active (null). A JSON null status is NOT active: map it to "".
!r.TryGetProperty("status", out JsonElement s) ? null
: s.ValueKind == JsonValueKind.Null ? "" : s.GetString(),
r.TryGetProperty("has_user_overrides", out JsonElement h) && h.GetBoolean()))]);
private static List<RuleCondition> Conditions(JsonElement owner) =>
[.. owner.GetProperty("conditions").EnumerateArray().Select(c => new RuleCondition(
c.GetProperty("field").GetString()!,
c.GetProperty("op").GetString()!,
[.. c.GetProperty("values").EnumerateArray().Select(JsValue.FromJson)]))];
}
Then, at the call site:
public static class Example
{
public static async Task<bool> IsNewCheckoutOnAsync(HttpClient http, string secretKey, string userId)
{
// http.BaseAddress = https://api.sheepit.ai/ (trailing slash). Fetch once and reuse; this package does not poll.
SheepitFlags flags = await SheepitFlags.FetchAsync(http, secretKey);
var context = new Dictionary<string, object?>
{
["country"] = "AR",
["attributes"] = new Dictionary<string, object?> { ["plan"] = "pro", ["seats"] = 12d },
};
return flags.Evaluate("new_checkout", userId, deviceId: null, context, callerDefault: false) is true;
}
}
Things that matter when you write the mapping
BaseAddressmust end in/. The request path is relative (v1/flags/ruleset, no leading slash), so it is appended to the base. A leading slash would replace the base's path, which breaks a self-hosted install served under a prefix such ashttps://sheepit.example.com/api/.Context values use JSON's value domain:
null,bool,double,string, a list of those, or aDictionary<string, object?>. Numbers must bedouble(12d, not12) — the evaluator follows JavaScript semantics, where every number is a double, and anintis not one of them.Identity.
Bucketing.Identity(userId, deviceId)prefers the user id. When it returnsnull, return your own default — do not evaluate, and do not treat it as "not in the rollout".Two defaults. An unknown flag key returns your
callerDefault; a known flag that matches no rule and no rollout returns the ruleset'sdefault_value. A killed flag returns the ruleset's default.Rollout
status. Absent means active. A JSONnullmeans not active — map it to any non-null string other than"active"(the sample uses""), never to C#null.A
JsonElementin the context is safe as of 0.1.2, but still not the recommended shape.JsonSerializer.Deserialize<Dictionary<string, object?>>(json)boxes every value as aJsonElement. Before 0.1.2 the evaluator had no case for one:eq/neq/in/not_incompared it by identity and never looked at the value, whilecontains/regex/the ordering operators stringified it viaToString()—JsonElement's own rendering, not JavaScript's — so a boxed value that genuinely matched could still makeneq/not_inanswertrue. As of 0.1.2,JsValueunwraps aJsonElementto its real value (the same conversionJsValue.FromJsondoes) before comparing, converting or stringifying it, so a boxed value now agrees with the real one exactly, wherever it appears — a top-level field, a nested attribute, or a member of an_user_groups/in/not_inlist. That includes aJsonElementof kindNull/Undefined, which is a non-null .NET struct: it now takes the ordinary absent-field path (evaluatesfalse, includingneq/not_in) instead of sailing past the null check and being compared as a value later. ⚠️ One case is not, and cannot be, made safe: aJsonElementwhose backingJsonDocumenthas already been disposed — the usual parse-inside-a-using. That is a caller bug, not malformed data, soEvaluatetreats it the same as any other unconvertible input: the condition answers no-match rather than crashing your process, but it does not answer correctly — you lose that comparison, silently. Build the context from values that outlive the call, or convert eagerly:// Converts once, up front, rather than on every comparison — recommended for anything you // evaluate more than once. Non-JsonElement values pass through UNCHANGED, not nulled — a // trusted value you added to the bag yourself (see _user_groups below) survives the pass. var context = JsonSerializer.Deserialize<Dictionary<string, object?>>(json)! .ToDictionary(kv => kv.Key, kv => kv.Value is JsonElement e ? JsValue.FromJson(e) : kv.Value);JsValue.FromJsonitself is also safe against malformed-but-legal JSON as of 0.1.2: a duplicate object key keeps the LAST value (matchingJSON.parse, where it used to throwArgumentException), and a KEY containing an unpaired surrogate escape drops that one entry (the field is simply absent) rather than throwingInvalidOperationException. A string VALUE with one is nevernull— it degrades to its raw JSON text (quotes and all) rather than the decoded string, specifically so it can never silently void a_user_groupsset the way anullmember would (see "the members must be real strings" below).FromJsondoes NOT swallow a disposed document: that still throws when you call it directly, on purpose (see above).⚠️ Two more things that guarantee does not cover, on purpose. A
nullconditionorcontextpassed toConditionEvaluator.Evaluatestill throwsArgumentNullException— that is a caller programming error (a DI wiring mistake, an unpopulated cache), not malformed data, and it stays as loud as it would anywhere else in .NET. And a cyclic (a list containing itself — measured: two lines of C# is enough) or pathologically deep caller-constructed structure can still exhaust the call stack;StackOverflowExceptioncannot be caught by any .NET process, so there is no guarantee against it here. JavaScript'sArray.prototype.join, which this evaluator otherwise mirrors, detects a cycle instead of recursing into it; this port does not.Pinned by
GroupMembershipTests; applies to every field, not just_user_groups.Fields the server fills in. The server resolves some fields from its own tables, and locally you only get what you pass. An ordinary field you did not supply — absent, not present-but-boxed (see above) — evaluates to
false, includingneqandnot_in. 🔴 Group membership is the exception, and it fails open. Groups arrive as_user_groups; with none supplied the group set is empty rather than unknown, sonot_inandneqonuser_groupevaluate totrueand a rule meant to exclude a group matches every user. (inandeqstill evaluate tofalse.) Supply_user_groupswhenever your rules target groups, and pass every other field your rules target too._user_groupsis read only as an array or list, because the evaluator tests forIReadOnlyList<object?>:string[],object?[],List<string>andList<object?>are read, and as of 0.1.2 so is a rawJsonElementarray —JsonSerializer.Deserialize<object?>(json)gives you one of those, and it used to fail the shape check outright and land you on the empty-set fail-open;EvaluateUserGroupnow unwraps it first. A member that is itself aJsonElementstring is unwrapped too —JsonSerializer.Deserialize<List<object?>>(json)boxes each member that way, and it used to silently void the whole set (see "the members must be real strings" below); both traps are fixed. 🔴 What is still ignored, unchanged: aHashSet<string>, a LINQ sequence (groups.Select(g => g.Name)), and a barestring(one group passed unwrapped) — none of those becomes a list no matter how aJsonElementis unwrapped, so they are still silently ignored, landing you on the empty-set fail-open above while believing you supplied the groups. For a sequence, call.ToArray()or.ToList(); for a single group, wrap it (new[] { group }). Adefault(ImmutableArray<string>)or other unpopulated struct collection passes the shape check but throws when enumerated —Evaluatecatches that and answers false for every operator, which is NOT the same as the empty-set'snot_in/neq→true: an uninitialised collection is safer than a genuinely empty one, but still silently wrong, so initialise your collections regardless. (Measured on net8.0 and net10.0; pinned byGroupMembershipTests.)The members must be real
strings. A single non-string member voids the whole set — matching the server — and then you are back on the fail-open.List<object?>fromJsonSerializer.Deserialize<List<object?>>(json)used to trip this by accident (its members areJsonElement, notstring, so the list passed the accepted-shape check above and was then voided here); that case is unwrapped correctly as of 0.1.2. A genuinely mixed set — a real number or bool alongside a string — still voids the set on purpose.Refuse a mismatched
bucketing_version. The sample throws when the ruleset declares a bucketing version other thanBucketing.Version, rather than silently bucketing users differently from the server.
Experiments
Only the bucketing primitives are here: Bucketing.ExperimentPoolBucket and
Bucketing.ExperimentVariantBucket (both 0..9999, basis points), matching the server's seeds.
The ruleset payload does not carry experiment bindings yet. For a flag with a running experiment bound
to it, /v1/config returns the experiment variant's value while local evaluation of the ruleset returns
the rollout-or-default value — for the same user, with no error on either side. Do not put an
experiment-bound flag behind local evaluation.
Per-user rollout overrides are not enforced locally either. The payload carries only
has_user_overrides: true, never the override rows (they would be a list of your users' ids in a polled
document). An override set in the dashboard applies on the server and not in this evaluator; treat
HasUserOverrides == true as "this rollout can differ from the server for pinned users".
Events
This package does not send events or exposures. Use the HTTP ingest API (POST /v1/ingest) or the
Node server SDK (@sheepit-ai/server).
Behavioural parity
The test suite runs all four files of the Sheepit conformance corpus — hash, bucket, conditions and
evaluate — against this package on both net8.0 and net10.0, with no network access. It also pins the
places where a straightforward C# port disagrees with JavaScript: Number() / String() conversion,
undefined versus null, whitespace, number formatting and stable rule ordering.
regex conditions follow the server, which evaluates them with JavaScript's RegExp. Every pattern is
rewritten by JsRegexTranslator into its JavaScript meaning. A pattern with no backreference, no
lookaround, no \b, and no \D/\W/\S inside a character class runs on .NET's linear-time
NonBacktracking engine; the rest run on the backtracking engine under a 100 ms timeout. The results
are pinned by a 136-case oracle generated in the API's pinned node 20 image.
Known differences, each measured in that oracle and asserted by name:
- JavaScript resets a group's capture on every quantifier iteration; .NET keeps it.
\Sinside a character class keeps .NET's ASCII meaning.- A non-ASCII or escaped group name evaluates
false. - A quantifier bound above
int.MaxValueevaluatesfalse.
A pattern that has to run on the backtracking engine and exceeds the 100 ms timeout also evaluates
false — it fails closed, and near the timeout the answer can vary between calls. A pattern too large
for the linear engine (over 10,000 nodes) also falls back to backtracking. That limit is the same on
.NET 8 and .NET 10, so a rule gets the same engine, and the same answer, on both.
.NET 8's own default limit is 1,000 nodes. To match .NET 10, the SDK raises it to 10,000 for the
moment it compiles a pattern between the two sizes, through the REGEX_NONBACKTRACKING_MAX_AUTOMATA_SIZE
AppContext setting, and then puts the previous value back. What this means for your app on .NET 8:
- A
RegexOptions.NonBacktrackingregex your app builds on another thread at that same moment is also allowed up to 10,000 nodes. - If your app sets that value itself as a positive
int(AppContext.SetData(..., 5000)), the SDK uses your limit and never changes the setting. That is also how to opt out. .NET 10 honours the same setting, so with a limit below 10,000 both runtimes agree with each other, but a rule between your limit and 10,000 can then time out and answer differently from the server. - A value set in
runtimeconfig.jsonarrives as a string, and .NET also ignores 0 or a negativeint, so the SDK treats any of those as unset and restores the value afterwards.
A pattern longer than 200 characters never matches, and the input is truncated to its first 1024 characters before matching.
What is not here yet
- A ruleset poller, in-memory cache, and
ETag/304handling (the endpoint supportsIf-None-Match). - A seed file and last-known-good persistence across restarts.
- An exposure queue with bounded drain.
- A dependency-injection /
IServiceCollectionpackage.
License
MIT. Copyright (c) 2026 GoaTech AI LLC. Sheepit is a product of GoaTech AI LLC.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net8.0 is compatible. net8.0-android was computed. net8.0-browser was computed. net8.0-ios was computed. net8.0-maccatalyst was computed. net8.0-macos was computed. net8.0-tvos was computed. net8.0-windows was computed. net9.0 was computed. net9.0-android was computed. net9.0-browser was computed. net9.0-ios was computed. net9.0-maccatalyst was computed. net9.0-macos was computed. net9.0-tvos was computed. net9.0-windows was computed. net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- No dependencies.
-
net8.0
- No dependencies.
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.