diff --git a/specs/ai.allium b/specs/ai.allium index 4dfcdf1..c44c571 100644 --- a/specs/ai.allium +++ b/specs/ai.allium @@ -44,6 +44,27 @@ entity ChatMessage { 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) -- All use OpenAI Chat Completions wire format. -- Endpoint routing: see AirplaneModeGating invariant below. @@ -95,13 +116,26 @@ rule TranslateMedia { rule StartChat { 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 { when: SendChatMessageRequested(conversation, content) 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) -- AI SDK v6 streamText() with tool-call loop (max 10 rounds) -- 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. } +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 { -- API keys are never stored in plain text -- Always encrypted via OS keychain before DB storage diff --git a/specs/git.allium b/specs/git.allium index f488f7b..58a5c72 100644 --- a/specs/git.allium +++ b/specs/git.allium @@ -25,9 +25,31 @@ entity GitRepository { 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 { 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) -- .gitignore manages: thumbnails, generated html, node_modules, etc. ensures: LfsTrackingConfigured(project) @@ -62,7 +84,7 @@ rule Fetch { rule Pull { when: GitPullRequested(project) ensures: LocalBranchUpdated(project) - ensures: ReconcileFromGit(project) + ensures: GitReconcileRequested(project, previous_head(project), current_head(project)) -- After pull, detect changed files and reconcile DB } diff --git a/specs/mcp.allium b/specs/mcp.allium index 00ccff1..c2c5caa 100644 --- a/specs/mcp.allium +++ b/specs/mcp.allium @@ -22,6 +22,11 @@ entity Proposal { data: String created_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 is_expired: expires_at <= now @@ -39,6 +44,25 @@ config { 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 { -- HTTP transport binds to 127.0.0.1 only -- Origin validation: localhost only @@ -161,7 +185,20 @@ rule DraftPost { content: params.content, 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 { @@ -174,7 +211,20 @@ rule ProposeScript { content: params.content, 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 { @@ -187,17 +237,58 @@ rule ProposeTemplate { content: params.content, 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 { 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 { 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 @@ -207,15 +298,16 @@ rule AcceptProposal { requires: not proposal.is_expired ensures: if proposal.kind = draft_post: - post/PublishPostRequested(proposal.entity_id) + post/PublishPostRequested(proposal.draft_post) if proposal.kind = propose_script: - script/PublishScriptRequested(proposal.entity_id) + script/PublishScriptRequested(proposal.proposed_script) if proposal.kind = propose_template: - template/PublishTemplateRequested(proposal.entity_id) + template/PublishTemplateRequested(proposal.proposed_template) 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: - post/UpdatePostRequested(proposal.entity_id, proposal.data) + post/UpdatePostRequested(proposal.target_post, deserialize_post_changes(proposal.data)) + proposal.status = accepted not exists proposal } @@ -223,17 +315,19 @@ rule DiscardProposal { when: DiscardProposalRequested(proposal) ensures: if proposal.kind = draft_post: - post/DeletePostRequested(proposal.entity_id) + post/DeletePostRequested(proposal.draft_post) if proposal.kind = propose_script: - script/DeleteScriptRequested(proposal.entity_id) + script/DeleteScriptRequested(proposal.proposed_script) if proposal.kind = propose_template: - template/DeleteTemplateRequested(proposal.entity_id) + template/DeleteTemplateRequested(proposal.proposed_template) + proposal.status = discarded not exists proposal } rule ExpireProposal { when: proposal: Proposal.is_expired becomes true -- On expiry: clean up draft DB rows + ensures: proposal.status = expired ensures: DiscardProposalRequested(proposal) } @@ -255,3 +349,9 @@ rule UninstallAgentConfig { when: UninstallAgentConfigRequested(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. +} diff --git a/specs/publishing.allium b/specs/publishing.allium index ee7cbfe..ddefc83 100644 --- a/specs/publishing.allium +++ b/specs/publishing.allium @@ -25,38 +25,94 @@ value UploadTarget { 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 { when: UploadSiteRequested(project, credentials) - -- Three upload targets run as parallel tasks - ensures: UploadTargetStarted(html, "html/", credentials.ssh_remote_path, credentials) - ensures: UploadTargetStarted(thumbnails, "thumbnails/", credentials.ssh_remote_path + "/thumbnails", credentials) - ensures: UploadTargetStarted(media, "media/", credentials.ssh_remote_path + "/media", credentials) + ensures: + let job = PublishJob.created( + ssh_host: credentials.ssh_host, + 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 { - when: UploadTargetCompleted(target, credentials) + when: UploadTargetStarted(job, target, local_dir, remote_dir, credentials) requires: credentials.ssh_mode = scp -- mtime-based upload detection: skip unchanged files -- Uses SSH agent (SSH_AUTH_SOCK) for authentication - ensures: ScpUploadCompleted(target) + ensures: ScpUploadCompleted(job, target) + ensures: UploadTargetCompleted(job, target) } rule UploadViaRsync { - when: UploadTargetCompleted(target, credentials) + when: UploadTargetStarted(job, target, local_dir, remote_dir, credentials) requires: credentials.ssh_mode = rsync -- rsync --update --compress --verbose -- Media uploads exclude .meta sidecar files - ensures: RsyncUploadCompleted(target) + ensures: RsyncUploadCompleted(job, target) + ensures: UploadTargetCompleted(job, target) @guidance -- 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 { -- .meta sidecar files are never uploaded to the remote server -- 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 { -- Publishing uses SSH_AUTH_SOCK for key-based authentication -- No password prompts, no interactive auth diff --git a/specs/script.allium b/specs/script.allium index 963e6c3..842ac0e 100644 --- a/specs/script.allium +++ b/specs/script.allium @@ -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 { for a in Scripts: for b in Scripts: @@ -61,18 +75,19 @@ rule CreateAndPublishScript { when: CreateAndPublishScriptRequested(title, kind, content, entrypoint) let slug = slugify(title) requires: ValidateScript(content) = valid - ensures: Script.created( - slug: slug, - title: title, - kind: kind, - content: null, - entrypoint: entrypoint ?? "render", - status: published, - enabled: true, - version: 1, - file_path: format("scripts/{slug}.lua", slug: slug) - ) - ensures: ScriptFileWritten(script) + ensures: + let new_script = Script.created( + slug: slug, + title: title, + kind: kind, + content: null, + entrypoint: entrypoint ?? "render", + status: published, + enabled: true, + version: 1, + file_path: format("scripts/{slug}.lua", slug: slug) + ) + ScriptFileWritten(new_script) } rule PublishScript { diff --git a/specs/template.allium b/specs/template.allium index 7f93b59..c213534 100644 --- a/specs/template.allium +++ b/specs/template.allium @@ -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 { for a in Templates: for b in Templates: @@ -67,17 +79,18 @@ rule CreateAndPublishTemplate { when: CreateAndPublishTemplateRequested(title, kind, content) let slug = slugify(title) requires: ValidateLiquid(content) = valid - ensures: Template.created( - slug: slug, - title: title, - kind: kind, - content: null, - status: published, - enabled: true, - version: 1, - file_path: format("templates/{slug}.liquid", slug: slug) - ) - ensures: TemplateFileWritten(template) + ensures: + let new_template = Template.created( + slug: slug, + title: title, + kind: kind, + content: null, + status: published, + enabled: true, + version: 1, + file_path: format("templates/{slug}.liquid", slug: slug) + ) + TemplateFileWritten(new_template) } rule UpdateTemplate {