chore: tending to specs to clear out problems

This commit is contained in:
2026-04-09 17:01:40 +02:00
parent 6955defbcf
commit 5eff5b9636
6 changed files with 295 additions and 48 deletions

View File

@@ -44,6 +44,27 @@ entity ChatMessage {
created_at: Timestamp created_at: Timestamp
} }
surface OneShotAiSurface {
facing _: AiOperator
provides:
AnalyzeTaxonomyRequested(post)
AnalyzeImageRequested(media)
AnalyzePostRequested(post)
DetectLanguageRequested(text)
TranslatePostRequested(post, target_language)
TranslateMediaRequested(media, target_language)
}
surface AiChatSurface {
facing _: ChatOperator
provides:
StartChatRequested(model)
SendChatMessageRequested(conversation, content)
RefreshModelCatalogRequested(endpoint)
}
-- One-shot AI tasks (core scope, no streaming) -- One-shot AI tasks (core scope, no streaming)
-- All use OpenAI Chat Completions wire format. -- All use OpenAI Chat Completions wire format.
-- Endpoint routing: see AirplaneModeGating invariant below. -- Endpoint routing: see AirplaneModeGating invariant below.
@@ -95,13 +116,26 @@ rule TranslateMedia {
rule StartChat { rule StartChat {
when: StartChatRequested(model) when: StartChatRequested(model)
ensures: ChatConversation.created(model: model) ensures: ChatConversation.created(
title: generated_chat_title(model),
model: model,
created_at: now,
updated_at: now
)
} }
rule SendChatMessage { rule SendChatMessage {
when: SendChatMessageRequested(conversation, content) when: SendChatMessageRequested(conversation, content)
requires: active_endpoint_configured requires: active_endpoint_configured
ensures: ChatMessage.created(conversation: conversation, role: user, content: content) ensures: ChatMessage.created(
conversation: conversation,
role: user,
content: content,
token_usage_input: null,
token_usage_output: null,
created_at: now
)
ensures: conversation.updated_at = now
ensures: AiStreamingResponse(conversation) ensures: AiStreamingResponse(conversation)
-- AI SDK v6 streamText() with tool-call loop (max 10 rounds) -- AI SDK v6 streamText() with tool-call loop (max 10 rounds)
-- Blog data tools for post/media querying during chat -- Blog data tools for post/media querying during chat
@@ -135,6 +169,13 @@ invariant TwoEndpointModel {
-- This replaces the TypeScript app's 4 named provider model. -- This replaces the TypeScript app's 4 named provider model.
} }
invariant AiSpecPartitioning {
-- This file covers two distinct but related AI contracts:
-- 1. Core one-shot operations (taxonomy, vision, translation, language detection)
-- 2. Extension chat/model-catalog behaviour
-- Both share the same endpoint routing and airplane-mode gating rules.
}
invariant SecureKeyStorage { invariant SecureKeyStorage {
-- API keys are never stored in plain text -- API keys are never stored in plain text
-- Always encrypted via OS keychain before DB storage -- Always encrypted via OS keychain before DB storage

View File

@@ -25,9 +25,31 @@ entity GitRepository {
has_lfs: Boolean has_lfs: Boolean
} }
surface GitControlSurface {
facing _: GitOperator
provides:
InitializeRepoRequested(project)
GitStatusRequested(project)
GitDiffRequested(project)
GitHistoryRequested(project, branch)
GitFetchRequested(project)
GitPullRequested(project)
GitPushRequested(project)
GitCommitAllRequested(project, message)
GitReconcileRequested(project, old_commit, new_commit)
PruneLfsCacheRequested(project, retain_recent)
}
rule InitializeRepo { rule InitializeRepo {
when: InitializeRepoRequested(project) when: InitializeRepoRequested(project)
ensures: GitRepository.created(is_initialized: true, has_lfs: true) ensures: GitRepository.created(
is_initialized: true,
remote_url: null,
provider: null,
current_branch: "master",
has_lfs: true
)
ensures: GitignoreCreated(project) ensures: GitignoreCreated(project)
-- .gitignore manages: thumbnails, generated html, node_modules, etc. -- .gitignore manages: thumbnails, generated html, node_modules, etc.
ensures: LfsTrackingConfigured(project) ensures: LfsTrackingConfigured(project)
@@ -62,7 +84,7 @@ rule Fetch {
rule Pull { rule Pull {
when: GitPullRequested(project) when: GitPullRequested(project)
ensures: LocalBranchUpdated(project) ensures: LocalBranchUpdated(project)
ensures: ReconcileFromGit(project) ensures: GitReconcileRequested(project, previous_head(project), current_head(project))
-- After pull, detect changed files and reconcile DB -- After pull, detect changed files and reconcile DB
} }

View File

@@ -22,6 +22,11 @@ entity Proposal {
data: String data: String
created_at: Timestamp created_at: Timestamp
expires_at: Timestamp expires_at: Timestamp
draft_post: post/Post?
proposed_script: script/Script?
proposed_template: template/Template?
target_media: media/Media?
target_post: post/Post?
-- Derived -- Derived
is_expired: expires_at <= now is_expired: expires_at <= now
@@ -39,6 +44,25 @@ config {
proposal_ttl_cli: Duration = 8.hours proposal_ttl_cli: Duration = 8.hours
} }
surface McpAutomationSurface {
facing _: McpClient
provides:
McpToolInvoked("check_term", term)
McpToolInvoked("search_posts", params)
McpToolInvoked("count_posts", params)
McpToolInvoked("read_post_by_slug", slug, language)
McpToolInvoked("draft_post", params)
McpToolInvoked("propose_script", params)
McpToolInvoked("propose_template", params)
McpToolInvoked("propose_media_metadata", params)
McpToolInvoked("propose_post_metadata", params)
AcceptProposalRequested(proposal)
DiscardProposalRequested(proposal)
InstallAgentConfigRequested(agent_kind)
UninstallAgentConfigRequested(agent_kind)
}
invariant LocalhostOnlyHttp { invariant LocalhostOnlyHttp {
-- HTTP transport binds to 127.0.0.1 only -- HTTP transport binds to 127.0.0.1 only
-- Origin validation: localhost only -- Origin validation: localhost only
@@ -161,7 +185,20 @@ rule DraftPost {
content: params.content, content: params.content,
status: draft status: draft
) )
Proposal.created(kind: draft_post, entity_id: new_post.id, status: pending) let proposal = Proposal.created(
kind: draft_post,
entity_id: new_post.id,
data: "",
created_at: now,
expires_at: now + config.proposal_ttl_app,
draft_post: new_post,
proposed_script: null,
proposed_template: null,
target_media: null,
target_post: null,
status: pending
)
proposal.status = pending
} }
rule ProposeScript { rule ProposeScript {
@@ -174,7 +211,20 @@ rule ProposeScript {
content: params.content, content: params.content,
status: draft status: draft
) )
Proposal.created(kind: propose_script, entity_id: new_script.id, status: pending) let proposal = Proposal.created(
kind: propose_script,
entity_id: new_script.id,
data: "",
created_at: now,
expires_at: now + config.proposal_ttl_app,
draft_post: null,
proposed_script: new_script,
proposed_template: null,
target_media: null,
target_post: null,
status: pending
)
proposal.status = pending
} }
rule ProposeTemplate { rule ProposeTemplate {
@@ -187,17 +237,58 @@ rule ProposeTemplate {
content: params.content, content: params.content,
status: draft status: draft
) )
Proposal.created(kind: propose_template, entity_id: new_template.id, status: pending) let proposal = Proposal.created(
kind: propose_template,
entity_id: new_template.id,
data: "",
created_at: now,
expires_at: now + config.proposal_ttl_app,
draft_post: null,
proposed_script: null,
proposed_template: new_template,
target_media: null,
target_post: null,
status: pending
)
proposal.status = pending
} }
rule ProposeMediaMetadata { rule ProposeMediaMetadata {
when: McpToolInvoked("propose_media_metadata", params) when: McpToolInvoked("propose_media_metadata", params)
ensures: Proposal.created(kind: propose_media_metadata, entity_id: params.media_id, data: params, status: pending) ensures:
let proposal = Proposal.created(
kind: propose_media_metadata,
entity_id: params.media_id,
data: serialize(params),
created_at: now,
expires_at: now + config.proposal_ttl_app,
draft_post: null,
proposed_script: null,
proposed_template: null,
target_media: params.media,
target_post: null,
status: pending
)
proposal.status = pending
} }
rule ProposePostMetadata { rule ProposePostMetadata {
when: McpToolInvoked("propose_post_metadata", params) when: McpToolInvoked("propose_post_metadata", params)
ensures: Proposal.created(kind: propose_post_metadata, entity_id: params.post_id, data: params, status: pending) ensures:
let proposal = Proposal.created(
kind: propose_post_metadata,
entity_id: params.post_id,
data: serialize(params),
created_at: now,
expires_at: now + config.proposal_ttl_app,
draft_post: null,
proposed_script: null,
proposed_template: null,
target_media: null,
target_post: params.post,
status: pending
)
proposal.status = pending
} }
-- Proposal lifecycle -- Proposal lifecycle
@@ -207,15 +298,16 @@ rule AcceptProposal {
requires: not proposal.is_expired requires: not proposal.is_expired
ensures: ensures:
if proposal.kind = draft_post: if proposal.kind = draft_post:
post/PublishPostRequested(proposal.entity_id) post/PublishPostRequested(proposal.draft_post)
if proposal.kind = propose_script: if proposal.kind = propose_script:
script/PublishScriptRequested(proposal.entity_id) script/PublishScriptRequested(proposal.proposed_script)
if proposal.kind = propose_template: if proposal.kind = propose_template:
template/PublishTemplateRequested(proposal.entity_id) template/PublishTemplateRequested(proposal.proposed_template)
if proposal.kind = propose_media_metadata: if proposal.kind = propose_media_metadata:
media/UpdateMediaRequested(proposal.entity_id, proposal.data) media/UpdateMediaRequested(proposal.target_media, deserialize_media_changes(proposal.data))
if proposal.kind = propose_post_metadata: if proposal.kind = propose_post_metadata:
post/UpdatePostRequested(proposal.entity_id, proposal.data) post/UpdatePostRequested(proposal.target_post, deserialize_post_changes(proposal.data))
proposal.status = accepted
not exists proposal not exists proposal
} }
@@ -223,17 +315,19 @@ rule DiscardProposal {
when: DiscardProposalRequested(proposal) when: DiscardProposalRequested(proposal)
ensures: ensures:
if proposal.kind = draft_post: if proposal.kind = draft_post:
post/DeletePostRequested(proposal.entity_id) post/DeletePostRequested(proposal.draft_post)
if proposal.kind = propose_script: if proposal.kind = propose_script:
script/DeleteScriptRequested(proposal.entity_id) script/DeleteScriptRequested(proposal.proposed_script)
if proposal.kind = propose_template: if proposal.kind = propose_template:
template/DeleteTemplateRequested(proposal.entity_id) template/DeleteTemplateRequested(proposal.proposed_template)
proposal.status = discarded
not exists proposal not exists proposal
} }
rule ExpireProposal { rule ExpireProposal {
when: proposal: Proposal.is_expired becomes true when: proposal: Proposal.is_expired becomes true
-- On expiry: clean up draft DB rows -- On expiry: clean up draft DB rows
ensures: proposal.status = expired
ensures: DiscardProposalRequested(proposal) ensures: DiscardProposalRequested(proposal)
} }
@@ -255,3 +349,9 @@ rule UninstallAgentConfig {
when: UninstallAgentConfigRequested(agent_kind) when: UninstallAgentConfigRequested(agent_kind)
ensures: AgentConfigRemoved(agent_kind) ensures: AgentConfigRemoved(agent_kind)
} }
invariant ProposalPayloadEncoding {
-- Proposal.data stores a serialized payload for metadata proposals.
-- draft_post / propose_script / propose_template proposals keep the
-- created entity reference directly on the proposal record.
}

View File

@@ -25,38 +25,94 @@ value UploadTarget {
remote_dir: String remote_dir: String
} }
surface PublishingControlSurface {
facing _: PublishOperator
provides:
UploadSiteRequested(project, credentials)
}
surface PublishingRuntimeSurface {
facing _: PublishRuntime
provides:
PublishJobStarted(project, job, credentials)
PublishTargetFailed(job, target, error)
}
rule UploadSite { rule UploadSite {
when: UploadSiteRequested(project, credentials) when: UploadSiteRequested(project, credentials)
-- Three upload targets run as parallel tasks ensures:
ensures: UploadTargetStarted(html, "html/", credentials.ssh_remote_path, credentials) let job = PublishJob.created(
ensures: UploadTargetStarted(thumbnails, "thumbnails/", credentials.ssh_remote_path + "/thumbnails", credentials) ssh_host: credentials.ssh_host,
ensures: UploadTargetStarted(media, "media/", credentials.ssh_remote_path + "/media", credentials) ssh_user: credentials.ssh_user,
ssh_remote_path: credentials.ssh_remote_path,
ssh_mode: credentials.ssh_mode,
status: pending
)
job.status = pending
PublishJobStarted(project, job, credentials)
}
rule StartPublishJob {
when: PublishJobStarted(project, job, credentials)
ensures: job.status = running
ensures: UploadTargetStarted(job, html, "html/", credentials.ssh_remote_path, credentials)
ensures: UploadTargetStarted(job, thumbnails, "thumbnails/", credentials.ssh_remote_path + "/thumbnails", credentials)
ensures: UploadTargetStarted(job, media, "media/", credentials.ssh_remote_path + "/media", credentials)
} }
rule UploadViaScp { rule UploadViaScp {
when: UploadTargetCompleted(target, credentials) when: UploadTargetStarted(job, target, local_dir, remote_dir, credentials)
requires: credentials.ssh_mode = scp requires: credentials.ssh_mode = scp
-- mtime-based upload detection: skip unchanged files -- mtime-based upload detection: skip unchanged files
-- Uses SSH agent (SSH_AUTH_SOCK) for authentication -- Uses SSH agent (SSH_AUTH_SOCK) for authentication
ensures: ScpUploadCompleted(target) ensures: ScpUploadCompleted(job, target)
ensures: UploadTargetCompleted(job, target)
} }
rule UploadViaRsync { rule UploadViaRsync {
when: UploadTargetCompleted(target, credentials) when: UploadTargetStarted(job, target, local_dir, remote_dir, credentials)
requires: credentials.ssh_mode = rsync requires: credentials.ssh_mode = rsync
-- rsync --update --compress --verbose -- rsync --update --compress --verbose
-- Media uploads exclude .meta sidecar files -- Media uploads exclude .meta sidecar files
ensures: RsyncUploadCompleted(target) ensures: RsyncUploadCompleted(job, target)
ensures: UploadTargetCompleted(job, target)
@guidance @guidance
-- rsync exclude filters for .meta files on media target -- rsync exclude filters for .meta files on media target
} }
rule CompletePublishJob {
when: PublishTargetsCompleted(job)
requires: job.status = running
ensures: job.status = completed
}
rule FailPublishJob {
when: PublishTargetFailed(job, target, error)
requires: job.status = running
ensures: job.status = failed
}
rule TrackUploadCompletion {
when: UploadTargetCompleted(job, target)
requires: all_upload_targets_completed(job)
ensures: PublishTargetsCompleted(job)
}
invariant MediaSidecarsExcludedFromUpload { invariant MediaSidecarsExcludedFromUpload {
-- .meta sidecar files are never uploaded to the remote server -- .meta sidecar files are never uploaded to the remote server
-- They are project metadata, not public content -- They are project metadata, not public content
} }
invariant PublishJobLifecycle {
-- UploadSiteRequested creates one PublishJob in pending state.
-- PublishJobStarted moves the job to running before any target starts.
-- A job reaches completed only after PublishTargetsCompleted(job).
-- Any PublishTargetFailed(job, target, error) transitions the job to failed.
}
invariant SshAgentAuth { invariant SshAgentAuth {
-- Publishing uses SSH_AUTH_SOCK for key-based authentication -- Publishing uses SSH_AUTH_SOCK for key-based authentication
-- No password prompts, no interactive auth -- No password prompts, no interactive auth

View File

@@ -26,6 +26,20 @@ entity Script {
} }
} }
surface ScriptManagementSurface {
facing _: ScriptOperator
provides:
CreateScriptRequested(title, kind, content, entrypoint)
CreateAndPublishScriptRequested(title, kind, content, entrypoint)
PublishScriptRequested(script)
DeleteScriptRequested(script)
RunUtilityRequested(script)
MacroExpansionRequested(script, template_context)
BlogmarkReceived(data)
RebuildScriptsFromFilesRequested(project)
}
invariant UniqueScriptSlug { invariant UniqueScriptSlug {
for a in Scripts: for a in Scripts:
for b in Scripts: for b in Scripts:
@@ -61,7 +75,8 @@ rule CreateAndPublishScript {
when: CreateAndPublishScriptRequested(title, kind, content, entrypoint) when: CreateAndPublishScriptRequested(title, kind, content, entrypoint)
let slug = slugify(title) let slug = slugify(title)
requires: ValidateScript(content) = valid requires: ValidateScript(content) = valid
ensures: Script.created( ensures:
let new_script = Script.created(
slug: slug, slug: slug,
title: title, title: title,
kind: kind, kind: kind,
@@ -72,7 +87,7 @@ rule CreateAndPublishScript {
version: 1, version: 1,
file_path: format("scripts/{slug}.lua", slug: slug) file_path: format("scripts/{slug}.lua", slug: slug)
) )
ensures: ScriptFileWritten(script) ScriptFileWritten(new_script)
} }
rule PublishScript { rule PublishScript {

View File

@@ -27,6 +27,18 @@ entity Template {
} }
} }
surface TemplateManagementSurface {
facing operator: TemplateOperator
provides:
CreateTemplateRequested(title, kind, content)
CreateAndPublishTemplateRequested(title, kind, content)
UpdateTemplateRequested(template, changes)
PublishTemplateRequested(template)
DeleteTemplateRequested(template)
RebuildTemplatesFromFilesRequested(project)
}
invariant UniqueTemplateSlug { invariant UniqueTemplateSlug {
for a in Templates: for a in Templates:
for b in Templates: for b in Templates:
@@ -67,7 +79,8 @@ rule CreateAndPublishTemplate {
when: CreateAndPublishTemplateRequested(title, kind, content) when: CreateAndPublishTemplateRequested(title, kind, content)
let slug = slugify(title) let slug = slugify(title)
requires: ValidateLiquid(content) = valid requires: ValidateLiquid(content) = valid
ensures: Template.created( ensures:
let new_template = Template.created(
slug: slug, slug: slug,
title: title, title: title,
kind: kind, kind: kind,
@@ -77,7 +90,7 @@ rule CreateAndPublishTemplate {
version: 1, version: 1,
file_path: format("templates/{slug}.liquid", slug: slug) file_path: format("templates/{slug}.liquid", slug: slug)
) )
ensures: TemplateFileWritten(template) TemplateFileWritten(new_template)
} }
rule UpdateTemplate { rule UpdateTemplate {