"TIME auto-detect proposals — Member-confirm suggestions from PROJ activity (status changes + comment patterns + commit/PR activity)"
§1 — Description (BCP-14 normative)
The TIME service MUST ship auto-detect proposal engine at services/time/src/proposals/ watching PROJ activity, proposing TIME entries with confidence scores, requiring Member confirmation (no auto-create), 7-day expiry, and 4 memory audit kinds.
- MUST define closed
proposal_sourceenum:('proj_status_change','proj_comment_burst','proj_attachment_added','git_commit','calendar_event')per DEC-1441. Cardinality 5. Slice 2 active: proj_* (3 of 5); git_commit + calendar_event slice 3.
- MUST define closed
proposal_stateenum:('pending','accepted','rejected','expired')per DEC-1442. Cardinality 4.
- MUST define
time_proposalstable at migration0008:(proposal_id UUID PRIMARY KEY, tenant_id UUID NOT NULL, member_subject_id UUID NOT NULL, engagement_id UUID NOT NULL, project_id UUID, task_id UUID, source proposal_source NOT NULL, source_ref UUID NOT NULL, suggested_start TIMESTAMPTZ NOT NULL, suggested_end TIMESTAMPTZ NOT NULL, suggested_description TEXT, confidence_score INT NOT NULL CHECK (confidence_score BETWEEN 50 AND 100), state proposal_state NOT NULL DEFAULT 'pending', created_at TIMESTAMPTZ NOT NULL DEFAULT now(), expires_at TIMESTAMPTZ NOT NULL, accepted_entry_id UUID, trace_id CHAR(32)). RLS scoped to Member.
- MUST subscribe to PROJ NATS events via
proj_activity_watcher.rs:
proj.issue.status_changed: firesproj_status_changeproposal evaluator.proj.comment.created: per-issue rolling count →proj_comment_burstif > 5 comments in 1h.proj.attachment.added: firesproj_attachment_added.
- MUST compute confidence via
confidence_score.rs::score(source, context):
- Status change from In-Progress → Done: ~85 (likely user did work).
- Comment burst: ~70 (probably discussion + work).
- Attachment added: ~65 (artifact suggests work).
- Multiple signals same hour: aggregate +10 each (cap 100).
- User's role on engagement matches issue assignee: +5.
- Issue assignee = Member: required (else skip).
- MUST filter proposals < 50 confidence per DEC-1444 — never persist.
- MUST propose duration from event timestamps. For status_change:
now() - prior_status_change_at(capped at 4h). For comment_burst: span of comments (capped at 2h). For attachment: 30min default.
- **MUST NEVER auto-create TIME entry per DEC-1445. Always requires
POST /v1/time/proposals/{id}/acceptfrom Member with body{ duration_seconds_override?, description_override? }. Handler:
- Validates Member owns proposal.
- Creates TIME entry via TASK-TIME-001 with Member's overrides applied.
- Transitions state='accepted'.
- Emits
time.proposal_acceptedsev-2.
- MUST support reject
POST /v1/time/proposals/{id}/rejectbody{ reason? }. Transitions state='rejected'. Emitstime.proposal_rejectedsev-3.
- MUST expire pending proposals past
expires_atper DEC-1443 via daily job. Transition state='expired'. Emittime.proposal_expiredsev-3.
- MUST expose
GET /v1/time/proposals?state=pendingfor Member's pending list.
- MUST emit 4 memory audit kinds per DEC-1446. PII-scrub description via TASK-MEMORY-111.
- MUST thread trace_id from PROJ event through proposal creation.
- MUST NOT auto-create entry (DEC-1445).
- MUST NOT emit < 50 confidence (DEC-1444).
§2 — Why this design (rationale)
Why never auto-create (§1 #8, DEC-1445)? AI-proposed wrong entries bill clients — fraud risk. Member confirmation is the security gate.
Why confidence filtering (§1 #6, DEC-1444)? Low-confidence proposals are noise; Members tune out → all proposals ignored → primitive worthless. 50% threshold = quality filter.
Why 7-day expiry (§1 #10, DEC-1443)? Older than a week, Member forgot; proposal is stale; clean up.
Why slice 2 not slice 1 (SHOULD)? Not required for billing pipeline; quality-of-life UX. Build after core TIME flow works.
§3 — API contract
-- 0008_time_proposals.sql
CREATE TYPE proposal_source AS ENUM ('proj_status_change','proj_comment_burst','proj_attachment_added','git_commit','calendar_event');
CREATE TYPE proposal_state AS ENUM ('pending','accepted','rejected','expired');
CREATE TABLE time_proposals (
proposal_id UUID PRIMARY KEY,
tenant_id UUID NOT NULL,
member_subject_id UUID NOT NULL,
engagement_id UUID NOT NULL,
project_id UUID,
task_id UUID,
source proposal_source NOT NULL,
source_ref UUID NOT NULL,
suggested_start TIMESTAMPTZ NOT NULL,
suggested_end TIMESTAMPTZ NOT NULL,
suggested_description TEXT,
confidence_score INT NOT NULL CHECK (confidence_score BETWEEN 50 AND 100),
state proposal_state NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL,
accepted_entry_id UUID,
reject_reason TEXT,
trace_id CHAR(32)
);
CREATE INDEX idx_proposals_member_pending
ON time_proposals(member_subject_id, created_at DESC)
WHERE state = 'pending';
ALTER TABLE time_proposals ENABLE ROW LEVEL SECURITY;
CREATE POLICY time_proposals_rls ON time_proposals
USING (tenant_id = current_setting('auth.tenant_id')::uuid
AND member_subject_id = current_setting('auth.subject_id')::uuid)
WITH CHECK (tenant_id = current_setting('auth.tenant_id')::uuid
AND member_subject_id = current_setting('auth.subject_id')::uuid);
REVOKE DELETE ON time_proposals FROM cyberos_app;
GRANT UPDATE (state, accepted_entry_id, reject_reason) ON time_proposals TO cyberos_app;
Endpoints:
GET /v1/time/proposals?state=pending
POST /v1/time/proposals/{id}/accept
POST /v1/time/proposals/{id}/reject
§4 — Acceptance criteria
- proposal_source cardinality 5.
- proposal_state cardinality 4.
- Status change generates proposal — issue done → proposal created with > 50 confidence.
- Comment burst threshold — 6 comments in 1h → proposal.
- Accept creates TIME entry — accept → TIME entry persisted + state=accepted.
- Reject transitions — reject → state=rejected.
- Expire 7d — pending past 7d → expired.
- No auto-create — proposal exists but no TIME entry until accept.
- < 50 confidence filtered — heuristic produces 40 → no row persisted.
- Accept respects override — duration_seconds_override applied to TIME entry.
- 4 memory audit kinds emitted.
- RLS Member-scoped.
- PII scrub description.
- Trace_id from PROJ event preserved.
- Non-assigned Member skipped — issue assignee != Member → no proposal.
- Multi-source same hour aggregates — status + comment + attachment within 1h → single high-confidence proposal.
- Pending list sorted by created_at desc.
- Slice 2 sources only — git/calendar sources rejected at insert.
- Concurrent accept race — first wins; second sees state≠pending → 409.
- Audit on each transition.
§5 — Verification
#[tokio::test]
async fn status_change_to_done_creates_proposal() {
let ctx = TestContext::with_member_assigned_to_issue().await;
ctx.publish_proj_event("issue.status_changed", json!({
"issue_id": ctx.issue_id, "from": "in_progress", "to": "done",
"assignee_id": ctx.member_id
})).await;
tokio::time::sleep(Duration::from_millis(500)).await;
let count: i64 = sqlx::query_scalar("SELECT count(*) FROM time_proposals WHERE member_subject_id=$1 AND state='pending'")
.bind(ctx.member_id).fetch_one(&ctx.pool).await.unwrap();
assert!(count >= 1);
}
#[tokio::test]
async fn accept_creates_time_entry() {
let ctx = TestContext::with_pending_proposal().await;
let r = ctx.accept_proposal(ctx.proposal_id, json!({})).await;
assert_eq!(r.status(), 201);
let entry_id: Uuid = r.json::<serde_json::Value>().await.unwrap()["entry_id"].as_str().unwrap().parse().unwrap();
let exists: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM time_entries WHERE entry_id=$1)")
.bind(entry_id).fetch_one(&ctx.pool).await.unwrap();
assert!(exists);
}
#[tokio::test]
async fn no_auto_create_without_accept() {
let ctx = TestContext::with_pending_proposal().await;
let count: i64 = sqlx::query_scalar("SELECT count(*) FROM time_entries WHERE member_subject_id=$1")
.bind(ctx.member_id).fetch_one(&ctx.pool).await.unwrap();
assert_eq!(count, 0);
}
#[tokio::test]
async fn expire_after_7d() {
let ctx = TestContext::with_old_pending_proposal(8).await;
ctx.run_expire_job().await;
let state: String = sqlx::query_scalar("SELECT state::text FROM time_proposals WHERE proposal_id=$1")
.bind(ctx.proposal_id).fetch_one(&ctx.pool).await.unwrap();
assert_eq!(state, "expired");
}
// 5.5..5.10: confidence filter, assignee match, audit, cardinality
§7 — Dependencies
Upstream: TASK-PROJ-002 (NATS events to subscribe). Cross-module: TASK-TIME-001 (entry create), TASK-AI-003, TASK-MEMORY-111.
§8 — Example payload
time.proposal_generated:
{
"kind": "time.proposal_generated",
"severity": 3,
"tenant_id": "8a2f...",
"actor_id": "system.time.detector",
"trace_id": "...",
"payload": {
"proposal_id": "0190...",
"member_subject_id_hash16": "f8a1...",
"engagement_id": "0190...",
"source": "proj_status_change",
"confidence_score": 85,
"expires_at": "2026-05-24T..."
}
}
§9 — Open questions
Deferred:
- Deferred: Git commit detection — slice 3.
- Deferred: Calendar event detection — slice 3.
- Deferred: ML confidence scoring (vs heuristics) — slice 3.
- Deferred: Bulk-accept (Monday morning batch confirm) — slice 3.
- Deferred: Decline-pattern learning (Member rejects X repeatedly → stop proposing) — slice 3.
§10 — Failure modes inventory
| Failure | Detection | Outcome | Recovery |
|---|---|---|---|
| PROJ NATS event delivery failure | NATS retry | Proposal not created; sev-3 | Inherent NATS retry |
| Confidence score < 50 | filter | Not persisted | Inherent |
| Member not assignee | check | Skipped | Inherent |
| Concurrent accept race | state check | Second 409 | Inherent |
| Accepted proposal entry creation fails | rollback | State remains pending | Member retries |
| Expired proposal accept attempt | state check | 409 + expired | Inherent |
| Proposal for non-existent issue | FK soft | Created; FK check at accept | Inherent |
| Member rejected then status revives | new proposal generated | Treated independently | Inherent |
| Proposal description PII not scrubbed | TASK-MEMORY-111 | Audit dropped; sev-3 | Inherent |
| Confidence > 100 from heuristic bug | CHECK constraint | INSERT fails | Bug fix |
| Multi-source proposals overlap timewise | each independent | Member chooses which to accept | Inherent |
| Pending list grows unbounded | expire job | Daily cleanup | Inherent |
| Trace_id from NATS dropped | propagation guard | Sev-3 if missing | Inherent |
| Slice-3 source attempted | CHECK CHECK on source enum | INSERT rejected | Inherent |
| Cross-tenant proposal injection | RLS | 0 rows visible | Inherent |
§11 — Implementation notes
§11.1 NATS subscriber consumes PROJ events as fire-and-forget; failures don't block PROJ.
§11.2 Confidence heuristics tunable via tenant config (slice 3 enhancement).
§11.3 Expire job runs daily 04:00.
§11.4 Member assignee check prevents proposals for delegated work.
§11.5 Accept handler creates entry via TASK-TIME-001 standard path — same caps + validations.
§11.6 Proposal description hashed via TASK-MEMORY-111 in audit.
§11.7 Multi-signal aggregation uses time-window join (1h).
§11.8 Trace_id forwarded from PROJ event to proposal to accepted entry.
§11.9 SHOULD priority — graceful degradation; if engine down, manual entry still works.
§11.10 Per-Member pending count exposed via UI badge.
End of TASK-TIME-004 spec.