Sheepit.Sdk 0.1.2

dotnet add package Sheepit.Sdk --version 0.1.2
                    
NuGet\Install-Package Sheepit.Sdk -Version 0.1.2
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Sheepit.Sdk" Version="0.1.2" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Sheepit.Sdk" Version="0.1.2" />
                    
Directory.Packages.props
<PackageReference Include="Sheepit.Sdk" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Sheepit.Sdk --version 0.1.2
                    
#r "nuget: Sheepit.Sdk, 0.1.2"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Sheepit.Sdk@0.1.2
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Sheepit.Sdk&version=0.1.2
                    
Install as a Cake Addin
#tool nuget:?package=Sheepit.Sdk&version=0.1.2
                    
Install as a Cake Tool

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

  • BaseAddress must 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 as https://sheepit.example.com/api/.

  • Context values use JSON's value domain: null, bool, double, string, a list of those, or a Dictionary<string, object?>. Numbers must be double (12d, not 12) — the evaluator follows JavaScript semantics, where every number is a double, and an int is not one of them.

  • Identity. Bucketing.Identity(userId, deviceId) prefers the user id. When it returns null, 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's default_value. A killed flag returns the ruleset's default.

  • Rollout status. Absent means active. A JSON null means not active — map it to any non-null string other than "active" (the sample uses ""), never to C# null.

  • A JsonElement in 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 a JsonElement. Before 0.1.2 the evaluator had no case for one: eq/neq/in/not_in compared it by identity and never looked at the value, while contains/regex/the ordering operators stringified it via ToString() — JsonElement's own rendering, not JavaScript's — so a boxed value that genuinely matched could still make neq/not_in answer true. As of 0.1.2, JsValue unwraps a JsonElement to its real value (the same conversion JsValue.FromJson does) 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_in list. That includes a JsonElement of kind Null/Undefined, which is a non-null .NET struct: it now takes the ordinary absent-field path (evaluates false, including neq/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: a JsonElement whose backing JsonDocument has already been disposed — the usual parse-inside-a-using. That is a caller bug, not malformed data, so Evaluate treats 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.FromJson itself is also safe against malformed-but-legal JSON as of 0.1.2: a duplicate object key keeps the LAST value (matching JSON.parse, where it used to throw ArgumentException), and a KEY containing an unpaired surrogate escape drops that one entry (the field is simply absent) rather than throwing InvalidOperationException. A string VALUE with one is never null — it degrades to its raw JSON text (quotes and all) rather than the decoded string, specifically so it can never silently void a _user_groups set the way a null member would (see "the members must be real strings" below). FromJson does 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 null condition or context passed to ConditionEvaluator.Evaluate still throws ArgumentNullException — 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; StackOverflowException cannot be caught by any .NET process, so there is no guarantee against it here. JavaScript's Array.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, including neq and not_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, so not_in and neq on user_group evaluate to true and a rule meant to exclude a group matches every user. (in and eq still evaluate to false.) Supply _user_groups whenever your rules target groups, and pass every other field your rules target too.

  • _user_groups is read only as an array or list, because the evaluator tests for IReadOnlyList<object?>: string[], object?[], List<string> and List<object?> are read, and as of 0.1.2 so is a raw JsonElement array — 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; EvaluateUserGroup now unwraps it first. A member that is itself a JsonElement string 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: a HashSet<string>, a LINQ sequence (groups.Select(g => g.Name)), and a bare string (one group passed unwrapped) — none of those becomes a list no matter how a JsonElement is 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 }). A default(ImmutableArray<string>) or other unpopulated struct collection passes the shape check but throws when enumerated — Evaluate catches that and answers false for every operator, which is NOT the same as the empty-set's not_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 by GroupMembershipTests.)

  • 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?> from JsonSerializer.Deserialize<List<object?>>(json) used to trip this by accident (its members are JsonElement, not string, 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 than Bucketing.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.
  • \S inside a character class keeps .NET's ASCII meaning.
  • A non-ASCII or escaped group name evaluates false.
  • A quantifier bound above int.MaxValue evaluates false.

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.NonBacktracking regex 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.json arrives as a string, and .NET also ignores 0 or a negative int, 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 / 304 handling (the endpoint supports If-None-Match).
  • A seed file and last-known-good persistence across restarts.
  • An exposure queue with bounded drain.
  • A dependency-injection / IServiceCollection package.

License

MIT. Copyright (c) 2026 GoaTech AI LLC. Sheepit is a product of GoaTech AI LLC.

Product 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.
  • 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.

Version Downloads Last Updated
0.1.2 94 9/23/2026
0.1.1 94 9/22/2026
0.1.0 89 9/22/2026