fix: complete state-contract naming cleanup

This commit is contained in:
2026-10-10 17:48:59 +09:00
parent 3263d50125
commit 6a12b6cc34
109 changed files with 3696 additions and 576 deletions
+8
View File
@@ -5,6 +5,14 @@
- プロンプトはすべて`resources/prompts`に集約している。管理効率の向上のためであると同時に、ユーザーがオーバーライドする形式でもある。
- 変更量を最小にするために設計を歪めたり、設計問題に対して不必要な後方互換性を作らない。長期的なメンテナンスと型安全性を追求すること。
### 状態契約の命名・競合制御
- `revision` という名前を、大小文字や複合語を含め、新しい型・フィールド・変数・API・DB列・文書に導入しない。
- 不透明な更新番号を別の汎用名へ言い換えて済ませない。何を、どの根拠と比較し、どの操作の前/中/後に確認するかを、コードの名前と境界処理で示す。
- 到着順、操作時刻、先勝ち/後勝ちだけで成立する処理はそれで実装する。内容の一致、鍵の一致、同じ操作の再送、同じ接続の生存期間を確認する必要がある場合は、その対象を明示し、順序や時刻だけでは足りない理由を残す。
- 旧保存データを読むための綴りは現行契約と分離する。削除によるデータ喪失を避けるために残す場合も、必要な行と理由を明示して検査対象にする。文字列分割・符号化や広いディレクトリ除外で検査を逃れない。
- 完了前に `env -u LD_LIBRARY_PATH deno run --allow-read --allow-run=git tools/check-state-contract-names.ts` を実行する。例外一覧を機械的に増やして合格させない。
### LLM コンテキストの加工原則
LLM に投げる context はappend-onlyが基本であり、またその永続化形式からAPIコールの形式を純粋に再現可能である必要が有る。
+1 -1
View File
@@ -65,7 +65,7 @@ impl<T: OpenApiSchema> OpenApiSchema for BTreeMap<String, T> {}
pub struct OpenApiInfo<'a> {
pub title: &'a str,
pub version: &'a str,
/// Opaque digest of the source revision used to build the contract.
/// Source-content digest used to identify the exported contract's build inputs.
pub source_digest: &'a str,
}
+4 -4
View File
@@ -15,7 +15,7 @@ fn request() -> JobRequest {
}
#[test]
fn input_content_identity_is_computed_and_old_counter_is_not_accepted() {
fn input_content_identity_is_computed_and_unknown_fields_are_rejected() {
let original = request();
let mut changed = original.clone();
changed.input = json!({"title": "changed"});
@@ -28,9 +28,9 @@ fn input_content_identity_is_computed_and_old_counter_is_not_accepted() {
changed.input_digest().unwrap(),
fingerprint(&changed.input).unwrap()
);
let mut old = serde_json::to_value(&original).unwrap();
old["input_revision"] = json!(7);
assert!(serde_json::from_value::<JobRequest>(old).is_err());
let mut unknown = serde_json::to_value(&original).unwrap();
unknown["unexpected_state_guard"] = json!(7);
assert!(serde_json::from_value::<JobRequest>(unknown).is_err());
}
#[test]
+15 -11
View File
@@ -1141,7 +1141,8 @@ struct LegacyMemoryConfig {
_query_excerpt_lines: Option<usize>,
inject_summary: Option<bool>,
workspace_id: Option<String>,
settings_revision: Option<u64>,
#[serde(rename = "settings_revision")]
legacy_settings_counter: Option<u64>,
language: Option<String>,
extract_model: Option<ModelManifest>,
extract_threshold: Option<u64>,
@@ -1241,8 +1242,9 @@ fn validate_persisted_worker_manifest(
Ok(manifest)
}
/// Frozen migration for schema 2/3 resolved manifests. Keep actual Workspace
/// identity and language unchanged; the obsolete sequence has no execution role.
/// Frozen migration for schema 2/3 resolved manifests. During persisted snapshot
/// decoding, reject an invalid old counter; do not compare it for execution freshness.
/// Keep actual Workspace identity and language unchanged; the sequence has no execution role.
fn migrate_legacy_memory_settings_counter(
manifest: &mut serde_json::Value,
) -> Result<(), serde_json::Error> {
@@ -1342,14 +1344,16 @@ fn migrate_legacy_resolved_manifest_snapshot(
serde_json::from_value(legacy_memory.unwrap_or_else(|| serde_json::json!({})))?;
let mut workspace_settings = match (
legacy_memory.workspace_id,
legacy_memory.settings_revision,
legacy_memory.legacy_settings_counter,
legacy_memory.language,
) {
(Some(workspace_id), Some(settings_revision), Some(language)) => Some(serde_json::json!({
"workspace_id": workspace_id,
"settings_revision": settings_revision,
"language": language,
})),
(Some(workspace_id), Some(legacy_settings_counter), Some(language)) => {
Some(serde_json::json!({
"workspace_id": workspace_id,
"settings_revision": legacy_settings_counter,
"language": language,
}))
}
(None, None, None) => None,
_ => {
return Err(serde_json::Error::io(std::io::Error::new(
@@ -1891,10 +1895,10 @@ model_id = "claude-sonnet-4-20250514"
}
#[test]
fn current_settings_snapshot_rejects_removed_counter_outside_migration() {
fn current_settings_snapshot_rejects_unknown_fields_and_invalid_language() {
assert!(
serde_json::from_value::<WorkspaceMemorySettingsSnapshot>(serde_json::json!({
"workspace_id": "workspace-1", "language": "English", "settings_revision": 9
"workspace_id": "workspace-1", "language": "English", "unexpected_state_guard": 9
}))
.is_err()
);
+4 -2
View File
@@ -606,8 +606,10 @@ pub struct CompleteMergeRequest {
pub struct TicketCompletionEvent {
pub operation_id: String,
pub ticket_id: String,
/// Frozen historical payload only; not a content attestation or live API.
pub item_revision: String,
/// Frozen historical marker, read only to preserve archived completion payloads.
/// It is never checked for readiness, content attestation, or live completion.
#[serde(rename = "item_revision")]
pub legacy_item_marker: String,
pub merge_request_ids: Vec<String>,
pub requirement_approval_event_id: String,
pub completed_by: WorkerIdentity,
+5 -4
View File
@@ -164,11 +164,11 @@ fn v12_migration_revokes_old_grants_preserves_integration_and_requires_fresh_con
assert_eq!(grants[1].0, "old-issued");
assert_eq!(grants[1].1, "revoked");
assert!(grants[1].2.is_some());
let legacy_terms: i64 = connection.query_row(
"SELECT COUNT(*) FROM merge_request_thread_events WHERE payload_json LIKE '%ticket_item_revision%'",
let invalid_content_attestations: i64 = connection.query_row(
"SELECT COUNT(*) FROM merge_request_thread_events WHERE kind IN ('review_requested','review') AND json_extract(payload_json, '$.ticket_content_digest') IS NOT ''",
[], |row| row.get(0),
).unwrap();
assert_eq!(legacy_terms, 0);
assert_eq!(invalid_content_attestations, 0);
let saved = store.get_by_id("W", "MR").unwrap();
assert_eq!(saved.merged_result().unwrap(), &merged);
let integration = saved.integration_approval().unwrap();
@@ -1314,7 +1314,7 @@ fn historical_ticket_completion_payload_remains_readable_and_unchanged_on_migrat
let payload = r#"{
"operation_id": "historical-operation",
"ticket_id": "T",
"item_revision": "historical-revision",
"item_revision": "historical-item-marker",
"merge_request_ids": ["MR-2", "MR"],
"requirement_approval_event_id": "historical-review",
"completed_by": {"runtime_id": "runtime", "worker_id": "coder"},
@@ -1350,6 +1350,7 @@ fn historical_ticket_completion_payload_remains_readable_and_unchanged_on_migrat
assert_eq!(body, "historical body");
assert_eq!(state, "done");
let historical: TicketCompletionEvent = serde_json::from_str(&saved_payload).unwrap();
assert_eq!(historical.legacy_item_marker, "historical-item-marker");
assert_eq!(
serde_json::to_value(&historical).unwrap(),
serde_json::from_str::<serde_json::Value>(payload).unwrap()
+23 -23
View File
@@ -1142,7 +1142,8 @@ pub enum Event {
notification_request_id: String,
message: String,
},
/// Revisioned FIFO replacement following enqueue, activation, cancel, or clear.
/// Complete FIFO replacement following enqueue, activation, cancel, or clear.
/// Clients replace their pending view in stream order; reconnect uses the snapshot.
PendingSubmissionsChanged {
pending: PendingSubmissionsSnapshot,
},
@@ -2035,8 +2036,6 @@ mod tests {
}
})
);
assert!(value.get("execution_generation").is_none());
assert!(value.get("revision").is_none());
}
#[test]
@@ -2276,7 +2275,7 @@ mod tests {
}
#[test]
fn lifecycle_commands_reject_removed_nested_preconditions() {
fn lifecycle_commands_reject_unknown_nested_preconditions() {
for method in ["pause", "cancel", "resume", "compact", "shutdown"] {
let wire = serde_json::json!({
"method": method,
@@ -2284,16 +2283,12 @@ mod tests {
});
// Current commands are arrival-time operations, not Run-bound CAS.
assert!(serde_json::from_value::<Method>(wire.clone()).is_ok());
for field in [
"expected_execution_generation",
"expected_worker_state_revision",
] {
let mut old_command = wire.clone();
old_command["params"]["command"][field] = serde_json::json!(1);
let error = serde_json::from_value::<Method>(old_command)
.expect_err("removed command preconditions must not be silently ignored");
assert!(error.to_string().contains(field), "{method}: {error}");
}
let field = "unexpected_state_guard";
let mut unknown_command = wire.clone();
unknown_command["params"]["command"][field] = serde_json::json!(1);
let error = serde_json::from_value::<Method>(unknown_command)
.expect_err("unknown command preconditions must not be silently ignored");
assert!(error.to_string().contains(field), "{method}: {error}");
}
}
@@ -2308,8 +2303,6 @@ mod tests {
},
] {
let json = serde_json::to_string(&method).unwrap();
assert!(!json.contains("expected_execution_generation"));
assert!(!json.contains("expected_worker_state_revision"));
let decoded: Method = serde_json::from_str(&json).unwrap();
match decoded {
Method::Pause { command } | Method::Compact { command } => {
@@ -2867,14 +2860,21 @@ mod tests {
};
let json = serde_json::to_string(&event).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["event"], "worker_state");
assert!(
parsed["data"]["snapshot"]
.get("execution_generation")
.is_none()
assert_eq!(
parsed,
serde_json::json!({
"event": "worker_state",
"data": {
"snapshot": {
"last_command_id": 9,
"state": {
"kind": "busy",
"state": { "kind": "run", "state": "running" }
}
}
}
})
);
assert!(parsed["data"]["snapshot"].get("revision").is_none());
assert_eq!(parsed["data"]["snapshot"]["state"]["kind"], "busy");
let decoded: Event = serde_json::from_str(&json).unwrap();
assert!(matches!(
+49 -16
View File
@@ -6022,7 +6022,6 @@ pub enum WorkspaceDeletionState {
#[serde(rename_all = "snake_case")]
pub enum WorkspaceDeletionBlockerKind {
LastAccessibleWorkspace,
RevisionConflict,
DirtyWorkdir,
WorkerRemovalBlocked,
WorkdirRemovalBlocked,
@@ -11845,7 +11844,6 @@ mod tests {
let source: RepositorySource = serde_json::from_value(serde_json::json!({
"kind": "http",
"uri": "http://git.example.test/team/project.git",
"revision": 1,
}))
.unwrap();
@@ -11925,7 +11923,10 @@ mod tests {
let decoded: SkillCatalogResponse =
serde_json::from_str(&json).expect("deserialize Skill catalog");
assert_eq!(decoded, response);
assert!(!json.contains("\"revision\""));
assert_eq!(
serde_json::from_str::<serde_json::Value>(&json).unwrap()["projection"],
serde_json::json!({"tree_digest": "tree-digest"})
);
assert!(!json.contains("\"tree_digest\":null"));
}
@@ -12200,6 +12201,26 @@ mod tests {
#[test]
fn workspace_deletion_wire_contract_is_closed_and_typed() {
let categories = serde_json::to_value([
WorkspaceDeletionBlockerKind::LastAccessibleWorkspace,
WorkspaceDeletionBlockerKind::DirtyWorkdir,
WorkspaceDeletionBlockerKind::WorkerRemovalBlocked,
WorkspaceDeletionBlockerKind::WorkdirRemovalBlocked,
WorkspaceDeletionBlockerKind::RetentionHold,
WorkspaceDeletionBlockerKind::CleanupUnavailable,
])
.unwrap();
assert_eq!(
categories,
serde_json::json!([
"last_accessible_workspace",
"dirty_workdir",
"worker_removal_blocked",
"workdir_removal_blocked",
"retention_hold",
"cleanup_unavailable"
])
);
let preflight = WorkspaceDeletionPreflightResponse {
workspace_id: "workspace-test".to_string(),
display_name: "Test".to_string(),
@@ -12221,7 +12242,7 @@ mod tests {
preflight
);
let mut stale = value.as_object().unwrap().clone();
stale.insert("revision".to_string(), serde_json::json!(7));
stale.insert("unexpected".to_string(), serde_json::json!(7));
assert!(
serde_json::from_value::<WorkspaceDeletionPreflightResponse>(stale.into()).is_err()
);
@@ -12516,16 +12537,14 @@ mod tests {
}
#[test]
fn repository_response_rejects_stale_field_aliases() {
let stale = serde_json::json!({
fn repository_response_rejects_unknown_item_fields() {
let valid = serde_json::json!({
"workspace_id": "workspace-test",
"items": [{
"repository_key": "main",
"display_name": "main",
"kind": "git",
"provider": "git",
"source": {"kind": "local_path", "uri": "/srv/project"},
"source_revision": 1,
"source_fingerprint": "sha256:test",
"observed_status": "ready",
"record_authority": "workspace-control-plane"
@@ -12533,8 +12552,10 @@ mod tests {
"source": "workspace-control-plane",
"diagnostics": []
});
assert!(serde_json::from_value::<RepositoryListResponse>(stale).is_err());
serde_json::from_value::<RepositoryListResponse>(valid.clone()).unwrap();
let mut unknown = valid;
unknown["items"][0]["unexpected_state_guard"] = serde_json::json!(1);
assert!(serde_json::from_value::<RepositoryListResponse>(unknown).is_err());
}
#[test]
@@ -12657,21 +12678,23 @@ mod tests {
};
round_trip(evidence.clone());
let mut stale = serde_json::to_value(evidence).unwrap();
stale["workspace_identity_revision"] = serde_json::json!(1);
stale["unexpected_state_guard"] = serde_json::json!(1);
assert!(serde_json::from_value::<RuntimeVerificationEvidenceSummary>(stale).is_err());
round_trip(RevokeRuntimeTrustKeyRequest {
expected_binding_id: "binding-observed".into(),
});
assert!(
serde_json::from_value::<RevokeRuntimeTrustKeyRequest>(serde_json::json!({
"expected_revision": 1
"expected_binding_id": "binding-observed",
"unexpected_state_guard": 1
}))
.is_err()
);
assert!(
serde_json::from_value::<RemoveRuntimeRequest>(serde_json::json!({
"operation_id": "remove-binding",
"expected_binding_revision": 1
"expected_binding_id": "binding-observed",
"unexpected_state_guard": 1
}))
.is_err()
);
@@ -12683,6 +12706,14 @@ mod tests {
#[test]
fn repository_key_mutations_require_observed_key_fingerprints() {
round_trip(DeleteRepositorySshCredentialRequest {
operation_id: "delete-key".into(),
expected_public_key_fingerprint: "sha256:observed-key".into(),
});
round_trip(DeleteRepositorySshHostTrustRequest {
operation_id: "delete-trust".into(),
expected_fingerprint: "sha256:observed-host-key".into(),
});
round_trip(RotateRepositorySshCredentialRequest {
operation_id: "rotate-key".into(),
expected_public_key_fingerprint: "sha256:observed-key".into(),
@@ -12700,14 +12731,16 @@ mod tests {
assert!(
serde_json::from_value::<DeleteRepositorySshCredentialRequest>(serde_json::json!({
"operation_id": "delete-key",
"expected_revision": 1
"expected_public_key_fingerprint": "sha256:observed-key",
"unexpected_state_guard": 1
}))
.is_err()
);
assert!(
serde_json::from_value::<DeleteRepositorySshHostTrustRequest>(serde_json::json!({
"operation_id": "delete-trust",
"expected_revision": 1
"expected_fingerprint": "sha256:observed-host-key",
"unexpected_state_guard": 1
}))
.is_err()
);
@@ -12763,7 +12796,7 @@ mod tests {
assert!(output.contains("export type WorkspaceConfigTreeResponse ="));
assert!(output.contains("export type ConfigCommitRequest ="));
assert!(output.contains("export type ProfileSettingsResponse ="));
assert!(!output.contains("config_revision"));
assert!(output.contains("base_digest: string"));
assert!(output.contains("provenance: WorkspaceProfileSourceProvenance"));
assert!(output.contains(
"export type WorkspaceProfileSourceProvenance = \"project_profile_source_tree\""
+7 -5
View File
@@ -314,11 +314,13 @@ mod tests {
let old = r#"{"kind":"subject_behavior_refresh","body":"exact archived body","behavior_revision":7}"#;
let item: SystemItem = serde_json::from_str(old).unwrap();
assert_eq!(item.history_text(), "exact archived body");
assert!(
serde_json::to_value(item)
.unwrap()
.get("behavior_revision")
.is_none()
assert_eq!(
serde_json::to_value(item).unwrap(),
serde_json::json!({
"kind": "subject_behavior_refresh",
"body": "exact archived body",
"behavior_md": ""
})
);
}
@@ -103,7 +103,8 @@ mod tests {
let mut original = serde_json::to_value(request("done")).unwrap();
original["input_revision"] = json!("old-token");
let original = serde_json::to_string_pretty(&original).unwrap();
let receipt = "{\"accepted\":true,\"input_revision\":\"receipt-domain-data\"}".to_string();
// A Job result is opaque domain data, not a legacy attempt binding.
let receipt = "{\"accepted\":true,\"receipt_tag\":\"receipt-domain-data\"}".to_string();
let digest = job::result_digest(
&serde_json::from_str(&receipt).unwrap(),
job::ABSOLUTE_MAX_RESULT_BYTES,
+6 -2
View File
@@ -426,6 +426,10 @@ impl StandaloneWorkerStore {
// The create-new commit marker excludes other writers across this
// comparison and replacement. Compare the observed metadata itself,
// including its manifest, pointer and lifecycle state, not a counter.
// Before replacing the file, reject an update based on metadata from
// before another writer stopped/resumed the Worker or changed its
// session pointer. Those changes may share a timestamp; arrival-order
// wins would silently restore an old lifecycle/pointer.
if serde_json::to_value(&current).map_err(StandaloneStoreError::Json)?
!= serde_json::to_value(expected).map_err(StandaloneStoreError::Json)?
{
@@ -961,7 +965,7 @@ permission = "write"
write_worker_record(&mut encoded, &migrated).unwrap();
let saved: serde_json::Value = serde_json::from_slice(&encoded).unwrap();
assert!(saved.get("revision").is_none());
assert_eq!(saved["schema_version"], 2);
assert_eq!(saved["schema_version"], SCHEMA_VERSION);
let resumed = store
.update_active_pointer(
&migrated,
@@ -974,7 +978,7 @@ permission = "write"
)
.unwrap();
assert!(disk.get("revision").is_none());
assert_eq!(disk["schema_version"], 2);
assert_eq!(disk["schema_version"], SCHEMA_VERSION);
assert_eq!(resumed.subject, record.subject);
assert_eq!(resumed.active_segment_id, record.active_segment_id);
}
+11 -10
View File
@@ -187,7 +187,7 @@ WHEN EXISTS (
AND memory_id = NEW.memory_id
AND revision = NEW.revision
) BEGIN
SELECT RAISE(ABORT, 'subjektiv revision evidence is sealed');
SELECT RAISE(ABORT, 'subjektiv memory history evidence is sealed');
END;
CREATE TRIGGER memory_revision_derivations_no_late_insert
BEFORE INSERT ON memory_revision_derivations
@@ -220,11 +220,11 @@ END;
CREATE TRIGGER memory_revision_seals_no_update
BEFORE UPDATE ON memory_revision_seals BEGIN
SELECT RAISE(ABORT, 'subjektiv revision seals are immutable');
SELECT RAISE(ABORT, 'subjektiv memory history seals are immutable');
END;
CREATE TRIGGER memory_revision_seals_no_delete
BEFORE DELETE ON memory_revision_seals BEGIN
SELECT RAISE(ABORT, 'subjektiv revision seals are retained');
SELECT RAISE(ABORT, 'subjektiv memory history seals are retained');
END;
CREATE TRIGGER staging_resolution_seals_no_update
BEFORE UPDATE ON staging_resolution_seals BEGIN
@@ -245,19 +245,19 @@ END;
CREATE TRIGGER memory_revisions_no_update
BEFORE UPDATE ON memory_revisions BEGIN
SELECT RAISE(ABORT, 'subjektiv memory revisions are immutable');
SELECT RAISE(ABORT, 'subjektiv memory history entries are immutable');
END;
CREATE TRIGGER memory_revisions_no_delete
BEFORE DELETE ON memory_revisions BEGIN
SELECT RAISE(ABORT, 'subjektiv memory revisions are retained');
SELECT RAISE(ABORT, 'subjektiv memory history entries are retained');
END;
CREATE TRIGGER memory_revision_candidates_no_update
BEFORE UPDATE ON memory_revision_candidates BEGIN
SELECT RAISE(ABORT, 'subjektiv revision evidence is immutable');
SELECT RAISE(ABORT, 'subjektiv memory history evidence is immutable');
END;
CREATE TRIGGER memory_revision_candidates_no_delete
BEFORE DELETE ON memory_revision_candidates BEGIN
SELECT RAISE(ABORT, 'subjektiv revision evidence is retained');
SELECT RAISE(ABORT, 'subjektiv memory history evidence is retained');
END;
CREATE TRIGGER memory_revision_derivations_no_update
BEFORE UPDATE ON memory_revision_derivations BEGIN
@@ -858,7 +858,8 @@ mod tests {
subject_id: String,
body_md: String,
memory_refs: Vec<MemoryChangeRef>,
built_from_store_revision: u64,
#[serde(rename = "built_from_store_revision")]
legacy_memory_counter: u64,
created_at: String,
}
static V1_MIGRATIONS: &[FeatureMigration] = &[FeatureMigration::new(
@@ -983,7 +984,7 @@ mod tests {
subject_id: subject_id.clone(),
body_md: "Legacy summary without generation-policy evidence.".into(),
memory_refs: Vec::new(),
built_from_store_revision: 0,
legacy_memory_counter: 0,
created_at: now(),
};
store
@@ -1010,7 +1011,7 @@ mod tests {
params![
subject_id,
snapshot.id,
to_i64(snapshot.built_from_store_revision)?,
to_i64(snapshot.legacy_memory_counter)?,
serde_json::to_string(&snapshot)?,
snapshot.created_at
],
+8
View File
@@ -1628,6 +1628,10 @@ impl SubjektivStore {
"unknown generation `{generation_id}` for subject `{subject_id}`"
))
})?;
// Before publication, compare the generation's frozen Memory input
// with live input in this same IMMEDIATE transaction. An older job
// can finish after Memory changes; first/last arrival and timestamps
// cannot make its summary describe the newer input.
if current_fingerprint != memory_fingerprint {
return Err(SubjektivError::SurfaceGenerationConflict(format!(
"generation input {memory_fingerprint} is stale; current Memory input fingerprint is {}",
@@ -2167,6 +2171,10 @@ fn write_memory_change(
.optional()?;
let current = current_raw.map(|raw| parse_memory(&raw)).transpose()?;
// Compare the observed Memory change before appending history or replacing
// current state, under this same IMMEDIATE transaction. A late decision based
// on old content must not overwrite a newer accepted change. Timestamps can
// tie, and arrival order does not prove which Memory the decision examined.
let (change_id, created_at) = match (current.as_ref(), expected_change_id) {
(None, None) => (issued_id("memory-change"), now()),
(None, Some(_)) => return Err(SubjektivError::MemoryNotFound(memory_id.to_string())),
+4
View File
@@ -2523,6 +2523,10 @@ impl SqliteTicketBackend {
// Exact recorded replay is independent of today's live role/assignment.
// Parent transport authenticates identity before restoring receipt context.
self.require_active_source_assignment(conn, ticket_id)?;
// Compare content and workflow state before any state/audit write, using
// the snapshot read in this same write transaction. Acceptance criteria
// may change without a state change, so a late completion must not win
// solely by arrival order. Equal timestamps do not prove equal content.
let actual = ticket_content_digest(&previous);
if actual != request.expected_content_digest {
return Err(TicketError::StaleContent {
+3 -5
View File
@@ -3125,11 +3125,9 @@ mod tests {
}});
migrate_legacy_request_evidence(&mut document).unwrap();
let request = &document["request"];
assert_eq!(request["memory_settings"]["language"], "English");
assert!(
request["memory_settings"]
.get("settings_revision")
.is_none()
assert_eq!(
request["memory_settings"],
serde_json::json!({"workspace_id": "workspace-a", "language": "English"})
);
assert_eq!(
request["repository"]["source_fingerprint"],
+58 -1
View File
@@ -640,6 +640,9 @@ struct RuntimeAuthFile {
identity: Option<RuntimeIdentityMaterial>,
#[serde(default)]
workspace_issuers: Vec<WorkspaceIssuerTrustRecord>,
/// Frozen pre-ID issuer records, retained as TOML history only, never as authority.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
archived_legacy_issuers: Vec<String>,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -938,6 +941,7 @@ fn read_runtime_auth_file(path: &Path) -> Result<RuntimeAuthFile, ProcessError>
let mut document: toml::Value = toml::from_str(&contents)
.map_err(|_| ProcessError::auth("runtime auth store is corrupt"))?;
let mut migrated = false;
let mut archived_legacy_issuers = Vec::new();
if let Some(records) = document
.get_mut("workspace_issuers")
.and_then(toml::Value::as_array_mut)
@@ -951,6 +955,9 @@ fn read_runtime_auth_file(path: &Path) -> Result<RuntimeAuthFile, ProcessError>
legacy,
)
.map_err(|_| ProcessError::auth("runtime legacy trust store is corrupt"))?;
archived_legacy_issuers.push(toml::to_string(&*value).map_err(|_| {
ProcessError::auth("runtime auth history serialization failed")
})?);
*value = toml::Value::try_from(record).map_err(|_| {
ProcessError::auth("runtime auth migration serialization failed")
})?;
@@ -958,9 +965,10 @@ fn read_runtime_auth_file(path: &Path) -> Result<RuntimeAuthFile, ProcessError>
}
}
}
let auth: RuntimeAuthFile = document
let mut auth: RuntimeAuthFile = document
.try_into()
.map_err(|_| ProcessError::auth("runtime auth store is corrupt"))?;
auth.archived_legacy_issuers.extend(archived_legacy_issuers);
validate_workspace_issuer_trust_records(&auth.workspace_issuers)
.map_err(|_| ProcessError::auth("runtime Workspace issuer trust store is corrupt"))?;
if migrated {
@@ -1637,6 +1645,54 @@ mod tests {
assert_eq!(error, "invalid trust-workspace list arguments");
}
#[test]
fn pre_id_issuer_migration_retains_history_without_restoring_authority() {
use sha2::{Digest as _, Sha256};
let identity = RuntimeIdentityMaterial::generate("WK-legacy").unwrap();
let public_key = decode_public_key(&identity.public_key).unwrap();
let fingerprint = format!(
"sha256:{}",
Sha256::digest(public_key)
.iter()
.map(|byte| format!("{byte:02x}"))
.collect::<String>()
);
for state in ["active", "revoked"] {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("runtime-auth.toml");
// These frozen field spellings prove the persisted pre-ID boundary.
let legacy = serde_json::json!({
"workspace_id": "workspace-1",
"backend_url": "https://backend.example.test",
"key_id": "WK-legacy",
"algorithm": "ed25519",
"public_key": identity.public_key,
"public_key_fingerprint": fingerprint,
"identity_revision": 7,
"trust_generation": 9,
"state": state,
"registered_at_unix": 10,
"updated_at_unix": 20,
});
let original_record = toml::Value::try_from(&legacy).unwrap();
let document = serde_json::json!({"workspace_issuers": [legacy]});
std::fs::write(&path, toml::to_string(&document).unwrap()).unwrap();
let migrated = read_runtime_auth_file(&path).unwrap();
assert_eq!(
migrated.workspace_issuers[0].state,
worker_runtime::workspace_issuer::WorkspaceIssuerTrustState::Revoked
);
assert_eq!(migrated.archived_legacy_issuers.len(), 1);
let archived: toml::Value =
toml::from_str(&migrated.archived_legacy_issuers[0]).unwrap();
assert_eq!(archived, original_record);
write_runtime_auth_file(&path, &migrated).unwrap();
assert_eq!(read_runtime_auth_file(&path).unwrap(), migrated);
}
}
#[test]
fn oversized_runtime_auth_store_fails_closed_before_parsing() {
let temp = tempfile::tempdir().unwrap();
@@ -1691,6 +1747,7 @@ mod tests {
&RuntimeAuthFile {
identity: Some(identity),
workspace_issuers: Vec::new(),
archived_legacy_issuers: Vec::new(),
},
)
.unwrap();
@@ -1480,7 +1480,6 @@ mod tests {
assert!(request.starts_with("POST /api/w/workspace-a/workers/remove HTTP/1.1"));
assert!(request.contains("\"target_runtime_id\":\"runtime-target\""));
assert!(request.contains("\"target_worker_id\":\"worker-target\""));
assert!(!request.contains("expected_worker_revision"));
assert!(request.contains("\"reason\":\"retire obsolete Worker\""));
let request_source_token = request
.lines()
+18 -17
View File
@@ -62,8 +62,9 @@ pub struct WorkingDirectoryEvidence {
pub host_key_fingerprint: Option<String>,
}
// Frozen persisted evidence reader. Legacy counters only mark that SSH
// access is required; they are never converted to key identity or grants.
// Frozen persisted evidence reader. At decode time, reject zero legacy counters
// and use their presence only to require fresh SSH access before session opening.
// They never identify keys or grant access; live authority checks fingerprints.
#[derive(Deserialize)]
struct FrozenEvidenceRead {
repository_id: String,
@@ -76,19 +77,21 @@ struct FrozenEvidenceRead {
public_key_fingerprint: Option<String>,
host_key_fingerprint: Option<String>,
repository_access_required: Option<bool>,
credential_revision: Option<u64>,
host_trust_revision: Option<u64>,
#[serde(rename = "credential_revision")]
legacy_credential_counter: Option<u64>,
#[serde(rename = "host_trust_revision")]
legacy_host_trust_counter: Option<u64>,
}
impl TryFrom<FrozenEvidenceRead> for WorkingDirectoryEvidence {
type Error = &'static str;
fn try_from(wire: FrozenEvidenceRead) -> Result<Self, Self::Error> {
if wire.credential_revision == Some(0) || wire.host_trust_revision == Some(0) {
if wire.legacy_credential_counter == Some(0) || wire.legacy_host_trust_counter == Some(0) {
return Err("legacy SSH evidence contains an invalid authority marker");
}
let repository_access_required = wire.repository_access_required.unwrap_or(false)
|| wire.credential_revision.is_some()
|| wire.host_trust_revision.is_some()
|| wire.legacy_credential_counter.is_some()
|| wire.legacy_host_trust_counter.is_some()
|| wire.public_key_fingerprint.is_some()
|| wire.host_key_fingerprint.is_some();
Ok(Self {
@@ -3284,6 +3287,8 @@ mod tests {
let evidence = record["working_directory"]["evidence"]
.as_object_mut()
.unwrap();
let mut expected_evidence = evidence.clone();
expected_evidence.insert("repository_access_required".into(), serde_json::json!(true));
evidence.remove("repository_access_required");
evidence.insert("credential_revision".into(), serde_json::json!(3));
evidence.insert("host_trust_revision".into(), serde_json::json!(2));
@@ -3299,10 +3304,7 @@ mod tests {
let migrated: serde_json::Value =
serde_json::from_slice(&fs::read(&record_path).unwrap()).unwrap();
let evidence = &migrated["working_directory"]["evidence"];
assert_eq!(evidence["repository_access_required"], true);
assert!(evidence.get("credential_revision").is_none());
assert!(evidence.get("host_trust_revision").is_none());
assert!(evidence.get("public_key_fingerprint").is_none());
assert_eq!(evidence, &serde_json::json!(expected_evidence));
drop(materializer);
assert_eq!(
RuntimeGitMaterializer::new(runtime.path())
@@ -4771,8 +4773,8 @@ mod tests {
.join(MATERIALIZATION_RECORD);
let mut legacy: serde_json::Value =
serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
legacy["working_directory"]["evidence"]["repository_source_revision"] =
serde_json::json!(7);
let expected_evidence = legacy["working_directory"]["evidence"].clone();
legacy["working_directory"]["evidence"]["obsolete_test_attribute"] = serde_json::json!(7);
let original = serde_json::to_vec_pretty(&legacy).unwrap();
fs::write(&path, &original).unwrap();
@@ -4788,10 +4790,9 @@ mod tests {
materializer.read_binding(id).unwrap();
let canonical: serde_json::Value =
serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
assert!(
canonical["working_directory"]["evidence"]
.get("repository_source_revision")
.is_none()
assert_eq!(
canonical["working_directory"]["evidence"],
expected_evidence
);
materializer.cleanup_working_directory(id).unwrap();
assert!(!binding.working_directory_root().exists());
+60 -10
View File
@@ -92,21 +92,25 @@ struct LegacyWorkspaceIssuerTrustRecord {
algorithm: String,
public_key: String,
public_key_fingerprint: String,
identity_revision: u64,
trust_generation: u64,
#[serde(rename = "identity_revision")]
legacy_identity_counter: u64,
#[serde(rename = "trust_generation")]
legacy_trust_counter: u64,
state: WorkspaceIssuerTrustState,
registered_at_unix: i64,
updated_at_unix: i64,
}
/// Migrate a pre-ID enrollment as revoked. Only an explicit operator replacement
/// can accept new trust; no legacy capability can authorize its own migration.
/// Migrate a pre-ID enrollment as revoked. At persisted-data decode, legacy counters
/// must be positive but are never compared with current identity or trust.
/// Only an explicit operator replacement can accept new trust; no legacy
/// capability can authorize its own migration.
pub fn migrate_legacy_workspace_issuer_trust_record(
value: serde_json::Value,
) -> Result<WorkspaceIssuerTrustRecord, WorkspaceIssuerTrustError> {
let legacy: LegacyWorkspaceIssuerTrustRecord =
serde_json::from_value(value).map_err(|_| WorkspaceIssuerTrustError::InvalidIdentifier)?;
if legacy.identity_revision == 0 || legacy.trust_generation == 0 {
if legacy.legacy_identity_counter == 0 || legacy.legacy_trust_counter == 0 {
return Err(WorkspaceIssuerTrustError::InvalidIdentifier);
}
let mut record = WorkspaceIssuerTrustRecord::from_bundle(
@@ -474,6 +478,9 @@ struct WorkspaceRuntimeVerificationDocument {
records: Vec<WorkspaceRuntimeVerificationRecord>,
#[serde(default)]
pending: Vec<WorkspaceRuntimeVerificationChallenge>,
/// Exact pre-ID document retained only as history; never used to authorize requests.
#[serde(default, skip_serializing_if = "Option::is_none")]
archived_legacy_document: Option<String>,
}
// Frozen verification evidence from before trust/binding IDs. Never promote
@@ -490,12 +497,16 @@ struct LegacyVerificationDocument {
struct LegacyVerificationRecord {
workspace_id: String,
runtime_id: String,
binding_revision: u64,
#[serde(rename = "binding_revision")]
legacy_binding_counter: u64,
workspace_key_id: String,
workspace_identity_revision: u64,
workspace_trust_generation: u64,
#[serde(rename = "workspace_identity_revision")]
legacy_workspace_identity_counter: u64,
#[serde(rename = "workspace_trust_generation")]
legacy_workspace_trust_counter: u64,
runtime_public_key_fingerprint: String,
runtime_identity_revision: u64,
#[serde(rename = "runtime_identity_revision")]
legacy_runtime_identity_counter: u64,
verified_at: i64,
}
@@ -507,6 +518,9 @@ impl FileWorkspaceRuntimeVerificationAuthority {
}
}
// No pre-ID acknowledgement is live authority. Keep its exact source bytes
// in the persisted archive while new challenges populate only current records.
// Neither read(), get(), nor capability verification consults that archive.
fn read(
&self,
) -> Result<WorkspaceRuntimeVerificationDocument, WorkspaceCapabilityVerificationError> {
@@ -531,6 +545,9 @@ impl FileWorkspaceRuntimeVerificationAuthority {
version: 2,
records: Vec::new(),
pending: Vec::new(),
archived_legacy_document: Some(String::from_utf8(bytes).map_err(|_| {
WorkspaceCapabilityVerificationError::VerificationAuthorityUnavailable
})?),
};
self.write(&migrated)?;
return Ok(migrated);
@@ -575,6 +592,7 @@ impl FileWorkspaceRuntimeVerificationAuthority {
version: 2,
records: Vec::new(),
pending: Vec::new(),
archived_legacy_document: None,
})
}
Err(_) => Err(WorkspaceCapabilityVerificationError::VerificationAuthorityUnavailable),
@@ -1993,7 +2011,7 @@ mod tests {
}
#[test]
fn frozen_verification_file_is_invalidated_durably() {
fn frozen_verification_file_is_invalidated_durably_without_losing_archived_evidence() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("verification.json");
fs::write(&path, serde_json::to_vec(&serde_json::json!({"version":1,"records":[{
@@ -2001,18 +2019,50 @@ mod tests {
"workspace_key_id":"WK-1","workspace_identity_revision":1,"workspace_trust_generation":1,
"runtime_public_key_fingerprint":"sha256:old","runtime_identity_revision":1,"verified_at":10
}]})).unwrap()).unwrap();
let original_bytes = fs::read(&path).unwrap();
let authority = FileWorkspaceRuntimeVerificationAuthority::new(&path);
assert!(authority.get("workspace-1", "runtime-1").unwrap().is_none());
let migrated: serde_json::Value =
serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
assert_eq!(migrated["version"], 2);
assert_eq!(migrated["records"], serde_json::json!([]));
assert_eq!(
migrated["archived_legacy_document"]
.as_str()
.unwrap()
.as_bytes(),
original_bytes
);
assert!(
FileWorkspaceRuntimeVerificationAuthority::new(&path)
.get("workspace-1", "runtime-1")
.unwrap()
.is_none()
);
let current = WorkspaceRuntimeVerificationRecord {
workspace_id: "workspace-1".into(),
runtime_id: "runtime-1".into(),
binding_id: "accepted-binding".into(),
workspace_key_id: "WK-1".into(),
workspace_public_key_fingerprint: format!("sha256:{}", "a".repeat(64)),
workspace_trust_id: "accepted-trust".into(),
runtime_public_key_fingerprint: format!("sha256:{}", "b".repeat(64)),
verified_at: 20,
};
authority.record(current.clone()).unwrap();
let restored = FileWorkspaceRuntimeVerificationAuthority::new(&path);
assert_eq!(
restored.get("workspace-1", "runtime-1").unwrap(),
Some(current)
);
let updated: serde_json::Value = serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
assert_eq!(
updated["archived_legacy_document"]
.as_str()
.unwrap()
.as_bytes(),
original_bytes
);
}
#[test]
@@ -2111,15 +2111,7 @@ mod tests {
for field in ["runtime_id", "worker_id", "reason"] {
assert!(schema.contains(field));
}
for forbidden in [
"expected_worker_revision",
"proof",
"actor",
"workspace_id",
"policy",
"plan",
"stage",
] {
for forbidden in ["proof", "actor", "workspace_id", "policy", "plan", "stage"] {
assert!(!schema.contains(forbidden), "schema leaked {forbidden}");
}
}
@@ -2149,23 +2141,28 @@ mod tests {
.await
.unwrap_err();
}
let _error = tool
.execute(
&serde_json::json!({
"subject": {
"kind": "runtime_worker",
"runtime_id": "runtime-1",
"worker_id": "worker-7",
},
"expected_worker_revision": "revision-1",
"reason": "retire",
"source_proof": "caller-controlled"
})
.to_string(),
ToolExecutionContext::new("call-spoof", "batch-remove", 0),
)
.await
.unwrap_err();
// Unknown authority fields must be rejected individually, before mutation.
for (field, value) in [
("unexpected_state_guard", "caller-controlled"),
("source_proof", "caller-controlled"),
] {
let mut input = serde_json::json!({
"subject": {
"kind": "runtime_worker",
"runtime_id": "runtime-1",
"worker_id": "worker-7",
},
"reason": "retire",
});
input[field] = serde_json::json!(value);
let _error = tool
.execute(
&input.to_string(),
ToolExecutionContext::new("call-spoof", "batch-remove", 0),
)
.await
.unwrap_err();
}
assert!(client.removals.lock().unwrap().is_empty());
}
@@ -841,7 +841,7 @@ mod tests {
let projected = project_ticket_detail(json!({
"id": "internal-ticket",
"resource_key": "T-588",
"content_digest": "revision-1",
"content_digest": "ticket-content-digest",
"title": "Queued Submit",
"body": "Body",
"state": "planning",
@@ -892,7 +892,7 @@ mod tests {
.expect("current Ticket blocker shape must project");
let projected = serde_json::to_value(projected).expect("serialize Ticket detail");
assert_eq!(projected["content_digest"], "revision-1");
assert_eq!(projected["content_digest"], "ticket-content-digest");
assert_eq!(
projected["relations"]["blockers"],
json!([{
@@ -944,7 +944,7 @@ mod tests {
});
let projected = project_ticket_detail(json!({
"resource_key": "T-716",
"content_digest": "current-ticket-revision",
"content_digest": "current-ticket-content-digest",
"title": "Source evidence",
"body": "Body",
"state": if merged { "done" } else { "inprogress" },
@@ -982,7 +982,7 @@ mod tests {
Some(&json!(integration_error))
);
}
assert_eq!(projected["content_digest"], "current-ticket-revision");
assert_eq!(projected["content_digest"], "current-ticket-content-digest");
assert_eq!(projected["evidence"]["review_after_rescope"], attested);
assert_eq!(projected["evidence"]["complete_for_integration"], merged);
}
-1
View File
@@ -806,7 +806,6 @@ mod tests {
"MergeRequestReview",
"MergeRequestReadinessCheck",
"MergeRequestComplete",
"MergeRequestAddRevision",
] {
assert!(
!combined.contains(stale_operation),
+10 -3
View File
@@ -393,9 +393,16 @@ mod tests {
)));
let parsed: serde_json::Value = serde_json::from_str(&state.status_json()).unwrap();
assert_eq!(parsed["state"], "running");
assert!(parsed["worker_state"].get("execution_generation").is_none());
assert!(parsed["worker_state"].get("revision").is_none());
assert_eq!(parsed["worker_state"]["state"]["kind"], "busy");
assert_eq!(
parsed["worker_state"],
json!({
"last_command_id": 0,
"state": {
"kind": "busy",
"state": { "kind": "maintenance", "state": "compacting" }
}
})
);
assert_eq!(parsed["worker_name"], "test-worker");
assert!(parsed["segment_id"].is_string());
}
+1 -2
View File
@@ -10793,7 +10793,7 @@ mod build_summary_prompt_tests {
let binding = crate::BackendJobExecutionBinding {
job_id: "job-1".into(),
attempt_id: "attempt-1".into(),
input_digest: Some("revision-1".into()),
input_digest: Some("input-content-digest".into()),
subjektiv_consolidation: true,
};
worker.bind_backend_job(binding.clone()).unwrap();
@@ -14244,7 +14244,6 @@ permission = "write"
_ => None,
})
.unwrap();
assert!(payload.get("revision").is_none());
let restored: PendingActivationState = serde_json::from_value(payload.clone()).unwrap();
assert_eq!(restored.snapshot(), cleared);
}
+3
View File
@@ -690,6 +690,9 @@ fn directory(c: &Connection, id: NodeId) -> Result<()> {
// Called only inside the same IMMEDIATE transaction that publishes the mutation.
// Compare the last successful request, not time: even identical bytes or a move
// away and back cannot revive an old request. Receipts prevent request ID reuse.
// A queued update/delete based on an older observation must not overwrite/delete
// a newer commit merely because it arrives last; same-ms commits also rule out
// timestamp comparison. The check precedes blob writes and node publication.
fn changeable(c: &Transaction<'_>, id: NodeId, expected_mutation_id: &str) -> Result<Node> {
let n = node(c, id)?;
if n.parent_id.is_none() {
+150 -10
View File
@@ -12,6 +12,7 @@ pub(super) static MIGRATIONS: &[FeatureMigration] = &[
"bind_nodes_to_committed_requests",
bind_committed_requests,
),
FeatureMigration::new(3, "name_legacy_replay_counter", name_legacy_replay_counter),
];
fn create_original_schema(tx: &Transaction<'_>) -> feature_storage::Result<()> {
tx.execute_batch("\
@@ -61,7 +62,7 @@ fn bind_committed_requests(tx: &Transaction<'_>) -> feature_storage::Result<()>
WHERE json_extract(result_json, '$.deleted') = 0 GROUP BY 1,2;
CREATE TABLE drive_legacy_replays (
request_id TEXT PRIMARY KEY REFERENCES drive_receipts(request_id) ON DELETE CASCADE,
expected_mutation_id TEXT NOT NULL, expected_revision TEXT NOT NULL);
expected_mutation_id TEXT NOT NULL, legacy_expected_counter TEXT NOT NULL);
INSERT INTO drive_legacy_replays
SELECT receipt.request_id, observed.request_id, CAST(observed.previous_counter AS TEXT)
FROM drive_receipts AS receipt JOIN drive_observed_requests AS observed
@@ -85,6 +86,23 @@ fn bind_committed_requests(tx: &Transaction<'_>) -> feature_storage::Result<()>
Ok(())
}
fn name_legacy_replay_counter(tx: &Transaction<'_>) -> feature_storage::Result<()> {
// Already-deployed v2 databases used this column name. New v2 applications
// create the concrete name directly; both paths retain the same replay-only
// decimal strings and leave the original request digests untouched.
let old_column: bool = tx.query_row(
"SELECT EXISTS(SELECT 1 FROM pragma_table_info('drive_legacy_replays') WHERE name='expected_revision')",
[],
|row| row.get(0),
)?;
if old_column {
tx.execute_batch(
"ALTER TABLE drive_legacy_replays RENAME COLUMN expected_revision TO legacy_expected_counter;",
)?;
}
Ok(())
}
// Frozen pre-cutover typed payload encoding, including field order and decimal
// strings. Never reconstruct a fingerprint from the result: compare the caller's
// complete actor/payload against the original digest. Historical counters below
@@ -94,19 +112,22 @@ fn bind_committed_requests(tx: &Transaction<'_>) -> feature_storage::Result<()>
enum LegacyMutation<'a> {
Update {
id: NodeId,
expected_revision: &'a str,
#[serde(rename = "expected_revision")]
legacy_expected_counter: &'a str,
content_type: &'a str,
bytes: &'a [u8],
},
Relocate {
id: NodeId,
expected_revision: &'a str,
#[serde(rename = "expected_revision")]
legacy_expected_counter: &'a str,
parent: NodeId,
name: &'a str,
},
Delete {
id: NodeId,
expected_revision: &'a str,
#[serde(rename = "expected_revision")]
legacy_expected_counter: &'a str,
},
}
@@ -119,7 +140,7 @@ pub(super) fn matches_legacy_replay(
) -> crate::Result<bool> {
let evidence = tx
.query_row(
"SELECT expected_mutation_id,expected_revision FROM drive_legacy_replays WHERE request_id=?1",
"SELECT expected_mutation_id,legacy_expected_counter FROM drive_legacy_replays WHERE request_id=?1",
[request_id],
|r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)),
)
@@ -139,7 +160,7 @@ pub(super) fn matches_legacy_replay(
expected_mutation_id,
LegacyMutation::Update {
id: *id,
expected_revision: &counter,
legacy_expected_counter: &counter,
content_type,
bytes,
},
@@ -153,7 +174,7 @@ pub(super) fn matches_legacy_replay(
expected_mutation_id,
LegacyMutation::Relocate {
id: *id,
expected_revision: &counter,
legacy_expected_counter: &counter,
parent: *parent,
name,
},
@@ -165,7 +186,7 @@ pub(super) fn matches_legacy_replay(
expected_mutation_id,
LegacyMutation::Delete {
id: *id,
expected_revision: &counter,
legacy_expected_counter: &counter,
},
),
Mutation::CreateFolder { .. } | Mutation::CreateFile { .. } => return Ok(false),
@@ -254,7 +275,10 @@ mod tests {
let storage = FeatureStorage::new(&path).workspace("ws").unwrap();
let registration = Drive::register(&storage).unwrap();
let drive = Drive::open(&storage, &registration, &temp.path().join("blobs")).unwrap();
assert_eq!(drive.database.schema_version().unwrap(), 2);
assert_eq!(
drive.database.schema_version().unwrap(),
MIGRATIONS.last().unwrap().version()
);
assert_eq!(drive.root().unwrap().last_mutation_id, "");
let file = drive.metadata(NodeId(4)).unwrap();
assert_eq!(file.last_mutation_id, "file-create");
@@ -394,6 +418,119 @@ mod tests {
.unwrap()
}
#[test]
fn fresh_schema_uses_only_the_concrete_replay_counter_column() {
let temp = tempfile::tempdir().unwrap();
let storage = FeatureStorage::new(temp.path().join("metadata"))
.workspace("ws")
.unwrap();
let registration = Drive::register(&storage).unwrap();
let drive = Drive::open(&storage, &registration, &temp.path().join("blobs")).unwrap();
assert_current_replay_schema(&drive.database);
}
fn assert_current_replay_schema(database: &feature_storage::FeatureDatabase) {
assert_eq!(
database.schema_version().unwrap(),
MIGRATIONS.last().unwrap().version()
);
database
.try_with_connection::<_, feature_storage::FeatureStorageError>(|c| {
let mut columns = c.prepare(
"SELECT name FROM pragma_table_info('drive_legacy_replays') ORDER BY cid",
)?;
let names = columns
.query_map([], |row| row.get::<_, String>(0))?
.collect::<rusqlite::Result<Vec<_>>>()?;
assert_eq!(
names,
[
"request_id",
"expected_mutation_id",
"legacy_expected_counter"
]
);
Ok(())
})
.unwrap();
}
#[test]
fn v2_replay_column_upgrade_preserves_evidence_digests_and_replay_after_reopen() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("metadata");
seed_update(&path, false);
let original = {
let storage = FeatureStorage::new(&path).workspace("ws").unwrap();
let registration = storage
.register(FeatureRegistration::new(
"workspace-drive",
&MIGRATIONS[..2],
))
.unwrap();
let database = storage.open(&registration).unwrap();
// Actual deployed v2 schema: the old name is a frozen DB boundary,
// not the DDL used when applying v2 to a newly created database.
database
.transaction(|tx| {
tx.execute_batch(
"ALTER TABLE drive_legacy_replays RENAME COLUMN legacy_expected_counter TO expected_revision;",
)?;
Ok(())
})
.unwrap();
fingerprints(&database)
};
let storage = FeatureStorage::new(&path).workspace("ws").unwrap();
let registration = Drive::register(&storage).unwrap();
let drive = Drive::open(&storage, &registration, &temp.path().join("blobs")).unwrap();
assert_current_replay_schema(&drive.database);
assert_eq!(fingerprints(&drive.database), original);
drive
.database
.try_with_connection::<_, feature_storage::FeatureStorageError>(|c| {
let mut rows = c.prepare(
"SELECT request_id,expected_mutation_id,legacy_expected_counter FROM drive_legacy_replays ORDER BY request_id",
)?;
let evidence = rows
.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
row.get::<_, String>(2)?,
))
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
assert_eq!(
evidence,
vec![
("delete".into(), "create-deleted".into(), "1".into()),
("file-update".into(), "file-create".into(), "1".into()),
("move-0".into(), "create".into(), "1".into()),
]
);
Ok(())
})
.unwrap();
let nodes = drive.list(NodeId(1), None, 100).unwrap().nodes;
for (request, mutation) in changed_replays() {
let RequestStatus::Committed { result } = drive.request_status(request).unwrap() else {
panic!("lost {request}")
};
assert_eq!(drive.mutate(request, "actor", mutation).unwrap(), result);
}
assert_eq!(drive.list(NodeId(1), None, 100).unwrap().nodes, nodes);
assert_eq!(fingerprints(&drive.database), original);
drop(drive);
let drive = Drive::open(&storage, &registration, &temp.path().join("blobs")).unwrap();
assert_current_replay_schema(&drive.database);
assert_eq!(fingerprints(&drive.database), original);
assert_eq!(drive.list(NodeId(1), None, 100).unwrap().nodes, nodes);
for (request, mutation) in changed_replays() {
assert!(drive.mutate(request, "actor", mutation).is_ok());
}
}
#[test]
fn upgrade_preserves_original_digests_and_never_uses_old_counters_for_new_mutations() {
let temp = tempfile::tempdir().unwrap();
@@ -445,7 +582,10 @@ mod tests {
let storage = FeatureStorage::new(&path).workspace("ws").unwrap();
let registration = Drive::register(&storage).unwrap();
let drive = Drive::open(&storage, &registration, &temp.path().join("blobs")).unwrap();
assert_eq!(drive.database.schema_version().unwrap(), 2);
assert_eq!(
drive.database.schema_version().unwrap(),
MIGRATIONS.last().unwrap().version()
);
// Historical replay must not overwrite a newer committed mutation.
for (id, expected) in [(NodeId(2), "move-0"), (NodeId(4), "file-update")] {
drive
+4 -1
View File
@@ -20,7 +20,10 @@ fn create(parent: NodeId, name: &str) -> Mutation {
fn migration_enforces_workspace_parent_and_unique_names() {
let t = tempfile::tempdir().unwrap();
let d = fixture(&t);
assert_eq!(d.database.schema_version().unwrap(), 2);
assert_eq!(
d.database.schema_version().unwrap(),
MIGRATIONS.last().unwrap().version()
);
let root = d.root().unwrap();
d.mutate("a", "actor", create(root.id, "a")).unwrap();
assert!(matches!(
+38 -33
View File
@@ -174,8 +174,11 @@ impl merge_request::RepositorySource for AuthorityMergeRequestSource {
}
}
pub trait TicketMergeRevisionSource: Send + Sync {
fn resolve_subject_ref(
/// Resolve an open Merge Request source selector to its provider-published Git commit.
/// Ticket detail/query calls this for open requests only; merged requests use their
/// immutable recorded source/result commits and need no mutable branch observation.
pub trait TicketSourceCommitResolver: Send + Sync {
fn resolve_source_commit(
&self,
ticket_id: &str,
repository_id: &str,
@@ -183,10 +186,10 @@ pub trait TicketMergeRevisionSource: Send + Sync {
) -> std::result::Result<String, server_api::MergeRequestRefDiagnostic>;
}
struct UnresolvedTicketMergeRevisionSource;
struct UnresolvedTicketSourceCommitResolver;
impl TicketMergeRevisionSource for UnresolvedTicketMergeRevisionSource {
fn resolve_subject_ref(
impl TicketSourceCommitResolver for UnresolvedTicketSourceCommitResolver {
fn resolve_source_commit(
&self,
_ticket_id: &str,
_repository_id: &str,
@@ -205,7 +208,7 @@ pub struct SqliteWorkspaceAuthority {
store: SqliteWorkspaceStore,
ticket_backend: SqliteTicketBackend,
merge_request_store: Arc<MergeRequestStore>,
merge_revision_source: Arc<dyn TicketMergeRevisionSource>,
source_commit_resolver: Arc<dyn TicketSourceCommitResolver>,
}
impl SqliteWorkspaceAuthority {
@@ -231,15 +234,15 @@ impl SqliteWorkspaceAuthority {
)
.map_err(|error| Error::Store(error.to_string()))?,
),
merge_revision_source: Arc::new(UnresolvedTicketMergeRevisionSource),
source_commit_resolver: Arc::new(UnresolvedTicketSourceCommitResolver),
})
}
pub fn with_merge_revision_source(
pub fn with_source_commit_resolver(
mut self,
merge_revision_source: Arc<dyn TicketMergeRevisionSource>,
source_commit_resolver: Arc<dyn TicketSourceCommitResolver>,
) -> Self {
self.merge_revision_source = merge_revision_source;
self.source_commit_resolver = source_commit_resolver;
self
}
@@ -843,7 +846,7 @@ impl SqliteWorkspaceAuthority {
})
},
|selector| {
self.merge_revision_source.resolve_subject_ref(
self.source_commit_resolver.resolve_source_commit(
id,
&request.repository_id,
selector,
@@ -1883,7 +1886,9 @@ fn ticket_evidence_summary(
server_api::TicketSourceRefObservation::Unavailable { .. }
)
});
// Exact revision/snapshot attestation, not a timestamp heuristic.
// Evaluated on every Ticket detail/query: approval must bind the current Ticket
// content digest and every relevant MR source/result commit. Later timestamps
// or last-arriving approvals cannot attest to different content.
let review_after_rescope = approved_current_subject && requirement_approved;
let review_status = if !has_merge_request {
None
@@ -2778,7 +2783,7 @@ mod tests {
event_id: "request-1".to_string(),
sequence: 1,
subject_ref: "commit-1".to_string(),
ticket_content_digest: "revision-1".to_string(),
ticket_content_digest: "ticket-content-1".to_string(),
ticket_merge_request_subjects: vec![merge_request::MergeRequestReviewSubject {
merge_request_id: "mr-1".to_string(),
subject_ref: "commit-1".to_string(),
@@ -2792,7 +2797,7 @@ mod tests {
sequence: 2,
request_event_id: "request-1".to_string(),
subject_ref: "commit-1".to_string(),
ticket_content_digest: "revision-1".to_string(),
ticket_content_digest: "ticket-content-1".to_string(),
ticket_merge_request_subjects: vec![merge_request::MergeRequestReviewSubject {
merge_request_id: "mr-1".to_string(),
subject_ref: "commit-1".to_string(),
@@ -2884,7 +2889,7 @@ mod tests {
fn project_evidence(
requests: &[MergeRequest],
revision: &str,
content_digest: &str,
observations: &[std::result::Result<String, server_api::MergeRequestRefDiagnostic>],
) -> (Vec<TicketMergeRequestSummary>, TicketEvidenceSummary) {
let summaries = requests
@@ -2898,7 +2903,7 @@ mod tests {
)
})
.collect::<Vec<_>>();
let approved = ticket_requirement_approved(requests, &summaries, revision);
let approved = ticket_requirement_approved(requests, &summaries, content_digest);
let evidence = ticket_evidence_summary(&["main", "other"], &summaries, approved);
(summaries, evidence)
}
@@ -2919,7 +2924,7 @@ mod tests {
] {
let (summaries, evidence) = project_evidence(
&[reviewed_merge_request(decision, revoked)],
"revision-1",
"ticket-content-1",
&[Ok(source.into())],
);
assert_eq!(evidence.complete_for_integration, approved);
@@ -2941,7 +2946,7 @@ mod tests {
)];
let (summaries, evidence) = project_evidence(
&[reviewed_merge_request(ReviewDecision::Approve, false)],
"revision-2",
"ticket-content-2",
&[unavailable_source("source_ref_provider_timeout")],
);
assert!(
@@ -2976,7 +2981,7 @@ mod tests {
unavailable_source("source_ref_runtime_unavailable"),
] {
let (summaries, evidence) =
project_evidence(&[merged_request()], "revision-1", &[observation]);
project_evidence(&[merged_request()], "ticket-content-1", &[observation]);
assert_eq!(
summaries[0].current_subject_ref.as_deref(),
Some("commit-1")
@@ -3032,7 +3037,7 @@ mod tests {
}
let (summaries, evidence) = project_evidence(
&[request],
"revision-1",
"ticket-content-1",
&[unavailable_source("source_ref_not_found")],
);
assert!(summaries[0].integration_evidence_error.is_some(), "{case}");
@@ -3047,7 +3052,7 @@ mod tests {
fn approve_snapshot(
request: &mut MergeRequest,
revision: &str,
content_digest: &str,
subjects: Vec<merge_request::MergeRequestReviewSubject>,
) {
let sequence = request
@@ -3063,14 +3068,14 @@ mod tests {
MergeRequestThreadEvent::ReviewRequested(review) => {
review.event_id = format!("request-{sequence}");
review.sequence = sequence;
review.ticket_content_digest = revision.into();
review.ticket_content_digest = content_digest.into();
review.ticket_merge_request_subjects = subjects.clone();
}
MergeRequestThreadEvent::Review(review) => {
review.event_id = format!("review-{sequence}");
review.request_event_id = format!("request-{sequence}");
review.sequence = sequence + 1;
review.ticket_content_digest = revision.into();
review.ticket_content_digest = content_digest.into();
review.ticket_merge_request_subjects = subjects.clone();
}
_ => unreachable!(),
@@ -3080,7 +3085,7 @@ mod tests {
}
#[test]
fn exact_revision_and_multi_result_snapshot_require_fresh_attestation_not_later_dates() {
fn exact_ticket_content_and_multi_result_snapshot_require_fresh_attestation_not_later_dates() {
let mut requests = vec![
merged_request(),
reviewed_merge_request(ReviewDecision::Approve, false),
@@ -3091,7 +3096,7 @@ mod tests {
unavailable_source("source_ref_not_found"),
Ok("commit-1".into()),
];
let (_, stale) = project_evidence(&requests, "revision-1", &observations);
let (_, stale) = project_evidence(&requests, "ticket-content-1", &observations);
assert!(stale.approved_current_subject);
assert!(!stale.review_after_rescope); // linked set grew, even though dates did not change
assert!(!ticket_evidence_matches(&stale, "approved_review"));
@@ -3105,12 +3110,12 @@ mod tests {
subject_ref: "commit-1".into(),
},
];
approve_snapshot(&mut requests[0], "revision-2", snapshot);
let (summaries, fresh) = project_evidence(&requests, "revision-2", &observations);
approve_snapshot(&mut requests[0], "ticket-content-2", snapshot);
let (summaries, fresh) = project_evidence(&requests, "ticket-content-2", &observations);
assert!(fresh.complete_for_integration);
assert_eq!(summaries[0].review_excerpt.as_deref(), Some("review body"));
assert!(
!project_evidence(&requests, "revision-3", &observations)
!project_evidence(&requests, "ticket-content-3", &observations)
.1
.complete_for_integration
);
@@ -3123,12 +3128,12 @@ mod tests {
}
requests[1].state = MergeRequestState::Merged;
assert!(
project_evidence(&requests, "revision-2", &observations)
project_evidence(&requests, "ticket-content-2", &observations)
.1
.complete_for_integration
);
assert!(
!project_evidence(&requests[..1], "revision-2", &observations[..1])
!project_evidence(&requests[..1], "ticket-content-2", &observations[..1])
.1
.complete_for_integration
);
@@ -3137,7 +3142,7 @@ mod tests {
"2026-01-01T00:00:00Z",
Some("body"),
)];
let stale = project_evidence(&requests, "revision-3", &observations).1;
let stale = project_evidence(&requests, "ticket-content-3", &observations).1;
assert!(ticket_attention_matches(
"done",
&stale,
@@ -3163,7 +3168,7 @@ mod tests {
);
let (summaries, _) = project_evidence(
&[merged_request()],
"revision-1",
"ticket-content-1",
&[unavailable_source("unused")],
);
assert!(
@@ -3173,7 +3178,7 @@ mod tests {
let mut closed = reviewed_merge_request(ReviewDecision::Approve, false);
closed.state = MergeRequestState::Closed;
assert!(
!project_evidence(&[closed], "revision-1", &[Ok("commit-1".into())])
!project_evidence(&[closed], "ticket-content-1", &[Ok("commit-1".into())])
.1
.complete_for_integration
);
+8 -8
View File
@@ -564,9 +564,9 @@ mod tests {
fn request() -> BackendJobRequest {
BackendJobRequest {
job_id: "ticket-check:T-1:r7".to_string(),
job_id: "ticket-check:T-1:content-7".to_string(),
purpose: "ticket_item_check".to_string(),
input_ref: "ticket://T-1/revisions/7".to_string(),
input_ref: "ticket://T-1/contents/content-7".to_string(),
input: serde_json::json!({"title": "Check me"}),
instruction: "Check this immutable Ticket snapshot.".to_string(),
profile: "builtin:backend-job".to_string(),
@@ -591,8 +591,8 @@ mod tests {
},
"attempt": {
"workspace_id": "workspace-a",
"job_id": "ticket-check:T-1:r7",
"attempt_id": "ticket-check:T-1:r7:attempt:2",
"job_id": "ticket-check:T-1:content-7",
"attempt_id": "ticket-check:T-1:content-7:attempt:2",
"attempt": 2,
"input_digest": "7",
"state": "reserved",
@@ -687,7 +687,7 @@ mod tests {
job::JobOutcome {
job_id: reservation.job.request.job_id.clone(),
input_digest: "7".into(),
attempt_id: "ticket-check:T-1:r7:attempt:2".into(),
attempt_id: "ticket-check:T-1:content-7:attempt:2".into(),
attempt: 2,
state: job::JobState::Pending,
attempt_state: job::JobAttemptState::Reserved,
@@ -766,11 +766,11 @@ mod tests {
let encoded = serde_json::to_string(&request).unwrap();
assert_eq!(
encoded,
r#"{"job_id":"ticket-check:T-1:r7","purpose":"ticket_item_check","input_ref":"ticket://T-1/revisions/7","input":{"title":"Check me"},"instruction":"Check this immutable Ticket snapshot.","profile":"builtin:backend-job","source_worker":{"runtime_id":"runtime-a","worker_id":"worker-a"},"notification_target":{"runtime_id":"runtime-a","worker_id":"worker-a"},"limits":{"max_concurrent_jobs":8,"timeout_seconds":120,"max_result_bytes":16384,"max_attempts":2}}"#
r#"{"job_id":"ticket-check:T-1:content-7","purpose":"ticket_item_check","input_ref":"ticket://T-1/contents/content-7","input":{"title":"Check me"},"instruction":"Check this immutable Ticket snapshot.","profile":"builtin:backend-job","source_worker":{"runtime_id":"runtime-a","worker_id":"worker-a"},"notification_target":{"runtime_id":"runtime-a","worker_id":"worker-a"},"limits":{"max_concurrent_jobs":8,"timeout_seconds":120,"max_result_bytes":16384,"max_attempts":2}}"#
);
assert_eq!(
request.fingerprint().unwrap(),
"sha256:250b626572e954d2aa2228364274c946d9f6b176eb0f0a205157e55031581147"
"sha256:d7a3eb420eb1404e1a0aacd198726ce7f23b84a00b5d81a1b15d002b3075b8b0"
);
let neutral = request.to_job_request();
neutral.validate().unwrap();
@@ -784,7 +784,7 @@ mod tests {
);
assert_eq!(
request.worker_input("attempt-1").unwrap(),
"Check this immutable Ticket snapshot.\n\nBackend Job envelope (immutable):\njob_id: ticket-check:T-1:r7\nattempt_id: attempt-1\ninput_digest: sha256:7d0f26b7fc612e982fd7acf2b64f29d2e8f65c7e1292683e36d7dcac38905c50\ninput_ref: ticket://T-1/revisions/7\ninput_json: {\"title\":\"Check me\"}\n\nReturn success only through the structured Backend Job result capability. Final prose and Worker Idle/Stopped state are not result authority."
"Check this immutable Ticket snapshot.\n\nBackend Job envelope (immutable):\njob_id: ticket-check:T-1:content-7\nattempt_id: attempt-1\ninput_digest: sha256:7d0f26b7fc612e982fd7acf2b64f29d2e8f65c7e1292683e36d7dcac38905c50\ninput_ref: ticket://T-1/contents/content-7\ninput_json: {\"title\":\"Check me\"}\n\nReturn success only through the structured Backend Job result capability. Final prose and Worker Idle/Stopped state are not result authority."
);
}
@@ -89,7 +89,7 @@ fn migrate_content_state_v85_to_v86(conn: &Connection) -> Result<()> {
"worker_create_reservations",
"memory_settings_revision",
)? {
remove_legacy_reservation_memory_revision(&tx)?;
remove_legacy_reservation_memory_counter(&tx)?;
}
// Older retained chains rebuilt this table without the lookup index.
// Converge on the canonical schema for both legacy creation paths.
@@ -169,7 +169,7 @@ fn migrate_content_state_v85_to_v86(conn: &Connection) -> Result<()> {
result
}
fn remove_legacy_reservation_memory_revision(conn: &Connection) -> Result<()> {
fn remove_legacy_reservation_memory_counter(conn: &Connection) -> Result<()> {
// This is the exact table CHECK introduced by the frozen v52 -> v53
// migration. Fresh v85 databases do not have it. Do not rewrite arbitrary
// constraints: SQLite's DROP COLUMN must still reject unknown dependencies.
@@ -371,7 +371,7 @@ fn content_cutover_keeps_each_evaluation_provenance_and_deduplicates_only_identi
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("content.db");
let (conn, _) = legacy_content_database(&path);
for (revision, toolchain, projection, schema) in [
for (legacy_counter, toolchain, projection, schema) in [
(4, "new-toolchain", "projection", "schema-2"),
(5, "toolchain", "new-projection", "schema-1"),
] {
@@ -379,7 +379,7 @@ fn content_cutover_keeps_each_evaluation_provenance_and_deduplicates_only_identi
"INSERT INTO workspace_config_tree_revisions
SELECT workspace_id,?1,tree_digest,?2,?3,manifest_json,'later',?4
FROM workspace_config_tree_revisions WHERE revision=1",
params![revision, toolchain, projection, schema],
params![legacy_counter, toolchain, projection, schema],
)
.unwrap();
}
@@ -18,11 +18,11 @@ fn migrate_flow_content_v84_to_v85(conn: &Connection) -> Result<()> {
while let Some(row) = rows.next()? {
let workspace_id: String = row.get(0)?;
let flow_id: String = row.get(1)?;
let revision: i64 = row.get(2)?;
let legacy_counter: i64 = row.get(2)?;
let content: String = row.get(3)?;
let stored_digest: String = row.get(4)?;
let definition_json: String = row.get(5)?;
let identity = format!("{workspace_id}/{flow_id} revision {revision}");
let identity = format!("{workspace_id}/{flow_id} legacy counter {legacy_counter}");
let source_digest = format!(
"sha256:{}",
Sha256::digest(content.as_bytes())
@@ -56,7 +56,7 @@ fn legacy_flow_database(path: &Path) -> Connection {
params![alpha, alpha_definition.content_digest],
)
.unwrap();
for (revision, content, definition, created_at) in [
for (legacy_counter, content, definition, created_at) in [
(1, &alpha, &alpha_definition, "first"),
(2, &beta, &beta_definition, "second"),
(3, &alpha, &alpha_definition, "third"),
@@ -64,7 +64,7 @@ fn legacy_flow_database(path: &Path) -> Connection {
conn.execute(
"INSERT INTO flow_source_revisions VALUES('space','flow',?1,?2,?3,?4,?5)",
params![
revision,
legacy_counter,
content,
definition.content_digest,
serde_json::to_string_pretty(definition).unwrap(),
@@ -32,8 +32,8 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
tx.execute_batch(
"CREATE TEMP TABLE repository_key_cutover (
workspace_id TEXT NOT NULL, kind TEXT NOT NULL, resource_id TEXT NOT NULL,
legacy_revision INTEGER NOT NULL, operation_id TEXT NOT NULL, fingerprint TEXT NOT NULL, created_at TEXT NOT NULL,
PRIMARY KEY(workspace_id,kind,resource_id,legacy_revision));",
legacy_counter INTEGER NOT NULL, operation_id TEXT NOT NULL, fingerprint TEXT NOT NULL, created_at TEXT NOT NULL,
PRIMARY KEY(workspace_id,kind,resource_id,legacy_counter));",
)?;
for (table, kind, id, fingerprint) in [
(
@@ -65,10 +65,10 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
})?
.collect::<rusqlite::Result<Vec<_>>>()?
};
for (workspace, resource, revision, fingerprint, created) in rows {
for (workspace, resource, legacy_counter, fingerprint, created) in rows {
let mut statement = tx.prepare("SELECT operation_id FROM repository_secret_operations WHERE workspace_id=?1 AND resource_kind=?2 AND resource_id=?3 AND result_revision=?4 AND created_at=?5")?;
let matches = statement
.query_map(params![workspace, kind, resource, revision, created], |r| {
.query_map(params![workspace, kind, resource, legacy_counter, created], |r| {
r.get::<_, String>(0)
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
@@ -81,7 +81,7 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
workspace,
kind,
resource,
revision,
legacy_counter,
matches[0],
fingerprint,
created
@@ -126,12 +126,12 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
tx.execute_batch("DROP INDEX idx_workdir_create_credential_candidates_revision;")?;
tx.execute_batch(include_str!("frozen_repository_key_schema.sql"))?;
tx.execute_batch("INSERT INTO repository_secret_operations SELECT workspace_id,operation_id,request_fingerprint,resource_kind,resource_id,operation_id,created_at FROM repository_secret_operations_v86;
INSERT INTO repository_ssh_credentials SELECT c.workspace_id,c.credential_id,c.name,c.public_key_algorithm,c.public_key_fingerprint,m.operation_id,c.status,c.created_at,c.rotated_at FROM repository_ssh_credentials_v86 c LEFT JOIN repository_key_cutover m ON m.workspace_id=c.workspace_id AND m.kind='credential' AND m.resource_id=c.credential_id AND m.legacy_revision=c.current_revision;
INSERT INTO repository_ssh_credential_keys SELECT c.workspace_id,c.credential_id,m.operation_id,c.public_key_algorithm,c.public_key_fingerprint,c.created_at FROM repository_ssh_credential_revisions_v86 c LEFT JOIN repository_key_cutover m ON m.workspace_id=c.workspace_id AND m.kind='credential' AND m.resource_id=c.credential_id AND m.legacy_revision=c.revision;
INSERT INTO repository_ssh_host_trusts SELECT c.workspace_id,c.host_trust_id,c.hostname,c.port,c.key_algorithm,c.host_key,c.fingerprint,m.operation_id,c.created_at,c.updated_at FROM repository_ssh_host_trusts_v86 c LEFT JOIN repository_key_cutover m ON m.workspace_id=c.workspace_id AND m.kind='host_trust' AND m.resource_id=c.host_trust_id AND m.legacy_revision=c.current_revision;
INSERT INTO repository_ssh_host_trust_keys SELECT c.workspace_id,c.host_trust_id,m.operation_id,c.hostname,c.port,c.key_algorithm,c.host_key,c.fingerprint,c.created_at FROM repository_ssh_host_trust_revisions_v86 c LEFT JOIN repository_key_cutover m ON m.workspace_id=c.workspace_id AND m.kind='host_trust' AND m.resource_id=c.host_trust_id AND m.legacy_revision=c.revision;")?;
INSERT INTO repository_ssh_credentials SELECT c.workspace_id,c.credential_id,c.name,c.public_key_algorithm,c.public_key_fingerprint,m.operation_id,c.status,c.created_at,c.rotated_at FROM repository_ssh_credentials_v86 c LEFT JOIN repository_key_cutover m ON m.workspace_id=c.workspace_id AND m.kind='credential' AND m.resource_id=c.credential_id AND m.legacy_counter=c.current_revision;
INSERT INTO repository_ssh_credential_keys SELECT c.workspace_id,c.credential_id,m.operation_id,c.public_key_algorithm,c.public_key_fingerprint,c.created_at FROM repository_ssh_credential_revisions_v86 c LEFT JOIN repository_key_cutover m ON m.workspace_id=c.workspace_id AND m.kind='credential' AND m.resource_id=c.credential_id AND m.legacy_counter=c.revision;
INSERT INTO repository_ssh_host_trusts SELECT c.workspace_id,c.host_trust_id,c.hostname,c.port,c.key_algorithm,c.host_key,c.fingerprint,m.operation_id,c.created_at,c.updated_at FROM repository_ssh_host_trusts_v86 c LEFT JOIN repository_key_cutover m ON m.workspace_id=c.workspace_id AND m.kind='host_trust' AND m.resource_id=c.host_trust_id AND m.legacy_counter=c.current_revision;
INSERT INTO repository_ssh_host_trust_keys SELECT c.workspace_id,c.host_trust_id,m.operation_id,c.hostname,c.port,c.key_algorithm,c.host_key,c.fingerprint,c.created_at FROM repository_ssh_host_trust_revisions_v86 c LEFT JOIN repository_key_cutover m ON m.workspace_id=c.workspace_id AND m.kind='host_trust' AND m.resource_id=c.host_trust_id AND m.legacy_counter=c.revision;")?;
let secrets = {
let mut statement = tx.prepare("SELECT s.workspace_id,s.secret_id,s.revision,s.purpose,s.encryption_algorithm,s.nonce,s.ciphertext,s.created_at,m.operation_id FROM server_secret_versions_v86 s LEFT JOIN repository_key_cutover m ON m.workspace_id=s.workspace_id AND m.kind='credential' AND m.resource_id=s.secret_id AND m.legacy_revision=s.revision")?;
let mut statement = tx.prepare("SELECT s.workspace_id,s.secret_id,s.revision,s.purpose,s.encryption_algorithm,s.nonce,s.ciphertext,s.created_at,m.operation_id FROM server_secret_versions_v86 s LEFT JOIN repository_key_cutover m ON m.workspace_id=s.workspace_id AND m.kind='credential' AND m.resource_id=s.secret_id AND m.legacy_counter=s.revision")?;
statement
.query_map([], |r| {
Ok((
@@ -148,7 +148,7 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
})?
.collect::<rusqlite::Result<Vec<_>>>()?
};
for (workspace, id, revision, purpose, algorithm, nonce, ciphertext, created, operation) in
for (workspace, id, legacy_counter, purpose, algorithm, nonce, ciphertext, created, operation) in
secrets
{
if algorithm != "aes-256-gcm-v1" {
@@ -164,7 +164,7 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
database,
&workspace,
&id,
revision,
legacy_counter,
&operation,
&purpose,
&nonce,
@@ -201,7 +201,7 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
})?
.collect::<rusqlite::Result<Vec<_>>>()?
};
for (workspace, event, kind, resource, revision, actor, created) in audits {
for (workspace, event, kind, resource, legacy_counter, actor, created) in audits {
let resource_kind = if kind.starts_with("credential_") {
"credential"
} else if kind.starts_with("host_trust_") {
@@ -212,7 +212,7 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
let mut statement=tx.prepare("SELECT operation_id FROM repository_secret_operations_v86 WHERE workspace_id=?1 AND resource_kind=?2 AND resource_id=?3 AND result_revision=?4 AND created_at=?5")?;
let matches = statement
.query_map(
params![workspace, resource_kind, resource, revision, created],
params![workspace, resource_kind, resource, legacy_counter, created],
|r| r.get::<_, String>(0),
)?
.collect::<rusqlite::Result<Vec<_>>>()?;
@@ -246,7 +246,7 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
HAVING count(*)=1) AS mutation_kind FROM repository_secret_operations_v86 o) e
LEFT JOIN repository_key_cutover m ON m.workspace_id=e.workspace_id AND m.kind=e.resource_kind AND m.resource_id=e.resource_id
AND m.created_at<=e.created_at
AND m.legacy_revision=CASE WHEN e.mutation_kind IN ('credential_rotated','host_trust_rotated') THEN e.result_revision-1
AND m.legacy_counter=CASE WHEN e.mutation_kind IN ('credential_rotated','host_trust_rotated') THEN e.result_revision-1
WHEN e.mutation_kind IN ('credential_deleted','host_trust_deleted') THEN e.result_revision END;
INSERT INTO workdir_create_operations
SELECT o.workspace_id,o.operation_id,'workdir-create-v86:' || COALESCE(CAST(o.source_revision AS TEXT),'unknown') || ':' || o.request_fingerprint,
@@ -257,14 +257,14 @@ fn migrate_repository_keys_v86_to_v87_with_secret_source(
CASE WHEN a.operation_id IS NULL THEN o.repository_access_mode END
FROM workdir_create_operations_v86 o
LEFT JOIN workdir_terminal_archive_cutover a ON a.workspace_id=o.workspace_id AND a.operation_id=o.operation_id
LEFT JOIN repository_key_cutover c ON c.workspace_id=o.workspace_id AND c.kind='credential' AND c.resource_id=o.credential_id AND c.legacy_revision=o.credential_revision AND c.created_at<=o.updated_at
LEFT JOIN repository_key_cutover h ON h.workspace_id=o.workspace_id AND h.kind='host_trust' AND h.resource_id=o.host_trust_id AND h.legacy_revision=o.host_trust_revision AND h.created_at<=o.updated_at;
LEFT JOIN repository_key_cutover c ON c.workspace_id=o.workspace_id AND c.kind='credential' AND c.resource_id=o.credential_id AND c.legacy_counter=o.credential_revision AND c.created_at<=o.updated_at
LEFT JOIN repository_key_cutover h ON h.workspace_id=o.workspace_id AND h.kind='host_trust' AND h.resource_id=o.host_trust_id AND h.legacy_counter=o.host_trust_revision AND h.created_at<=o.updated_at;
INSERT INTO workdir_create_credential_candidates
SELECT c.workspace_id,c.operation_id,c.ordinal,c.role,c.credential_id,m.fingerprint
FROM workdir_create_credential_candidates_v86 c JOIN workdir_create_operations_v86 o ON o.workspace_id=c.workspace_id AND o.operation_id=c.operation_id LEFT JOIN repository_key_cutover m
ON m.workspace_id=c.workspace_id AND m.kind='credential' AND m.resource_id=c.credential_id AND m.legacy_revision=c.credential_revision AND m.created_at<=o.updated_at
ON m.workspace_id=c.workspace_id AND m.kind='credential' AND m.resource_id=c.credential_id AND m.legacy_counter=c.credential_revision AND m.created_at<=o.updated_at
WHERE NOT EXISTS(SELECT 1 FROM workdir_terminal_archive_cutover a WHERE a.workspace_id=c.workspace_id AND a.operation_id=c.operation_id);
INSERT INTO workdir_create_credential_retentions SELECT c.workspace_id,c.operation_id,c.ordinal,c.credential_id,m.fingerprint,m.operation_id FROM workdir_create_credential_revision_retentions_v86 c LEFT JOIN repository_key_cutover m ON m.workspace_id=c.workspace_id AND m.kind='credential' AND m.resource_id=c.credential_id AND m.legacy_revision=c.credential_revision;")?;
INSERT INTO workdir_create_credential_retentions SELECT c.workspace_id,c.operation_id,c.ordinal,c.credential_id,m.fingerprint,m.operation_id FROM workdir_create_credential_revision_retentions_v86 c LEFT JOIN repository_key_cutover m ON m.workspace_id=c.workspace_id AND m.kind='credential' AND m.resource_id=c.credential_id AND m.legacy_counter=c.credential_revision;")?;
let missing:i64=tx.query_row("SELECT count(*) FROM workdir_create_operations WHERE (credential_id IS NOT NULL AND credential_fingerprint IS NULL) OR (host_trust_id IS NOT NULL AND host_trust_fingerprint IS NULL)",[],|r|r.get(0))?;
if missing != 0 {
return Err(Error::Store(
@@ -177,6 +177,8 @@ fn migrated_workdir_request_replays_and_retries_without_accepting_changed_displa
)
.unwrap();
migrate_repository_keys_v86_to_v87(&conn).unwrap();
// The service consumes current receipt columns; unrelated schemas are not this fixture.
rename_saved_secret_receipt_counters(&conn).unwrap();
let store = SqliteWorkspaceStore {
conn: Arc::new(Mutex::new(conn)),
};
@@ -358,6 +360,8 @@ fn deleted_successful_workdir_archives_verbatim_history_without_authorizing_retr
.unwrap(),
0
);
// The service consumes current receipt columns; unrelated schemas are not this fixture.
rename_saved_secret_receipt_counters(&conn).unwrap();
let store = Arc::new(SqliteWorkspaceStore {
conn: Arc::new(Mutex::new(conn)),
});
@@ -655,7 +659,7 @@ fn migrated_ssh_mutations_replay_complete_old_input_without_reexecution_or_recei
let rotated_digest = frozen_credential_request_digest("rotate", "", 1, &next_private);
let host_created_digest = frozen_host_request_digest("old.example.test", 0, &old_fp);
let host_rotated_digest = frozen_host_request_digest("new.example.test", 1, &old_fp);
for (operation, digest, kind, resource, revision, timestamp, audit) in [
for (operation, digest, kind, resource, legacy_counter, timestamp, audit) in [
(
"create-key",
&created_digest,
@@ -695,25 +699,25 @@ fn migrated_ssh_mutations_replay_complete_old_input_without_reexecution_or_recei
] {
conn.execute(
"INSERT INTO repository_secret_operations VALUES('space',?1,?2,?3,?4,?5,?6)",
params![operation, digest, kind, resource, revision, timestamp],
params![operation, digest, kind, resource, legacy_counter, timestamp],
)
.unwrap();
conn.execute(
"INSERT INTO repository_secret_audit_events VALUES('space',?1,?2,?3,?4,'actor',?5)",
params![operation, audit, resource, revision, timestamp],
params![operation, audit, resource, legacy_counter, timestamp],
)
.unwrap();
}
conn.execute("INSERT INTO repository_ssh_credentials VALUES('space','key','Key','ssh-ed25519',?1,2,'active','2026-01-01','2026-01-02')",[&next_fp]).unwrap();
let encryption_key = LessSafeKey::new(UnboundKey::new(&AES_256_GCM, &[42; 32]).unwrap());
for (revision, fingerprint, private, timestamp) in [
for (legacy_counter, fingerprint, private, timestamp) in [
(1u64, &old_fp, &old_private, "2026-01-01"),
(2, &next_fp, &next_private, "2026-01-02"),
] {
conn.execute("INSERT INTO repository_ssh_credential_revisions VALUES('space','key',?1,'ssh-ed25519',?2,?3)",params![revision,fingerprint,timestamp]).unwrap();
let nonce = [revision as u8; 12];
conn.execute("INSERT INTO repository_ssh_credential_revisions VALUES('space','key',?1,'ssh-ed25519',?2,?3)",params![legacy_counter,fingerprint,timestamp]).unwrap();
let nonce = [legacy_counter as u8; 12];
let mut ciphertext = private.as_bytes().to_vec();
let aad = format!("yoi/repository-secret/v1/space/key/{revision}/private_key");
let aad = format!("yoi/repository-secret/v1/space/key/{legacy_counter}/private_key");
encryption_key
.seal_in_place_append_tag(
Nonce::assume_unique_for_key(nonce),
@@ -721,16 +725,18 @@ fn migrated_ssh_mutations_replay_complete_old_input_without_reexecution_or_recei
&mut ciphertext,
)
.unwrap();
conn.execute("INSERT INTO server_secret_versions VALUES('space','key',?1,'private_key','aes-256-gcm-v1',?2,?3,?4)",params![revision,nonce.as_slice(),ciphertext,timestamp]).unwrap();
conn.execute("INSERT INTO server_secret_versions VALUES('space','key',?1,'private_key','aes-256-gcm-v1',?2,?3,?4)",params![legacy_counter,nonce.as_slice(),ciphertext,timestamp]).unwrap();
}
conn.execute("INSERT INTO repository_ssh_host_trusts VALUES('space','host','new.example.test',22,'ssh-ed25519',?1,?2,2,'2026-01-01','2026-01-02')",params![public,old_fp]).unwrap();
for (revision, hostname, timestamp) in [
for (legacy_counter, hostname, timestamp) in [
(1, "old.example.test", "2026-01-01"),
(2, "new.example.test", "2026-01-02"),
] {
conn.execute("INSERT INTO repository_ssh_host_trust_revisions VALUES('space','host',?1,?2,22,'ssh-ed25519',?3,?4,?5)",params![revision,hostname,public,old_fp,timestamp]).unwrap();
conn.execute("INSERT INTO repository_ssh_host_trust_revisions VALUES('space','host',?1,?2,22,'ssh-ed25519',?3,?4,?5)",params![legacy_counter,hostname,public,old_fp,timestamp]).unwrap();
}
migrate_repository_keys_v86_to_v87(&conn).unwrap();
// The service consumes current receipt columns; unrelated schemas are not this fixture.
rename_saved_secret_receipt_counters(&conn).unwrap();
let store = Arc::new(SqliteWorkspaceStore {
conn: Arc::new(Mutex::new(conn)),
});
@@ -881,6 +887,8 @@ fn deleted_legacy_receipt_does_not_bind_to_later_reused_key_ordinal() {
INSERT INTO repository_secret_operations VALUES('space','old-delete','intent','credential','key',7,'2025-01-01');
INSERT INTO repository_secret_audit_events VALUES('space','old-delete-audit','credential_deleted','key',7,'actor','2025-01-01');").unwrap();
migrate_repository_keys_v86_to_v87(&conn).unwrap();
// The service consumes current receipt columns; unrelated schemas are not this fixture.
rename_saved_secret_receipt_counters(&conn).unwrap();
let store = Arc::new(SqliteWorkspaceStore {
conn: Arc::new(Mutex::new(conn)),
});
@@ -62,7 +62,7 @@ fn migrate_runtime_bindings_v87_to_v88(conn: &Connection) -> Result<()> {
)?;
if unsupported {
return Err(Error::Store(
"unsupported legacy Workspace signing identity revision".into(),
"legacy Workspace signing identity counter must equal one".into(),
));
}
}
@@ -54,7 +54,7 @@ CREATE TABLE "artifacts" (
worker_display TEXT,
repository_id TEXT,
source_kind TEXT,
source_revision TEXT,
source_reference TEXT,
PRIMARY KEY (workspace_id, artifact_id),
CHECK ((worker_ref_kind IS NULL) = (worker_ref_key IS NULL)),
FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,
@@ -1679,10 +1679,10 @@ CREATE TABLE repository_secret_legacy_receipts (
request_fingerprint TEXT NOT NULL,
resource_kind TEXT NOT NULL,
resource_id TEXT NOT NULL,
result_revision INTEGER NOT NULL,
legacy_result_counter INTEGER NOT NULL,
created_at TEXT NOT NULL,
mutation_kind TEXT,
expected_revision INTEGER,
legacy_expected_counter INTEGER,
expected_operation_id TEXT,
expected_key_fingerprint TEXT,
PRIMARY KEY (workspace_id, operation_id),
@@ -0,0 +1,24 @@
// These are provenance and frozen replay evidence, not live update guards.
// Keep the original data while removing the misleading names from current DDL.
fn migrate_evidence_column_names_v88_to_v89(conn: &Connection) -> Result<()> {
if current_schema_version(conn)? != 88 {
return Err(Error::Store(
"Evidence column migration requires schema 88".into(),
));
}
let tx = conn.unchecked_transaction()?;
tx.execute_batch("ALTER TABLE artifacts RENAME COLUMN source_revision TO source_reference;")?;
rename_saved_secret_receipt_counters(&tx)?;
tx.execute("INSERT INTO __yoi_schema_migrations(version,name) VALUES(89,'descriptive legacy evidence columns')", [])?;
tx.commit()?;
Ok(())
}
// Also used by focused legacy SSH fixtures that do not contain unrelated tables.
fn rename_saved_secret_receipt_counters(conn: &Connection) -> Result<()> {
conn.execute_batch(
"ALTER TABLE repository_secret_legacy_receipts RENAME COLUMN result_revision TO legacy_result_counter;
ALTER TABLE repository_secret_legacy_receipts RENAME COLUMN expected_revision TO legacy_expected_counter;",
)?;
Ok(())
}
@@ -0,0 +1,124 @@
// These fixtures use actual schema-88 column names as input, not as current APIs.
fn old_evidence_database(path: &Path) -> Connection {
prepare_retained_schema(path, 88);
let conn = Connection::open(path).unwrap();
configure_sqlite(&conn).unwrap();
seed_migration_workspace(&conn);
for (index, source) in [Some("immutable-source-commit"), None]
.into_iter()
.enumerate()
{
conn.execute("INSERT INTO artifacts(workspace_id,artifact_id,kind,uri,created_at,created_by_kind,created_by_key,created_by_display,source_revision)
VALUES('space',?1,'file','artifact://saved','created','user','owner','Owner',?2)",params![format!("artifact-{index}"),source]).unwrap();
}
for (operation, result, expected) in [
("old-update", 7, Some(6)),
("old-create", 1, None),
("old-zero", 0, Some(0)),
] {
conn.execute("INSERT INTO repository_secret_operations VALUES('space',?1,'saved-request-digest','credential','deleted-key',?1,'created')",[operation]).unwrap();
conn.execute("INSERT INTO repository_secret_legacy_receipts VALUES('space',?1,'saved-request-digest','credential','deleted-key',?2,'created','credential_deleted',?3,'prior-operation','key-fingerprint')",params![operation,result,expected]).unwrap();
}
conn
}
fn evidence_rows(conn: &Connection, table: &str) -> Vec<Vec<rusqlite::types::Value>> {
let mut stmt = conn
.prepare(&format!("SELECT * FROM {table} ORDER BY 1,2"))
.unwrap();
let count = stmt.column_count();
stmt.query_map([], |row| {
(0..count)
.map(|i| row.get(i))
.collect::<rusqlite::Result<Vec<_>>>()
})
.unwrap()
.collect::<rusqlite::Result<Vec<_>>>()
.unwrap()
}
#[test]
fn evidence_column_cutover_preserves_all_values_and_current_schema_names() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("server.db");
let conn = old_evidence_database(&path);
let artifacts = evidence_rows(&conn, "artifacts");
let receipts = evidence_rows(&conn, "repository_secret_legacy_receipts");
drop(conn);
let upgraded = SqliteWorkspaceStore::open(&path).unwrap();
upgraded
.with_conn(|conn| {
assert_eq!(current_schema_version(conn)?, 89);
assert_eq!(evidence_rows(conn, "artifacts"), artifacts);
assert_eq!(
evidence_rows(conn, "repository_secret_legacy_receipts"),
receipts
);
assert_eq!(
conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| {
row.get::<_, i64>(0)
})?,
0
);
for (table, column) in [
("artifacts", "source_reference"),
("repository_secret_legacy_receipts", "legacy_result_counter"),
(
"repository_secret_legacy_receipts",
"legacy_expected_counter",
),
] {
assert!(column_exists(conn, table, column)?);
}
Ok(())
})
.unwrap();
for store in [upgraded, SqliteWorkspaceStore::in_memory().unwrap()] {
store.with_conn(|conn| {
let mut stmt = conn.prepare("SELECT name,sql FROM sqlite_schema WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%'")?;
let objects = stmt.query_map([],|row|Ok((row.get::<_,String>(0)?,row.get::<_,String>(1)?)))?.collect::<rusqlite::Result<Vec<_>>>()?;
for (name,sql) in objects {
assert!(!name.to_ascii_lowercase().contains("revision") && !sql.to_ascii_lowercase().contains("revision"),"old name in current schema: {name}");
}
Ok(())
}).unwrap();
}
}
#[test]
fn evidence_column_cutover_rolls_back_every_rename_before_retry() {
let dir = tempfile::tempdir().unwrap();
let conn = old_evidence_database(&dir.path().join("server.db"));
conn.execute_batch("CREATE TRIGGER reject_schema_89 BEFORE INSERT ON __yoi_schema_migrations WHEN NEW.version=89 BEGIN SELECT RAISE(ABORT,'reject schema 89'); END;").unwrap();
let schema = evidence_rows(&conn, "sqlite_schema");
let receipts = evidence_rows(&conn, "repository_secret_legacy_receipts");
let artifacts = evidence_rows(&conn, "artifacts");
let history = evidence_rows(&conn, "__yoi_schema_migrations");
let pragmas = |conn: &Connection| {
(
conn.pragma_query_value(None, "foreign_keys", |row| row.get::<_, i64>(0))
.unwrap(),
conn.pragma_query_value(None, "legacy_alter_table", |row| row.get::<_, i64>(0))
.unwrap(),
)
};
let before = pragmas(&conn);
assert!(
migrate_evidence_column_names_v88_to_v89(&conn)
.unwrap_err()
.to_string()
.contains("reject schema 89")
);
assert_eq!(current_schema_version(&conn).unwrap(), 88);
assert_eq!(evidence_rows(&conn, "sqlite_schema"), schema);
assert_eq!(
evidence_rows(&conn, "repository_secret_legacy_receipts"),
receipts
);
assert_eq!(evidence_rows(&conn, "artifacts"), artifacts);
assert_eq!(evidence_rows(&conn, "__yoi_schema_migrations"), history);
assert_eq!(pragmas(&conn), before);
conn.execute_batch("DROP TRIGGER reject_schema_89").unwrap();
migrate_evidence_column_names_v88_to_v89(&conn).unwrap();
assert_eq!(current_schema_version(&conn).unwrap(), 89);
}
+1 -1
View File
@@ -42,7 +42,7 @@ mod workspace_subscription;
pub use authority::{
InternalMemoryStagingEntry, MemoryAuthority, MemoryDocument, MemoryStagingResolution,
ObjectiveAuthority, SqliteWorkspaceAuthority, TicketAuthority, TicketMergeRevisionSource,
ObjectiveAuthority, SqliteWorkspaceAuthority, TicketAuthority, TicketSourceCommitResolver,
WorkspaceAuthority,
};
pub use config::{ResolvedWorkspaceBackendConfig, ServerHostConfigFile};
+2 -2
View File
@@ -160,7 +160,7 @@ fn load_skill_workspace_config(
let store = SqliteWorkspaceStore::open(ServerConfig::default_server_database_path())?;
store.load_workspace_config(workspace_id)?.ok_or_else(|| {
Box::new(CliError(format!(
"Workspace `{workspace_id}` has no active config revision"
"Workspace `{workspace_id}` has no active config snapshot"
))) as Box<dyn std::error::Error>
})
}
@@ -655,7 +655,7 @@ fn print_migrate_help() {
fn print_skills_help() {
println!(
"yoi-server skills\n\nUsage:\n yoi-server skills list --workspace <WORKSPACE_ID>\n yoi-server skills lint --workspace <WORKSPACE_ID>\n yoi-server skills show <NAME> --workspace <WORKSPACE_ID>\n\nDescription:\n Reads the active Server DB virtual-config revision. Catalog output is lightweight and omits imported Markdown content; detail output includes that content. allowed-tools and scripts are diagnostics only.\n\nOptions:\n --workspace <WORKSPACE_ID> Workspace id in the Server DB (required)\n -h, --help Print help"
"yoi-server skills\n\nUsage:\n yoi-server skills list --workspace <WORKSPACE_ID>\n yoi-server skills lint --workspace <WORKSPACE_ID>\n yoi-server skills show <NAME> --workspace <WORKSPACE_ID>\n\nDescription:\n Reads the active Server DB virtual-config snapshot. Catalog output is lightweight and omits imported Markdown content; detail output includes that content. allowed-tools and scripts are diagnostics only.\n\nOptions:\n --workspace <WORKSPACE_ID> Workspace id in the Server DB (required)\n -h, --help Print help"
);
}
@@ -308,7 +308,7 @@ impl RepositorySecretService {
let Some((_,resource_kind,resource_id,result_operation_id))=read_operation(conn,workspace_id,&operation_id)? else { return Ok(None); };
let created_at=conn.query_row("SELECT created_at FROM repository_secret_operations WHERE workspace_id=?1 AND operation_id=?2",params![workspace_id,operation_id],|r|r.get(0))?;
let legacy: Option<(Option<String>,Option<i64>,Option<String>,Option<String>)>=conn.query_row(
"SELECT mutation_kind,expected_revision,expected_operation_id,expected_key_fingerprint FROM repository_secret_legacy_receipts WHERE workspace_id=?1 AND operation_id=?2",
"SELECT mutation_kind,legacy_expected_counter,expected_operation_id,expected_key_fingerprint FROM repository_secret_legacy_receipts WHERE workspace_id=?1 AND operation_id=?2",
params![workspace_id,operation_id],|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?)),
).optional()?;
let (mutation_kind,legacy_precondition_proven)=if let Some((kind,counter,prior,key))=legacy {
@@ -1626,7 +1626,7 @@ fn matches_legacy_secret_replay(
request: LegacySecretRequest<'_>,
) -> Result<bool> {
let evidence: Option<(String,Option<String>,Option<i64>,Option<String>,Option<String>)> = conn.query_row(
"SELECT request_fingerprint,mutation_kind,expected_revision,expected_operation_id,expected_key_fingerprint FROM repository_secret_legacy_receipts WHERE workspace_id=?1 AND operation_id=?2 AND resource_id=?3",
"SELECT request_fingerprint,mutation_kind,legacy_expected_counter,expected_operation_id,expected_key_fingerprint FROM repository_secret_legacy_receipts WHERE workspace_id=?1 AND operation_id=?2 AND resource_id=?3",
params![workspace_id,operation_id,resource_id], |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?,r.get(4)?)),
).optional()?;
let Some((original, Some(mutation_kind), Some(counter), prior_operation, prior_key)) = evidence
@@ -2074,7 +2074,7 @@ pub(crate) fn migrate_legacy_repository_secret_envelope(
database_path: &Path,
workspace_id: &str,
credential_id: &str,
legacy_revision: u64,
legacy_counter: u64,
operation_id: &str,
purpose: &str,
nonce: &[u8],
@@ -2092,7 +2092,7 @@ pub(crate) fn migrate_legacy_repository_secret_envelope(
Error::RegistryInconsistency("Repository secret migration nonce is invalid".to_string())
})?;
let old_aad = format!(
"yoi/repository-secret/v1/{workspace_id}/{credential_id}/{legacy_revision}/{purpose}"
"yoi/repository-secret/v1/{workspace_id}/{credential_id}/{legacy_counter}/{purpose}"
);
let mut plaintext = zeroize::Zeroizing::new(ciphertext.to_vec());
let plaintext_len = key
@@ -42,6 +42,7 @@ fn fresh_and_upgraded_state_contract_schemas_enforce_the_same_constraints() {
let fresh = SqliteWorkspaceStore::in_memory().unwrap();
let mut mismatches = Vec::new();
for table in [
"artifacts",
"workspace_config_trees",
"workspace_config_tree_history",
"workspace_memory_settings",
+114 -63
View File
@@ -155,7 +155,7 @@ use crate::auth::{
};
use crate::authority::{
MemoryAuthority, ObjectiveAuthority, ObjectiveCreateInput, ObjectiveEditInput,
SqliteWorkspaceAuthority, TicketAuthority, TicketMergeRevisionSource, merge_request_summary,
SqliteWorkspaceAuthority, TicketAuthority, TicketSourceCommitResolver, merge_request_summary,
};
use crate::backend_job::{
ABSOLUTE_MAX_CONCURRENT_JOBS, BackendJobAttemptRecord, BackendJobAttemptState,
@@ -3607,11 +3607,9 @@ impl WorkspaceApi {
// The observer snapshot retains the unconfigured authority, not itself:
// source observation uses Repository access + Runtime authority without a cycle.
let observer_api = api.clone();
api.authority =
api.authority
.with_merge_revision_source(Arc::new(RuntimeTicketMergeRevisionSource {
api: observer_api,
}));
api.authority = api.authority.with_source_commit_resolver(Arc::new(
RuntimeTicketSourceCommitResolver { api: observer_api },
));
if let Some(dispatcher) = worker_remove_dispatcher {
dispatcher
.install_executor(Arc::new(
@@ -4635,7 +4633,7 @@ impl WorkspaceApi {
&self.config.workspace_id,
&delivery_id,
BackendJobDeliveryState::Failed,
Some("current_revision_unavailable"),
Some("current_ticket_content_unavailable"),
Some(&error.to_string()),
&Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true),
);
@@ -4657,7 +4655,7 @@ impl WorkspaceApi {
}
Ok(ticket_item_checker::TicketItemCheckNotification::Stale) => {
let detail = format!(
"checked revision {} is no longer current ({})",
"checked Ticket content digest {} is no longer current ({})",
checker_input.content_digest,
ticket_item_checker::content_digest(&current_ticket)
);
@@ -4665,7 +4663,7 @@ impl WorkspaceApi {
&self.config.workspace_id,
&delivery_id,
BackendJobDeliveryState::Completed,
Some("notification_suppressed_stale_revision"),
Some("notification_suppressed_stale_ticket_content"),
Some(&detail),
&Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true),
);
@@ -15190,12 +15188,12 @@ struct MergeRequestRepositorySource {
}
#[derive(Clone)]
struct RuntimeTicketMergeRevisionSource {
struct RuntimeTicketSourceCommitResolver {
api: WorkspaceApi,
}
impl TicketMergeRevisionSource for RuntimeTicketMergeRevisionSource {
fn resolve_subject_ref(
impl TicketSourceCommitResolver for RuntimeTicketSourceCommitResolver {
fn resolve_source_commit(
&self,
ticket_id: &str,
repository_id: &str,
@@ -15357,6 +15355,9 @@ fn remap_source_ref_error(error: ApiError) -> ApiError {
}
}
/// Observe the provider-published source commit through the assigned Runtime, not
/// a Server-local checkout. Open/list/show/review/complete paths call this at the
/// point they need current source evidence; provider failure remains unavailable.
fn observe_published_source_ref(
api: &WorkspaceApi,
workspace_id: &str,
@@ -15368,6 +15369,10 @@ fn observe_published_source_ref(
.map_err(remap_source_ref_error)
}
/// Before opening an MR or granting review of an open MR, re-read the current
/// Coder attachments and require one clean checkout of the same Repository,
/// normalized branch selector and provider-published HEAD commit. A newer
/// observation time alone says nothing about which code is being reviewed.
fn require_assigned_workdir_source(
api: &WorkspaceApi,
assignment: &crate::store::TicketWorkerAssignmentRecord,
@@ -15457,7 +15462,7 @@ fn validate_assigned_workdir_source(
if !workdir_selector_matches || workdir.current_ref.as_deref() != Some(resolved_ref) {
return Err(
worker_runtime::working_directory::WorkingDirectoryDiagnostic {
code: "source_ref_revision_mismatch".to_string(),
code: "source_workdir_ref_mismatch".to_string(),
message: "Current Coder Workdir selector and HEAD do not match the provider-published source ref".to_string(),
},
);
@@ -18017,7 +18022,7 @@ fn list_current_worker_workdir_catalog_for_worker(
workspace_id: api.config.workspace_id.clone(),
items,
next_cursor: page.next_cursor,
digest: page.revision,
digest: page.catalog_digest,
})
}
@@ -18033,7 +18038,7 @@ fn list_current_worker_workdir_attachments(
Error::InvalidInput("attachment page limit must be 1..=100".to_string()).into(),
);
}
let (mut links, digest) = api.store.list_worker_workdir_links_page_with_revision(
let (mut links, digest) = api.store.list_worker_workdir_links_page_with_digest(
&api.config.workspace_id,
worker,
limit + 1,
@@ -20839,7 +20844,7 @@ async fn scoped_subjektiv_memory_backend(
),
Op::ValidateProposal(_) => (
Some(SubjektivWorkerAuthority::Subject),
"revision proposal validation",
"Memory proposal validation",
),
Op::ReceiptStatus(_) => (
Some(SubjektivWorkerAuthority::Subject),
@@ -21289,7 +21294,7 @@ async fn scoped_list_skills(
.load_workspace_config(&path.workspace_id)?
.ok_or_else(|| {
Error::RegistryInconsistency(format!(
"Workspace {} has no active config revision",
"Workspace {} has no active config snapshot",
path.workspace_id
))
})?;
@@ -21306,7 +21311,7 @@ async fn scoped_lint_skills(
.load_workspace_config(&path.workspace_id)?
.ok_or_else(|| {
Error::RegistryInconsistency(format!(
"Workspace {} has no active config revision",
"Workspace {} has no active config snapshot",
path.workspace_id
))
})?;
@@ -21323,7 +21328,7 @@ async fn scoped_get_skill(
.load_workspace_config(&path.workspace_id)?
.ok_or_else(|| {
Error::RegistryInconsistency(format!(
"Workspace {} has no active config revision",
"Workspace {} has no active config snapshot",
path.workspace_id
))
})?;
@@ -21342,7 +21347,7 @@ async fn scoped_activate_skill(
.load_workspace_config(&path.workspace_id)?
.ok_or_else(|| {
Error::RegistryInconsistency(format!(
"Workspace {} has no active config revision",
"Workspace {} has no active config snapshot",
path.workspace_id
))
})?;
@@ -21611,7 +21616,7 @@ async fn scoped_confirm_repository_ssh_host_trust(
})?;
if request.expected_host_key_fingerprint != probe.expected_host_key_fingerprint {
return Err(ApiError::from(Error::WorkspaceConfigConflict(
"SSH host trust revision changed after the connection test".to_string(),
"SSH host trust fingerprint changed after the connection test".to_string(),
)));
}
let host_trust = api.repository_secrets.put_host_trust(
@@ -32899,9 +32904,7 @@ fn api_error_status(error: &Error) -> StatusCode {
StatusCode::NOT_IMPLEMENTED
}
Error::RuntimeOperationFailed { code, .. }
if code == "profile_registry_revision_conflict"
|| code == "profile_source_revision_conflict"
|| code == "workspace_metadata_updated_at_conflict"
if code == "workspace_metadata_updated_at_conflict"
|| code == "workspace_cleanup_plan_stale"
|| code == "workspace_cleanup_worker_blocked"
|| code == "workspace_cleanup_workdir_blocked"
@@ -35929,6 +35932,10 @@ mod tests {
);
}
// Architectural source check, run by the ordinary Server unit suite: route
// wiring must use Runtime/provider authority and capability validation must
// precede provider access. This is not a freshness proof; the boundary tests
// below and ticket_evidence_tests exercise commit/content equality directly.
#[test]
fn merge_request_http_paths_observe_refs_through_runtime_provider_authority() {
let source = include_str!("server.rs");
@@ -36034,8 +36041,57 @@ mod tests {
"work/T-549",
"abc123"
),
Err(diagnostic) if diagnostic.code == "source_ref_revision_mismatch"
Err(diagnostic) if diagnostic.code == "source_workdir_ref_mismatch"
));
// A recent observation cannot excuse the wrong branch, a missing HEAD,
// another Repository or unobserved cleanliness. Each input isolates one
// externally observed source requirement.
workdir.current_ref = Some("abc123".into());
workdir.observed_at_epoch_seconds = Some(u64::MAX);
for (repository_id, selector, head, cleanliness, expected_code) in [
(
"other-repository",
"work/T-549",
Some("abc123"),
Some("clean"),
"source_workdir_repository_mismatch",
),
(
"repository-1",
"other-branch",
Some("abc123"),
Some("clean"),
"source_workdir_ref_mismatch",
),
(
"repository-1",
"work/T-549",
None,
Some("clean"),
"source_workdir_ref_mismatch",
),
(
"repository-1",
"work/T-549",
Some("abc123"),
None,
"source_workdir_dirty",
),
] {
workdir.repository_id = repository_id.into();
workdir.current_selector = Some(selector.into());
workdir.current_ref = head.map(str::to_owned);
workdir.cleanliness = cleanliness.map(str::to_owned);
let diagnostic = validate_assigned_workdir_source(
&workdir,
"repository-1",
"refs/heads/work/T-549",
"abc123",
)
.unwrap_err();
assert_eq!(diagnostic.code, expected_code);
}
}
fn completed_upload_file(sha256: &str) -> protocol::UploadedFileRef {
@@ -40003,9 +40059,7 @@ mod tests {
created["behavior_md"],
"Prefer explicit evidence.\nAsk when uncertain."
);
assert!(created.get("behavior_revision").is_none());
assert_eq!(created["state"], "active");
assert!(created.get("store_revision").is_none());
assert!(created.get("current_worker").is_none());
let created_id = created["id"].as_str().unwrap();
let persisted = store.subject(created_id).unwrap().unwrap();
@@ -40040,7 +40094,6 @@ mod tests {
)
.await;
assert_eq!(updated["behavior_md"], "Updated without rewriting history.");
assert!(updated.get("behavior_revision").is_none());
request_json_authenticated(
app.clone(),
"PATCH",
@@ -40066,7 +40119,6 @@ mod tests {
)
.await;
assert_eq!(cleared["behavior_md"], "");
assert!(cleared.get("behavior_revision").is_none());
assert!(
api.store
.current_worker_singleton_owner(
@@ -42897,7 +42949,7 @@ mod tests {
);
let unknown_identity = RuntimeIdentityMaterial::generate("unknown-runtime").unwrap();
let unknown_revision = create_remote_runtime(
let unknown_key_generation = create_remote_runtime(
State(api.clone()),
Extension(test_owner_actor()),
Json(CreateRemoteRuntimeRequest {
@@ -42914,7 +42966,7 @@ mod tests {
.await
.unwrap_err();
assert_eq!(
unknown_revision.into_response().status(),
unknown_key_generation.into_response().status(),
StatusCode::CONFLICT
);
@@ -44249,7 +44301,7 @@ mod tests {
}
#[tokio::test]
async fn ticket_checker_burst_keeps_excess_revisions_durable_and_drains_them() {
async fn ticket_checker_burst_keeps_excess_content_checks_durable_and_drains_them() {
let temp = tempfile::tempdir().unwrap();
let (api, _execution) = test_api_with_recording_backend(temp.path()).await;
let source = spawn_ticket_check_source(&api, "ticket-check-burst-author");
@@ -44318,7 +44370,7 @@ mod tests {
);
// Restart reconciliation is idempotent for running attempts and retains
// the queued revisions until a slot becomes available.
// the queued content checks until a slot becomes available.
api.recover_backend_jobs().unwrap();
let mut completed = std::collections::BTreeSet::new();
for _ in 0..300 {
@@ -44421,7 +44473,7 @@ mod tests {
];
let mut checked_inputs = Vec::new();
for (ticket, source, expected_body) in snapshots {
let revision = ticket_item_checker::content_digest(&ticket);
let content_digest = ticket_item_checker::content_digest(&ticket);
let mut jobs = ticket_check_jobs_for_snapshot(&api, &ticket);
assert_eq!(
jobs.len(),
@@ -44430,7 +44482,7 @@ mod tests {
);
let job = jobs.pop().unwrap();
let input = ticket_item_checker::parse_input(&job.request.input).unwrap();
assert_eq!(input.content_digest, revision);
assert_eq!(input.content_digest, content_digest);
assert_eq!(input.body, expected_body);
assert_eq!(job.request.source_worker.as_ref(), Some(&source));
assert_eq!(job.request.notification_target.as_ref(), Some(&source));
@@ -44468,7 +44520,7 @@ mod tests {
let source = spawn_ticket_check_source(&api, "ticket-check-editor");
let backend = browser_ticket_backend(&api).unwrap();
let ticket_ref = backend
.create(ticket::NewTicket::new("Checker revision fencing"))
.create(ticket::NewTicket::new("Checker content freshness"))
.unwrap();
let edit = |body: &str| TicketBackendOperation::EditItem {
@@ -44563,7 +44615,7 @@ mod tests {
assert_eq!(stale_delivery.state, BackendJobDeliveryState::Completed);
assert_eq!(
stale_delivery.failure_category.as_deref(),
Some("notification_suppressed_stale_revision")
Some("notification_suppressed_stale_ticket_content")
);
api.accept_backend_job_result(
@@ -48559,11 +48611,11 @@ mod tests {
);
let Json(replayed) = call(request.clone()).await.unwrap();
assert_eq!(replayed.events, completed.events);
for field in ["reason", "revision", "state", "references"] {
for field in ["reason", "content_digest", "state", "references"] {
let mut changed = request.clone();
match field {
"reason" => changed.reason = "different judgment".into(),
"revision" => changed.expected_content_digest = "stale".into(),
"content_digest" => changed.expected_content_digest = "stale".into(),
"state" => changed.expected_state = TicketWorkflowState::Planning,
"references" => changed.references.push(ticket::TicketReference {
kind: "document".into(),
@@ -49989,7 +50041,7 @@ mod tests {
Json(server_api::BrowserTransitionTicketStateRequest {
state: server_api::BrowserTicketWorkflowState::Done,
operation_key: "stale".into(),
expected_content_digest: "stale-revision".into(),
expected_content_digest: "stale-content-digest".into(),
expected_state: server_api::BrowserTicketWorkflowState::Planning,
reason: "Stale decision".into(),
body: None,
@@ -52440,7 +52492,7 @@ mod tests {
.uri(format!("/api/w/{TEST_WORKSPACE_ID}/workers/remove"))
.header(CONTENT_TYPE, "application/json")
.body(Body::from(
r#"{"target_runtime_id":"runtime-target","target_worker_id":"target-worker","expected_worker_revision":"revision-1","reason":"retire target Worker","source_proof":"browser-controlled","actor":"orchestrator","policy":"purge"}"#,
r#"{"target_runtime_id":"runtime-target","target_worker_id":"target-worker","reason":"retire target Worker","source_proof":"browser-controlled","actor":"orchestrator","policy":"purge"}"#,
))
.unwrap(),
)
@@ -54116,7 +54168,7 @@ mod tests {
restarted
.workdir_catalog_page(TEST_WORKSPACE_ID, 100, None)
.unwrap()
.revision
.catalog_digest
);
let no_worker = runtime_source_request(&identity, None, "GET", &base, Vec::new());
assert_eq!(
@@ -54559,7 +54611,7 @@ mod tests {
assert_eq!(page.next_offset, None);
assert_eq!(
page.digest, first.digest,
"lookup does not narrow the collection revision"
"lookup does not narrow the collection digest"
);
let foreign_worker_id = seed_cleanup_worker(api, 902, "normal");
@@ -54621,7 +54673,7 @@ mod tests {
.unwrap();
assert_eq!(
unchanged, first,
"another caller's connection cannot change this revision"
"another caller's connection cannot change this digest"
);
api.store
.detach_worker_workdir_connection(
@@ -54657,8 +54709,8 @@ mod tests {
assert_eq!(capability_changed.items, reattached.items);
assert_ne!(capability_changed.digest, reattached.digest);
let restarted = SqliteWorkspaceStore::open(&api.config.database_path).unwrap();
let (restarted_page, restarted_revision) = restarted
.list_worker_workdir_links_page_with_revision(
let (restarted_page, restarted_digest) = restarted
.list_worker_workdir_links_page_with_digest(
TEST_WORKSPACE_ID,
&fixture.worker,
51,
@@ -54667,8 +54719,8 @@ mod tests {
)
.unwrap();
assert_eq!(restarted_page.len(), 51);
assert_eq!(restarted_revision, capability_changed.digest);
// A SQLite backup restores the same authoritative content/revision, without a sync store.
assert_eq!(restarted_digest, capability_changed.digest);
// A SQLite backup restores the same authoritative content/digest, without a sync store.
let source = rusqlite::Connection::open(&api.config.database_path).unwrap();
let mut snapshot = rusqlite::Connection::open_in_memory().unwrap();
rusqlite::backup::Backup::new(&source, &mut snapshot)
@@ -54678,7 +54730,7 @@ mod tests {
let restored = SqliteWorkspaceStore::from_connection(snapshot).unwrap();
assert_eq!(
restored
.list_worker_workdir_links_page_with_revision(
.list_worker_workdir_links_page_with_digest(
TEST_WORKSPACE_ID,
&fixture.worker,
51,
@@ -54686,10 +54738,10 @@ mod tests {
None
)
.unwrap(),
(restarted_page, restarted_revision)
(restarted_page, restarted_digest)
);
// Independent SQLite connections race a last-row capability update against exact lookup.
// The page and complete revision must always describe the same snapshot, not two reads.
// The page and complete collection digest must always describe the same snapshot, not two reads.
let mut read_write_links = api
.store
.list_worker_workdir_links(TEST_WORKSPACE_ID, &fixture.worker)
@@ -54710,8 +54762,8 @@ mod tests {
&read_write_links,
)
.unwrap();
let (_, read_write_revision) = restarted
.list_worker_workdir_links_page_with_revision(
let (_, read_write_digest) = restarted
.list_worker_workdir_links_page_with_digest(
TEST_WORKSPACE_ID,
&fixture.worker,
1,
@@ -54719,7 +54771,7 @@ mod tests {
Some(&replacement.connection_id),
)
.unwrap();
assert_ne!(read_write_revision, capability_changed.digest);
assert_ne!(read_write_digest, capability_changed.digest);
let reader = SqliteWorkspaceStore::open(&api.config.database_path).unwrap();
let barrier = Arc::new(std::sync::Barrier::new(2));
std::thread::scope(|scope| {
@@ -54743,8 +54795,8 @@ mod tests {
});
barrier.wait();
for _ in 0..100 {
let (page, revision) = reader
.list_worker_workdir_links_page_with_revision(
let (page, digest) = reader
.list_worker_workdir_links_page_with_digest(
TEST_WORKSPACE_ID,
worker,
1,
@@ -54753,13 +54805,13 @@ mod tests {
)
.unwrap();
assert_eq!(page.len(), 1);
if revision == read_write_revision {
if digest == read_write_digest {
assert_eq!(
page[0].capabilities,
workdir::WorkdirSessionCapabilities::READ_WRITE
);
} else {
assert_eq!(revision, capability_changed.digest);
assert_eq!(digest, capability_changed.digest);
assert_eq!(
page[0].capabilities,
workdir::WorkdirSessionCapabilities::READ_ONLY
@@ -57895,7 +57947,7 @@ mod tests {
}
#[tokio::test]
async fn runtime_removal_config_and_revision_guards_preserve_active_trust() {
async fn runtime_removal_config_and_binding_identity_guards_preserve_active_trust() {
let root = tempfile::tempdir().unwrap();
let api = test_api(root.path()).await;
let original = register_test_runtime(&api, "guarded-runtime").await;
@@ -61450,14 +61502,14 @@ VALUES ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', 3);
.await;
let committed_digest = committed["snapshot"]["digest"].as_str().unwrap();
let revision_snapshot = get_json(
let config_snapshot = get_json(
app.clone(),
&format!("{source_tree_path}/history/{committed_digest}"),
)
.await;
assert_eq!(revision_snapshot["digest"], committed_digest);
assert_eq!(config_snapshot["digest"], committed_digest);
assert_eq!(
revision_snapshot["entries"]["notes/readme.txt"]["content"],
config_snapshot["entries"]["notes/readme.txt"]["content"],
"nested entry"
);
@@ -61484,7 +61536,6 @@ VALUES ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', 3);
let path = format!("/api/w/{TEST_WORKSPACE_ID}/settings/profiles");
let settings = get_json(app.clone(), &path).await;
assert_eq!(settings["default_profile"], "builtin:companion");
assert!(settings.get("config_revision").is_none());
assert!(settings["tree_digest"].as_str().is_some());
assert!(settings["projection_digest"].as_str().is_some());
assert!(
@@ -282,7 +282,7 @@ mod result_budget_tests {
let result = serde_json::json!({
"subject_id":"\\\"".repeat(128),"candidate_ids":batch,
"candidate_dispositions":dispositions,
"surface":{"availability":"failed","generation_id":"g".repeat(100),"store_revision":u64::MAX,"reason_code":"\\\"".repeat(128)},
"surface":{"availability":"failed","generation_id":"g".repeat(100),"reason_code":"\\\"".repeat(128)},
});
assert!(
serde_json::to_vec(&result).unwrap().len()
@@ -19,7 +19,7 @@ const EVIDENCE_SOURCE: &str = "immutable-approved-source";
const EVIDENCE_TARGET: &str = "immutable-target-result";
// Deliberately freeze review dates: freshness must follow the exact item
// revision and source/result snapshot, not a comparison of wall-clock dates.
// content digest and source/result snapshot, not a comparison of wall-clock dates.
fn evidence_time() -> chrono::DateTime<Utc> {
"2000-01-01T00:00:00Z".parse().unwrap()
}
@@ -121,7 +121,7 @@ impl EvidenceApiFixture {
}
}
fn revision(&self) -> String {
fn content_digest(&self) -> String {
ticket_item_checker::content_digest(
&self.backend.show(self.ticket_id.clone().into()).unwrap(),
)
@@ -150,7 +150,7 @@ impl EvidenceApiFixture {
.request_review(RequestMergeRequestReview {
merge_request_id: EVIDENCE_MR.into(),
ticket_id: self.ticket_id.clone(),
ticket_content_digest: self.revision(),
ticket_content_digest: self.content_digest(),
ticket_merge_request_subjects: self.subjects(),
subject_ref: EVIDENCE_SOURCE.into(),
child_session_id,
@@ -194,7 +194,7 @@ impl EvidenceApiFixture {
fn completion(&self, _approval: &ReviewEvent) -> ticket::TicketCompletion {
ticket::TicketCompletion {
operation_key: "evidence-ticket-completion".into(),
expected_content_digest: self.revision(),
expected_content_digest: self.content_digest(),
expected_state: TicketWorkflowState::InProgress,
reason: "Implementation judged complete independently of MR attestation".into(),
references: Vec::new(),
@@ -287,7 +287,7 @@ impl EvidenceApiFixture {
self.ticket_id.clone().into(),
TicketItemEdit {
body: Some(MarkdownText::new(
"Revised requirements require a fresh exact-revision approval.",
"Revised requirements require a fresh exact-content approval.",
)),
..Default::default()
},
@@ -301,12 +301,12 @@ async fn done_merged_remote_ticket_uses_immutable_result_in_show_query_list_and_
let fixture = EvidenceApiFixture::new().await;
let approval = fixture.approve("integration");
let merge = fixture.integrate(&approval);
let revision = fixture.revision();
let content_digest = fixture.content_digest();
let completion = fixture
.backend
.complete(&fixture.ticket_id, fixture.completion(&approval))
.unwrap();
assert_eq!(ticket::ticket_content_digest(&completion), revision);
assert_eq!(ticket::ticket_content_digest(&completion), content_digest);
assert!(
fixture
.api
@@ -326,7 +326,7 @@ async fn done_merged_remote_ticket_uses_immutable_result_in_show_query_list_and_
let shown = fixture.show().await;
assert_eq!(shown.state, "done");
assert_eq!(shown.content_digest, revision);
assert_eq!(shown.content_digest, content_digest);
assert!(
shown.evidence.complete_for_integration,
"{:?}",
@@ -376,13 +376,13 @@ async fn done_merged_remote_ticket_uses_immutable_result_in_show_query_list_and_
}
#[tokio::test]
async fn latest_postmerge_approval_requires_exact_revision_and_stored_source_result_snapshot() {
async fn latest_postmerge_approval_requires_exact_content_and_stored_source_result_snapshot() {
let fixture = EvidenceApiFixture::new().await;
let integration = fixture.approve("integration");
let merge = fixture.integrate(&integration);
fixture.rescope();
let revision = fixture.revision();
assert_ne!(integration.ticket_content_digest, revision);
let content_digest = fixture.content_digest();
assert_ne!(integration.ticket_content_digest, content_digest);
let stale = fixture.show().await;
assert!(stale.evidence.approved_current_subject); // integration proof survives
@@ -405,9 +405,9 @@ async fn latest_postmerge_approval_requires_exact_revision_and_stored_source_res
);
assert_eq!(fixture.show().await.state, "inprogress");
let latest = fixture.approve("latest-revision");
let latest = fixture.approve("latest-content");
assert_eq!(latest.created_at, integration.created_at);
assert_eq!(latest.ticket_content_digest, revision);
assert_eq!(latest.ticket_content_digest, content_digest);
assert_eq!(latest.ticket_merge_request_subjects, fixture.subjects());
let fresh = fixture.show().await;
assert!(
@@ -420,7 +420,7 @@ async fn latest_postmerge_approval_requires_exact_revision_and_stored_source_res
Some("Requirement approval integration")
);
fixture.assert_filters(true, false, false).await;
// A current revision alone is insufficient: simulate a stored attestation
// A current Ticket content digest alone is insufficient: simulate a stored attestation
// against a different result set using typed serialized payloads in this
// temporary fixture database, never a live Workspace database.
let mut wrong_snapshot = latest.clone();
@@ -490,7 +490,7 @@ async fn latest_postmerge_approval_requires_exact_revision_and_stored_source_res
.backend
.complete(&fixture.ticket_id, fixture.completion(&repaired))
.unwrap();
assert_eq!(ticket::ticket_content_digest(&completion), revision);
assert_eq!(ticket::ticket_content_digest(&completion), content_digest);
assert_eq!(fixture.show().await.state, "done");
fixture.assert_filters(true, false, false).await;
}
@@ -649,7 +649,7 @@ async fn closing_ticket_with_open_unreviewed_mr_does_not_change_its_proof_or_sta
&fixture.ticket_id,
ticket::TicketStateUpdate {
operation_key: "close-with-open-mr".into(),
expected_content_digest: fixture.revision(),
expected_content_digest: fixture.content_digest(),
expected_state: TicketWorkflowState::InProgress,
state: TicketWorkflowState::Closed,
reason: "The request was withdrawn; no integration or approval is asserted".into(),
@@ -458,7 +458,7 @@ async fn pending_manual_worker_claim_reconciles_same_runtime_restore_and_commits
),
)
.unwrap();
let admitted_revision = api.authority.ticket(&ticket_id).unwrap().content_digest;
let admitted_content_digest = api.authority.ticket(&ticket_id).unwrap().content_digest;
let request = server_api::SetTicketRoleAssignmentRequest {
operation_id: "claim-runtime-reconciliation".into(),
principal: server_api::TicketAssignmentPrincipal::Worker {
@@ -515,7 +515,7 @@ async fn pending_manual_worker_claim_reconciles_same_runtime_restore_and_commits
.get_ticket_claim_recovery(TEST_WORKSPACE_ID, &request.operation_id)
.unwrap()
.unwrap();
assert_eq!(recovery.content_digest, admitted_revision);
assert_eq!(recovery.content_digest, admitted_content_digest);
assert!(recovery.selected.is_empty());
assert!(recovery.original_links.is_empty());
assert!(recovery.effective_links.is_empty());
@@ -572,7 +572,7 @@ async fn pending_manual_worker_claim_reconciles_same_runtime_restore_and_commits
);
assert_eq!(
api.authority.ticket(&ticket_id).unwrap().content_digest,
admitted_revision
admitted_content_digest
);
assert_eq!(
api.config_store
@@ -438,7 +438,7 @@ async fn value_profiles_save_project_and_runtime_consume_the_same_content() {
"omission must remain omission in the Profile value"
);
let mut snapshot = serde_json::to_value(&resolved).unwrap();
snapshot.as_object_mut().unwrap().remove("profile"); // revision-specific provenance only
snapshot.as_object_mut().unwrap().remove("profile"); // snapshot-specific provenance only
manifests.push(snapshot);
if index == 0 {
saved_manifest =
@@ -785,7 +785,7 @@ async fn value_profiles_legacy_source_config_requires_explicit_lossless_migratio
old_schema,
)
.unwrap();
// Fixture simulates a previously persisted v1 schema/revision.
// Fixture simulates a previously persisted v1 schema/config snapshot.
let legacy = api
.config_store
.commit_evaluated_workspace_config(TEST_WORKSPACE_ID, &candidate)
@@ -434,7 +434,7 @@ impl WorkspaceWorker {
})
}
/// The caller owns the full cleanup-plan revision/digest check. The removal service
/// The caller owns the full cleanup-plan digest check. The removal service
/// rechecks live assignment/retention/use at its existing commit boundary.
pub(super) async fn delete_from_plan(
&self,
+2 -2
View File
@@ -217,7 +217,7 @@ fn merged_skills(
source_diagnostic = Some(SkillDiagnostic::error(
"skill_source_mismatch",
format!(
"Skill `{name}` must be the imported value of `{canonical_path}` in the active config revision"
"Skill `{name}` must be the imported value of `{canonical_path}` in the active config snapshot"
),
Some(format!("workspace:{name}")),
));
@@ -233,7 +233,7 @@ fn merged_skills(
} else {
source_diagnostic = Some(SkillDiagnostic::error(
"missing_skill_source",
format!("Skill `{name}` requires `{canonical_path}` in the active config revision"),
format!("Skill `{name}` requires `{canonical_path}` in the active config snapshot"),
Some(format!("workspace:{name}")),
));
}
+37 -24
View File
@@ -28,7 +28,7 @@ use crate::workspace_deletion::WorkspaceDeletionStore;
use crate::{Error, Result};
const OLDEST_SCHEMA_VERSION: i64 = 50;
const LATEST_SCHEMA_VERSION: i64 = 88;
const LATEST_SCHEMA_VERSION: i64 = 89;
const WORKSPACE_DRIVE_GRANTS_MIGRATION_NAME: &str = "durable Workspace Drive grants";
const WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME: &str = "Workspace config grants and logical Workdirs";
const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
@@ -276,6 +276,11 @@ const MIGRATIONS: &[Migration] = &[
name: "Runtime binding identities and retention content contracts",
apply: migrate_runtime_bindings_v87_to_v88,
},
Migration {
version: 89,
name: "descriptive legacy evidence columns",
apply: migrate_evidence_column_names_v88_to_v89,
},
];
#[derive(Clone, Copy)]
@@ -956,7 +961,7 @@ pub struct WorkdirRegistryRecord {
pub updated_at: String,
}
/// Projection inputs captured together with the catalog revision in one SQLite snapshot.
/// Projection inputs captured together with the catalog digest in one SQLite snapshot.
#[derive(Debug, Clone)]
pub struct WorkdirCatalogEntry {
pub record: WorkdirRegistryRecord,
@@ -970,7 +975,9 @@ pub struct WorkdirCatalogEntry {
pub struct WorkdirCatalogPage {
pub entries: Vec<WorkdirCatalogEntry>,
pub next_cursor: Option<String>,
pub revision: String,
/// SHA-256 of the full safe public catalog projection, independent of this page.
/// The entries and digest are read in one SQLite transaction.
pub catalog_digest: String,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -2126,7 +2133,7 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore {
offset: u32,
connection_id: Option<&str>,
) -> Result<Vec<WorkerWorkdirLinkRecord>>;
fn list_worker_workdir_links_page_with_revision(
fn list_worker_workdir_links_page_with_digest(
&self,
workspace_id: &str,
worker: &RuntimeWorkerRef,
@@ -6760,7 +6767,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
.ok_or_else(|| Error::InvalidInput(format!("unknown Backend Job attempt `{attempt_id}`")))?;
if attempt.attempt != job.current_attempt || attempt.input_digest != job.request.input_digest()? {
return Err(Error::InvalidInput(
"Backend Job attempt is stale for the current input revision".to_string(),
"Backend Job attempt is stale for the current input digest".to_string(),
));
}
if let Some(bound) = &attempt.worker {
@@ -6875,7 +6882,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|| attempt.attempt != job.current_attempt
{
return Err(Error::InvalidInput(
"Backend Job result is stale for the current attempt or input revision".to_string(),
"Backend Job result is stale for the current attempt or input digest".to_string(),
));
}
if attempt.worker.as_ref() != Some(source_worker) {
@@ -9369,7 +9376,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
Ok(WorkdirCatalogPage {
entries,
next_cursor,
revision: hasher.finalize().iter().map(|byte| format!("{byte:02x}")).collect::<String>(),
catalog_digest: hasher.finalize().iter().map(|byte| format!("{byte:02x}")).collect::<String>(),
})
})
}
@@ -10421,7 +10428,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
offset: u32,
connection_id: Option<&str>,
) -> Result<Vec<WorkerWorkdirLinkRecord>> {
self.list_worker_workdir_links_page_with_revision(
self.list_worker_workdir_links_page_with_digest(
workspace_id,
worker,
limit,
@@ -10431,7 +10438,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
.map(|(links, _)| links)
}
fn list_worker_workdir_links_page_with_revision(
fn list_worker_workdir_links_page_with_digest(
&self,
workspace_id: &str,
worker: &RuntimeWorkerRef,
@@ -12636,7 +12643,7 @@ struct FrozenWorkspaceRuntimeBinding {
base_url: String,
public_key: String,
public_key_fingerprint: String,
binding_revision: u64,
legacy_counter: u64,
state: WorkspaceRuntimeBindingState,
authentication_mode: WorkspaceRuntimeAuthenticationMode,
workspace_key_id: Option<String>,
@@ -12666,7 +12673,7 @@ fn read_frozen_workspace_runtime_binding(
base_url: row.get(3)?,
public_key: row.get(4)?,
public_key_fingerprint: row.get(5)?,
binding_revision: row.get(6)?,
legacy_counter: row.get(6)?,
state,
authentication_mode,
workspace_key_id: row.get(9)?,
@@ -12681,6 +12688,7 @@ include!("frozen_flow_content_migration.rs");
include!("frozen_content_state_migration.rs");
include!("frozen_repository_key_migration.rs");
include!("frozen_runtime_binding_migration.rs");
include!("legacy_evidence_column_migration.rs");
fn prepare_connection(conn: &Connection) -> Result<()> {
prepare_connection_with_secret_source(
@@ -12971,15 +12979,14 @@ fn verify_workspace_runtime_binding_schema(conn: &Connection) -> Result<()> {
));
}
}
let revision_default = conn.query_row(
let legacy_counter_default = conn.query_row(
"SELECT dflt_value FROM pragma_table_info('workspace_runtime_bindings') WHERE name = 'binding_revision'",
[],
|row| row.get::<_, Option<String>>(0),
)?;
if revision_default.as_deref() != Some("1") {
if legacy_counter_default.as_deref() != Some("1") {
return Err(Error::Store(
"workspace_runtime_bindings binding_revision default does not match schema-52"
.to_string(),
"legacy Runtime binding counter default must equal one for schema-52".to_string(),
));
}
let sql = conn.query_row(
@@ -13072,7 +13079,7 @@ fn verify_workspace_runtime_binding_schema(conn: &Connection) -> Result<()> {
base_url: row.get(3)?,
public_key: row.get(4)?,
public_key_fingerprint: row.get(5)?,
binding_revision: row.get(6)?,
legacy_counter: row.get(6)?,
state: if row.get::<_, Option<String>>(9)?.is_some() {
WorkspaceRuntimeBindingState::Revoked
} else {
@@ -14647,7 +14654,7 @@ mod tests {
use super::*;
#[test]
fn schema_v74_migrates_historical_worker_projection_rows_without_revision_state() {
fn schema_v74_migrates_historical_worker_projection_rows_without_counter_state() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("workspace.db");
drop(SqliteWorkspaceStore::open(&path).unwrap());
@@ -16326,6 +16333,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
include!("schema_equivalence_tests.rs");
include!("migration_entrypoint_tests.rs");
include!("legacy_evidence_column_tests.rs");
fn prepare_retained_schema(path: &Path, version: i64) {
prepare_schema_v52(path);
@@ -16638,6 +16646,10 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
version: 88,
name: "Runtime binding identities and retention content contracts".to_string()
},
WorkspaceSchemaMigrationStep {
version: 89,
name: "descriptive legacy evidence columns".to_string()
},
]
);
@@ -16736,6 +16748,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
(86, "content-fenced config, Memory settings and Jobs".to_string()),
(87, "Repository SSH key identities and encrypted operation objects".to_string()),
(88, "Runtime binding identities and retention content contracts".to_string()),
(89, "descriptive legacy evidence columns".to_string()),
]
);
assert!(!table_exists(conn, "trusted_runtime_records")?);
@@ -16954,14 +16967,14 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
workspace_id, job_id, purpose, input_revision, input_ref, request_json,
intent_fingerprint, state, current_attempt, created_at, updated_at
) VALUES (
'workspace-a', 'job-a', 'check', 'revision-a', 'test://job-a', '{}',
'workspace-a', 'job-a', 'check', 'legacy-input-token', 'test://job-a', '{}',
'sha256:test', 'pending', 1, '1', '1'
);
INSERT INTO backend_job_attempts (
workspace_id, job_id, attempt_id, attempt, input_revision, state,
deadline_at, created_at, updated_at
) VALUES (
'workspace-a', 'job-a', 'job-a:attempt:1', 1, 'revision-a', 'reserved',
'workspace-a', 'job-a', 'job-a:attempt:1', 1, 'legacy-input-token', 'reserved',
'2', '1', '1'
);
INSERT INTO backend_job_deliveries (
@@ -17659,7 +17672,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
}
#[test]
fn workspace_runtime_verification_is_revision_bound_and_restart_safe() {
fn workspace_runtime_verification_is_key_bound_and_restart_safe() {
let temp = tempfile::tempdir().unwrap();
let path = temp.path().join("server.db");
let store = SqliteWorkspaceStore::open(&path).unwrap();
@@ -18063,7 +18076,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
}
#[test]
fn runtime_binding_key_mutations_are_revisioned_idempotent_and_audited() {
fn runtime_binding_key_mutations_are_identity_guarded_idempotent_and_audited() {
let store = SqliteWorkspaceStore::in_memory().unwrap();
store
.with_conn(|conn| {
@@ -18444,7 +18457,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
}
#[tokio::test]
async fn workspace_display_name_update_is_revision_guarded_and_preserves_identity() {
async fn workspace_display_name_update_is_timestamp_guarded_and_preserves_identity() {
let dir = tempfile::tempdir().unwrap();
let database_path = dir.path().join("server.db");
let store = SqliteWorkspaceStore::open(&database_path).unwrap();
@@ -23188,7 +23201,7 @@ INSERT INTO worker_registry (
let mut refresh = resource_job_request(
&format!("surface-{surface_state}"),
&subject,
"surface-revision",
"surface-generation",
);
refresh
.grants
@@ -23435,7 +23448,7 @@ INSERT INTO worker_registry (
let request = BackendJobRequest {
job_id: "check:T-1:r1".to_string(),
purpose: "ticket_item_check".to_string(),
input_ref: "ticket://T-1/revisions/1".to_string(),
input_ref: "ticket://T-1/contents/content-1".to_string(),
input: serde_json::json!({"title": "check"}),
instruction: "Check the immutable input.".to_string(),
profile: "builtin:backend-job".to_string(),
@@ -99,8 +99,8 @@ pub fn request_fingerprint_for_replay(
return Ok(current);
}
if let Some(legacy) = persisted.strip_prefix("workdir-create-v86:") {
if let Some((revision, digest)) = legacy.split_once(':') {
if let Ok(revision) = revision.parse::<u64>() {
if let Some((legacy_counter, digest)) = legacy.split_once(':') {
if let Ok(legacy_counter) = legacy_counter.parse::<u64>() {
let mut hasher = request_hasher(
repository_id,
selector,
@@ -108,7 +108,7 @@ pub fn request_fingerprint_for_replay(
display_name,
repository_source_fingerprint,
);
hasher.update(revision.to_be_bytes());
hasher.update(legacy_counter.to_be_bytes());
if encode_request_digest(hasher) == digest {
return Ok(persisted.to_string());
}
@@ -357,7 +357,8 @@ mod frozen_material_v1 {
version: u32,
workspace_id: String,
key_id: String,
revision: u64,
#[serde(rename = "revision")]
legacy_counter: u64,
private_key: String,
}
@@ -369,7 +370,7 @@ mod frozen_material_v1 {
pub(super) fn decode(bytes: &[u8]) -> Option<WorkspaceSigningPrivateMaterial> {
let old: Material = serde_json::from_slice(bytes).ok()?;
if old.version != 1 || old.revision != 1 {
if old.version != 1 || old.legacy_counter != 1 {
return None;
}
Some(WorkspaceSigningPrivateMaterial {
@@ -415,8 +416,8 @@ mod frozen_material_v1 {
let material = WorkspaceSigningPrivateMaterial::generate("ws-1", "WK-1").unwrap();
let mut old = serde_json::to_value(material).unwrap();
old["version"] = 1.into();
for revision in [0, 2, u64::MAX] {
old["revision"] = revision.into();
for legacy_counter in [0, 2, u64::MAX] {
old["revision"] = legacy_counter.into();
assert!(decode_private_material(&serde_json::to_vec(&old).unwrap()).is_err());
}
old["revision"] = 1.into();
+2 -2
View File
@@ -24,8 +24,8 @@ The validation counts and former API terminology below record T-709 at the cited
- `worker::subjektiv` exposes a nonserializable live Host capability plus a separate attenuated consolidation capability. Backend creation still requires the actual authenticated client and bound snapshot. Local creation is explicit Host injection; unavailable Backend never falls back locally.
- The local catalog keeps private scope/language settings and common Feature storage under `subjektiv/`, separately from generic Subject Job ledgers. The common attribution table's existing `runtime_id` lookup key is the local storage namespace for local rows, not a running Runtime identity; public local evidence leaves `runtime_id`, Workspace, and Account fields absent. Worker IDs, Subject binding, and operator-managed scope must all match before Session content is read.
- The shared strict FsStore public index is also used for committed evidence admission. Stable references are intersected with visible committed entries; numeric hints accept the two existing capture producers. Open-Run evidence is deferred only before a request, never exposed by Session tools or recorded before commit. Persisted local human provenance is distinct from Backend Account provenance and maps to the existing public `HumanInput` class.
- Standalone integration tests use real Hosts/SQLite, scripted extraction/consolidation/surface models, and test-owned second processes. Nine cases cover inert/disabled policy, reopen, saved-binding restore, cross-Worker recall/source discovery, exact explicit commit staging, normal extract/pointer → generic Job → CAS apply → resident surface, same-Subject cross-process exclusion/abnormal-exit recovery, and transport loss after apply followed by surface-only repair without duplicate Memory revision.
- Generic Job tests additionally cover immutable grant persistence, missing/foreign/revision/consumer grants, per-attempt/deadline revocation, cancellation, timeout, cleanup failure, and stale capabilities. Session adapter tests cover no-candidate discovery, foreign Subject/scope/Worker denial, bounded UTF-8 and JSON output, filter/generation-bound cursors, missing/corrupt storage, and hidden/uncommitted/partial-tail exclusion without observation writes.
- Standalone integration tests use real Hosts/SQLite, scripted extraction/consolidation/surface models, and test-owned second processes. Nine cases cover inert/disabled policy, reopen, saved-binding restore, cross-Worker recall/source discovery, exact explicit commit staging, normal extract/pointer → generic Job → CAS apply → resident surface, same-Subject cross-process exclusion/abnormal-exit recovery, and transport loss after apply followed by surface-only repair without duplicate immutable Memory change.
- Generic Job tests additionally cover immutable grant persistence, missing grants, foreign grants, mismatched input bindings and wrong-consumer grants, per-attempt/deadline revocation, cancellation, timeout, cleanup failure, and stale capabilities. Session adapter tests cover no-candidate discovery, foreign Subject/scope/Worker denial, bounded UTF-8 and JSON output, filter/generation-bound cursors, missing/corrupt storage, and hidden/uncommitted/partial-tail exclusion without observation writes.
Verified commands:
@@ -1,10 +1,10 @@
# Subject / Memory information audit (T-692)
This document records T-692's UI decisions and validation at the commits cited below. Its counter labels and fixture values describe that historical implementation, not the current API. Current Memory uses opaque `change_id` references, `memory_fingerprint` and `built_from_memory_fingerprint`; see [the current store contract](../../design/subjektiv-store.md). Historical observations and test results below are retained unchanged.
This document records T-692's UI decisions and validation at the commits cited below. Its counter values describe that historical implementation, not the current API. Obsolete UI labels are paraphrased by purpose below, not quoted as current copy. Current Memory uses immutable `change_id` references, `memory_fingerprint` and `built_from_memory_fingerprint`; see [the current store contract](../../design/subjektiv-store.md). Historical validation results are not new executions.
## Decision and scope
The previous Subjects index exposed `Store revision` alongside identity, state and
The previous Subjects index exposed a committed-Memory update counter alongside identity, state and
update time; Subject detail led with technical metadata and raw surface states.
These numbers require implementation knowledge before they help a user choose a
Subject or understand what is available. The revised hierarchy starts with
@@ -23,22 +23,22 @@ surface does not attest what is already in any running Worker's history.
| --- | --- | --- |
| Subject role and ID | Normal identification. Similar or identical roles need a stable identifier for selection and comparison. | Keep role as heading and the complete wrapping ID visible in index and detail. |
| Subject active/retired state | Normal operation decision: whether a new Worker connection can use the Subject. | Replace raw `active` with `Available`; keep `Retired`. Detail explains connection eligibility, not an assertion that a Worker is running. |
| Current Worker | Normal connection decision, derived from the existing Backend singleton projection. | Promote `Connected` / `Not connected` and display name. Do not infer ownership from store revision or claim all Workers received current Memory. Worker start remains the ordinary launch form. |
| Committed-Memory presence | Normal reading decision, independent from surface generation or candidates. | Show `Available` / `None` only on the successful first bounded page. On later pages say `N on this page`; errors say `Unavailable`. Never infer counts from revision or scan all pages. |
| Current Worker | Normal connection decision, derived from the existing Backend singleton projection. | Promote `Connected` / `Not connected` and display name. Do not infer ownership from historical store counter or claim all Workers received current Memory. Worker start remains the ordinary launch form. |
| Committed-Memory presence | Normal reading decision, independent from surface generation or candidates. | Show `Available` / `None` only on the successful first bounded page. On later pages say `N on this page`; errors say `Unavailable`. Never infer counts from an update counter or scan all pages. |
| List counts | Normal navigation context, not a store-wide statistic. | Label `N shown` and `more available`, preserving existing bounded cursor pagination. |
| Resident availability | Normal context decision. A missing surface is not missing committed Memory. | `Ready to use`, `Ready, no context`, `Not generated`, `Needs refresh`, `Generation failed`, and request `Unavailable` are distinct. These describe the current generated store artifact, not content truth or Worker-history propagation. |
| Resident body and generated time | Normal content/read freshness context. | Keep the shared Markdown renderer, generated date and exact snapshot reference count. Ready-empty explains that a valid snapshot has no body; stale/failed do not display a partial or old snapshot. |
| Subject store revision | Diagnostic only: internal committed-Memory change number, not record count, schema version or quality score. | Remove it from the index's normal columns. Retain the exact value under detail's `Technical details`, with its meaning explained. Backend comparisons are untouched. |
| Historical Subject store counter | Diagnostic only: internal committed-Memory change number, not record count, schema version or quality score. | Remove it from the index's normal columns. Retain the exact value under detail's `Technical details`, with its meaning explained. Backend comparisons are untouched. |
| Subject update time | Secondary metadata; not a context-injection or generation completion timestamp. | Label `Last changed`; on detail put it with Subject diagnostics rather than competing with the availability summary. |
| Snapshot ID and built-from revision | Diagnostic correlation, distinct from per-Memory revisions. | Retain under `Surface sources and diagnostics`, labelled `Generated from Subject revision` and explained. |
| Surface Memory references | Detailed audit/reachability. | Preserve reference IDs, immutable revision values and Memory links in the disclosure. |
| Snapshot ID and historical built-from counter | Diagnostic correlation, distinct from individual Memory history entries. | Retain under `Surface sources and diagnostics`, labelled the historical generated-from counter label and explained. |
| Surface Memory references | Detailed audit/reachability. | Preserve reference IDs, immutable history references and Memory links in the disclosure. |
| Memory claim, kind, lifecycle state, excerpt and body | Normal content decision. Active, resolved and retracted are not interchangeable. | Retain list scanning and body reading. Keep the Markdown renderer and current/historical distinction. |
| Individual Memory revision and history | Detailed content comparison, also useful for identifying a selected version. | Explicit `Memory revision` labels, separate from Subject counters. Preserve immutable history, selected revision links and revision pagination. |
| Candidate provenance, evidence, origin, source refs and derivations | Detailed audit, required to understand why a record exists. | Retain IDs, ranges, summaries, excerpts, origins and derived-revision links. Counts say `shown` except where the API provides exact per-candidate totals. Preserve body and evidence continuation links. |
| Raw field names / duplicated general revision terminology | Unnecessary in the normal hierarchy; multiple unrelated counters are misleading. | Replace bare revision labels and raw surface enums with purpose-specific labels. Do not delete API fields, stored information, diagnostics or their detailed access. |
| Individual Memory history entries | Detailed content comparison, also useful for identifying a selected version. | The historical UI labelled each Memory history entry separately from Subject counters. Preserve immutable history, selected history-entry links and history pagination. |
| Candidate provenance, evidence, origin, source refs and derivations | Detailed audit, required to understand why a record exists. | Retain IDs, ranges, summaries, excerpts, origins and derived-history-entry links. Counts say `shown` except where the API provides exact per-candidate totals. Preserve body and evidence continuation links. |
| Raw field names / duplicated general update-counter terminology | Unnecessary in the normal hierarchy; multiple unrelated counters are misleading. | Replace bare counter labels and raw surface enums with purpose-specific labels. Do not delete API fields, stored information, diagnostics or their detailed access. |
No diagnostic datum was removed from Subject detail, surface detail or Memory
provenance/history. The index no longer shows a store revision, but the exact
provenance/history. The index no longer shows a historical store counter, but the exact
value remains one click away in Subject detail. No invented regeneration,
editing or recovery action was added; `Needs refresh` does not mean an update job
is running. Creation still creates only a Subject, and selection still uses the
@@ -55,7 +55,7 @@ The existing real-store regression
stages a candidate and attempts an invalid apply. It asserts the candidate has no
resolution and the committed Memory list remains empty. The component regression
`candidate-only Subjects show no committed Memories, not an inferred candidate count`
uses that read projection (store revision 0, ungenerated surface, empty successful
uses that read projection (historical store counter 0, ungenerated surface, empty successful
committed list) and checks `None`, `No committed Memories yet.`, `Not connected`
and `Not generated`, with no resident body. Thus a staged candidate does not turn
into a committed-Memory count or a claim that context has been generated.
@@ -99,7 +99,7 @@ supplement does not change rendered markup, styling, copy or data contracts.
- The focused production-shell browser test proves main-owned vertical scrolling
with wheel/keyboard/touch, 320px/mobile shell behavior, no page-wide overflow,
local table/code scroll, Markdown safety, creation/launch separation, bounded
pagination and Memory/source/revision reachability.
pagination and Memory/source/history-entry reachability.
Independent review at source `4cf6c130` ran root `cargo check`,
`cargo fmt --all -- --check`, diff hygiene, Web check (0 errors/warnings), 12
@@ -0,0 +1,20 @@
# Web state checks and retained identities
The Web client uses purpose-specific content and operation identities, not an independent update counter. The checks below describe current boundaries, not a claim that the full cleanup or final generated-contract validation is complete.
| Boundary | What is compared, how, and when | Why order or time alone is insufficient |
| ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Config source commit | The editor sends its saved `base_digest` and staged changes; update/rename/delete carry each observed entry's `expected_digest`. Local WASM checks entry conditions before replacing local content. The Server owns persistence admission. Success installs the returned snapshot as the new base; rejection retains the draft until explicit reload/reapply. | A later request can be authored from old content. Digests identify the actual source tree/entry, including a return to identical content; wall-clock age is not the condition. |
| Config diagnostics | Before publication the editor requires a non-aborted request and filters diagnostics by the analyzed tree digest. | A late result is not evidence about currently displayed source. Content identity and asynchronous invalidation are different checks. |
| Drive update/relocate/delete | The controller pins `expectedMutationId` from the user's observed node. The adapter sends it unchanged. `workspace-drive::mutate` checks current `last_mutation_id` via `changeable` in the same SQLite transaction before mutation. Publication stores the actual successful request ID and a durable receipt. | This identifies a real mutation, not a separately allocated state number. Content-only equality misses move/rename or ABA return to identical bytes. Last-arriving writes can overwrite another operation's result; timestamps do not prove operation identity. |
| Drive read/preview | Each chunk checks the current node's `last_mutation_id` against the observed operation inside the store transaction. Conflict does not silently switch to latest bytes. | Chunks from different writes must not be combined, regardless of arrival order. |
| Drive response-loss recovery | Before sending, the controller records one pending request ID. Pending/unknown receipts block another write for that selection. Recovery queries the same request. The store checks actor and typed payload against the existing receipt fingerprint before returning its result. | Unknown outcome does not mean the write failed. A fresh ID can duplicate a committed side effect; ordering cannot identify which payload committed. |
| UI asynchronous publication | Selection and write/page invalidation fences are checked before publishing returned data. These are local request-lifetime fences, not persisted resource-state conditions. | The projection follows the latest selected request, not the last response to arrive. No server-visible opaque state number is needed here. |
| Subject behavior save | The client sends exact `expected_behavior_md`, including empty text, with the replacement. A successful response must describe the requested text; mismatch is an unknown outcome and is not automatically retried. | Observed text already supplies the content condition; no extra counter is needed. Application to Worker history is a separate operation. |
| Memory history display | `change_id` and `current_change_id` identify immutable entries. The parser preserves Server order; current/historical is exact identity equality. | Stable links need immutable identities, but IDs do not establish chronology and must not be parsed or sorted as numbers. |
| Runtime trust display | The parser requires verification `binding_id` to equal the displayed binding. Mutations use the observed enrollment identity; revoke confirmation also compares the exact displayed key fingerprint before sending. | Re-enrolling the same public key creates a different binding lifetime. Matching key bytes or a recent response cannot make old verification current. |
| Merge Request review freshness | Review/request `subject_ref` must match current Git source ref. Revoked verdicts are excluded; effective events follow supplied thread order. Merged results use validated immutable integration evidence, not a new live observation. | Source equality identifies what was reviewed. Arrival order or target-only movement cannot confer approval on another source commit. |
`ConfigProjectionValidator` is a typed static-template-catalog validation policy (`kind`, namespace, key aliases), not an opaque freshness token. Source tree, projection and toolchain digests describe different inputs/results. Key fingerprints identify key bytes; binding IDs identify enrollment lifetimes; mutation IDs identify actual committed requests. These are not interchangeable and are not increasing numbers.
WIP remains `0.2.0`; naming cleanup alone does not justify a new package/protocol version or generic validator number. Final API/OpenAPI/protocol freshness must be checked after authoritative Rust changes are integrated. Earlier Web build or test success does not prove that final generation check.
@@ -3,7 +3,7 @@
This change improves the internal `api_error` record for
`POST /api/w/{workspace_id}/workers/self/workdir-session/operations`.
It does **not** identify or resolve the reported 2026-10-09 403: the running
binary revision and actual request are still unverified. Fixture success is
binary source commit and actual request are still unverified. Fixture success is
not evidence about that incident. No deployment, live asset cleanup, privilege
change, retry policy, or WIP dependency/version change is included (WIP remains
0.2.0).
@@ -7,7 +7,7 @@ Workspace HTTP dispatch uses `WorkspaceAdmission` for **lifecycle admission**, n
1. Acquire an immediate lifecycle lease. While deletion drains, new ordinary requests fail closed instead of queueing behind the deletion operation.
2. Validate the bounded request body, Runtime signature, audience/Workspace/permission/method/target/body binding, expiry, transactional JTI consumption, current Runtime trust, Worker membership/reservation and singleton ownership. Browser actor/origin checks are unchanged. No TTL extension or 401 retry policy is introduced.
3. Check the persisted Workspace state. Scoped requests, including GETs, reject a deleting Workspace, including after Server reconstruction. An absent Workspace remains 404. Server-owned deletion status/polling is outside scoped dispatch.
4. Execute the inner router. Resource owners retain their existing exclusion: per-Worker session/control/removal locks; transactional Workdir attachment exclusivity and removal fences; SQLite domain transactions and revision/CAS checks; the resource broker's one-shot handle consumption. Legacy memory document read/replace/write uses a short, resource-specific mutex because it spans multiple store calls.
4. Execute the inner router. Resource owners retain their existing exclusion: per-Worker session/control/removal locks; transactional Workdir attachment exclusivity and removal fences; SQLite domain transactions and content/identity precondition checks; the resource broker's one-shot handle consumption. Legacy memory document read/replace/write uses a short, resource-specific mutex because it spans multiple store calls.
5. Drop the lifecycle lease on response, error, or cancellation. An admitted long operation is not rechecked for proof expiry merely because its business execution lasts past the proof TTL. This does not grant subsequent requests or disable provider/attachment authority checks.
The proof verifier's clock is injected into the Server instance for deterministic route tests, not obtained from test environment variables. Production uses the same wall-clock seconds and existing proof rules. Clock-skew policy remains a separate scope (T-728).
@@ -31,4 +31,4 @@ They do not bypass authentication or authorization. Callback admission closes at
Timeout uses Tokio's test clock; proof tests advance an independent injected clock by 120 seconds. Bounds detect hangs, not guessed sleeps. Dispatch task cancellation represents cancellation of the HTTP handler future; provider disconnect is a dropped transport response. These are in-process boundary tests, not a real-socket disconnect or live browser/Runtime E2E test. Background command and websocket lifetimes retain their existing domain-specific cleanup; request admission covers the handler until its response, not the entire upgraded connection.
Past production `401 expired` incidents have **not** been correlated with live revision/request traces. The structural failure path and deterministic regression evidence do not establish that all prior 401s had this cause. T-731 and its retained session/MR/source/Workdir are unrelated and untouched. WIP remains 0.2.0; no generated DTO/schema/route artifacts change.
Past production `401 expired` incidents have **not** been correlated with live binary-source/request traces. The structural failure path and deterministic regression evidence do not establish that all prior 401s had this cause. T-731 and its retained session/MR/source/Workdir are unrelated and untouched. WIP remains 0.2.0; no generated DTO/schema/route artifacts change.
@@ -15,7 +15,7 @@ The current retained Workspace chain is:
1. schema 50: `workspace schema baseline`
2. schema 51: `workspace runtime bindings`
3. schema 52: `workspace Runtime binding revision and audit`
3. schema 52: Runtime-binding counter and audit (historical migration purpose; the persisted migration name remains frozen).
4. schema 53: `durable Workspace deletion operations`
A database may begin at any retained baseline. Its following history rows must be the exact prefix
@@ -2,6 +2,8 @@
Tracking Ticket: `00001KZRNHB35`
2026-10-10 編集注: 以下は当時の調査と提案の要旨であり、現行 API の仕様ではない。旧状態番号を含む仮の型名や逐語 schema は、対象 snapshot / Git commit / 履歴参照という目的別の説明へ整理した。新しい API 名が当時存在したという記録にはしない。
## 発生日
2026-08-06
@@ -64,7 +66,7 @@ commit `e6a2da54`
- test evidence
- dirty state
- assignment id
- report対象revision
- report対象の item snapshot
Markdown headingや文言に依存した監査になる。
@@ -76,7 +78,7 @@ threadの`item_edit` eventは、変更fieldとreplacement countだけを返す
Ticket item updated: title, body. Body replacement applied to 10 occurrence(s).
```
編集前title/body、編集後snapshot、item revision idを取得できない。Implementation reportがどのitem revisionを対象にしたかも記録されない。
編集前title/body、編集後snapshot、item 履歴参照を取得できない。Implementation reportがどのitem snapshotを対象にしたかも記録されない。
このため、「実装完了後にTicketが別scopeへrescopeされた」のか、「Implementation reportの読み違い」なのかをthreadだけから確実に判定できない。
@@ -89,7 +91,7 @@ Ticket item updated: title, body. Body replacement applied to 10 occurrence(s).
- close後にunresolved request_changesが追加された
- Implementation report後のmaterial rescope
- report commitがcurrent repositoryで解決不能
- MR current revisionとreview対象revisionが不一致
- MR current commit と review 対象 commitが不一致
### relation削除operationがない
@@ -167,29 +169,20 @@ Markdown bodyに加えて、最低限次をtyped attributesとして保存する
- `repository_id`
- `base_commit`
- `head_commit`
- `merge_request_id` / `revision_id`
- MR ID / 対象 Git commit の参照
- validation evidence refs
- dirty/untracked state
- source Runtime/Worker identity
reportは作成時のTicket item revisionを参照する。`QueryTicket.evidence`と`attention`はMarkdown headingをparseせず、このtyped authorityをqueryする。
reportは作成時のTicket item snapshotを参照する。`QueryTicket.evidence`と`attention`はMarkdown headingをparseせず、このtyped authorityをqueryする。
### retrievable item revisions
### retrievable item snapshots
`ShowTicket`のoptional revision selectorまたはbounded version projectionでitem historyを取得できるようにする。専用tool追加は、同じprojectionではsize/authorityを分離できない場合だけ検討する。
`ShowTicket`のoptional history selectorまたはbounded version projectionでitem historyを取得できるようにする。専用tool追加は、同じprojectionではsize/authorityを分離できない場合だけ検討する。
```text
TicketItemRevision {
revision_id,
title,
body_digest,
body or bounded diff,
edited_at,
source,
}
```
当時の提案は、編集前後の title、body digest、本文または bounded diff、編集時刻、source と、その内容を再取得できる履歴参照を一組として保存するものだった。ここでは当時の仮の型/schema 名は再掲せず、現行型名としても提示しない。
Implementation report/review/close resolutionから対象item revisionを参照する。
Implementation report/review/close resolutionから対象item snapshotを参照する。
### stable tool projection
@@ -199,7 +192,7 @@ Implementation report/review/close resolutionから対象item revisionを参照
relationのadd/remove、Queue、review、Close、item editなどはauthorization、precondition、idempotency、notification、compensation、audit/result型が異なるため、generic `MutateTicket`や`MutateObjective`へ統合しない。明示commandを維持し、model-facing tool数はprofile・role・Flow別catalog projectionで抑える。
Implementation reportはtyped Ticket thread eventとして扱い、item revision参照とbounded query結果から監査可能にする。
Implementation reportはtyped Ticket thread eventとして扱い、item snapshot参照とbounded query結果から監査可能にする。
## 推奨順序
@@ -213,7 +206,7 @@ Implementation reportはtyped Ticket thread eventとして扱い、item revision
## 期待する監査手順
1. `QueryTicket(states=active, attention=implementation_report_not_closed)`で候補抽出。
2. candidateごとに`ShowTicket`でcurrent item revision、report対象revision、latest reviewを取得。
2. candidateごとに`ShowTicket`でcurrent item snapshot、report対象の item snapshot、latest reviewを取得。
3. typed commit/MR evidenceをrepository authorityで検証。
4. unresolved `request_changes`とdependencyを確認。
5. User/Orchestratorが明示的にcloseする。
@@ -1,5 +1,7 @@
# Flow verifier uses a stale committed-session capture
Editorial note (2026-10-10): obsolete state terminology is summarized by purpose below; cited IDs, commits and validation results still describe the original investigation, not new executions.
Date: 2026-08-11
Ticket: `00001KZPQW4GJ`
Flow instance: `019ff243-d4df-71f2-beb3-cbc360f58c34`
@@ -33,7 +35,7 @@ Independent read-only Reviewer verdict:
> APPROVE — I found no blocker/high security or correctness issue in commits `8ae930c5` + `f60c2d58`.
The Reviewer explicitly confirmed the constrained four-field tool input, proof-only destructive boundary, exact Runtime-result Worker revision binding, successful recovery after registry purge, and failed-operation re-entry through the authoritative prepare/executing fence.
The Reviewer explicitly confirmed the constrained four-field tool input, proof-only destructive boundary, exact Runtime-result Worker-state binding used at the time, successful recovery after registry purge, and failed-operation re-entry through the authoritative prepare/executing fence.
## Impact
@@ -41,7 +43,7 @@ A correct, tested, independently approved implementation cannot advance from `im
## Suggested fix
Before evaluating a transition, refresh the verifier's session capture from the latest committed Worker history revision and include stable references to:
Before evaluating a transition, refresh the verifier's session capture from the latest committed Worker history snapshot and include stable references to:
- the current branch and commit,
- bounded validation command results,
@@ -1,5 +1,7 @@
# Repository secret delivery expiry deletes a live Workdir helper and blocks restore
Editorial note (2026-10-10): obsolete state terminology is summarized by purpose below; cited IDs, commits and validation results still describe the original investigation, not new executions.
## Symptom
The `workspace:wip` T-3 Coder completed a clean local commit but could not publish it:
@@ -112,7 +114,7 @@ Server-issued operation: runtime:read
Runtime expectation: workers:input
```
The exact 401 text is `WorkspaceCapabilityVerificationError::WrongOperation`. Verification had already accepted token shape, Workspace trust/signature, Workspace, Runtime, binding revision, and Worker identity. Method, path, body-digest, and expiry checks occur later and were not reached.
The exact 401 text is `WorkspaceCapabilityVerificationError::WrongOperation`. Verification had already accepted token shape, Workspace trust/signature, Workspace, Runtime, the historical binding counter, and Worker identity. Method, path, body-digest, and expiry checks occur later and were not reached.
Retrying the same restore without correcting the Server classifier deterministically returns the same 401.
@@ -1,5 +1,7 @@
# Worker lifecycle, restore atomicity, and observation freshness
Editorial note (2026-10-10): obsolete state terminology is summarized by purpose below; cited IDs, commits and validation results still describe the original investigation, not new executions.
## Scope
This report records the state-model and crash-boundary problems found while investigating why one restored Worker appeared as `idle` in the Web client and `unknown` in the Runtime CLI. The display mismatch exposed a broader problem: lifecycle, controller state, observation freshness, restart intent, and recovery evidence are represented by overlapping fields without one enforced authority.
@@ -57,7 +59,7 @@ A concrete counterexample is:
The persisted aggregate was not rolled back: its identity file has already changed while its execution file is old. This outcome must not be classified as a proven rollback. Identity, restore specification, execution binding, and recovery evidence need one atomic aggregate commit.
A revision manifest is not preferable here. The store is single-writer, Worker records are bounded, and the existing same-directory temporary-file replacement already supports one aggregate `worker.json`. A manifest would add a second commit pointer and orphan cleanup without resolving Backend reconciliation.
A manifest pointing to separate immutable aggregate snapshots is not preferable here. The store is single-writer, Worker records are bounded, and the existing same-directory temporary-file replacement already supports one aggregate `worker.json`. A manifest would add a second commit pointer and orphan cleanup without resolving Backend reconciliation.
Even a single-file replacement has a commit-ambiguity boundary. `atomic_write_json` renames the temporary file and then syncs the parent directory. If rename succeeds but directory sync fails, the new record is already visible while crash durability is unknown. Persistence errors therefore need a phase-aware classification: failure before rename is definitely not committed; failure after rename is `CommitOutcomeUnknown` and must never be reported as a proven rollback.
@@ -187,7 +189,7 @@ Fault injection must target each persistence boundary. Same-process assertions a
## Implementation order
1. Stop publishing stale unavailable snapshots as current REST `worker_state` and add projection regression coverage.
2. Replace split identity/execution authority with an atomic aggregate record or revisioned aggregate commit while preserving validated migration input.
2. Replace split identity/execution authority with an atomic aggregate record or immutable aggregate snapshot commit while preserving validated migration input.
3. Introduce explicit durable reconciliation evidence and stable operation identity for Restore and Stop.
4. Quarantine candidate execution observation until aggregate commit and make retry/restart converge on the recorded operation.
5. Remove catalog foreground-state duplication and update clients to use current observed snapshots.
@@ -1,6 +1,8 @@
# Review verdict validation and single-call policy
During T-696's independent review, `ReviewRequested` captured MR `01a10bc8-59f4-7630-8166-d8cd71943e15`, source `9812e68b4d4021ea778a40d9ead4fe545d09e8bf`, and Ticket revision `00001M4517HE0:5`. The Reviewer reproduced a pending-upload deletion defect and attempted a structured `request_changes` verdict. The request used finding severity `medium`; the Backend returned HTTP 422, listing the accepted values as `blocker`, `major`, `minor`, and `note`. No review ID or verdict event was returned.
Editorial note (2026-10-10): obsolete state terminology is summarized by purpose below; cited IDs, commits and validation results still describe the original investigation, not new executions.
During T-696's independent review, `ReviewRequested` captured MR `01a10bc8-59f4-7630-8166-d8cd71943e15`, source `9812e68b4d4021ea778a40d9ead4fe545d09e8bf`, and the then-current Ticket item reference `00001M4517HE0:5`. The Reviewer reproduced a pending-upload deletion defect and attempted a structured `request_changes` verdict. The request used finding severity `medium`; the Backend returned HTTP 422, listing the accepted values as `blocker`, `major`, `minor`, and `note`. No review ID or verdict event was returned.
The Reviewer obeyed the role prompt's instruction to call `ReviewMergeRequest` exactly once and did not retry. Its prose recommendation and artifacts are evidence of investigation, not recorded review authority. The parent preserves the finding, fixes the defect, and requests a new independent review bound to the updated source; it does not invent a verdict or bypass the rejected operation.
@@ -1,10 +1,12 @@
# Reviewer SubWorkerがreview開始前に停止する
Editorial note (2026-10-10): obsolete state terminology is summarized by purpose below; cited IDs, commits and validation results still describe the original investigation, not new executions.
## 状況
T-698の公開済みMerge Requestに対して、`builtin:reviewer`を指定し、対象Ticket/Merge Request、writable delegated scope、command grantを明示してReviewer SubWorkerを3回起動した。
いずれも`review_requested` eventは正しいsource refとTicket revisionを記録したが、SubWorkerは直後に`Stopped`となった。session observationには最初のuser taskだけが存在し、assistant output、tool call、typed review verdictは記録されなかった。Merge Request threadにもapprove/request_changesは追加されなかった。
いずれも`review_requested` eventは正しいsource refと当時の Ticket item referenceを記録したが、SubWorkerは直後に`Stopped`となった。session observationには最初のuser taskだけが存在し、assistant output、tool call、typed review verdictは記録されなかった。Merge Request threadにもapprove/request_changesは追加されなかった。
## 障壁
@@ -1,8 +1,10 @@
# Delegated coding context and artifact access
During T-696 recovery, the parent Coder could read the authoritative Ticket through `ShowTicket`, but default coding SubWorkers did not receive that tool. A delegated task that simply asks a child to reread the Ticket therefore cannot be fulfilled. One child attempted a CLI fallback; this is not an authorized substitute for typed Backend tools. The parent supplied the authoritative revision and relevant requirements instead and retained lifecycle/publish authority.
Editorial note (2026-10-10): obsolete state terminology is summarized by purpose below; cited IDs, commits and validation results still describe the original investigation, not new executions.
Improvement: make the child tool/capability projection explicit in the spawn result, and support passing a bounded authoritative Ticket snapshot with revision into a child without implying mutation authority. Delegation prompts should include the necessary contract rather than assuming the child has the parent's Backend tools.
During T-696 recovery, the parent Coder could read the authoritative Ticket through `ShowTicket`, but default coding SubWorkers did not receive that tool. A delegated task that simply asks a child to reread the Ticket therefore cannot be fulfilled. One child attempted a CLI fallback; this is not an authorized substitute for typed Backend tools. The parent supplied the authoritative Ticket snapshot and relevant requirements instead and retained lifecycle/publish authority.
Improvement: make the child tool/capability projection explicit in the spawn result, and support passing a bounded authoritative Ticket snapshot with its immutable reference into a child without implying mutation authority. Delegation prompts should include the necessary contract rather than assuming the child has the parent's Backend tools.
The Bash provider also advertised a full-output spill under the parent's declared readable `bash-output` directory, but `Read` rejected the returned absolute path as outside the attachment scope. The parent did not bypass that rejection; it used smaller bounded queries. Improvement: return a typed read capability/artifact reference for spill output, or ensure the advertised spill path is actually readable through the selected attachment.
+4 -2
View File
@@ -1,5 +1,7 @@
# T-704 implementation and validation ledger
Editorial note (2026-10-10): obsolete state terminology is summarized by purpose below; cited IDs, commits and validation results still describe the original investigation, not new executions.
Base: `6f9621ca` (clean detached assigned checkout). Parent created
`work/t-704-backend-subjektiv-jobs` without replacing a branch or discarding changes.
Parent owns Backend/Profile/Subject authorization, cutover, documentation and
@@ -24,7 +26,7 @@ Independent MR review is separate from implementation delegation.
- Synchronous bounded clean-context surface generation before result submission.
Preparation reports an already-current snapshot to avoid needless conflicting
regeneration. Job failure acknowledgements are `failed_confirmed` only after
checking the actual failed marker/reason/revision and generation provenance.
checking the actual failed marker/reason/then-current store condition and generation provenance.
Subject feature migration 6 adds immutable generation Job/attempt provenance,
without changing Subject/Memory/candidate content.
- Durable results contain actual candidate dispositions and a separate verified
@@ -99,7 +101,7 @@ resolution records for a 100-ID batch could exceed the common 16 KiB result boun
even when candidate decisions and surface generation had actually completed.
That pending old-source review was stopped rather than treated as approval.
The follow-up keeps full immutable receipts/reasons/revision/source edges in their
The follow-up keeps full immutable receipts/reasons/immutable history references/source edges in their
existing store and persists only verified `{candidate_id, action}` summaries in
the Job result. Batch selection conservatively budgets the complete result,
including JSON-escaped IDs in both claim and outcome, plus a reserved bounded
+2 -2
View File
@@ -5,7 +5,7 @@ Status: fixed at the snapshot import-loader boundary; regression coverage retain
## Dogfooding problem
During T-711 builtin-import validation, a nested import cycle in a lazy object caused a stack overflow instead of a recoverable configuration diagnostic. A configuration author could therefore terminate evaluation while writing an invalid recipe, rather than receive an error and keep the active revision. This report records the implementation-session failure and the guard added by the parent config-source implementation; it is not a recommendation to rerun the crashing version inside a live Yoi Worker.
During T-711 builtin-import validation, a nested import cycle in a lazy object caused a stack overflow instead of a recoverable configuration diagnostic. A configuration author could therefore terminate evaluation while writing an invalid recipe, rather than receive an error and keep the active configuration snapshot. This report records the implementation-session failure and the guard added by the parent config-source implementation; it is not a recommendation to rerun the crashing version inside a live Yoi Worker.
## Minimal reproducer
@@ -59,7 +59,7 @@ cargo test -p config-source --lib builtin_values_do_not_bypass_cycle_or_path_lim
cargo test -p config-source --lib snapshot_import_depth_is_checked_before_evaluation_recurses
```
The server follow-up test `value_profiles_builtin_http_config_tree_is_read_only_and_never_falls_back` separately exercises authenticated HTTP commits: reserved builtin mutations and unknown imports are rejected without changing the active revision, even when a Workspace file exists at the unknown builtin's suffix. Editor import completions remain covered by the WASM editor tests; no server completion endpoint was introduced.
The server follow-up test `value_profiles_builtin_http_config_tree_is_read_only_and_never_falls_back` separately exercises authenticated HTTP commits: reserved builtin mutations and unknown imports are rejected without changing the active configuration snapshot, even when a Workspace file exists at the unknown builtin's suffix. Editor import completions remain covered by the WASM editor tests; no server completion endpoint was introduced.
## Operational lesson
@@ -1,109 +1,67 @@
# State-contract cleanup completion and recovery record
# State-contract cleanup historical checkpoint and follow-up
The interrupted cleanup has been restored, integrated with `develop` at
`72ce817de4592e8c9703e0725dd705f46cba63aa`, completed, and validated. This record
supersedes the earlier incomplete restart checkpoint. No live Server, Runtime,
or user database was stopped or migrated as part of this work.
This record describes the earlier recovery and validation after integration with `develop` at `72ce817de4592e8c9703e0725dd705f46cba63aa`. Its former assertion of complete cleanup was incorrect: the subsequent case-insensitive audit at `c78f7f7d` found 875 matching lines in 96 files, including current names, comments, documents and fixtures. The earlier results below remain historical evidence, not proof that the original full-codebase removal request was satisfied. No live Server, Runtime, or user database was stopped or migrated during that earlier work.
## Recovery and integration
- Workdir attachment: `yoi-origin`; branch: `hare/develop`.
- Preserved the Workdir delegation fix (`7321d6c7`), asynchronous cleanup fix
(`dc8e586a`), and the T-731/T-732 changes integrated by `72ce817d`.
- Applied stash `dd31c0ecd41d2aca1ef29889a5194fb4499cb149` once after merging
develop. It remains as recovery evidence; do not apply it again over this work.
- Preserved the Workdir delegation fix (`7321d6c7`), asynchronous cleanup fix (`dc8e586a`), and the T-731/T-732 changes integrated by `72ce817d`.
- Applied stash `dd31c0ecd41d2aca1ef29889a5194fb4499cb149` once after merging develop. It remains as recovery evidence; do not apply it again over this work.
- Older stash `a386a9e2943d53f331a555a8c255741bc004c419` also remains untouched.
- Direct implementation/review SubWorkers were collected after their committed
sessions and concrete changes/test evidence were inspected.
- Direct implementation/review SubWorkers were collected after their committed sessions and concrete changes/test evidence were inspected.
## Completed contracts
## Contracts recorded by the earlier implementation
State/update ordinals have been replaced by content digests, key fingerprints,
binding/trust/connection IDs, immutable mutation IDs, and observation tokens.
Schema/protocol versions, ordered events, command IDs, and local asynchronous
invalidation fences remain distinct contracts and were not indiscriminately removed.
State/update ordinals have been replaced by content digests, key fingerprints, binding/trust/connection IDs, immutable mutation IDs, and observation tokens. Schema/protocol versions, ordered events, command IDs, and local asynchronous invalidation fences remain distinct contracts and were not indiscriminately removed.
- Config source/history preserves separate source/toolchain/projection evaluations.
Cache identities occupy a namespace forbidden to real virtual paths, including
JSON-deserialized paths. Native and generated WASM regressions cover the boundary.
- Job/content migration retains completed results and cleanup/replay evidence.
Proven reserved-only work can fail definitively; uncertain execution is not
silently admitted again. Historical Flow definitions are validated, not recompiled.
- Drive preserves old receipt fingerprints and validates old Update/Relocate/Delete
replays against frozen evidence rather than granting authority from old ordinals.
- SSH migration reseals envelopes under actual mutation receipts. Terminal Workdir
history whose credentials were deleted remains non-authorizing archival evidence.
INSERT/UPDATE guards prevent that archive from acquiring candidate/lease authority.
- Runtime binding verification ties together binding/trust identities and actual
public-key fingerprints. Worker command-ID replay is separate from admission-time
state. Ticket checker intent IDs include the complete immutable request.
- Config source/history preserves separate source/toolchain/projection evaluations. Cache identities occupy a namespace forbidden to real virtual paths, including JSON-deserialized paths. Native and generated WASM regressions cover the boundary.
- Job/content migration retains completed results and cleanup/replay evidence. Proven reserved-only work can fail definitively; uncertain execution is not silently admitted again. Historical Flow definitions are validated, not recompiled.
- Drive preserves old receipt fingerprints and validates old Update/Relocate/Delete replays against frozen evidence rather than granting authority from old ordinals.
- SSH migration reseals envelopes under actual mutation receipts. Terminal Workdir history whose credentials were deleted remains non-authorizing archival evidence. INSERT/UPDATE guards prevent that archive from acquiring candidate/lease authority.
- Runtime binding verification ties together binding/trust identities and actual public-key fingerprints. Worker command-ID replay is separate from admission-time state. Ticket checker intent IDs include the complete immutable request.
## Migration safety and review corrections
- Uncertain Worker removals (`executing`/`failed`) block **before any retained
migration runs**, including ordinary Server open and migration CLI entrypoints.
Tests start at schema84, verify unchanged schema/receipt after rejection, then
reconcile with the old contract and upgrade successfully.
- SSH dry-run uses the original database's secret context while validating an
in-memory backup. Actual encrypted-envelope tests prove dry-run non-mutation,
formal migration, new-AAD decryption, and unchanged master-key material.
- Interrupted signing identity provisioning resumes the same reserved key/material.
Frozen old receipt validation precedes operation-key/fingerprint conversion;
activation and migration failures roll back. Completed and create receipts are
also covered.
- The old reservation CHECK constraint is removed through a transactional rebuild
retaining other columns, constraints, indexes, triggers, and incoming/outgoing
foreign keys. Unknown dependencies are not broadly rewritten.
- Fresh/upgraded metadata comparison covers 36 state-contract tables: columns,
defaults, PK/FK structure, index columns, and triggers. It caught and repaired a
missing history DEFAULT and retained-chain reservation lookup index. This is not
a general SQL-equivalence proof of all CHECK expressions or index predicates;
separate behavior/rollback tests cover the relevant authority constraints.
- Independent data and Server reviews found the above defects; follow-up reviews
confirmed their resolution without additional blockers in the reviewed scope.
- Uncertain Worker removals (`executing`/`failed`) block **before any retained migration runs**, including ordinary Server open and migration CLI entrypoints. Tests start at schema84, verify unchanged schema/receipt after rejection, then reconcile with the old contract and upgrade successfully.
- SSH dry-run uses the original database's secret context while validating an in-memory backup. Actual encrypted-envelope tests prove dry-run non-mutation, formal migration, new-AAD decryption, and unchanged master-key material.
- Interrupted signing identity provisioning resumes the same reserved key/material. Frozen old receipt validation precedes operation-key/fingerprint conversion; activation and migration failures roll back. Completed and create receipts are also covered.
- The old reservation CHECK constraint is removed through a transactional rebuild retaining other columns, constraints, indexes, triggers, and incoming/outgoing foreign keys. Unknown dependencies are not broadly rewritten.
- Fresh/upgraded metadata comparison covers 36 state-contract tables: columns, defaults, PK/FK structure, index columns, and triggers. It caught and repaired a missing history DEFAULT and retained-chain reservation lookup index. This is not a general SQL-equivalence proof of all CHECK expressions or index predicates; separate behavior/rollback tests cover the relevant authority constraints.
- Independent data and Server reviews found the above defects; follow-up reviews confirmed their resolution without additional blockers in the reviewed scope.
## Final validation
## Earlier validation (not rerun evidence)
- Root `cargo check`: PASS.
- All 21 changed Rust crates tested together: **3,795 passed**, **3 ignored**,
zero failures, including doctests. The Server contributes 809 library tests and
11 CLI tests (one library test ignored).
- All 21 changed Rust crates tested together: **3,795 passed**, **3 ignored**, zero failures, including doctests. The Server contributes 809 library tests and 11 CLI tests (one library test ignored).
- `server-api --features typescript`: **96 passed**.
- Additional T-731/T-732 integration coverage: yoi, workdir, tools, fs-operation
tests passed separately.
- Additional T-731/T-732 integration coverage: yoi, workdir, tools, fs-operation tests passed separately.
- Web standard suite: **589 passed**; component suite: **315 passed / 33 files**.
- Web type check: zero errors/warnings; production build passed (chunk-size warning).
- Config WASM parity includes 18 passing cases, including literal-cache-key collision
and JSON URI-path refusal. Generated JS/types/WASM were compared with the pinned
wasm-bindgen 0.2.117 toolchain. OpenAPI/API/protocol generated checks passed.
- `cargo fmt --all -- --check` and `git diff --check HEAD`: PASS. Included frozen
Rust migration/test files were formatted explicitly as well.
- Config WASM parity includes 18 passing cases, including literal-cache-key collision and JSON URI-path refusal. Generated JS/types/WASM were compared with the pinned wasm-bindgen 0.2.117 toolchain. OpenAPI/API/protocol generated checks passed.
- `cargo fmt --all -- --check` and `git diff --check HEAD`: PASS. Included frozen Rust migration/test files were formatted explicitly as well.
Local evidence logs (ignored build artifacts, not part of stash/history):
`target/revision-final-combined-rust-tests.log`,
`target/revision-final-typescript-tests.log`,
`target/revision-final-root-check.log`,
`target/revision-final-web-{tests,component,check,build}.log`,
`target/revision-virtual-path-web-{config-tests,final-check}.log`,
`target/revision-web-final-{artifacts,normalized-artifacts}.log`, and
`target/revision-schema-equivalence.log`.
The earlier session recorded local ignored evidence logs for the combined Rust crate tests, TypeScript-feature tests, root check, Web standard/component/check/build, virtual-path WASM parity, generated-artifact comparison and schema equivalence. The old basename list is summarized here rather than renamed to invented paths; these local artifacts were not part of stash/history.
## Deployment and separate findings
Deployment/restart remains user-operated. Take the normal database/key-material
backup before migration. Resolve uncertain old Worker removals with the old Server
before attempting cutover. Existing migrated Runtime bindings become configured;
re-enrollment/verification requires the actual Runtime-issued trust ID from
`trust-workspace add/show`, not an invented value or public-key fingerprint.
Deployment/restart remains user-operated. Take the normal database/key-material backup before migration. Resolve uncertain old Worker removals with the old Server before attempting cutover. Existing migrated Runtime bindings become configured; re-enrollment/verification requires the actual Runtime-issued trust ID from `trust-workspace add/show`, not an invented value or public-key fingerprint.
Previously identified Server-only capability revocation and missing enrollment
identity in Runtime-to-Server source proofs predate this cleanup. They are not
claimed fixed here. The separately recorded Runtime HTTP-only feature dependency
issue also remains outside this cleanup.
Previously identified Server-only capability revocation and missing enrollment identity in Runtime-to-Server source proofs predate this cleanup. They are not claimed fixed here. The separately recorded Runtime HTTP-only feature dependency issue also remains outside this cleanup.
Workdir operations intermittently returned HTTP 401 during the work. Failed calls
were not treated as successful edits/tests; subsequent reads and final combined
validation established the recorded result. No authorization or live-process
workaround was used. The default wasm-bindgen CLI had a schema-version mismatch;
a matching CLI was installed only under ignored `target/revision-wasm-tools`, with
no global CLI change or additional change to the restored Cargo.lock.
Workdir operations intermittently returned HTTP 401 during the work. Failed calls were not treated as successful edits/tests; subsequent reads and final combined validation established the recorded result. No authorization or live-process workaround was used. The default wasm-bindgen CLI had a schema-version mismatch; a matching CLI was installed only under an ignored target-local tools directory, with no global CLI change or additional change to the restored Cargo.lock.
## Original-request follow-up (2026-10-10; implementation and audit recorded)
The complete follow-up result is in `docs/report/state-contract-name-audit.md`. The original request was applied to current identifiers, API/type/field names, comments, documentation, fixtures and generated contracts, not merely counters. The follow-up was implemented and validated on `hare/develop` with base `3263d501`, which added the separate original-directive evidence document after `c78f7f7d`.
- The case-insensitive audit started at 96 tracked text files / 875 matching lines. Final source inventory has 32 matching files / 546 lines: 522 saved-format and migration-fixture lines, 14 removal/rejection checks, and 10 naming-policy or original-directive evidence lines. Current identifiers, API/generated contracts, current schema names and file names have no remaining prohibited name.
- This is **not** a claim of zero literal occurrences. Exact per-file, per-line exceptions and data-loss consequences are recorded in `tools/state-contract-name-exceptions.json`; `--report` emits every literal. The guard passes with zero unapproved occurrences and zero stale exceptions.
- The final audit rejected 14 unnecessary diagnostic/local-variable/fixture lines; those were fixed rather than added to the exceptions. Existing migration labels remain because `verify_schema_history` compares their saved names exactly.
- Server schema89 and Drive schema3 remove the old names from current evidence columns while retaining original values, receipts and fingerprints. Migration tests cover populated old inputs, replay, rollback and fresh/upgraded schema.
- The detailed report records what is compared, to which authority, and when, and where FIFO/content/timestamps replace generic update counters.
- Regular generators and freshness checks synchronized OpenAPI, API and protocol. The combined 14-crate run passed 3,209 tests with 3 ignored. After the last 14-line cleanup, the changed three crates passed again. Additional protocol TypeScript, api-macros OpenAPI and E2E compile checks passed; no product E2E was run.
- Generated Web validation passed: standard 591, component 315, check with zero errors/warnings, production build with the existing chunk-size warning. An extra nonstandard test has one pre-existing Ticket route source assertion failure, explicitly retained in the detailed report; this is not an all-tests-green claim.
- Root compile, format/diff checks and the naming guard's five tests passed. Independent read-only review found no blocking defect in the migration/guard scope; exception review separately refused unnecessary uses before final cleanup.
- Ignored own Web outputs and local documentation were separately audited after rebuild: 2,135 regular files, zero matches. Old build binaries/logs, dependencies, Git history/stashes and external symlink targets are not claimed erased.
No live environment/database was changed, no push was performed, and the two recovery stashes remain preserved. Deployment/restart remains user-operated.
+126
View File
@@ -0,0 +1,126 @@
# 状態契約の命名整理と全件監査
## 判定
現行の識別子・API・生成契約・最新DDLから禁止名称を除去し、具体的な状態契約と再導入検査を実装・検証した。ただし、コードベースの文字列を完全ゼロにはしていない。大小文字無視の最終結果は **32ファイル・546該当行**。内訳は旧保存形式とそのfixture **522行**、移行後の消失などを確かめる検査 **14行**、命名禁止規則とユーザー原文証拠 **10行**。必要性と削除時の影響を以下と限定例外一覧に記録した。
guardは未承認0・stale例外0で成功。これは旧語の完全ゼロを意味せず、残す全行が分類・固定され、それ以外の再導入が拒否されることを意味する。新しい識別子・ファイル名の残存は0。今回の変更は `hare/develop` の `3263d501` を基準に実装・検証した。
元依頼は数値カウンタの廃止だけではなく、コードベース全体から禁止された名称を除去し、何を・どう・いつ確認するのかを明示するもの。先行実装 `c78f7f7d` だけで完了とした判断は誤りだった。今回の開始時監査は追跡テキスト96ファイル・875該当行。途中のHEAD `3263d501` は別作業によるユーザー原文証拠の文書追加のみで、その証拠は改変していない。
## 実装の範囲
現行Rust/API/Web/文書/無関係fixtureと生成物の名前を用途に合わせて整理した。未使用conflict enumは別名で温存せず削除。Workdir一覧の値は実際の公開projectionの `catalog_digest`、MR source解決はGit commit、歴史的な完了記録のRust側フィールドは `legacy_item_marker` として現行判断から分離した。
Server schema89はartifact provenanceと旧secret receiptの列名を変更する。Drive schema3は既存schema2の旧replay列を変更する。保存された値、原request fingerprint、receiptは変更しない。現在のDDLには旧綴りを作らない。旧SQL/JSONの綴りは移行・原文保持・旧fingerprintの再現に限って残す。
## 何を、どう、いつ確認するか
| 境界 | 対象・比較・時点 | 順序や時刻だけでは足りない理由 |
| --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------- |
| Ticket状態変更 | 書込みtransaction内で、要求時の内容digestとworkflow stateを現在値と比較してからstate/auditを書込む。同一operationの再送は別途receipt照合。 | stateが同じまま受入条件を編集した場合、古い条件による完了判断を通してはいけない。 |
| MR review/integration | 公開Git refをcommitへ解決し、review対象のcommit・現在のTicket内容など、承認が実際に対象としたものを判定時に照合。 | 到着順の後勝ちは、未承認commitを承認済みとして扱ってしまう。 |
| Drive | 同一IMMEDIATE transaction内でnodeの最後のmutation request IDを比較してから更新・移動・削除。chunk readも同じIDに固定。 | 同一時刻の更新、同内容書戻し、移動の往復、複数chunkの新旧混在を時刻では区別できない。 |
| Config | tree全体または変更entryの実内容digestをcommit前に照合。解決結果cacheは入力とtoolchain/projection条件で識別。 | 古い編集結果で他者の内容を消さない。単なる新旧順では内容一致を証明できない。 |
| Subject/Memory | Memory変更の履歴追加前に観測したchange IDを比較。surface公開前には生成入力のfingerprintと現在値を照合。behavior編集は観測した本文そのものを照合。 | 新しいMemoryの後に到着した古い生成Jobを公開してはいけない。 |
| Runtime/鍵 | enrollment lifetimeをbinding/trust IDで区別し、鍵の実体をfingerprintで比較して検証・利用する。旧文書は現行認可に復活させない。 | 同じ鍵の再登録や失効・再登録を時刻や鍵だけでは区別できない。 |
| standalone保存 | create-new commit marker保持中に観測metadata全体を比較し、確認後にファイル置換。 | 停止・再開・session pointer変更を古いrecordで戻さない。別の更新番号は不要。 |
| pending FIFO/表示 | stream順の完全置換、再接続時snapshot。Memory historyはServerの順序を保持。 | この用途では追加の汎用更新番号は不要。IDから順序も推測しない。 |
実装境界のコメントと `docs/development/web-state-preconditions.md` に対象・比較・時点と理由を記録した。schema/protocol versionは保存形式の選択であり、状態の新旧を任せる番号とは区別する。今回の名称修正でWIPの新番号は追加していない。
## 残す綴りと削除時の影響
各行の固定根拠は `tools/state-contract-name-exceptions.json`、生の全該当行は命名guardの `--report` で再現できる。以下はファイル別の全残存。`crates/` 配下は同prefixを省略した。
| ファイル | 該当行数 | 必要性・対象 |
| ---------------------------------------------------------- | -------: | ------------------------------------------------------------------- |
| `manifest/src/lib.rs` | 8 | 旧Memory settings snapshotの読取り・復元fixture |
| `merge-request/src/lib.rs` | 2 | 保存済みcompletion属性のroundtrip、旧review archive移行 |
| `merge-request/tests/store.rs` | 2 | 旧review/completion原文保持のfixture |
| `session-store/src/system_item.rs` | 1 | 旧Session履歴の読取りfixture |
| `standalone/src/job_store/legacy_migrations.rs` | 14 | schema1/2 Job request/attempt/receipt/再送の移行・rollback |
| `standalone/src/store.rs` | 4 | schema1 Worker metadata復元と旧key除去 |
| `subjektiv/src/legacy_migrations.rs` | 113 | 旧Memory履歴・参照edges・decision receipt・surface provenanceの移行 |
| `ticket/src/lib.rs` | 1 | 旧receiptを現行判断の証拠に昇格させないfixture |
| `worker-runtime/src/fs_store.rs` | 14 | 旧Job/request/config/SSH evidenceの変換・fixture |
| `worker-runtime/src/main.rs` | 2 | 旧enrollmentの検出・原文保持・失効 |
| `worker-runtime/src/working_directory.rs` | 4 | 旧SSH evidenceの読取りとfresh access必須化 |
| `worker-runtime/src/workspace_issuer.rs` | 8 | 旧trust/verificationの読取りと旧authority失効 |
| `workspace-drive/src/migrations.rs` | 25 | 旧node/receipt移行、旧typed payloadのfingerprint再現、schema2列改名 |
| `workspace-server/src/frozen_content_state_migration.rs` | 14 | 旧Config/Memory/reservation/Job保存形式の移行 |
| `workspace-server/src/frozen_content_state_tests.rs` | 31 | 同移行の実形式fixture・消失・rollback |
| `workspace-server/src/frozen_flow_content_migration.rs` | 7 | 旧Flow source/compiled definitionを再compileせず移行 |
| `workspace-server/src/frozen_flow_content_tests.rs` | 13 | 旧Flow履歴保持と不整合rollback |
| `workspace-server/src/frozen_legacy_migrations.rs` | 49 | retained migration chainの旧列・表・FK・index |
| `workspace-server/src/frozen_repository_key_migration.rs` | 39 | 旧secret復号/reseal、operation fingerprint、SSH retention/audit |
| `workspace-server/src/frozen_repository_key_schema.sql` | 2 | schema87/88の旧receipt列、schema89への移行入力 |
| `workspace-server/src/frozen_repository_key_tests.rs` | 22 | 旧暗号secret/SSH fixture、再送・archive・rollback |
| `workspace-server/src/frozen_runtime_binding_migration.rs` | 25 | 旧binding/policy/署名鍵/provisioning receiptの移行 |
| `workspace-server/src/frozen_runtime_binding_tests.rs` | 17 | 同移行の実形式fixtureと失敗時保持 |
| `workspace-server/src/frozen_schema_v85.sql` | 60 | 旧DB移行を検証する凍結DDL |
| `workspace-server/src/legacy_evidence_column_migration.rs` | 3 | 旧artifact/SSH receipt列を値保持して改名する入力 |
| `workspace-server/src/legacy_evidence_column_tests.rs` | 2 | 旧artifact値保持・現行DDLの旧名消失 |
| `workspace-server/src/migration_entrypoint_tests.rs` | 5 | 公開migration入口の旧policy/removal/SSH fixture |
| `workspace-server/src/store.rs` | 44 | 旧schema reader/validation/fixture、保存済みmigration名 |
| `workspace-server/src/workspace_signing_identity.rs` | 5 | version1のprivate materialを同じ鍵のまま復元 |
| `AGENTS.md` | 1 | 禁止対象の明示 |
| `tools/check-state-contract-names.ts` | 1 | 検出対象の定義 |
| `docs/report/2026-10-10-compaction-user-directive-loss.md` | 8 | ユーザー指示と指示脱落の原文証拠 |
| **合計** | **546** | **32ファイル、全行に限定例外の根拠あり** |
特に `store.rs` のmigration名2行は `verify_schema_history()` が保存済み `__yoi_schema_migrations.name` と完全一致を要求する。これは単なる表示文ではなく、改名すると既存DBが非canonicalとして拒否される保存形式境界。一方、診断文・triggerエラー文・ローカル変数・汎用unknown-field fixtureの14行は監査で不要と判定し、例外へ追加せず修正した。
- 旧DBの列・表・制約名:既存DBを特定して移行するため。削除すると移行SQLが旧データを読めない。
- 旧保存JSON/TOMLのキー:履歴・snapshot・receiptを読み、元記録を保持するため。現行Rustフィールドや新規入力契約とは分離する。削除すると旧データが読めなくなる、または原文を欠落させる。
- 旧fingerprint/AADのbyte encoding:キー名と順序を含む旧payloadを正しく照合・復号するため。変更すると同一の正当な再送や暗号化保存データを読めなくなる。
- 旧形式fixtureと消失検査:上記移行とrollbackを実際の旧入力で証明するため。無関係な旧名fixtureや重複した不存在assertは除去した。
- 禁止規則自身とユーザー原文証拠:検出対象の定義と、実際の依頼を狭めないための証拠。現行状態モデルの名前ではない。
これは旧設計を現在の操作へ延命する許可ではない。既存保存形式の移行・再送・履歴保持サポートを廃止すれば互換literalは削除できるが、その場合に読めなくなるデータがある。文字列分割・符号化で検索を逃れる処置はしていない。
## 再導入防止
`AGENTS.md` に命名禁止と具体的な状態契約の規則を置いた。`tools/check-state-contract-names.ts` はGit管理ファイルと非ignored未追跡ファイルのパス・本文を大小文字無視で検査し、binaryも対象とする。例外はファイルごとの理由、該当行のdigest、出現数で固定し、追加・変更・別ファイルへのコピー・削除による古い例外を拒否する。directory単位の免除はない。`--report` は該当箇所の実際の文字列を出力する。
```sh
env -u LD_LIBRARY_PATH deno test tools/check-state-contract-names.test.ts
env -u LD_LIBRARY_PATH deno run --allow-read --allow-run=git tools/check-state-contract-names.ts --report
```
Git履歴・stash・他作業のWorkdir・外部依存・旧実行ログは現在のコードとは分けて扱い、検索をゼロに見せる目的で消していない。ignored inventoryはtarget配下162,446件、node_modules 13,975件、Web生成領域2,336件、その他55件(検査時点)。targetの旧バイナリ/証拠ログ、外部依存、symlink先、zipバックアップ、別Workdirの内容までゼロ化したという主張はしない。
ignored内の自作Web build/生成server・client出力/ローカル文書については、最終再生成後の通常ファイル2,135件を別監査し一致0。旧検証ログの名前/内容とローカルwasm-bindgen-test-runnerには残存があるが、履歴/外部toolを削除して検索結果を隠してはいない。Gitの非ignored監査とは別に `target/state-name-ignored-inventory.json`、`target/state-name-ignored-owned-audit.json`、`target/state-name-ignored-current-generated-final.json` に対象と結果を記録した。
## 生成の再現
正規generatorを使用した。主な再生成コマンドは次のとおり(rootで実行)。`generate_legacy_typescript` のstdoutは `web/workspace/src/lib/generated/legacy-server-api.ts` と比較・同期する。
```sh
cargo run -p server-api --example export_openapi -- openapi/server-api.json
for generator in generate_repository_openapi_types generate_runtime_api_types generate_ticket_api_types generate_workdir_api_types generate_worker_launch_api_types generate_companion_api_types; do
cargo run -p server-api --features typescript --example "$generator"
done
cargo run -p server-api --features typescript --example generate_legacy_typescript
cargo run -p protocol --features typescript --example generate_typescript
```
6つのAPI generatorとprotocol generatorには `-- --check` を付けてfreshnessも確認した。`cargo test -p server-api --features typescript` の96 testsと、protocol TypeScript featureの85 testsも成功した。
## 検証
- 変更14 Rust crateをまとめて検証(server-apiのTypeScript featureを含む):3,209 passed、3 ignored、0 failed。
- Server schema89:保存値/NULL/FK/current DDLを検査、最後のhistory INSERT失敗時の全rename rollbackと再試行も成功。
- Drive schema3:既存schema2のevidence/digest/replay結果/live nodeと再open後の動作を保持。
- protocolのTypeScript feature、api-macrosのOpenAPI feature、E2E fixtureのcompileを追加検証。E2Eの実プロセスは起動しない。
- 正規generatorによるOpenAPI/API/protocol同期とfreshness check成功。途中の未同期による失敗は生成後の統合テストで解消。
- 生成後Web標準591 passed、component315 passed/33 files、checkは0 errors/0 warnings、build成功(chunk-size warning)。
- root cargo check、workspace format、included migration/test filesのformat、diff check成功。最終命名guardは546該当行・未承認0・stale例外0。
- 最後の不要14行修正後、対象25 testsと変更3crate全体(Subjektiv/Runtime/Server)を再実行して成功。例外一覧を増やさずguardを通した。
- 命名guardの5 tests成功(大小文字・複合語・ファイル名・binary・限定例外の改変/増殖/残骸を検査)。
- 独立read-only reviewでは今回のmigrationとguard本体/直接testsにblocking指摘なし。schema同等性検査はCHECK式やpartial-index述語の完全な同等性証明ではない。
- 追加実行した非標準Webテストには既存のsource-text assertion失敗が1件ある。`Current requirement evidence` を変更していないTicket routeへ要求するもので、HEADでも同文字列が無いことを確認。無関係な修正はしていない。`target/state-name-nonstandard-existing-failure.log` に記録。
今回の主証拠ログは `target/state-name-rust-combined-final.log`、`target/state-name-root-check-final.log`、`target/state-name-api-freshness.log`、`target/state-name-generated-web-{standard,component,check,build}.log`。前回の検証件数を今回の結果として流用していない。
live Server/Runtime/DBは変更せず、push・restart・rolloutは実施していない。既存stash2件は保持している。
+3 -1
View File
@@ -1,5 +1,7 @@
# T-718: SubWorker の cwd・参照コンテキストに関する障壁
Editorial note (2026-10-10): obsolete state terminology is summarized by purpose below; cited IDs, commits and validation results still describe the original investigation, not new executions.
## 観測
T-718 の実装を `crates/ticket` / `crates/worker` / `crates/merge-request` に分担した。SubWorkerSpawn の cwd を各 crate に設定すると、子のファイル tool は `src/lib.rs` 等で成功する一方、親で使える repository-root-relative な `crates/<crate>/src/...` は crate prefix が二重になって失敗した。cwd 未指定の Glob は一部の子で transient bash-output 側のパスを logical path として扱い失敗した。
@@ -10,6 +12,6 @@ T-718 の実装を `crates/ticket` / `crates/worker` / `crates/merge-request`
- 子の startup context に実際の Workdir attachment alias・logical root・tool cwd を明示する。
- cwd と path root の関係を tool schema / Spawn 説明と一致させ、cwd 外に委譲した read-only scope の参照方法も示す。
- Ticket implementation の子へ typed Ticket read を委譲できない場合は、親に authoritative item revision と本文を渡す責任があることを明示する。
- Ticket implementation の子へ typed Ticket read を委譲できない場合は、親に authoritative item snapshot reference と本文を渡す責任があることを明示する。
本件では委譲 scope を広げたり Backend storage を直接操作する回避はしていない。製品要件の追加ではなく、分担時の tooling/context で発生した調整コストの記録である。
+8 -6
View File
@@ -1,5 +1,7 @@
# T-724 validation evidence
Editorial note (2026-10-10): obsolete state terminology is summarized by purpose below; cited IDs, commits and validation results still describe the original investigation, not new executions.
Scope: `T-724`, authoritative item `00001M4D5ZN6P:4`, resume instruction thread
sequence 7. Baseline: `c79506b60f1abcb3d8698d01491f2b6e644170d0` (clean detached
checkout). Source branch: `work/T-724-workspace-drive`. T-717/T-722 are integrated;
@@ -12,9 +14,9 @@ T-723 Tools/WIP and T-714 implementation are not modified.
| Shared Workspace asset, no Repository/Git prerequisite | Workspace sidebar Drive; optional-node route; real `/api/workspaces` repositoryless bootstrap, zero repository records/config, folder/Markdown/PNG/empty-file and Worker artifact roundtrip. |
| DB hierarchy, stable string node URL | `driveHref`, strict decimal/ref/metadata parsers; root/list/parent-chain APIs only; rename/move/delete/recreate lifecycle in real adapter and production browser. No blob path or second persisted tree. |
| Paged folder list, metadata and mutations | Service cursors, bounded pages, breadcrumb, search, creation, native binary download, replace, relocate, empty-folder delete; real and fixture roundtrips. |
| CAS and draft retention | Per-Workspace/node draft with read revision; 2-account real concurrent CAS (one success/one conflict), 2-tab browser drafts, explicit refresh vs discard; component regression proves open rename form does not silently rebase on refresh. |
| Identity/revision fences | Controller selection/write/page epochs; saved tuple, scoped receipts and separate cancel generation; delayed old read/save/status/workspace browser gates; keyed image decode events and URL revocation component regression. |
| Safe Markdown/raster/binary | Existing raw-HTML-rejecting renderer; same-Workspace explicit resource navigation and credential-free external HTTP(S), no automatic inline image requests; real revision-bound PNG transfer, 256 KiB raster bound, HTML/SVG download-only, failed image/truncated/empty browser states. |
| CAS and draft retention | Per-Workspace/node draft with observed node state at read time; 2-account real concurrent CAS (one success/one conflict), 2-tab browser drafts, explicit refresh vs discard; component regression proves open rename form does not silently rebase on refresh. |
| Identity/content-condition fences | Controller selection/write/page epochs; saved tuple, scoped receipts and separate cancel generation; delayed old read/save/status/workspace browser gates; keyed image decode events and URL revocation component regression. |
| Safe Markdown/raster/binary | Existing raw-HTML-rejecting renderer; same-Workspace explicit resource navigation and credential-free external HTTP(S), no automatic inline image requests; real node-content-bound PNG transfer, 256 KiB raster bound, HTML/SVG download-only, failed image/truncated/empty browser states. |
| Upload publication / errors / OutcomeUnknown | Raw Blob ≤16 MiB + SHA-256; transfer receipt vs DB-published acknowledgment; cancellation/loss preserve request ID and query current-authorized result, no unconditional reupload; typed name/CAS/limit/denied/storage failures. |
| Backend current permission and Worker grants | Existing authoritative Worker catalog; owner setting flag; explicit read_only/read_write, separate Profile enablement, complete paged grants, revoke; real signed read-only Worker denial/revoke and exact Web grant-adapter owner/member HTTP boundaries; component and browser grant interactions. |
| Latest only / no added workflow | Explicit replacement/history warning; no versions, approval/publication workflow, public share links or recursive folder delete. |
@@ -61,7 +63,7 @@ Reproduce using `web/workspace/test/drive-real-api/README.md` or
- Web `deno task test`: **566 passed**, including 36 content/controller boundary
tests and 2 preview/link-policy tests.
- Web `deno task test:component`: **302 passed / 32 files**, including actual
Markdown rendering/alt labels, late decode fences, pinned form revision, grant
Markdown rendering/alt labels, late decode fences, pinned observed form condition, grant
administration, and dependent existing Worker/Console/Markdown surfaces.
- Separate fixture-client compatibility tests: **4 passed**. These prove mock
wire compatibility, not real storage or authorization.
@@ -95,7 +97,7 @@ Production rebuild and implementer-owned visual acceptance completed on
runner correctly reports `completed-with-errors` for injected failures; visual
acceptance is the implementer's review, not that exit/status label.
- Visual result: **pass**, no unresolved new visual regression. Artifacts use
baseline source revision plus dirty context; production source is frozen by
baseline source commit plus dirty context; production source is frozen by
the subsequent commit (only test formatting/documentation changed afterward).
A final grant regression also proves catalog readiness toggles cannot abandon an
@@ -124,7 +126,7 @@ Intermediate visual review found and fixed:
actual `text` prop; retain no automatic image loading. Component and production
browser regressions protect this.
- Parent semantic review additionally fixed late image-decode event identity and
open rename/replacement revision pinning, with focused component tests.
open rename/replacement observed-condition pinning, with focused component tests.
Injected permission/storage/list errors and superseded aborted breadcrumb reads
remain in official context with explicit dispositions. A successful capture exit
@@ -1,8 +1,10 @@
# T-731 independent review verdict registration rejected
Editorial note (2026-10-10): obsolete state terminology is summarized by purpose below; cited IDs, commits and validation results still describe the original investigation, not new executions.
## Confirmed observation
- Ticket: T-731, item revision `00001M4G6PHJQ:0`.
- Ticket: T-731, historical item reference `00001M4G6PHJQ:0`.
- Selected MR: `01a120d6-36fb-7673-92ce-a4cd50ba0d7d`, repository `main`.
- Selectors: `work/T-731-workdir-denial-diagnostics` → `develop`.
- First reviewed source: `f9d70e2ae2c1b226fd5832dfa13102b9f1b6ac4c`.
@@ -45,7 +47,7 @@ in this Ticket.
- Reviewed source after the first forward fix:
`10c55874e3cc4c3562f608e10c56ff1c4c2a4d3c`.
- Fresh request: MR seq3 event `01a120f4-6c90-7791-a36f-69d52e2cbd4b`,
`2026-10-09T13:57:31.897275884+00:00`, same Ticket revision and sole MR.
`2026-10-09T13:57:31.897275884+00:00`, same Ticket item snapshot and sole MR.
- Independent review found a second operational regression: normal Write's
bare NotFound match failed to create files for typed/nested diagnostic context.
A public-tool harness reproduced it despite 59 focused tests passing.
+4 -2
View File
@@ -1,5 +1,7 @@
# Test code audit — 2026-08-06
Editorial note (2026-10-10): obsolete state terminology is summarized by purpose below; cited IDs, commits and validation results still describe the original investigation, not new executions.
## Conclusion
The repository has a large test corpus and many high-value authority/state tests, but it is not currently a reliable repository-wide quality gate. Test quantity is not outgrowing production code, yet test bodies are becoming larger and are concentrated in a few inline modules. The Rust workspace suite does not currently compile, the tracked repository has no CI workflow that runs it, and part of the Web UI suite checks source text instead of behavior.
@@ -127,7 +129,7 @@ The repository documentation describes tests as required, but the repository its
The strongest tests use real SQLite stores, in-process routers, Runtime brokers, WASM artifacts, or lifecycle state and verify externally meaningful authority boundaries. Representative examples include:
- `workspace-server/config_source.rs::invalid_candidate_is_never_persisted`;
- `workspace-server/config_source.rs::stale_expected_revision_is_rejected`;
- the then-existing config-source test rejecting a stale expected state (historical test purpose, not a current test identifier);
- `workspace-server/store.rs::schema_v27_rebuild_rejects_cross_workspace_assignment_repository_drift`;
- `workspace-server/store.rs::worker_spawn_operation_retry_allows_same_reserved_workdir`;
- `workspace-server/server.rs::ticket_assignment_spawn_requires_inprogress_before_runtime_side_effects`;
@@ -138,7 +140,7 @@ The strongest tests use real SQLite stores, in-process routers, Runtime brokers,
- `web/workspace/test/config-source/wasm-parity.test.ts`;
- `web/workspace/src/lib/workspace/sidebar/worker-subscription.test.ts`.
These tests protect state ordering, idempotency, workspace isolation, migration atomicity, stale-revision rejection, and information disclosure. They make a real contribution to quality.
These tests protect state ordering, idempotency, workspace isolation, migration atomicity, stale-state rejection, and information disclosure. They make a real contribution to quality.
### Low-value and brittle tests
+1 -2
View File
@@ -14714,7 +14714,6 @@
"description": "Stable category explaining why Workspace deletion cannot currently advance.",
"enum": [
"last_accessible_workspace",
"revision_conflict",
"dirty_workdir",
"worker_removal_blocked",
"workdir_removal_blocked",
@@ -16136,7 +16135,7 @@
"info": {
"title": "Yoi Server API",
"version": "0.1.0",
"x-yoi-source-digest": "sha256:07cfe53bfdd700cad76d00695a031255ffa76a6062110a05596fa8b64ae372c4"
"x-yoi-source-digest": "sha256:fc25104c077ab8c5c43f77611f505171585d2d40fd54c6538774ac17aae6cce3"
},
"openapi": "3.1.0",
"paths": {
+17 -2
View File
@@ -422,6 +422,21 @@ impl WorkerE2eEnvironment {
false,
)?;
let enrollment = self.run_command(
&runtime_binary,
&[
"trust-workspace",
"show",
"--workspace-id",
&workspace_id,
"--fs-root",
self.runtime_data_dir.to_string_lossy().as_ref(),
],
false,
)?;
let enrollment: Value = serde_json::from_slice(&enrollment.stdout)?;
let workspace_trust_id = json_string(&enrollment, "/trust_id")?;
let mut command = self.isolated_command(&runtime_binary);
command.args([
"--bind",
@@ -449,9 +464,9 @@ impl WorkerE2eEnvironment {
.post(format!("{server_url}/api/w/{workspace_id}/runtimes"))
.json(&serde_json::json!({
"public_bundle": runtime_identity,
"workspace_trust_id": workspace_trust_id,
"display_name": format!("E2E {}", self.runtime_id),
"endpoint": runtime_url,
"expected_revision": null
"endpoint": runtime_url
})),
"register Runtime",
)?;
+90
View File
@@ -0,0 +1,90 @@
import {
checkFindings,
digest,
type ExceptionRule,
findNames,
forbiddenWord,
} from "./check-state-contract-names.ts";
function assert(condition: boolean, message: string) {
if (!condition) throw new Error(message);
}
const spelling = `${forbiddenWord.toUpperCase()}Conflict`;
Deno.test("case and compound names are rejected in content and file names", async () => {
const found = await findNames(
`src/${spelling}.rs`,
`enum Error { ${spelling} }`,
);
assert(found.length === 2, "file name and content must both be inspected");
assert(
checkFindings(found, []).unexpected.length === 2,
"both uses need review",
);
});
Deno.test("saved-data exceptions match an exact line in exactly one file", async () => {
const line = `SELECT old_${forbiddenWord} FROM archived_rows;`;
const rules: ExceptionRule[] = [{
path: "migration.sql",
kind: "saved_data",
reason: "Read the persisted old column before dropping it",
lines: [{ sha256: await digest(line), count: 1 }],
}];
assert(
checkFindings(await findNames("migration.sql", line), rules).unexpected
.length === 0,
"reviewed boundary should pass",
);
const copied = checkFindings(await findNames("current.sql", line), rules);
assert(
copied.unexpected.length === 1 && copied.stale.length === 1,
"copying an allowed line elsewhere must fail",
);
const changed = checkFindings(
await findNames("migration.sql", `${line} -- changed`),
rules,
);
assert(
changed.unexpected.length === 1 && changed.stale.length === 1,
"changed boundary requires another review",
);
});
Deno.test("duplicate uses cannot borrow the same exception and deleted uses leave stale exceptions", async () => {
const line = `old_${forbiddenWord}`;
const rules: ExceptionRule[] = [{
path: "wire.rs",
kind: "saved_data",
reason: "Deserialize a saved key",
lines: [{ sha256: await digest(line), count: 1 }],
}];
assert(
checkFindings(await findNames("wire.rs", `${line}\n${line}`), rules)
.unexpected.length === 1,
"extra copy must fail",
);
assert(
checkFindings([], rules).stale.length === 1,
"unused exceptions must be removed",
);
});
Deno.test("exceptions require a classified rationale", () => {
let rejected = false;
try {
checkFindings([], [{
path: "x",
kind: "saved_data",
reason: "",
lines: [],
}]);
} catch {
rejected = true;
}
assert(rejected, "a blank waiver cannot be accepted");
});
Deno.test("generated binary bytes are not excluded from name detection", async () => {
const binary = new TextEncoder().encode(`\0asm\0${spelling}\0`);
assert(
(await findNames("module.wasm", new TextDecoder().decode(binary)))
.length === 1,
"binary names must be audited too",
);
});
+132
View File
@@ -0,0 +1,132 @@
// The sole spelling here defines the prohibited vocabulary, not a state model.
export const forbiddenWord = "revision";
export type ExceptionKind = "saved_data" | "rejection_test" | "naming_policy";
export interface ExceptionRule {
path: string;
kind: ExceptionKind;
reason: string;
lines: { sha256: string; count: number }[];
}
export interface Finding {
path: string;
line: number;
text: string;
sha256: string;
}
export async function digest(text: string): Promise<string> {
const bytes = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(text),
);
return Array.from(
new Uint8Array(bytes),
(byte) => byte.toString(16).padStart(2, "0"),
).join("");
}
export async function findNames(
path: string,
content: string,
): Promise<Finding[]> {
const pattern = new RegExp(forbiddenWord, "i");
const findings: Finding[] = [];
if (pattern.test(path)) {
findings.push({ path, line: 0, text: path, sha256: await digest(path) });
}
const lines = content.split(/\r?\n/);
for (let i = 0; i < lines.length; i++) {
if (pattern.test(lines[i])) {
findings.push({
path,
line: i + 1,
text: lines[i],
sha256: await digest(lines[i].trim()),
});
}
}
return findings;
}
export function checkFindings(findings: Finding[], rules: ExceptionRule[]) {
const remaining = new Map<string, number>();
for (const rule of rules) {
if (
!rule.reason.trim() ||
!["saved_data", "rejection_test", "naming_policy"].includes(rule.kind)
) {
throw new Error(`Missing classified rationale for ${rule.path}`);
}
for (const line of rule.lines) {
const key = `${rule.path}\0${line.sha256}`;
if (
remaining.has(key) || !Number.isSafeInteger(line.count) ||
line.count < 1
) {
throw new Error(`Invalid or duplicate exception in ${rule.path}`);
}
remaining.set(key, line.count);
}
}
const unexpected: Finding[] = [];
for (const finding of findings) {
const key = `${finding.path}\0${finding.sha256}`;
const count = remaining.get(key) ?? 0;
if (count === 0) unexpected.push(finding);
else remaining.set(key, count - 1);
}
const stale = [...remaining].filter(([, count]) => count !== 0).map(([key]) =>
key.replace("\0", ":")
);
return { unexpected, stale };
}
if (import.meta.main) {
const result = await new Deno.Command("git", {
args: ["ls-files", "--cached", "--others", "--exclude-standard", "-z"],
stdout: "piped",
stderr: "piped",
}).output();
if (!result.success) throw new Error(new TextDecoder().decode(result.stderr));
const paths = [
...new Set(
new TextDecoder().decode(result.stdout).split("\0").filter(Boolean),
),
].sort();
const findings: Finding[] = [];
for (const path of paths) {
let bytes: Uint8Array;
try {
bytes = await Deno.readFile(path);
} catch (error) {
if (error instanceof Deno.errors.NotFound) continue;
throw error;
}
// Include generated binaries in the audit; decoding is only for finding ASCII
// names, never for rewriting data. No directory or extension is exempted.
findings.push(...await findNames(path, new TextDecoder().decode(bytes)));
}
const rules: ExceptionRule[] = JSON.parse(
await Deno.readTextFile("tools/state-contract-name-exceptions.json"),
);
const { unexpected, stale } = checkFindings(findings, rules);
if (Deno.args.includes("--report")) {
for (const finding of findings) {
console.log(`${finding.path}:${finding.line}: ${finding.text}`);
}
for (const rule of rules) {
console.log(`[${rule.kind}] ${rule.path}: ${rule.reason}`);
}
}
for (const finding of unexpected) {
console.error(
`UNAPPROVED ${finding.path}:${finding.line}: ${finding.text}`,
);
}
for (const key of stale) console.error(`STALE EXCEPTION ${key}`);
console.log(
`${findings.length} matching lines; ${unexpected.length} unapproved; ${stale.length} stale exceptions`,
);
Deno.exit(unexpected.length || stale.length ? 1 : 0);
}
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -214,7 +214,7 @@ confirmation. Interaction screenshots are saved under `target/web-ux/settings-in
`queue`, `notifications`, `legacy`, or `empty`. Each state includes a task for typography comparison.
It captures 1440px, 768px, and 390px; `browser-tests/console_pending_shell_test.ts` additionally checks
320px and both themes, independent group visibility, full-width single groups, uniform 16px rows,
monospace inheritance, truncation, and retained-session cancellation gating. Live revision-fenced
monospace inheritance, truncation, and retained-session cancellation gating. Live ordered-FIFO-content-fenced
cancellation and state transitions are covered by Console component tests. No real Worker is used.
## Console live Feature invocation fixture
@@ -367,7 +367,7 @@ Deno.test("production subject Memory and Worker launch shells preserve exact sco
).count(),
1,
);
assertEquals(await paginationPage.getByText("Store revision", { exact: true }).count(), 0);
assertEquals(await paginationPage.getByText("Memory fingerprint", { exact: true }).count(), 0);
assert((await paginationPage.getByText("Not connected", { exact: true }).count()) > 0);
assertEquals(await paginationPage.getByText("Subject on the next page").count(), 0);
await paginationPage.getByRole("navigation", { name: "Subject pages" }).getByRole(
@@ -1,6 +1,6 @@
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:07cfe53bfdd700cad76d00695a031255ffa76a6062110a05596fa8b64ae372c4)
// Input contract: 0.1.0 (sha256:fc25104c077ab8c5c43f77611f505171585d2d40fd54c6538774ac17aae6cce3)
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/companion-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
@@ -63,7 +63,6 @@ export type WorkspaceDeletionState =
export type WorkspaceDeletionBlockerKind =
| "last_accessible_workspace"
| "revision_conflict"
| "dirty_workdir"
| "worker_removal_blocked"
| "workdir_removal_blocked"
File diff suppressed because one or more lines are too long
@@ -1,6 +1,6 @@
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:07cfe53bfdd700cad76d00695a031255ffa76a6062110a05596fa8b64ae372c4)
// Input contract: 0.1.0 (sha256:fc25104c077ab8c5c43f77611f505171585d2d40fd54c6538774ac17aae6cce3)
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
// Output: web/workspace/src/lib/generated/repository-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:07cfe53bfdd700cad76d00695a031255ffa76a6062110a05596fa8b64ae372c4)
// Input contract: 0.1.0 (sha256:fc25104c077ab8c5c43f77611f505171585d2d40fd54c6538774ac17aae6cce3)
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/runtime-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:07cfe53bfdd700cad76d00695a031255ffa76a6062110a05596fa8b64ae372c4)
// Input contract: 0.1.0 (sha256:fc25104c077ab8c5c43f77611f505171585d2d40fd54c6538774ac17aae6cce3)
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/ticket-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-workdir-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:07cfe53bfdd700cad76d00695a031255ffa76a6062110a05596fa8b64ae372c4)
// Input contract: 0.1.0 (sha256:fc25104c077ab8c5c43f77611f505171585d2d40fd54c6538774ac17aae6cce3)
// Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/workdir-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:07cfe53bfdd700cad76d00695a031255ffa76a6062110a05596fa8b64ae372c4)
// Input contract: 0.1.0 (sha256:fc25104c077ab8c5c43f77611f505171585d2d40fd54c6538774ac17aae6cce3)
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
@@ -135,7 +135,7 @@ function repositoryArray(value: unknown, path: string): unknown[] {
return parsed;
}
function repositorySourceRevision(value: unknown, path: string): number {
function nonnegativeSafeInteger(value: unknown, path: string): number {
const parsed = integer(value, path);
if (parsed < 0) {
throw new Error(`${path} must be between 0 and Number.MAX_SAFE_INTEGER`);
@@ -833,7 +833,7 @@ export function parseRepositorySshConnectionProbeResponse(
}
const WORKSPACE_DELETION_MAX_OPERATION_ID_BYTES = 128;
const WORKSPACE_DELETION_MAX_REVISION_BYTES = 128;
const WORKSPACE_DELETION_MAX_UPDATED_AT_BYTES = 128;
const WORKSPACE_DELETION_MAX_BLOCKERS = 1024;
const WORKSPACE_DELETION_MAX_CHILD_OPERATION_IDS = 4096;
const WORKSPACE_DELETION_MAX_RESOURCE_VALUE_BYTES = 128;
@@ -872,7 +872,6 @@ const deletionStates = new Set<WorkspaceDeletionState>([
]);
const deletionBlockerKinds = new Set<WorkspaceDeletionBlockerKind>([
"last_accessible_workspace",
"revision_conflict",
"dirty_workdir",
"worker_removal_blocked",
"workdir_removal_blocked",
@@ -981,7 +980,7 @@ export function parseWorkspaceDeletionPreflightResponse(
expected_workspace_updated_at: deletionBoundedString(
item.expected_workspace_updated_at,
"Workspace deletion preflight.expected_workspace_updated_at",
WORKSPACE_DELETION_MAX_REVISION_BYTES,
WORKSPACE_DELETION_MAX_UPDATED_AT_BYTES,
),
can_delete: boolean(
item.can_delete,
@@ -1089,7 +1088,7 @@ export function parseHostListResponse(value: unknown): HostListResponse {
["workspace_id", "limit", "items", "source", "diagnostics"],
"host list response",
);
const limit = repositorySourceRevision(
const limit = nonnegativeSafeInteger(
response.limit,
"host list response.limit",
);
@@ -1278,7 +1278,6 @@ Deno.test("Worker Console route resolves logical Worker authority before Runtime
consolePage.includes('sendWorkerControl("cancel")') &&
consolePage.includes("lifecycleMethod(command)") &&
consolePage.includes("command_id: commandId") &&
!consolePage.includes("expected_worker_state_revision") &&
!consolePage.includes("expected_execution_generation") &&
consolePage.includes("onsubmit={handleComposerSubmit}") &&
consolePage.includes("disabled={!composerEditable}") &&
@@ -177,7 +177,7 @@ Deno.test("Merge Request routes distinguish recorded approval from live source o
),
);
assertEquals(detail.includes("Recorded integration approval"), true);
assertEquals(detail.includes("Live source revision"), true);
assertEquals(detail.includes("Live source ref"), true);
assertEquals(detail.includes("Source observation error"), true);
});
@@ -92,7 +92,7 @@ export function sourceReviewFreshness(
return `Fresh source review required: no effective verdict exists for ${source}.`;
}
// Source/integration approval can survive rescope; only this exact revision
// Source/integration approval can survive rescope; only this exact item snapshot
// and linked-result attestation approves the current Ticket requirements.
export function currentRequirementApprovalStatus(
evidence: TicketEvidenceSummary,
@@ -1,6 +1,6 @@
import type { DriveEntryRef } from "#lib/generated/drive-api.ts";
/** Human-facing latest-only link: name, path, revision and bearer tokens are absent. */
/** Human-facing latest-only link: name, path, content digest and bearer tokens are absent. */
export function driveHref(entry: DriveEntryRef): string {
if (
!/^[1-9][0-9]{0,18}$/.test(entry.node_id) ||
@@ -97,7 +97,7 @@ function optionalBoundedString(
return boundedStringValue(value, context, maxBytes);
}
function optionalRevision(
function optionalByteCount(
value: unknown,
context: string,
): number | null | undefined {
@@ -250,7 +250,7 @@ function parseWorkspaceProfileSourceSummary(
502,
);
}
const sizeBytes = optionalRevision(item.size_bytes, "Profile source catalog");
const sizeBytes = optionalByteCount(item.size_bytes, "Profile source catalog");
if (sizeBytes === undefined || sizeBytes === null) {
throw new ProfileApiError(
"Profile source catalog returned an invalid response.",
@@ -53,9 +53,9 @@ Deno.test("Git Workdir with a selector shows selector at current ref", () => {
Deno.test("non-Git Workdir does not receive Git hash formatting", () => {
assertEquals(
formatCurrentWorkdirCheckout(
workdir("snapshot", "revision-value"),
workdir("snapshot", "snapshot-id"),
"archive",
),
"snapshot · revision-value",
"snapshot · snapshot-id",
);
});
@@ -47,7 +47,7 @@ function attachment(
};
}
Deno.test("logical config sidebar label is not fabricated as a repository revision", () => {
Deno.test("logical config sidebar label is not fabricated as a repository checkout", () => {
const meta = sidebarWorkdirMeta([attachment("workspace-config", {
display_name: "Workspace configuration",
source: { kind: "workspace_config", access: "read_write", content_path: "/workspace-config", purpose: "Configuration" },
@@ -87,6 +87,17 @@ Deno.test("Worker sidebar Workdir label uses the creation branch before observat
assertEquals(meta.text, "main:develop");
});
Deno.test("Worker sidebar Workdir label shows the creation ref before a current checkout is observed", () => {
const meta = sidebarWorkdirMeta([
attachment("checkout", {
creation_selector: "refs/tags/release",
creation_ref: "0123456789abcdef0123456789abcdef01234567",
}),
]);
assertEquals(meta.text, "main:creation-ref@0123456789ab");
});
Deno.test("Worker sidebar Workdir label marks detached HEAD instead of reusing creation branch", () => {
const meta = sidebarWorkdirMeta([
attachment("checkout", {
@@ -38,10 +38,10 @@ function attachmentLabel(attachment: SidebarWorkdirAttachment): string {
const repository = workdir?.source.kind === "repository"
? clean(workdir.source.repository_key)
: clean(attachment.repository_key);
return `${repository ?? "unknown-repo"}:${revisionLabel(attachment)}`;
return `${repository ?? "unknown-repo"}:${checkoutLabel(attachment)}`;
}
function revisionLabel(attachment: SidebarWorkdirAttachment): string {
function checkoutLabel(attachment: SidebarWorkdirAttachment): string {
const workdir = attachment.working_directory;
if (!workdir) {
return `unknown@${shortReference(attachment.working_directory_id)}`;
@@ -66,7 +66,7 @@ function revisionLabel(attachment: SidebarWorkdirAttachment): string {
const creationReference = clean(workdir.creation_ref);
if (creationSelector || creationReference) {
return explicitReferenceFallback(
"revision",
"creation-ref",
creationReference ?? creationSelector,
);
}
@@ -84,7 +84,7 @@ function branchName(selector: string | null): string | null {
}
function explicitReferenceFallback(
kind: "detached" | "revision",
kind: "detached" | "creation-ref",
reference: string | null,
): string {
return reference ? `${kind}@${shortReference(reference)}` : kind;
@@ -75,7 +75,7 @@ test("renders the subject index as the Memory product entry", () => {
.toBeNull();
expect(screen.getByText("subject-1")).not.toBeNull();
expect(screen.getByText("Not connected")).not.toBeNull();
expect(screen.queryByText("Store revision", { exact: true })).toBeNull();
expect(screen.queryByText("Memory fingerprint", { exact: true })).toBeNull();
expect(screen.getByText("1 shown · more available")).not.toBeNull();
expect(screen.getByRole("link", { name: "First page" }).getAttribute("href"))
.toBe("/w/workspace-1/memory");
@@ -114,7 +114,6 @@ test("saved settings have no editable form, badges or raw data expanded; each te
);
expect(document.querySelectorAll("details[open]")).toHaveLength(0);
expect(document.querySelector(".badge")).toBeNull();
expect(screen.queryByText("Revision", { exact: true })).toBeNull();
expect(
screen.getByText(identityFixture().public_bundle!.public_key_fingerprint),
).toBeTruthy();

Some files were not shown because too many files have changed in this diff Show More