os-sdlc: reload a timestamp column after stamping it with the DB clock #557

Closed
opened 2026-09-11 15:46:41 +00:00 by jared · 3 comments
Owner

Context

ADR-0165 stamps lifecycle columns with Sequel::CURRENT_TIMESTAMP. Sequel does not reload a row after update, so the in-memory row keeps the Sequel::SQL::Constant object instead of a time. Model#refresh reloads the row but the associations plugin clears the association cache first, which drops the in-memory ADR-0171 commit record. During #552 this crashed DispatchSummary.seconds_between with undefined method 'to_time' for an instance of Sequel::SQL::Constant, and the interim fix reads a fresh row in Implementation#summary. Verified against sequel 5.108.0 source; vault note user-guide/sequel-model-transaction-and-multi-db-gotchas.md section 3.

Observed

Implementation#complete!, fail!, escalate! and the dispatch stamping methods leave the constant in memory. Implementation#summary works around it with Summary.for(Model.for(db).implementation[id]).

22 sites, all one shape (update(col: Sequel::CURRENT_TIMESTAMP, ...), no reload), under plugins/os-sdlc/lib/os_sdlc/model/:

implementation/instance_methods.rb:24,31,35,41,47
implementation/class_methods.rb:20
dispatch/instance_methods.rb:38,42,58,68,72,76,80
agent/instance_methods.rb:8,18
edge_spend/instance_methods.rb:10,15
handoff/instance_methods.rb:12
objection/instance_methods.rb:20,24
violations/class_methods.rb:20

Reproduce

In a test, open an implementation, call complete!(position: "start"), then inspect completed_at on the same object: it is a Sequel::SQL::Constant.

Expected

One helper stamps a column with the DB clock and then reloads only that column (via this.get(column) assigned into values), keeping the association cache. All _at writes use it. Implementation#summary goes back to Summary.for(self). ADR-0165 stays intact; the DB clock is still the writer. Replacing the constant with Time.now was rejected because ADR-0165 chose the DB clock so every writing process shares one clock.

Owner sketch

One module under plugins/os-sdlc/lib/os_sdlc/model/, included once where model/db.rb builds each model class (not per-table). Stamp columns are positional; other attributes pass through unchanged; updated_at is always stamped.

module OsSdlc
  class Model
    module TimestampStamping
      def stamp!(*stamps, **attrs)
        stamps = stamps | [:updated_at]
        update(attrs.merge(stamps.to_h { |c| [c, Sequel::CURRENT_TIMESTAMP] }))
        stamps.each { |c| values[c] = this.get(c) }
        self
      end
    end
  end
end

Caller shape:

def complete!(position: nil, root: nil, lifecycle: Pipeline::LspDaemonLifecycle)
  attrs = position ? { position: Model::NodeName.parse(position).to_s } : {}
  stamp!(:completed_at, **attrs)
  lifecycle.close(root)
end

Class-level sites (implementation/class_methods.rb:20, violations/class_methods.rb:20) update a dataset, not an instance; they keep their dataset update and are out of scope for the reload.

Cases

  1. After complete!(position: "start") on an Implementation whose commit_record association was loaded, completed_at on the same object is a Time, and the association cache still holds commit_record (no refetch).
  2. Implementation#summary is Summary.for(self) and returns the same result as before the revert.
  3. Every instance-level Sequel::CURRENT_TIMESTAMP write in plugins/os-sdlc/lib/os_sdlc/model/ goes through stamp!; grep -rn "Sequel::CURRENT_TIMESTAMP" plugins/os-sdlc/lib/os_sdlc/model matches only the helper and the two class-level dataset updates.

Tasks

  • Add TimestampStamping#stamp! and include it once in model/db.rb.
  • Route every instance-level CURRENT_TIMESTAMP lifecycle write through stamp!.
  • Revert Implementation#summary to Summary.for(self).
  • Test case 1 through the public complete! seam on Implementation.

Origin

Session: 5fdd0aa3-8a9c-4c31-a5b3-d575d8e709ab
Transcript: /home/jared/.claude/projects/-home-jared-dev-cc-os/5fdd0aa3-8a9c-4c31-a5b3-d575d8e709ab.jsonl
Where: plugins/os-sdlc/lib/os_sdlc/model/implementation/instance_methods.rb; plugins/os-sdlc/lib/os_sdlc/model/db.rb
Filed by: agent, from the user's question on 2026-09-11 about replacing CURRENT_TIMESTAMP with Time.now. Parent: #552.
Owner sketch and Cases added 2026-09-22 (session e8c75cf9-9b10-44bc-a4a2-7fc83305328b) after user-accepted triage.

## Context ADR-0165 stamps lifecycle columns with Sequel::CURRENT_TIMESTAMP. Sequel does not reload a row after update, so the in-memory row keeps the Sequel::SQL::Constant object instead of a time. Model#refresh reloads the row but the associations plugin clears the association cache first, which drops the in-memory ADR-0171 commit record. During #552 this crashed DispatchSummary.seconds_between with undefined method 'to_time' for an instance of Sequel::SQL::Constant, and the interim fix reads a fresh row in Implementation#summary. Verified against sequel 5.108.0 source; vault note user-guide/sequel-model-transaction-and-multi-db-gotchas.md section 3. ## Observed Implementation#complete!, fail!, escalate! and the dispatch stamping methods leave the constant in memory. Implementation#summary works around it with Summary.for(Model.for(db).implementation[id]). 22 sites, all one shape (`update(col: Sequel::CURRENT_TIMESTAMP, ...)`, no reload), under plugins/os-sdlc/lib/os_sdlc/model/: ``` implementation/instance_methods.rb:24,31,35,41,47 implementation/class_methods.rb:20 dispatch/instance_methods.rb:38,42,58,68,72,76,80 agent/instance_methods.rb:8,18 edge_spend/instance_methods.rb:10,15 handoff/instance_methods.rb:12 objection/instance_methods.rb:20,24 violations/class_methods.rb:20 ``` ## Reproduce In a test, open an implementation, call complete!(position: "start"), then inspect completed_at on the same object: it is a Sequel::SQL::Constant. ## Expected One helper stamps a column with the DB clock and then reloads only that column (via this.get(column) assigned into values), keeping the association cache. All <event>_at writes use it. Implementation#summary goes back to Summary.for(self). ADR-0165 stays intact; the DB clock is still the writer. Replacing the constant with Time.now was rejected because ADR-0165 chose the DB clock so every writing process shares one clock. ## Owner sketch One module under plugins/os-sdlc/lib/os_sdlc/model/, included once where model/db.rb builds each model class (not per-table). Stamp columns are positional; other attributes pass through unchanged; updated_at is always stamped. ```ruby module OsSdlc class Model module TimestampStamping def stamp!(*stamps, **attrs) stamps = stamps | [:updated_at] update(attrs.merge(stamps.to_h { |c| [c, Sequel::CURRENT_TIMESTAMP] })) stamps.each { |c| values[c] = this.get(c) } self end end end end ``` Caller shape: ```ruby def complete!(position: nil, root: nil, lifecycle: Pipeline::LspDaemonLifecycle) attrs = position ? { position: Model::NodeName.parse(position).to_s } : {} stamp!(:completed_at, **attrs) lifecycle.close(root) end ``` Class-level sites (implementation/class_methods.rb:20, violations/class_methods.rb:20) update a dataset, not an instance; they keep their dataset update and are out of scope for the reload. ## Cases 1. After `complete!(position: "start")` on an Implementation whose `commit_record` association was loaded, `completed_at` on the same object is a `Time`, and the association cache still holds `commit_record` (no refetch). 2. `Implementation#summary` is `Summary.for(self)` and returns the same result as before the revert. 3. Every instance-level `Sequel::CURRENT_TIMESTAMP` write in plugins/os-sdlc/lib/os_sdlc/model/ goes through `stamp!`; `grep -rn "Sequel::CURRENT_TIMESTAMP" plugins/os-sdlc/lib/os_sdlc/model` matches only the helper and the two class-level dataset updates. ## Tasks - [ ] Add TimestampStamping#stamp! and include it once in model/db.rb. - [ ] Route every instance-level CURRENT_TIMESTAMP lifecycle write through stamp!. - [ ] Revert Implementation#summary to Summary.for(self). - [ ] Test case 1 through the public complete! seam on Implementation. ## Origin Session: 5fdd0aa3-8a9c-4c31-a5b3-d575d8e709ab Transcript: /home/jared/.claude/projects/-home-jared-dev-cc-os/5fdd0aa3-8a9c-4c31-a5b3-d575d8e709ab.jsonl Where: plugins/os-sdlc/lib/os_sdlc/model/implementation/instance_methods.rb; plugins/os-sdlc/lib/os_sdlc/model/db.rb Filed by: agent, from the user's question on 2026-09-11 about replacing CURRENT_TIMESTAMP with Time.now. Parent: #552. Owner sketch and Cases added 2026-09-22 (session e8c75cf9-9b10-44bc-a4a2-7fc83305328b) after user-accepted triage.
Author
Owner

Triage 2026-09-22: still valid, approach accepted

Finding. Every lifecycle writer leaves a Sequel::CURRENT_TIMESTAMP constant in memory. Plain update sends the constant to SQLite and never reads the row back, so the in-memory column holds Sequel::SQL::Constant, not a Time. A full refresh fixes the value but clears every cached association, which drops the ADR-0171 in-memory commit record. This crashed the summary during #552 (undefined method 'to_time' for Sequel::SQL::Constant).

Sites (22, all one shape, none reload):

model/implementation/instance_methods.rb:24,31,35,41,47
model/implementation/class_methods.rb:20
model/dispatch/instance_methods.rb:38,42,58,68,72,76,80
model/agent/instance_methods.rb:8,18
model/edge_spend/instance_methods.rb:10,15
model/handoff/instance_methods.rb:12
model/objection/instance_methods.rb:20,24
model/violations/class_methods.rb:20
Point completed_at in memory
Before complete! nil
After plain update Sequel::SQL::Constant(:CURRENT_TIMESTAMP)
After per-column reload Time from DB clock, associations intact

Accepted solution. One module in plugins/os-sdlc/lib/os_sdlc/model/. Stamp columns are positional; other attributes pass through unchanged. updated_at is always stamped. Reload uses this.get(column), a plain SELECT that bypasses the associations plugin's refresh path.

module OsSdlc
  class Model
    module TimestampStamping
      def stamp!(*stamps, **attrs)
        stamps = stamps | [:updated_at]
        update(attrs.merge(stamps.to_h { |c| [c, Sequel::CURRENT_TIMESTAMP] }))
        stamps.each { |c| values[c] = this.get(c) }
        self
      end
    end
  end
end

Caller shape:

def complete!(position: nil, root: nil, lifecycle: Pipeline::LspDaemonLifecycle)
  attrs = position ? { position: Model::NodeName.parse(position).to_s } : {}
  stamp!(:completed_at, **attrs)
  lifecycle.close(root)
end

Decisions (user-accepted 2026-09-22):

  1. Placement: one shared include at the point where model/db.rb builds each model class. Not per-table modules.
  2. Scope: migrate all 22 sites in this ticket and revert Implementation#summary to Summary.for(self). Split only if the gate map reports a file with no mapped tests.
  3. Test: one seam through the public method on Implementation. Load commit_record, call complete!, assert completed_at is a Time and the association cache still holds commit_record on the same object. No per-table tests.

ADR-0165 stays intact: the DB clock remains the sole writer of these stamps.

## Triage 2026-09-22: still valid, approach accepted **Finding.** Every lifecycle writer leaves a `Sequel::CURRENT_TIMESTAMP` constant in memory. Plain `update` sends the constant to SQLite and never reads the row back, so the in-memory column holds `Sequel::SQL::Constant`, not a `Time`. A full `refresh` fixes the value but clears every cached association, which drops the ADR-0171 in-memory commit record. This crashed the summary during #552 (`undefined method 'to_time' for Sequel::SQL::Constant`). **Sites (22, all one shape, none reload):** ``` model/implementation/instance_methods.rb:24,31,35,41,47 model/implementation/class_methods.rb:20 model/dispatch/instance_methods.rb:38,42,58,68,72,76,80 model/agent/instance_methods.rb:8,18 model/edge_spend/instance_methods.rb:10,15 model/handoff/instance_methods.rb:12 model/objection/instance_methods.rb:20,24 model/violations/class_methods.rb:20 ``` | Point | `completed_at` in memory | |---|---| | Before `complete!` | `nil` | | After plain `update` | `Sequel::SQL::Constant(:CURRENT_TIMESTAMP)` | | After per-column reload | `Time` from DB clock, associations intact | **Accepted solution.** One module in `plugins/os-sdlc/lib/os_sdlc/model/`. Stamp columns are positional; other attributes pass through unchanged. `updated_at` is always stamped. Reload uses `this.get(column)`, a plain SELECT that bypasses the associations plugin's refresh path. ```ruby module OsSdlc class Model module TimestampStamping def stamp!(*stamps, **attrs) stamps = stamps | [:updated_at] update(attrs.merge(stamps.to_h { |c| [c, Sequel::CURRENT_TIMESTAMP] })) stamps.each { |c| values[c] = this.get(c) } self end end end end ``` Caller shape: ```ruby def complete!(position: nil, root: nil, lifecycle: Pipeline::LspDaemonLifecycle) attrs = position ? { position: Model::NodeName.parse(position).to_s } : {} stamp!(:completed_at, **attrs) lifecycle.close(root) end ``` **Decisions (user-accepted 2026-09-22):** 1. **Placement:** one shared include at the point where `model/db.rb` builds each model class. Not per-table modules. 2. **Scope:** migrate all 22 sites in this ticket and revert `Implementation#summary` to `Summary.for(self)`. Split only if the gate map reports a file with no mapped tests. 3. **Test:** one seam through the public method on Implementation. Load `commit_record`, call `complete!`, assert `completed_at` is a `Time` and the association cache still holds `commit_record` on the same object. No per-table tests. ADR-0165 stays intact: the DB clock remains the sole writer of these stamps.
Author
Owner

Work started 2026-09-22 on branch ticket-557 via /os-sdlc:implement (poodr-ticket-implementation map).

Work started 2026-09-22 on branch ticket-557 via /os-sdlc:implement (poodr-ticket-implementation map).
Author
Owner

Resolution

Done: TimestampStamping#stamp! on Model::Base stamps named lifecycle columns plus updated_at with Sequel::CURRENT_TIMESTAMP, then reloads only those columns via this.get so the association cache survives. All 20 instance-level CURRENT_TIMESTAMP writes across implementation, dispatch, agent, edge_spend, handoff, objection route through it. Implementation#summary is Summary.for(self) again. Two class-level dataset updates keep their shape by design.

Evidence: Commit 52072ad5 on branch ticket-557. Files: plugins/os-sdlc/lib/os_sdlc/model/timestamp_stamping.rb (new), model/base.rb (include), 6 instance_methods.rb files, tests/model/implementation_complete_stamp_test.rb (cases 1-2 through the public complete! seam: completed_at is a Time and commit_record stays cached; summary equals Summary.for(self)). Pipeline implementation 10: 8 dispatches, 0 retries, all pass; suite gate exit 0 (121 s); grep confirms only the helper and two class-level sites still name CURRENT_TIMESTAMP. History row added to docs/implementation-status/os-sdlc.md.

Follow-ups: none

## Resolution **Done:** TimestampStamping#stamp! on Model::Base stamps named lifecycle columns plus updated_at with Sequel::CURRENT_TIMESTAMP, then reloads only those columns via this.get so the association cache survives. All 20 instance-level CURRENT_TIMESTAMP writes across implementation, dispatch, agent, edge_spend, handoff, objection route through it. Implementation#summary is Summary.for(self) again. Two class-level dataset updates keep their shape by design. **Evidence:** Commit 52072ad5 on branch ticket-557. Files: plugins/os-sdlc/lib/os_sdlc/model/timestamp_stamping.rb (new), model/base.rb (include), 6 instance_methods.rb files, tests/model/implementation_complete_stamp_test.rb (cases 1-2 through the public complete! seam: completed_at is a Time and commit_record stays cached; summary equals Summary.for(self)). Pipeline implementation 10: 8 dispatches, 0 retries, all pass; suite gate exit 0 (121 s); grep confirms only the helper and two class-level sites still name CURRENT_TIMESTAMP. History row added to docs/implementation-status/os-sdlc.md. **Follow-ups:** none
jared closed this issue 2026-09-22 17:47:21 +00:00
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
jared/cc-os#557
No description provided.