docs: Campaign LA — pinned launch-contract schema COMMITTED into plan LA1

The LA3 Opus review process note was right: the contract both sides
implement lived only in orchestrator prompts, which is exactly the drift
mode the pin exists to prevent (and it produced the paths-key CRITICAL).
The schema, field rules, probe-mode discriminator, and status vocabulary
are now a binding plan section; amendments change this text first,
implementations second. Ledger: LA3 fix round dispatched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Erik 2026-08-14 16:04:32 +02:00
parent 0bcc7ba3a3
commit db9ad53c1c
38 changed files with 2397 additions and 40 deletions

View file

@ -0,0 +1,142 @@
using System.Text.Json.Serialization;
namespace AcDream.App.Configuration;
/// <summary>
/// Campaign LA slice LA1: the graphical host's reader for the pinned
/// session-config document shape shared with
/// <c>AcDream.Headless.Configuration.HeadlessConfiguration</c> — see
/// <c>docs/plans/2026-08-14-launcher-campaign.md</c> LA1 and
/// <c>docs/superpowers/specs/2026-08-14-launcher-campaign-design.md</c> §6.
///
/// <para>
/// This is a DELIBERATELY independent DTO set, not a shared type reused from
/// <c>AcDream.Headless</c> — Headless's config types are internal, tied to
/// its own OP7 <c>characterOptions</c> allow-list semantics, and Headless is
/// not a project App references. The two readers are cross-checked instead
/// by a shared fixture document both test suites parse
/// (<c>SessionConfigurationSharedFixtureTests</c> /
/// <c>HeadlessConfigurationSharedFixtureTests</c>).
/// </para>
///
/// <para>
/// Differences from the Headless reader, all intentional per the pinned
/// contract: <see cref="SessionDescriptor.Character"/> is OPTIONAL here
/// (absent = today's first-available fallback; the character-select screen
/// is LA7, not this slice); <see cref="SessionDescriptor.Policy"/> is parsed
/// but never consulted (App has no bot-policy concept); exactly ONE session
/// is required, not "one or more".
/// </para>
/// </summary>
internal sealed class SessionConfiguration
{
[JsonRequired]
public int Version { get; init; }
public SessionProcessSettings? Process { get; init; }
[JsonRequired]
public List<SessionDescriptor?> Sessions { get; init; } = [];
}
internal sealed class SessionProcessSettings
{
public SessionContentDescriptor? Content { get; init; }
}
internal sealed class SessionContentDescriptor
{
[JsonRequired]
public string DatDirectory { get; init; } = string.Empty;
[JsonRequired]
public string PreparedAssetPath { get; init; } = string.Empty;
}
internal sealed record SessionDescriptor
{
[JsonRequired]
public string Id { get; init; } = string.Empty;
[JsonRequired]
public SessionEndpointDescriptor Endpoint { get; init; } = new();
[JsonRequired]
public string Account { get; init; } = string.Empty;
/// <summary>Optional for the graphical host: absent means today's
/// existing first-available fallback stays in effect. The retail
/// character-select screen (LA7) is what actually consumes "no
/// selector" as "stop and let the user pick".</summary>
public SessionCharacterSelectorDescriptor? Character { get; init; }
/// <summary>Accepted so the SAME document also satisfies the Headless
/// loader's <c>JsonRequired</c> policy field — parsed and ignored here;
/// App has no bot-policy concept.</summary>
public SessionPolicyDescriptor? Policy { get; init; }
[JsonRequired]
public SessionCredentialDescriptor Credential { get; init; } = new();
/// <summary>Accepted-but-ignored by App; Headless's own loader owns the
/// allow-list semantics for this field (OP7 D8).</summary>
public Dictionary<string, bool>? CharacterOptions { get; init; }
/// <summary>LA1: plugin ids to load. Absent = load all (LA5 consumes
/// this; parsed and carried here now per the pinned launch contract).</summary>
public List<string>? Plugins { get; init; }
/// <summary>LA1: ordered chat-typed strings run after entering world
/// (LA6 consumes this; parsed and carried here now).</summary>
public List<string>? LoginCommands { get; init; }
/// <summary>LA1: inter-command delay for <see cref="LoginCommands"/>,
/// milliseconds. Matches the pinned contract default of 500 ms.</summary>
public int LoginCommandDelayMs { get; init; } = 500;
/// <summary>LA1: absolute path for the status-event JSONL stream.
/// Absent = no writer constructed.</summary>
public string? StatusFile { get; init; }
}
internal sealed class SessionEndpointDescriptor
{
[JsonRequired]
public string Host { get; init; } = string.Empty;
[JsonRequired]
public int Port { get; init; }
}
internal sealed class SessionCharacterSelectorDescriptor
{
public int? Index { get; init; }
public uint? Id { get; init; }
public string? Name { get; init; }
}
/// <summary>Loose by design: App never inspects the policy's shape beyond
/// "does this document parse" — <c>Id</c>/<c>Role</c> stay untyped strings so
/// this DTO never has to track Headless's own policy-id/role vocabulary.</summary>
internal sealed class SessionPolicyDescriptor
{
public string? Id { get; init; }
public string? Role { get; init; }
}
[JsonConverter(typeof(JsonStringEnumConverter<SessionCredentialProviderKind>))]
internal enum SessionCredentialProviderKind
{
Environment,
StandardInput,
File,
}
internal sealed class SessionCredentialDescriptor
{
[JsonRequired]
public SessionCredentialProviderKind Provider { get; init; }
[JsonRequired]
public string Reference { get; init; } = string.Empty;
}

View file

@ -0,0 +1,18 @@
namespace AcDream.App.Configuration;
/// <summary>Mirrors <c>AcDream.Headless.Configuration.HeadlessConfigurationException</c>
/// — a semantic validation failure of an already well-typed session-config
/// document (a type-SHAPE violation fails earlier, as a raw
/// <see cref="System.Text.Json.JsonException"/> during deserialization).</summary>
internal sealed class SessionConfigurationException : Exception
{
internal SessionConfigurationException(string message)
: base(message)
{
}
internal SessionConfigurationException(string message, Exception innerException)
: base(message, innerException)
{
}
}

View file

@ -0,0 +1,158 @@
using System.Text.Json;
using System.Text.Json.Serialization;
namespace AcDream.App.Configuration;
/// <summary>
/// Campaign LA slice LA1: loads and validates the <c>--session-config</c>
/// document for the graphical host. Same strictness as
/// <c>AcDream.Headless.Configuration.HeadlessConfigurationLoader</c>
/// (camelCase, <see cref="JsonUnmappedMemberHandling.Disallow"/>, camelCase
/// string enums) — see that type's own doc for why the two readers are
/// independent DTOs rather than a shared type.
/// </summary>
internal static class SessionConfigurationLoader
{
private const int CurrentVersion = 1;
private static readonly JsonSerializerOptions Options = new()
{
AllowTrailingCommas = false,
PropertyNameCaseInsensitive = false,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
ReadCommentHandling = JsonCommentHandling.Disallow,
UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow,
Converters =
{
new JsonStringEnumConverter(
JsonNamingPolicy.CamelCase,
allowIntegerValues: false),
},
};
/// <summary>Loads the document and returns the exact one configured
/// <see cref="SessionDescriptor"/> the graphical host runs — the
/// document itself may only ever declare exactly one session.</summary>
internal static (SessionConfiguration Configuration, SessionDescriptor Session) Load(
string path)
{
ArgumentException.ThrowIfNullOrWhiteSpace(path);
string fullPath = Path.GetFullPath(path);
using FileStream stream = File.OpenRead(fullPath);
SessionConfiguration? configuration =
JsonSerializer.Deserialize<SessionConfiguration>(stream, Options);
if (configuration is null)
{
throw new SessionConfigurationException(
"The configuration document is empty.");
}
if (configuration.Version != CurrentVersion)
{
throw new SessionConfigurationException(
$"Unsupported configuration version {configuration.Version}; "
+ $"expected {CurrentVersion}.");
}
if (configuration.Sessions is null
|| configuration.Sessions.Count != 1)
{
throw new SessionConfigurationException(
"The graphical host requires exactly one configured session.");
}
SessionDescriptor session = configuration.Sessions[0]
?? throw new SessionConfigurationException(
"The configured session cannot be null.");
ValidateContent(configuration.Process?.Content);
ValidateSession(session);
return (configuration, session);
}
private static void ValidateContent(SessionContentDescriptor? content)
{
if (content is null)
return;
if (string.IsNullOrWhiteSpace(content.DatDirectory)
|| string.IsNullOrWhiteSpace(content.PreparedAssetPath))
{
throw new SessionConfigurationException(
"process.content requires non-empty datDirectory and preparedAssetPath.");
}
}
private static void ValidateSession(SessionDescriptor session)
{
if (string.IsNullOrWhiteSpace(session.Id))
{
throw new SessionConfigurationException(
"The session requires a non-empty id.");
}
if (session.Endpoint is null
|| string.IsNullOrWhiteSpace(session.Endpoint.Host)
|| session.Endpoint.Port is < 1 or > 65535)
{
throw new SessionConfigurationException(
$"Session '{session.Id}' requires a host and a port from 1 through 65535.");
}
if (string.IsNullOrWhiteSpace(session.Account))
{
throw new SessionConfigurationException(
$"Session '{session.Id}' requires a non-empty account.");
}
if (session.Character is { } selector)
{
int selectorCount =
(selector.Index.HasValue ? 1 : 0)
+ (selector.Id.HasValue ? 1 : 0)
+ (!string.IsNullOrWhiteSpace(selector.Name) ? 1 : 0);
if (selectorCount != 1
|| selector.Index is < 0
|| selector.Id == 0u)
{
throw new SessionConfigurationException(
$"Session '{session.Id}' character selector must specify "
+ "exactly one valid index, id, or name.");
}
}
if (session.Credential is null
|| string.IsNullOrWhiteSpace(session.Credential.Reference))
{
throw new SessionConfigurationException(
$"Session '{session.Id}' requires a credential reference.");
}
if (session.Plugins is { } plugins)
{
foreach (string? plugin in plugins)
{
if (string.IsNullOrWhiteSpace(plugin))
{
throw new SessionConfigurationException(
$"Session '{session.Id}' plugins entries must be non-empty strings.");
}
}
}
if (session.LoginCommandDelayMs < 0)
{
throw new SessionConfigurationException(
$"Session '{session.Id}' loginCommandDelayMs must be non-negative.");
}
if (session.StatusFile is not null
&& string.IsNullOrWhiteSpace(session.StatusFile))
{
throw new SessionConfigurationException(
$"Session '{session.Id}' statusFile must be a non-empty path when present.");
}
}
}