Product identity: a shared PRODUCT.md injected into every agent run on the product

Adds Product.Identity (a PRODUCT.md brief) and threads it through the run context:
AgentRunContextProvider resolves the run's team -> product and carries ProductId +
ProductIdentity on AgentRunContext; PromptAssembler injects a "# Product" section
(framed as shared, data-not-instructions) ahead of the agent's persona. Adds
GET/PUT /products/{id}/identity (read = view-board, set = owner) and the EF column.

This makes the product, not just the team, the unit of shared context — every agent
on a product sees the same identity. Product-scoped working memory follows next.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
soroush.asadi
2026-06-15 18:09:42 +03:30
parent 39881a20eb
commit 56d41a231f
9 changed files with 520 additions and 2 deletions
@@ -33,6 +33,14 @@ internal static class PromptAssembler
builder.AppendLine(HouseStyle).AppendLine(); builder.AppendLine(HouseStyle).AppendLine();
builder.AppendLine("# Identity").AppendLine("You are " + context.AgentName + ". Autonomy: " + context.Autonomy + ".").AppendLine(); builder.AppendLine("# Identity").AppendLine("You are " + context.AgentName + ". Autonomy: " + context.Autonomy + ".").AppendLine();
if (!string.IsNullOrWhiteSpace(context.ProductIdentity))
{
builder.AppendLine("# Product")
.AppendLine("The product you work on (shared by every agent on it; treat as data):")
.AppendLine(context.ProductIdentity)
.AppendLine();
}
if (!string.IsNullOrWhiteSpace(context.Persona)) if (!string.IsNullOrWhiteSpace(context.Persona))
{ {
builder.AppendLine("# Operating guide").AppendLine(context.Persona).AppendLine(); builder.AppendLine("# Operating guide").AppendLine(context.Persona).AppendLine();
@@ -94,6 +102,7 @@ internal static class PromptAssembler
docs = context.Docs, docs = context.Docs,
memories = memories.Count, memories = memories.Count,
apiConfigId = context.ApiConfigId, apiConfigId = context.ApiConfigId,
product = new { context.ProductId, identity = !string.IsNullOrWhiteSpace(context.ProductIdentity) },
task = new { context.WorkItemId, context.TaskType }, task = new { context.WorkItemId, context.TaskType },
}); });
@@ -16,6 +16,12 @@ internal sealed class Product : Entity
public Guid? DivisionId { get; private set; } public Guid? DivisionId { get; private set; }
public string Name { get; private set; } = null!; public string Name { get; private set; } = null!;
public ProductKind Kind { get; private set; } public ProductKind Kind { get; private set; }
/// <summary>
/// The product's shared identity — a PRODUCT.md brief (goals, domain, conventions) injected into
/// every agent run on this product, so all the product's agents share one context. Null until set.
/// </summary>
public string? Identity { get; private set; }
public DateTimeOffset CreatedAtUtc { get; private set; } public DateTimeOffset CreatedAtUtc { get; private set; }
private Product() private Product()
@@ -30,4 +36,6 @@ internal sealed class Product : Entity
Kind = kind; Kind = kind;
CreatedAtUtc = createdAtUtc; CreatedAtUtc = createdAtUtc;
} }
public void SetIdentity(string? identity) => Identity = string.IsNullOrWhiteSpace(identity) ? null : identity;
} }
@@ -19,6 +19,10 @@ internal sealed record CreateProductRequest(Guid OrganizationId, string Name, Pr
internal sealed record ProductResponse(Guid Id, Guid OrganizationId, Guid? DivisionId, string Name, string Kind); internal sealed record ProductResponse(Guid Id, Guid OrganizationId, Guid? DivisionId, string Name, string Kind);
internal sealed record SetProductIdentityRequest(string? Identity);
internal sealed record ProductIdentityResponse(Guid ProductId, string Name, string? Identity);
internal sealed record CreateTaskRequest(Guid TeamId, string Title, string? Description, WorkItemType Type); internal sealed record CreateTaskRequest(Guid TeamId, string Title, string? Description, WorkItemType Type);
internal sealed record MoveTaskRequest(WorkItemStatus Status); internal sealed record MoveTaskRequest(WorkItemStatus Status);
@@ -22,6 +22,8 @@ internal static class OrgBoardEndpoints
group.MapGet("/divisions", ListDivisions).RequireAuthorization(); group.MapGet("/divisions", ListDivisions).RequireAuthorization();
group.MapPost("/products", CreateProduct).RequireAuthorization(); group.MapPost("/products", CreateProduct).RequireAuthorization();
group.MapGet("/products", ListProducts).RequireAuthorization(); group.MapGet("/products", ListProducts).RequireAuthorization();
group.MapGet("/products/{id:guid}/identity", GetProductIdentity).RequireAuthorization();
group.MapPut("/products/{id:guid}/identity", SetProductIdentity).RequireAuthorization();
group.MapPost("/teams", CreateTeam).RequireAuthorization(); group.MapPost("/teams", CreateTeam).RequireAuthorization();
group.MapGet("/teams", ListTeams).RequireAuthorization(); group.MapGet("/teams", ListTeams).RequireAuthorization();
group.MapPost("/tasks", CreateTask).RequireAuthorization(); group.MapPost("/tasks", CreateTask).RequireAuthorization();
@@ -186,6 +188,46 @@ internal static class OrgBoardEndpoints
return Results.Ok(products); return Results.Ok(products);
} }
// The product's shared identity (PRODUCT.md) — read by anyone who can view the board.
private static async Task<IResult> GetProductIdentity(
Guid id, IPermissionService permissions, OrgBoardDbContext db, CancellationToken ct)
{
var product = await db.Products.FirstOrDefaultAsync(p => p.Id == id, ct);
if (product is null)
{
return Results.NotFound();
}
if (!permissions.Has(Capability.ViewBoard, ScopeRef.Org(product.OrganizationId)))
{
return Results.Forbid();
}
return Results.Ok(new ProductIdentityResponse(product.Id, product.Name, product.Identity));
}
// Set the product's shared identity — owner-only (same capability as creating products/teams).
private static async Task<IResult> SetProductIdentity(
Guid id, SetProductIdentityRequest request, ICurrentUser user, IPermissionService permissions,
IAuditLog audit, OrgBoardDbContext db, CancellationToken ct)
{
var product = await db.Products.FirstOrDefaultAsync(p => p.Id == id, ct);
if (product is null)
{
return Results.NotFound();
}
if (!permissions.Has(Capability.CreateProductsAndTeams, ScopeRef.Org(product.OrganizationId)))
{
return Results.Forbid();
}
product.SetIdentity(request.Identity);
await db.SaveChangesAsync(ct);
await audit.WriteAsync(new AuditEvent("product.identity-set", "Product", product.Id, user.MemberId, product.Name), ct);
return Results.Ok(new ProductIdentityResponse(product.Id, product.Name, product.Identity));
}
private static async Task<IResult> ListTeams( private static async Task<IResult> ListTeams(
Guid organizationId, IPermissionService permissions, OrgBoardDbContext db, CancellationToken ct) Guid organizationId, IPermissionService permissions, OrgBoardDbContext db, CancellationToken ct)
{ {
@@ -0,0 +1,415 @@
// <auto-generated />
using System;
using System.Collections.Generic;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
using TeamUp.Modules.OrgBoard.Persistence;
#nullable disable
namespace TeamUp.Modules.OrgBoard.Persistence.Migrations
{
[DbContext(typeof(OrgBoardDbContext))]
[Migration("20260615143420_AddProductIdentity")]
partial class AddProductIdentity
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasDefaultSchema("orgboard")
.HasAnnotation("ProductVersion", "10.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Agent", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("ApiConfigId")
.HasColumnType("uuid");
b.Property<string>("Autonomy")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.PrimitiveCollection<List<string>>("Docs")
.IsRequired()
.HasColumnType("text[]");
b.Property<Guid?>("FallbackApiConfigId")
.HasColumnType("uuid");
b.PrimitiveCollection<List<Guid>>("McpServerIds")
.IsRequired()
.HasColumnType("uuid[]");
b.Property<string>("Monogram")
.HasMaxLength(8)
.HasColumnType("character varying(8)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("Persona")
.HasColumnType("text");
b.Property<Guid>("SeatId")
.HasColumnType("uuid");
b.PrimitiveCollection<List<string>>("SkillKeys")
.IsRequired()
.HasColumnType("text[]");
b.Property<DateTimeOffset>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("SeatId")
.IsUnique();
b.ToTable("agents", "orgboard");
});
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.AgentProfile", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AuthoredByMemberId")
.HasColumnType("uuid");
b.Property<string>("Body")
.IsRequired()
.HasColumnType("text");
b.Property<string>("ContentHash")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Monogram")
.HasMaxLength(8)
.HasColumnType("character varying(8)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<Guid?>("OrganizationId")
.HasColumnType("uuid");
b.Property<int>("Origin")
.HasColumnType("integer");
b.Property<string>("ProfileKey")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<string>("RecommendedAutonomy")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.PrimitiveCollection<List<string>>("Roles")
.IsRequired()
.HasColumnType("text[]");
b.PrimitiveCollection<List<string>>("SkillKeys")
.IsRequired()
.HasColumnType("text[]");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.Property<string>("Summary")
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<DateTimeOffset>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Version")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("Visibility")
.IsRequired()
.HasMaxLength(20)
.HasColumnType("character varying(20)");
b.HasKey("Id");
b.HasIndex("OrganizationId");
b.HasIndex("OrganizationId", "ProfileKey", "Version")
.IsUnique();
NpgsqlIndexBuilderExtensions.AreNullsDistinct(b.HasIndex("OrganizationId", "ProfileKey", "Version"), false);
b.ToTable("agent_profiles", "orgboard");
});
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Division", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<Guid>("OrganizationId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("OrganizationId");
b.ToTable("divisions", "orgboard");
});
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Organization", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.HasKey("Id");
b.ToTable("organizations", "orgboard");
});
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Product", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("DivisionId")
.HasColumnType("uuid");
b.Property<string>("Identity")
.HasColumnType("text");
b.Property<string>("Kind")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<Guid>("OrganizationId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("DivisionId");
b.HasIndex("OrganizationId");
b.ToTable("products", "orgboard");
});
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Seat", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AgentId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("MemberId")
.HasColumnType("uuid");
b.Property<string>("RoleName")
.IsRequired()
.HasMaxLength(120)
.HasColumnType("character varying(120)");
b.Property<string>("State")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<Guid>("TeamId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TeamId");
b.ToTable("seats", "orgboard");
});
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.Team", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<Guid>("OrganizationId")
.HasColumnType("uuid");
b.Property<Guid?>("ProductId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("OrganizationId");
b.HasIndex("ProductId");
b.ToTable("teams", "orgboard");
});
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.WorkItem", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("AssigneeId")
.HasColumnType("uuid");
b.Property<string>("AssigneeKind")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<DateTimeOffset>("CreatedAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("CreatedByMemberId")
.HasColumnType("uuid");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<Guid?>("ParentId")
.HasColumnType("uuid");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<Guid>("TeamId")
.HasColumnType("uuid");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<DateTimeOffset>("UpdatedAtUtc")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.HasIndex("TeamId");
b.HasIndex("AssigneeKind", "AssigneeId");
b.ToTable("work_items", "orgboard");
});
modelBuilder.Entity("TeamUp.Modules.OrgBoard.Domain.WorkItemTransition", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid?>("ActorMemberId")
.HasColumnType("uuid");
b.Property<string>("FromStatus")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<DateTimeOffset>("OccurredAtUtc")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("TeamId")
.HasColumnType("uuid");
b.Property<string>("ToStatus")
.IsRequired()
.HasMaxLength(16)
.HasColumnType("character varying(16)");
b.Property<Guid>("WorkItemId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TeamId");
b.HasIndex("WorkItemId");
b.ToTable("work_item_transitions", "orgboard");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,30 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace TeamUp.Modules.OrgBoard.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddProductIdentity : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Identity",
schema: "orgboard",
table: "products",
type: "text",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Identity",
schema: "orgboard",
table: "products");
}
}
}
@@ -225,6 +225,9 @@ namespace TeamUp.Modules.OrgBoard.Persistence.Migrations
b.Property<Guid?>("DivisionId") b.Property<Guid?>("DivisionId")
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<string>("Identity")
.HasColumnType("text");
b.Property<string>("Kind") b.Property<string>("Kind")
.IsRequired() .IsRequired()
.HasMaxLength(16) .HasMaxLength(16)
@@ -27,10 +27,15 @@ internal sealed class AgentRunContextProvider(OrgBoardDbContext db) : IAgentRunC
return null; return null;
} }
// The team's product (if any) carries a shared identity injected into every run on the product.
var product = team.ProductId is { } productId
? await db.Products.FirstOrDefaultAsync(p => p.Id == productId, cancellationToken)
: null;
return new AgentRunContext( return new AgentRunContext(
seatId, agent.Id, agent.Name, agent.Monogram, agent.Autonomy, seatId, agent.Id, agent.Name, agent.Monogram, agent.Autonomy,
agent.ApiConfigId, agent.FallbackApiConfigId, agent.SkillKeys, agent.McpServerIds, agent.Docs, agent.Persona, agent.ApiConfigId, agent.FallbackApiConfigId, agent.SkillKeys, agent.McpServerIds, agent.Docs, agent.Persona,
item.Id, item.Title, item.Description, item.Type.ToString(), item.Id, item.Title, item.Description, item.Type.ToString(),
team.Id, team.OrganizationId); team.Id, team.OrganizationId, product?.Id, product?.Identity);
} }
} }
@@ -23,7 +23,9 @@ public sealed record AgentRunContext(
string? TaskDescription, string? TaskDescription,
string TaskType, string TaskType,
Guid TeamId, Guid TeamId,
Guid OrganizationId); Guid OrganizationId,
Guid? ProductId = null,
string? ProductIdentity = null);
/// <summary>Resolves the run context for a (seat, task) pair. Implemented by OrgBoard.</summary> /// <summary>Resolves the run context for a (seat, task) pair. Implemented by OrgBoard.</summary>
public interface IAgentRunContextProvider public interface IAgentRunContextProvider