diff --git a/CHANGELOG.md b/CHANGELOG.md
index 259b651..a4e4865 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,17 @@ All notable changes to this repository will be documented in this file. The form
### Added
+- **`Trellis.Microservices.Abstractions` package** — first package in this repo. Ships one public static class `TrellisInternalJwtClaimNames` with the canonical contract literals (`Subject`, `JwtId`, `Permissions`, `ForbiddenPermissions`, `ContractVersion`, `PermissionsCount`, `ForbiddenPermissionsCount`, `CurrentContractVersion = "1"`). Promotes the previously-internal `TrellisInternalJwtClaimNames` (from `xavierjohn/Trellis`'s `Trellis.Yarp`) to `public`, eliminating the duplication where the consumer side (`Trellis.Asp.Authorization.TrellisInternalJwtActorOptions` defaults) hard-coded the same strings by convention. AOT-compatible, no runtime dependencies.
+- Tests: `Trellis.Microservices.Abstractions.Tests` — pins every literal value, snapshots the public const surface (catches silent additions / removals), and asserts non-empty + unique values.
+
+### Notes
+
+- The pre-existing `internal` copy of `TrellisInternalJwtClaimNames` (currently in `xavierjohn/Trellis`'s `Trellis.Yarp/src/`) is the duplication risk this package eliminates. When `Trellis.Yarp` lands in this repo via PR C, that version will reference `Trellis.Microservices.Abstractions.TrellisInternalJwtClaimNames` instead of carrying its own internal copy. **Removal of the internal copy from the upstream `xavierjohn/Trellis` repository is a separate PR (PR D) against that repo**, documented in that repo's CHANGELOG — not this one.
+
+## 0.1-alpha — Initial bootstrap
+
+### Added
+
- Initial repository scaffolding: `Directory.Build.props`, `Directory.Build.targets`, `Directory.Packages.props`, `.editorconfig`, `.gitignore`, `.gitattributes`, `global.json`, `version.json`, `nuget.config`, `LICENSE`, `README.md`, `CHANGELOG.md`, `Trellis.Microservices.slnx`, `build/test.props`, `build/Trellis.ApiReference.targets`, `icon.png`.
- LLM-discoverability documentation under `docs/docfx_project/api_reference/`:
- `trellis-api-microservices-cookbook.md` — entry-point cookbook with task-lookup table and recipe placeholders (Recipes 1 and 2 will be inlined verbatim from `xavierjohn/Trellis` Recipes 33 + 34 when the source files land).
@@ -14,8 +25,5 @@ All notable changes to this repository will be documented in this file. The form
- `trellis-api-microservices-abstractions.md` — new abstractions package reference.
- `.github/copilot-instructions.md` — agent instructions with the "P4 invariants — never regress" 14-row checklist for any change touching minter / validator / provider code.
- `.github/dependabot.yml` — weekly GitHub Actions and NuGet updates.
+- `docs/lint-api-reference.{ps1,md}` — API-reference doc lint (opt-in per project via `true`).
-### Notes
-
-- No `.cs` files yet. Source for the three packages lands in follow-up PRs: B (`Trellis.Microservices.Abstractions`), then C (`Trellis.Yarp` move + `Trellis.Microservices.AspNetCore` carve-out from `xavierjohn/Trellis`).
-- Package preview NuGets will be published once PR C lands.
diff --git a/Trellis.Microservices.Abstractions/src/NUGET_README.md b/Trellis.Microservices.Abstractions/src/NUGET_README.md
new file mode 100644
index 0000000..e8c3093
--- /dev/null
+++ b/Trellis.Microservices.Abstractions/src/NUGET_README.md
@@ -0,0 +1,38 @@
+# Trellis.Microservices.Abstractions
+
+Shared contract constants for the Trellis internal-network JWT v1.
+
+This package ships **one public static class** — `TrellisInternalJwtClaimNames` — that pairs the gateway-side minter (`Trellis.Yarp`) with the consumer-side actor provider (`Trellis.Microservices.AspNetCore`). Both sides reference these literals so any future contract version bump is one coordinated change.
+
+## Properties
+
+- AOT-compatible — ships only `public const string` literals
+- No runtime dependencies
+- Tiny — single class
+
+## Usage
+
+```csharp
+using Trellis.Microservices.Abstractions;
+
+identity.AddClaim(new Claim(TrellisInternalJwtClaimNames.ContractVersion,
+ TrellisInternalJwtClaimNames.CurrentContractVersion));
+identity.AddClaim(new Claim(TrellisInternalJwtClaimNames.PermissionsCount, "3"));
+identity.AddClaim(new Claim(TrellisInternalJwtClaimNames.Permissions, "orders:read"));
+```
+
+If you are using `Trellis.Yarp` AND `Trellis.Microservices.AspNetCore` (the standard pairing), you do NOT need to reference this package directly — both reference it transitively.
+
+## When to reference directly
+
+- You are implementing a third-party gateway against the Trellis internal JWT contract.
+- You are implementing a custom consumer-side actor provider.
+- You are writing an integration test that hand-crafts JWTs.
+
+## Documentation
+
+Full reference: [`trellis-api-microservices-abstractions.md`](https://github.com/xavierjohn/Trellis.Microservices/blob/main/docs/docfx_project/api_reference/trellis-api-microservices-abstractions.md).
+
+## License
+
+MIT.
diff --git a/Trellis.Microservices.Abstractions/src/README.md b/Trellis.Microservices.Abstractions/src/README.md
new file mode 100644
index 0000000..7edd302
--- /dev/null
+++ b/Trellis.Microservices.Abstractions/src/README.md
@@ -0,0 +1,65 @@
+# Trellis.Microservices.Abstractions
+
+Shared contract constants for the [Trellis](https://github.com/xavierjohn/Trellis) internal-network JWT v1.
+
+This package ships **one public static class** — `TrellisInternalJwtClaimNames` — that pairs the gateway-side minter ([`Trellis.Yarp`](../../docs/docfx_project/api_reference/trellis-api-yarp.md#use-this-file-when)) with the consumer-side actor provider ([`Trellis.Microservices.AspNetCore`](../../docs/docfx_project/api_reference/trellis-api-internal-jwt.md#use-this-file-when)). Both sides reference these literals so a future contract version bump is one coordinated change.
+
+## Why it exists
+
+Without this package, the canonical claim names lived as `internal const` literals inside `Trellis.Yarp`, with the consumer side hard-coding the same strings as defaults in `TrellisInternalJwtActorOptions`. Both sides agreed by **convention** — the only enforcement was code review and a contract test that loaded both projects and asserted equality. The risk was real: a typo or future-contract-version change to one side without the other would create a silent fail-open / fail-closed divergence (one side accepts a token, the other rejects, depending on the direction of the typo).
+
+This package promotes those constants to `public`, gives them a stable namespace, and lets BOTH sides reference the same literals. Third-party gateway and consumer implementations now have a versioned NuGet contract to compile against.
+
+## Properties
+
+- AOT-compatible — ships only `public const string` literals
+- No runtime dependencies
+- Tiny — single class, ~80 lines
+
+## Usage
+
+Add a NuGet reference:
+
+```xml
+
+```
+
+Then reference the constants:
+
+```csharp
+using Trellis.Microservices.Abstractions;
+
+// In a custom gateway or test:
+identity.AddClaim(new Claim(TrellisInternalJwtClaimNames.ContractVersion,
+ TrellisInternalJwtClaimNames.CurrentContractVersion));
+identity.AddClaim(new Claim(TrellisInternalJwtClaimNames.PermissionsCount, "3"));
+identity.AddClaim(new Claim(TrellisInternalJwtClaimNames.Permissions, "orders:read"));
+// ...
+```
+
+If you are using `Trellis.Yarp` AND `Trellis.Microservices.AspNetCore` (the standard pairing), you do NOT need to reference this package directly. Both packages reference it transitively and you can rely on the defaults in `TrellisActorForwardingOptions` / `TrellisInternalJwtActorOptions`.
+
+## Contract integrity rules
+
+These are enforced jointly by the gateway and consumer. A third-party implementation that omits any of them is **not** contract-conformant.
+
+1. **Always emit the sentinel.** Every minted token MUST carry `ContractVersion = CurrentContractVersion`. The consumer fails closed (`Maybe.None`) on missing or duplicated sentinel.
+2. **Always emit both counts.** `PermissionsCount` and `ForbiddenPermissionsCount` MUST be emitted as decimal-string non-negative integers, including `"0"`.
+3. **Always emit `JwtId`.** Fresh per token; the audit-correlation key.
+4. **Permissions / ForbiddenPermissions are multi-valued, never joined.** The consumer's `StrictClaimShape = true` (default) rejects values containing `,` or starting with `[` / `{`.
+5. **Counts must equal observed multi-valued occurrences.** Off-by-one yields `Maybe.None`.
+
+See the full spec in [`trellis-api-microservices-abstractions.md`](../../docs/docfx_project/api_reference/trellis-api-microservices-abstractions.md).
+
+## Version compatibility
+
+This package is **versioned independently** from the gateway and consumer packages. Within a single contract version (`CurrentContractVersion = "1"`), the literals are immutable and will not change in any v1.x release.
+
+A future v2 will:
+- Ship a new major version of `Trellis.Microservices.Abstractions` with `CurrentContractVersion = "2"` (and potentially renamed / added claim members).
+- Ship matching major versions of `Trellis.Yarp` and `Trellis.Microservices.AspNetCore` that depend on the new abstractions major.
+- Provide a migration runbook for operators standing up a heterogeneous-version fleet during rollout.
+
+## License
+
+[MIT](../../LICENSE).
diff --git a/Trellis.Microservices.Abstractions/src/Trellis.Microservices.Abstractions.csproj b/Trellis.Microservices.Abstractions/src/Trellis.Microservices.Abstractions.csproj
new file mode 100644
index 0000000..c33248b
--- /dev/null
+++ b/Trellis.Microservices.Abstractions/src/Trellis.Microservices.Abstractions.csproj
@@ -0,0 +1,24 @@
+
+
+
+ Trellis.Microservices.Abstractions
+ Shared contract constants for the Trellis internal-network JWT v1, paired with Trellis.Yarp (gateway-side minter) and Trellis.Microservices.AspNetCore (consumer-side actor provider). Ships only public string literals — AOT-compatible, no runtime dependencies.
+ trellis;microservices;jwt;contract;abstractions;ddd
+
+
+ microservices-abstractions
+
+
+ true
+
+
+ true
+
+
+
+
+
+
+
diff --git a/Trellis.Microservices.Abstractions/src/TrellisInternalJwtClaimNames.cs b/Trellis.Microservices.Abstractions/src/TrellisInternalJwtClaimNames.cs
new file mode 100644
index 0000000..8ba9700
--- /dev/null
+++ b/Trellis.Microservices.Abstractions/src/TrellisInternalJwtClaimNames.cs
@@ -0,0 +1,86 @@
+namespace Trellis.Microservices.Abstractions;
+
+///
+/// Canonical JWT claim names that pair the gateway-side minter (in
+/// Trellis.Yarp) with the consumer-side actor provider (in
+/// Trellis.Microservices.AspNetCore) for the Trellis internal-network
+/// JWT v1 contract.
+///
+///
+///
+/// These names match the default TrellisInternalJwtActorOptions values on
+/// the consumer side AND the literal strings the gateway-side minter emits.
+/// Centralizing them in a single public class minimizes the risk of operational
+/// drift between the gateway operator and the downstream service operator — both
+/// sides reference these literals so any future contract version bump is a single
+/// coordinated change.
+///
+///
+/// Consumers who need different claim names MUST configure both the gateway-side
+/// minter AND the downstream TrellisInternalJwtActorOptions in lock-step.
+/// v1 does not expose claim-name overrides on the gateway side — the contract is
+/// the contract.
+///
+///
+/// Third-party gateway and consumer implementations targeting the same contract
+/// MUST reference this package and these literals; freshly typing the strings
+/// risks introducing a typo that splits the gateway and consumer sides on a
+/// silent fail-open / fail-closed boundary.
+///
+///
+public static class TrellisInternalJwtClaimNames
+{
+ ///
+ /// JWT sub claim (the registered subject claim). Carries the namespaced
+ /// actor identifier produced by the gateway's ActorIdResolver.
+ ///
+ public const string Subject = "sub";
+
+ ///
+ /// JWT jti claim (the registered token-identifier claim). Fresh per token
+ /// (cryptographically-random GUID-N) so audit pipelines can correlate every minted
+ /// JWT to a single mint event without leaking actor identity.
+ ///
+ public const string JwtId = "jti";
+
+ ///
+ /// Per-actor authorization-grant claim. Emitted multi-valued (one JSON-array entry
+ /// per permission) — NEVER comma-joined or JSON-stringified, per the strict-shape
+ /// contract enforced by the consumer side.
+ ///
+ public const string Permissions = "permissions";
+
+ ///
+ /// Per-actor deny-set claim. Emitted multi-valued. The deny-overrides-allow
+ /// contract invariant requires the matching
+ /// claim to ALWAYS be emitted (even when the set is empty) so the consumer can
+ /// distinguish "evaluated to empty" from "stripped by a misbehaving proxy."
+ ///
+ public const string ForbiddenPermissions = "forbidden_permissions";
+
+ ///
+ /// Sentinel claim asserting which version of the internal-JWT contract this token
+ /// conforms to. v1 emits the literal "1" (see ).
+ ///
+ public const string ContractVersion = "trellis_actor_contract_version";
+
+ ///
+ /// Decimal-string count of claims emitted in the same
+ /// token. Always emitted (including "0" for empty sets) so the consumer
+ /// can fail closed when a proxy strips the multi-valued permission claims.
+ ///
+ public const string PermissionsCount = "trellis_permissions_count";
+
+ ///
+ /// Decimal-string count of claims emitted in the
+ /// same token. Always emitted (including "0" for empty sets) so the consumer
+ /// can detect the privilege-escalation footgun where a malicious proxy strips the
+ /// deny set silently.
+ ///
+ public const string ForbiddenPermissionsCount = "trellis_forbidden_permissions_count";
+
+ ///
+ /// The contract version value emitted by v1.
+ ///
+ public const string CurrentContractVersion = "1";
+}
diff --git a/Trellis.Microservices.Abstractions/tests/Trellis.Microservices.Abstractions.Tests.csproj b/Trellis.Microservices.Abstractions/tests/Trellis.Microservices.Abstractions.Tests.csproj
new file mode 100644
index 0000000..a62e265
--- /dev/null
+++ b/Trellis.Microservices.Abstractions/tests/Trellis.Microservices.Abstractions.Tests.csproj
@@ -0,0 +1,11 @@
+
+
+
+ false
+
+
+
+
+
+
+
diff --git a/Trellis.Microservices.Abstractions/tests/TrellisInternalJwtClaimNamesTests.cs b/Trellis.Microservices.Abstractions/tests/TrellisInternalJwtClaimNamesTests.cs
new file mode 100644
index 0000000..5afdd73
--- /dev/null
+++ b/Trellis.Microservices.Abstractions/tests/TrellisInternalJwtClaimNamesTests.cs
@@ -0,0 +1,132 @@
+using System.Reflection;
+using Trellis.Microservices.Abstractions;
+
+namespace Trellis.Microservices.Abstractions.Tests;
+
+///
+/// These tests pin the EXACT public claim-name literals shipped by v1 of the
+/// Trellis internal JWT contract. Any change to a value here is a contract break
+/// and requires a coordinated major-version bump of this package, Trellis.Yarp,
+/// and Trellis.Microservices.AspNetCore — do not "fix" a failing test here by
+/// editing the expected literal.
+///
+public class TrellisInternalJwtClaimNamesTests
+{
+ [Fact]
+ public void Subject_is_exactly_sub() =>
+ TrellisInternalJwtClaimNames.Subject.Should().Be("sub");
+
+ [Fact]
+ public void JwtId_is_exactly_jti() =>
+ TrellisInternalJwtClaimNames.JwtId.Should().Be("jti");
+
+ [Fact]
+ public void Permissions_is_exactly_permissions() =>
+ TrellisInternalJwtClaimNames.Permissions.Should().Be("permissions");
+
+ [Fact]
+ public void ForbiddenPermissions_is_exactly_forbidden_permissions() =>
+ TrellisInternalJwtClaimNames.ForbiddenPermissions.Should().Be("forbidden_permissions");
+
+ [Fact]
+ public void ContractVersion_is_exactly_trellis_actor_contract_version() =>
+ TrellisInternalJwtClaimNames.ContractVersion.Should().Be("trellis_actor_contract_version");
+
+ [Fact]
+ public void PermissionsCount_is_exactly_trellis_permissions_count() =>
+ TrellisInternalJwtClaimNames.PermissionsCount.Should().Be("trellis_permissions_count");
+
+ [Fact]
+ public void ForbiddenPermissionsCount_is_exactly_trellis_forbidden_permissions_count() =>
+ TrellisInternalJwtClaimNames.ForbiddenPermissionsCount.Should().Be("trellis_forbidden_permissions_count");
+
+ [Fact]
+ public void CurrentContractVersion_is_exactly_1() =>
+ TrellisInternalJwtClaimNames.CurrentContractVersion.Should().Be("1");
+
+ ///
+ /// Snapshot guard: catches silent additions / removals to the public surface. If a
+ /// new constant is added to the contract (a v2 evolution), this test fails so the
+ /// reviewer is forced to acknowledge the contract surface change explicitly.
+ ///
+ [Fact]
+ public void Public_const_surface_is_exactly_eight_members()
+ {
+ var members = typeof(TrellisInternalJwtClaimNames)
+ .GetFields(BindingFlags.Public | BindingFlags.Static)
+ .Where(f => f.IsLiteral && !f.IsInitOnly)
+ .Select(f => f.Name)
+ .OrderBy(n => n, StringComparer.Ordinal)
+ .ToArray();
+
+ members.Should().Equal(
+ "ContractVersion",
+ "CurrentContractVersion",
+ "ForbiddenPermissions",
+ "ForbiddenPermissionsCount",
+ "JwtId",
+ "Permissions",
+ "PermissionsCount",
+ "Subject");
+ }
+
+ ///
+ /// All values must be non-null, non-empty strings — defensive guard against a
+ /// future refactor that introduces a typo collapsing one to the empty string.
+ ///
+ [Fact]
+ public void All_constants_are_non_empty()
+ {
+ var values = typeof(TrellisInternalJwtClaimNames)
+ .GetFields(BindingFlags.Public | BindingFlags.Static)
+ .Where(f => f.IsLiteral && !f.IsInitOnly)
+ .Select(f => (string)f.GetRawConstantValue()!);
+
+ values.Should().OnlyContain(v => !string.IsNullOrEmpty(v));
+ }
+
+ ///
+ /// Reserved JWT claim names (sub, jti) and structural Trellis names
+ /// (permissions, forbidden_permissions, trellis_*) must not collide with
+ /// each other. Catches a copy-paste bug where two consts get the same value.
+ ///
+ [Fact]
+ public void All_constant_values_are_unique()
+ {
+ var values = typeof(TrellisInternalJwtClaimNames)
+ .GetFields(BindingFlags.Public | BindingFlags.Static)
+ .Where(f => f.IsLiteral && !f.IsInitOnly && f.Name != nameof(TrellisInternalJwtClaimNames.CurrentContractVersion))
+ .Select(f => (string)f.GetRawConstantValue()!)
+ .ToArray();
+
+ values.Should().OnlyHaveUniqueItems();
+ }
+
+ ///
+ /// Type modifier pin: the class MUST be `public static class`. Because
+ /// `Directory.Build.props` declares `InternalsVisibleTo` on the test
+ /// assembly unconditionally, the const-value pinning tests above would
+ /// still pass if the class accidentally got changed to `internal` — but
+ /// the published NuGet package would be unusable by external consumers.
+ /// This test catches that regression.
+ ///
+ [Fact]
+ public void Type_modifiers_match_public_static_class_contract()
+ {
+ var type = typeof(TrellisInternalJwtClaimNames);
+
+ type.IsPublic.Should().BeTrue("the consumer-facing contract requires the class to be public");
+ type.IsAbstract.Should().BeTrue("static classes are abstract sealed in CLR metadata");
+ type.IsSealed.Should().BeTrue("static classes are abstract sealed in CLR metadata");
+
+ var publicMembers = type.GetMembers(BindingFlags.Public | BindingFlags.Static | BindingFlags.DeclaredOnly);
+ var nonConstMembers = publicMembers
+ .Where(m => m is not FieldInfo f || !f.IsLiteral || f.IsInitOnly)
+ .Select(m => $"{m.MemberType}:{m.Name}")
+ .ToArray();
+
+ nonConstMembers.Should().BeEmpty(
+ "the only declared public members are the eight const string fields — adding " +
+ "any other member is a contract surface change that must be acknowledged");
+ }
+}
diff --git a/Trellis.Microservices.slnx b/Trellis.Microservices.slnx
index 2230e17..0bb5c35 100644
--- a/Trellis.Microservices.slnx
+++ b/Trellis.Microservices.slnx
@@ -22,4 +22,11 @@
+
+
+
+
+
+
+