Task lifecycle hook — completed_at and last_update_at stamping - #35

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-3-task-hook
Sep 1, 2026
Merged

Task lifecycle hook — completed_at and last_update_at stamping#35
os-warren merged 2 commits into
mainfrom
claude/issue-3-task-hook

Conversation

@os-warren

@os-warrenos-warren commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#3

Two server-owned timestamps on duly_task, written by one hook registered in dulyHooks.

What it does

beforeInsert — stamps last_update_at. A brand-new task has just been touched, by definition.

beforeUpdate

  • transition intodonecompleted_at = now
  • transition out ofdonecompleted_at = null
  • last_update_at = now only when status, note or skip_reasonactually changed against the pre-image

Everything keys off ctx.previous, so these are transitions rather than states. That is what stops a whole-record form re-submitting status: 'done' from overwriting the original completion instant on every save, and what makes "re-saving with no changes" a no-op.

The stagnation signal

last_update_at feeds the "Not moving" view — status in (open, in_progress) AND last_update_at < {14_days_ago}. A hook that stamped on every update would let one bulk re-owner, a business-unit backfill or an import silently reset the clock across the whole table. Nothing errors; the numbers just quietly improve and the signal goes quiet exactly when it matters.

So the touch list is exactly status, note, skip_reason. owner, business_unit, assignment, duty, due_date, visible_from, period_key, source and subject are deliberately excluded, and three of them are asserted directly:

✓ does NOT advance on an administrative write (business_unit)
✓ does NOT advance on a re-owner
✓ does NOT advance on a re-date
✓ does NOT advance on a re-save with no changes

A context with no pre-image — the whole-operation dispatch of an unscoped predicate write, i.e. the bulk write this hook most needs to survive — stamps nothing at all.

Three platform assumptions, verified rather than assumed

All three held. Measured against a real booted engine, not read off the docs:

AssumptionVerdict
beforeUpdate can see the previous field valuesTrue. The engine binds hookContext.previous from a pre-read of the row before dispatching the beforeUpdate chain, and throws recordNotFoundError if that read comes back empty — so on the by-id path it is always present. Pinned by a probe hook in the suite.
A beforeUpdate stamp lands ahead of validationTrue. Order is beforeUpdatevalidateRecord → readonly strip → evaluateValidationRules. A write carrying only { status: 'done' } therefore commits.
readonly stripping blocks caller values while the hook's write passesTrue, with one edge — see #31. The strip drops a readonly key only when its value is still exactly what the caller supplied, so a hook-derived value replaces a caller-supplied one.

Tests

test/task-hook.test.ts boots a real ObjectQL engine (in-memory driver) with this app's own objectstack.config.ts as the bundle. The handler runs only because AppPlugin found it in the real config, so the suite cannot pass on dead metadata.

Two things worth flagging to a reviewer:

The suite is pinned away from the build artifact. Left at its default, createStandaloneStack resolves dist/objectstack.json under the working directory, and when a local pnpm build has left one there the kernel loads its metadata — objects and hooks — from that file instead of from the config. The suite then reports on the last build rather than on src/, and behaves differently in CI (where pnpm test runs before pnpm build) than on a developer's machine. This is not hypothetical: it is what made the registration ablation come back green. The artifactPath sentinel in beforeAll is what closes it.

A negative control guards the completion assertions.completed_at_required_when_done is asserted to still refuse an insert of a done task, so if that rule ever goes quiet the completion tests stop proving anything.

Both ablations were run to confirm the assertions can fail:

  • stamping last_update_at unconditionally (the classic wrong hook) → 5 failed, including the business_unit assertion
  • removing the entry from dulyHooks11 failed, the registration and every positive behaviour test

Not in this PR

The attachment clause is not implemented — filed as #28. #3 asks for last_update_at to advance on "an attachment", but attachments are sys_attachment rows carrying parent_object/parent_id, so adding one is an insert on that object and no duly_task write happens at all. It needs a second hook on sys_attachment, an isSystem write-back to get past the readonly strip, and answers to product questions #3 did not settle (does removing an attachment count? does an importer's?).

Also filed: #31 (reopening fails to clear completed_at when the caller also sends completed_at: null — an upstream Object.is limitation in the readonly strip, deliberately not worked around here) and #32 (a task cannot be created directly in done — needs a product decision).

Gates

All four green at 864ba78:

pnpm validate ✓ Validation passed (237ms)
pnpm typecheck exit 0
pnpm test Test Files 2 passed (2) · Tests 27 passed (27)
pnpm build ✓ Build complete · dist/objectstack.json (53.3 KB)

The handler is written as one self-contained function so objectstack build lowers it to a metadata body rather than falling back to the legacy bundled runtime module — a fallback that only warns. The lowered body was executed against the real QuickJS sandbox to confirm it behaves identically there, since that is the surface a served artifact runs.

src/objects/task.object.ts was not touched.


Generated by Claude Code

os-warrenand others added 2 commits September 1, 2026 03:51
Left at its default, createStandaloneStack resolves <cwd>/dist/objectstack.json
and the kernel loads its metadata — objects AND hooks — from that file. With a
local pnpm build present the suite then reported on the last build rather than
on src/, and the registration ablation came back green with the barrel entry
deleted. Point the lookup at a sentinel path instead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
@os-warren
os-warren marked this pull request as ready for review September 1, 2026 04:04
@os-warren
os-warren merged commit c05fcd5 into mainSep 1, 2026
1 check passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Task lifecycle hook — completed_at and last_update_at stamping

1 participant

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

Task lifecycle hook — completed_at and last_update_at stamping - #35

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-3-task-hook
Sep 1, 2026
Merged

Task lifecycle hook — completed_at and last_update_at stamping#35
os-warren merged 2 commits into
mainfrom
claude/issue-3-task-hook

Conversation

@os-warren

@os-warrenos-warren commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#3

Two server-owned timestamps on duly_task, written by one hook registered in dulyHooks.

What it does

beforeInsert — stamps last_update_at. A brand-new task has just been touched, by definition.

beforeUpdate

  • transition intodonecompleted_at = now
  • transition out ofdonecompleted_at = null
  • last_update_at = now only when status, note or skip_reasonactually changed against the pre-image

Everything keys off ctx.previous, so these are transitions rather than states. That is what stops a whole-record form re-submitting status: 'done' from overwriting the original completion instant on every save, and what makes "re-saving with no changes" a no-op.

The stagnation signal

last_update_at feeds the "Not moving" view — status in (open, in_progress) AND last_update_at < {14_days_ago}. A hook that stamped on every update would let one bulk re-owner, a business-unit backfill or an import silently reset the clock across the whole table. Nothing errors; the numbers just quietly improve and the signal goes quiet exactly when it matters.

So the touch list is exactly status, note, skip_reason. owner, business_unit, assignment, duty, due_date, visible_from, period_key, source and subject are deliberately excluded, and three of them are asserted directly:

✓ does NOT advance on an administrative write (business_unit)
✓ does NOT advance on a re-owner
✓ does NOT advance on a re-date
✓ does NOT advance on a re-save with no changes

A context with no pre-image — the whole-operation dispatch of an unscoped predicate write, i.e. the bulk write this hook most needs to survive — stamps nothing at all.

Three platform assumptions, verified rather than assumed

All three held. Measured against a real booted engine, not read off the docs:

AssumptionVerdict
beforeUpdate can see the previous field valuesTrue. The engine binds hookContext.previous from a pre-read of the row before dispatching the beforeUpdate chain, and throws recordNotFoundError if that read comes back empty — so on the by-id path it is always present. Pinned by a probe hook in the suite.
A beforeUpdate stamp lands ahead of validationTrue. Order is beforeUpdatevalidateRecord → readonly strip → evaluateValidationRules. A write carrying only { status: 'done' } therefore commits.
readonly stripping blocks caller values while the hook's write passesTrue, with one edge — see #31. The strip drops a readonly key only when its value is still exactly what the caller supplied, so a hook-derived value replaces a caller-supplied one.

Tests

test/task-hook.test.ts boots a real ObjectQL engine (in-memory driver) with this app's own objectstack.config.ts as the bundle. The handler runs only because AppPlugin found it in the real config, so the suite cannot pass on dead metadata.

Two things worth flagging to a reviewer:

The suite is pinned away from the build artifact. Left at its default, createStandaloneStack resolves dist/objectstack.json under the working directory, and when a local pnpm build has left one there the kernel loads its metadata — objects and hooks — from that file instead of from the config. The suite then reports on the last build rather than on src/, and behaves differently in CI (where pnpm test runs before pnpm build) than on a developer's machine. This is not hypothetical: it is what made the registration ablation come back green. The artifactPath sentinel in beforeAll is what closes it.

A negative control guards the completion assertions.completed_at_required_when_done is asserted to still refuse an insert of a done task, so if that rule ever goes quiet the completion tests stop proving anything.

Both ablations were run to confirm the assertions can fail:

  • stamping last_update_at unconditionally (the classic wrong hook) → 5 failed, including the business_unit assertion
  • removing the entry from dulyHooks11 failed, the registration and every positive behaviour test

Not in this PR

The attachment clause is not implemented — filed as #28. #3 asks for last_update_at to advance on "an attachment", but attachments are sys_attachment rows carrying parent_object/parent_id, so adding one is an insert on that object and no duly_task write happens at all. It needs a second hook on sys_attachment, an isSystem write-back to get past the readonly strip, and answers to product questions #3 did not settle (does removing an attachment count? does an importer's?).

Also filed: #31 (reopening fails to clear completed_at when the caller also sends completed_at: null — an upstream Object.is limitation in the readonly strip, deliberately not worked around here) and #32 (a task cannot be created directly in done — needs a product decision).

Gates

All four green at 864ba78:

pnpm validate ✓ Validation passed (237ms)
pnpm typecheck exit 0
pnpm test Test Files 2 passed (2) · Tests 27 passed (27)
pnpm build ✓ Build complete · dist/objectstack.json (53.3 KB)

The handler is written as one self-contained function so objectstack build lowers it to a metadata body rather than falling back to the legacy bundled runtime module — a fallback that only warns. The lowered body was executed against the real QuickJS sandbox to confirm it behaves identically there, since that is the surface a served artifact runs.

src/objects/task.object.ts was not touched.


Generated by Claude Code

os-warrenand others added 2 commits September 1, 2026 03:51
Left at its default, createStandaloneStack resolves <cwd>/dist/objectstack.json
and the kernel loads its metadata — objects AND hooks — from that file. With a
local pnpm build present the suite then reported on the last build rather than
on src/, and the registration ablation came back green with the barrel entry
deleted. Point the lookup at a sentinel path instead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
@os-warren
os-warren marked this pull request as ready for review September 1, 2026 04:04
@os-warren
os-warren merged commit c05fcd5 into mainSep 1, 2026
1 check passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Task lifecycle hook — completed_at and last_update_at stamping

1 participant

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Task lifecycle hook — completed_at and last_update_at stamping - #35

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-3-task-hook
Sep 1, 2026
Merged

Task lifecycle hook — completed_at and last_update_at stamping#35
os-warren merged 2 commits into
mainfrom
claude/issue-3-task-hook

Conversation

@os-warren

@os-warrenos-warren commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#3

Two server-owned timestamps on duly_task, written by one hook registered in dulyHooks.

What it does

beforeInsert — stamps last_update_at. A brand-new task has just been touched, by definition.

beforeUpdate

  • transition intodonecompleted_at = now
  • transition out ofdonecompleted_at = null
  • last_update_at = now only when status, note or skip_reasonactually changed against the pre-image

Everything keys off ctx.previous, so these are transitions rather than states. That is what stops a whole-record form re-submitting status: 'done' from overwriting the original completion instant on every save, and what makes "re-saving with no changes" a no-op.

The stagnation signal

last_update_at feeds the "Not moving" view — status in (open, in_progress) AND last_update_at < {14_days_ago}. A hook that stamped on every update would let one bulk re-owner, a business-unit backfill or an import silently reset the clock across the whole table. Nothing errors; the numbers just quietly improve and the signal goes quiet exactly when it matters.

So the touch list is exactly status, note, skip_reason. owner, business_unit, assignment, duty, due_date, visible_from, period_key, source and subject are deliberately excluded, and three of them are asserted directly:

✓ does NOT advance on an administrative write (business_unit)
✓ does NOT advance on a re-owner
✓ does NOT advance on a re-date
✓ does NOT advance on a re-save with no changes

A context with no pre-image — the whole-operation dispatch of an unscoped predicate write, i.e. the bulk write this hook most needs to survive — stamps nothing at all.

Three platform assumptions, verified rather than assumed

All three held. Measured against a real booted engine, not read off the docs:

AssumptionVerdict
beforeUpdate can see the previous field valuesTrue. The engine binds hookContext.previous from a pre-read of the row before dispatching the beforeUpdate chain, and throws recordNotFoundError if that read comes back empty — so on the by-id path it is always present. Pinned by a probe hook in the suite.
A beforeUpdate stamp lands ahead of validationTrue. Order is beforeUpdatevalidateRecord → readonly strip → evaluateValidationRules. A write carrying only { status: 'done' } therefore commits.
readonly stripping blocks caller values while the hook's write passesTrue, with one edge — see #31. The strip drops a readonly key only when its value is still exactly what the caller supplied, so a hook-derived value replaces a caller-supplied one.

Tests

test/task-hook.test.ts boots a real ObjectQL engine (in-memory driver) with this app's own objectstack.config.ts as the bundle. The handler runs only because AppPlugin found it in the real config, so the suite cannot pass on dead metadata.

Two things worth flagging to a reviewer:

The suite is pinned away from the build artifact. Left at its default, createStandaloneStack resolves dist/objectstack.json under the working directory, and when a local pnpm build has left one there the kernel loads its metadata — objects and hooks — from that file instead of from the config. The suite then reports on the last build rather than on src/, and behaves differently in CI (where pnpm test runs before pnpm build) than on a developer's machine. This is not hypothetical: it is what made the registration ablation come back green. The artifactPath sentinel in beforeAll is what closes it.

A negative control guards the completion assertions.completed_at_required_when_done is asserted to still refuse an insert of a done task, so if that rule ever goes quiet the completion tests stop proving anything.

Both ablations were run to confirm the assertions can fail:

  • stamping last_update_at unconditionally (the classic wrong hook) → 5 failed, including the business_unit assertion
  • removing the entry from dulyHooks11 failed, the registration and every positive behaviour test

Not in this PR

The attachment clause is not implemented — filed as #28. #3 asks for last_update_at to advance on "an attachment", but attachments are sys_attachment rows carrying parent_object/parent_id, so adding one is an insert on that object and no duly_task write happens at all. It needs a second hook on sys_attachment, an isSystem write-back to get past the readonly strip, and answers to product questions #3 did not settle (does removing an attachment count? does an importer's?).

Also filed: #31 (reopening fails to clear completed_at when the caller also sends completed_at: null — an upstream Object.is limitation in the readonly strip, deliberately not worked around here) and #32 (a task cannot be created directly in done — needs a product decision).

Gates

All four green at 864ba78:

pnpm validate ✓ Validation passed (237ms)
pnpm typecheck exit 0
pnpm test Test Files 2 passed (2) · Tests 27 passed (27)
pnpm build ✓ Build complete · dist/objectstack.json (53.3 KB)

The handler is written as one self-contained function so objectstack build lowers it to a metadata body rather than falling back to the legacy bundled runtime module — a fallback that only warns. The lowered body was executed against the real QuickJS sandbox to confirm it behaves identically there, since that is the surface a served artifact runs.

src/objects/task.object.ts was not touched.


Generated by Claude Code

os-warrenand others added 2 commits September 1, 2026 03:51
Left at its default, createStandaloneStack resolves <cwd>/dist/objectstack.json
and the kernel loads its metadata — objects AND hooks — from that file. With a
local pnpm build present the suite then reported on the last build rather than
on src/, and the registration ablation came back green with the barrel entry
deleted. Point the lookup at a sentinel path instead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
@os-warren
os-warren marked this pull request as ready for review September 1, 2026 04:04
@os-warren
os-warren merged commit c05fcd5 into mainSep 1, 2026
1 check passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Task lifecycle hook — completed_at and last_update_at stamping

1 participant

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Task lifecycle hook — completed_at and last_update_at stamping - #35

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-3-task-hook
Sep 1, 2026
Merged

Task lifecycle hook — completed_at and last_update_at stamping#35
os-warren merged 2 commits into
mainfrom
claude/issue-3-task-hook

Conversation

@os-warren

@os-warrenos-warren commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#3

Two server-owned timestamps on duly_task, written by one hook registered in dulyHooks.

What it does

beforeInsert — stamps last_update_at. A brand-new task has just been touched, by definition.

beforeUpdate

  • transition intodonecompleted_at = now
  • transition out ofdonecompleted_at = null
  • last_update_at = now only when status, note or skip_reasonactually changed against the pre-image

Everything keys off ctx.previous, so these are transitions rather than states. That is what stops a whole-record form re-submitting status: 'done' from overwriting the original completion instant on every save, and what makes "re-saving with no changes" a no-op.

The stagnation signal

last_update_at feeds the "Not moving" view — status in (open, in_progress) AND last_update_at < {14_days_ago}. A hook that stamped on every update would let one bulk re-owner, a business-unit backfill or an import silently reset the clock across the whole table. Nothing errors; the numbers just quietly improve and the signal goes quiet exactly when it matters.

So the touch list is exactly status, note, skip_reason. owner, business_unit, assignment, duty, due_date, visible_from, period_key, source and subject are deliberately excluded, and three of them are asserted directly:

✓ does NOT advance on an administrative write (business_unit)
✓ does NOT advance on a re-owner
✓ does NOT advance on a re-date
✓ does NOT advance on a re-save with no changes

A context with no pre-image — the whole-operation dispatch of an unscoped predicate write, i.e. the bulk write this hook most needs to survive — stamps nothing at all.

Three platform assumptions, verified rather than assumed

All three held. Measured against a real booted engine, not read off the docs:

AssumptionVerdict
beforeUpdate can see the previous field valuesTrue. The engine binds hookContext.previous from a pre-read of the row before dispatching the beforeUpdate chain, and throws recordNotFoundError if that read comes back empty — so on the by-id path it is always present. Pinned by a probe hook in the suite.
A beforeUpdate stamp lands ahead of validationTrue. Order is beforeUpdatevalidateRecord → readonly strip → evaluateValidationRules. A write carrying only { status: 'done' } therefore commits.
readonly stripping blocks caller values while the hook's write passesTrue, with one edge — see #31. The strip drops a readonly key only when its value is still exactly what the caller supplied, so a hook-derived value replaces a caller-supplied one.

Tests

test/task-hook.test.ts boots a real ObjectQL engine (in-memory driver) with this app's own objectstack.config.ts as the bundle. The handler runs only because AppPlugin found it in the real config, so the suite cannot pass on dead metadata.

Two things worth flagging to a reviewer:

The suite is pinned away from the build artifact. Left at its default, createStandaloneStack resolves dist/objectstack.json under the working directory, and when a local pnpm build has left one there the kernel loads its metadata — objects and hooks — from that file instead of from the config. The suite then reports on the last build rather than on src/, and behaves differently in CI (where pnpm test runs before pnpm build) than on a developer's machine. This is not hypothetical: it is what made the registration ablation come back green. The artifactPath sentinel in beforeAll is what closes it.

A negative control guards the completion assertions.completed_at_required_when_done is asserted to still refuse an insert of a done task, so if that rule ever goes quiet the completion tests stop proving anything.

Both ablations were run to confirm the assertions can fail:

  • stamping last_update_at unconditionally (the classic wrong hook) → 5 failed, including the business_unit assertion
  • removing the entry from dulyHooks11 failed, the registration and every positive behaviour test

Not in this PR

The attachment clause is not implemented — filed as #28. #3 asks for last_update_at to advance on "an attachment", but attachments are sys_attachment rows carrying parent_object/parent_id, so adding one is an insert on that object and no duly_task write happens at all. It needs a second hook on sys_attachment, an isSystem write-back to get past the readonly strip, and answers to product questions #3 did not settle (does removing an attachment count? does an importer's?).

Also filed: #31 (reopening fails to clear completed_at when the caller also sends completed_at: null — an upstream Object.is limitation in the readonly strip, deliberately not worked around here) and #32 (a task cannot be created directly in done — needs a product decision).

Gates

All four green at 864ba78:

pnpm validate ✓ Validation passed (237ms)
pnpm typecheck exit 0
pnpm test Test Files 2 passed (2) · Tests 27 passed (27)
pnpm build ✓ Build complete · dist/objectstack.json (53.3 KB)

The handler is written as one self-contained function so objectstack build lowers it to a metadata body rather than falling back to the legacy bundled runtime module — a fallback that only warns. The lowered body was executed against the real QuickJS sandbox to confirm it behaves identically there, since that is the surface a served artifact runs.

src/objects/task.object.ts was not touched.


Generated by Claude Code

os-warrenand others added 2 commits September 1, 2026 03:51
Left at its default, createStandaloneStack resolves <cwd>/dist/objectstack.json
and the kernel loads its metadata — objects AND hooks — from that file. With a
local pnpm build present the suite then reported on the last build rather than
on src/, and the registration ablation came back green with the barrel entry
deleted. Point the lookup at a sentinel path instead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
@os-warren
os-warren marked this pull request as ready for review September 1, 2026 04:04
@os-warren
os-warren merged commit c05fcd5 into mainSep 1, 2026
1 check passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Task lifecycle hook — completed_at and last_update_at stamping

1 participant

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

Task lifecycle hook — completed_at and last_update_at stamping - #35

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-3-task-hook
Sep 1, 2026
Merged

Task lifecycle hook — completed_at and last_update_at stamping#35
os-warren merged 2 commits into
mainfrom
claude/issue-3-task-hook

Conversation

@os-warren

@os-warrenos-warren commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#3

Two server-owned timestamps on duly_task, written by one hook registered in dulyHooks.

What it does

beforeInsert — stamps last_update_at. A brand-new task has just been touched, by definition.

beforeUpdate

  • transition intodonecompleted_at = now
  • transition out ofdonecompleted_at = null
  • last_update_at = now only when status, note or skip_reasonactually changed against the pre-image

Everything keys off ctx.previous, so these are transitions rather than states. That is what stops a whole-record form re-submitting status: 'done' from overwriting the original completion instant on every save, and what makes "re-saving with no changes" a no-op.

The stagnation signal

last_update_at feeds the "Not moving" view — status in (open, in_progress) AND last_update_at < {14_days_ago}. A hook that stamped on every update would let one bulk re-owner, a business-unit backfill or an import silently reset the clock across the whole table. Nothing errors; the numbers just quietly improve and the signal goes quiet exactly when it matters.

So the touch list is exactly status, note, skip_reason. owner, business_unit, assignment, duty, due_date, visible_from, period_key, source and subject are deliberately excluded, and three of them are asserted directly:

✓ does NOT advance on an administrative write (business_unit)
✓ does NOT advance on a re-owner
✓ does NOT advance on a re-date
✓ does NOT advance on a re-save with no changes

A context with no pre-image — the whole-operation dispatch of an unscoped predicate write, i.e. the bulk write this hook most needs to survive — stamps nothing at all.

Three platform assumptions, verified rather than assumed

All three held. Measured against a real booted engine, not read off the docs:

AssumptionVerdict
beforeUpdate can see the previous field valuesTrue. The engine binds hookContext.previous from a pre-read of the row before dispatching the beforeUpdate chain, and throws recordNotFoundError if that read comes back empty — so on the by-id path it is always present. Pinned by a probe hook in the suite.
A beforeUpdate stamp lands ahead of validationTrue. Order is beforeUpdatevalidateRecord → readonly strip → evaluateValidationRules. A write carrying only { status: 'done' } therefore commits.
readonly stripping blocks caller values while the hook's write passesTrue, with one edge — see #31. The strip drops a readonly key only when its value is still exactly what the caller supplied, so a hook-derived value replaces a caller-supplied one.

Tests

test/task-hook.test.ts boots a real ObjectQL engine (in-memory driver) with this app's own objectstack.config.ts as the bundle. The handler runs only because AppPlugin found it in the real config, so the suite cannot pass on dead metadata.

Two things worth flagging to a reviewer:

The suite is pinned away from the build artifact. Left at its default, createStandaloneStack resolves dist/objectstack.json under the working directory, and when a local pnpm build has left one there the kernel loads its metadata — objects and hooks — from that file instead of from the config. The suite then reports on the last build rather than on src/, and behaves differently in CI (where pnpm test runs before pnpm build) than on a developer's machine. This is not hypothetical: it is what made the registration ablation come back green. The artifactPath sentinel in beforeAll is what closes it.

A negative control guards the completion assertions.completed_at_required_when_done is asserted to still refuse an insert of a done task, so if that rule ever goes quiet the completion tests stop proving anything.

Both ablations were run to confirm the assertions can fail:

  • stamping last_update_at unconditionally (the classic wrong hook) → 5 failed, including the business_unit assertion
  • removing the entry from dulyHooks11 failed, the registration and every positive behaviour test

Not in this PR

The attachment clause is not implemented — filed as #28. #3 asks for last_update_at to advance on "an attachment", but attachments are sys_attachment rows carrying parent_object/parent_id, so adding one is an insert on that object and no duly_task write happens at all. It needs a second hook on sys_attachment, an isSystem write-back to get past the readonly strip, and answers to product questions #3 did not settle (does removing an attachment count? does an importer's?).

Also filed: #31 (reopening fails to clear completed_at when the caller also sends completed_at: null — an upstream Object.is limitation in the readonly strip, deliberately not worked around here) and #32 (a task cannot be created directly in done — needs a product decision).

Gates

All four green at 864ba78:

pnpm validate ✓ Validation passed (237ms)
pnpm typecheck exit 0
pnpm test Test Files 2 passed (2) · Tests 27 passed (27)
pnpm build ✓ Build complete · dist/objectstack.json (53.3 KB)

The handler is written as one self-contained function so objectstack build lowers it to a metadata body rather than falling back to the legacy bundled runtime module — a fallback that only warns. The lowered body was executed against the real QuickJS sandbox to confirm it behaves identically there, since that is the surface a served artifact runs.

src/objects/task.object.ts was not touched.


Generated by Claude Code

os-warrenand others added 2 commits September 1, 2026 03:51
Left at its default, createStandaloneStack resolves <cwd>/dist/objectstack.json
and the kernel loads its metadata — objects AND hooks — from that file. With a
local pnpm build present the suite then reported on the last build rather than
on src/, and the registration ablation came back green with the barrel entry
deleted. Point the lookup at a sentinel path instead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
@os-warren
os-warren marked this pull request as ready for review September 1, 2026 04:04
@os-warren
os-warren merged commit c05fcd5 into mainSep 1, 2026
1 check passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Task lifecycle hook — completed_at and last_update_at stamping

1 participant

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Task lifecycle hook — completed_at and last_update_at stamping - #35

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-3-task-hook
Sep 1, 2026
Merged

Task lifecycle hook — completed_at and last_update_at stamping#35
os-warren merged 2 commits into
mainfrom
claude/issue-3-task-hook

Conversation

@os-warren

@os-warrenos-warren commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#3

Two server-owned timestamps on duly_task, written by one hook registered in dulyHooks.

What it does

beforeInsert — stamps last_update_at. A brand-new task has just been touched, by definition.

beforeUpdate

  • transition intodonecompleted_at = now
  • transition out ofdonecompleted_at = null
  • last_update_at = now only when status, note or skip_reasonactually changed against the pre-image

Everything keys off ctx.previous, so these are transitions rather than states. That is what stops a whole-record form re-submitting status: 'done' from overwriting the original completion instant on every save, and what makes "re-saving with no changes" a no-op.

The stagnation signal

last_update_at feeds the "Not moving" view — status in (open, in_progress) AND last_update_at < {14_days_ago}. A hook that stamped on every update would let one bulk re-owner, a business-unit backfill or an import silently reset the clock across the whole table. Nothing errors; the numbers just quietly improve and the signal goes quiet exactly when it matters.

So the touch list is exactly status, note, skip_reason. owner, business_unit, assignment, duty, due_date, visible_from, period_key, source and subject are deliberately excluded, and three of them are asserted directly:

✓ does NOT advance on an administrative write (business_unit)
✓ does NOT advance on a re-owner
✓ does NOT advance on a re-date
✓ does NOT advance on a re-save with no changes

A context with no pre-image — the whole-operation dispatch of an unscoped predicate write, i.e. the bulk write this hook most needs to survive — stamps nothing at all.

Three platform assumptions, verified rather than assumed

All three held. Measured against a real booted engine, not read off the docs:

AssumptionVerdict
beforeUpdate can see the previous field valuesTrue. The engine binds hookContext.previous from a pre-read of the row before dispatching the beforeUpdate chain, and throws recordNotFoundError if that read comes back empty — so on the by-id path it is always present. Pinned by a probe hook in the suite.
A beforeUpdate stamp lands ahead of validationTrue. Order is beforeUpdatevalidateRecord → readonly strip → evaluateValidationRules. A write carrying only { status: 'done' } therefore commits.
readonly stripping blocks caller values while the hook's write passesTrue, with one edge — see #31. The strip drops a readonly key only when its value is still exactly what the caller supplied, so a hook-derived value replaces a caller-supplied one.

Tests

test/task-hook.test.ts boots a real ObjectQL engine (in-memory driver) with this app's own objectstack.config.ts as the bundle. The handler runs only because AppPlugin found it in the real config, so the suite cannot pass on dead metadata.

Two things worth flagging to a reviewer:

The suite is pinned away from the build artifact. Left at its default, createStandaloneStack resolves dist/objectstack.json under the working directory, and when a local pnpm build has left one there the kernel loads its metadata — objects and hooks — from that file instead of from the config. The suite then reports on the last build rather than on src/, and behaves differently in CI (where pnpm test runs before pnpm build) than on a developer's machine. This is not hypothetical: it is what made the registration ablation come back green. The artifactPath sentinel in beforeAll is what closes it.

A negative control guards the completion assertions.completed_at_required_when_done is asserted to still refuse an insert of a done task, so if that rule ever goes quiet the completion tests stop proving anything.

Both ablations were run to confirm the assertions can fail:

  • stamping last_update_at unconditionally (the classic wrong hook) → 5 failed, including the business_unit assertion
  • removing the entry from dulyHooks11 failed, the registration and every positive behaviour test

Not in this PR

The attachment clause is not implemented — filed as #28. #3 asks for last_update_at to advance on "an attachment", but attachments are sys_attachment rows carrying parent_object/parent_id, so adding one is an insert on that object and no duly_task write happens at all. It needs a second hook on sys_attachment, an isSystem write-back to get past the readonly strip, and answers to product questions #3 did not settle (does removing an attachment count? does an importer's?).

Also filed: #31 (reopening fails to clear completed_at when the caller also sends completed_at: null — an upstream Object.is limitation in the readonly strip, deliberately not worked around here) and #32 (a task cannot be created directly in done — needs a product decision).

Gates

All four green at 864ba78:

pnpm validate ✓ Validation passed (237ms)
pnpm typecheck exit 0
pnpm test Test Files 2 passed (2) · Tests 27 passed (27)
pnpm build ✓ Build complete · dist/objectstack.json (53.3 KB)

The handler is written as one self-contained function so objectstack build lowers it to a metadata body rather than falling back to the legacy bundled runtime module — a fallback that only warns. The lowered body was executed against the real QuickJS sandbox to confirm it behaves identically there, since that is the surface a served artifact runs.

src/objects/task.object.ts was not touched.


Generated by Claude Code

os-warrenand others added 2 commits September 1, 2026 03:51
Left at its default, createStandaloneStack resolves <cwd>/dist/objectstack.json
and the kernel loads its metadata — objects AND hooks — from that file. With a
local pnpm build present the suite then reported on the last build rather than
on src/, and the registration ablation came back green with the barrel entry
deleted. Point the lookup at a sentinel path instead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
@os-warren
os-warren marked this pull request as ready for review September 1, 2026 04:04
@os-warren
os-warren merged commit c05fcd5 into mainSep 1, 2026
1 check passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Task lifecycle hook — completed_at and last_update_at stamping

1 participant

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

Task lifecycle hook — completed_at and last_update_at stamping - #35

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-3-task-hook
Sep 1, 2026
Merged

Task lifecycle hook — completed_at and last_update_at stamping#35
os-warren merged 2 commits into
mainfrom
claude/issue-3-task-hook

Conversation

@os-warren

@os-warrenos-warren commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#3

Two server-owned timestamps on duly_task, written by one hook registered in dulyHooks.

What it does

beforeInsert — stamps last_update_at. A brand-new task has just been touched, by definition.

beforeUpdate

  • transition intodonecompleted_at = now
  • transition out ofdonecompleted_at = null
  • last_update_at = now only when status, note or skip_reasonactually changed against the pre-image

Everything keys off ctx.previous, so these are transitions rather than states. That is what stops a whole-record form re-submitting status: 'done' from overwriting the original completion instant on every save, and what makes "re-saving with no changes" a no-op.

The stagnation signal

last_update_at feeds the "Not moving" view — status in (open, in_progress) AND last_update_at < {14_days_ago}. A hook that stamped on every update would let one bulk re-owner, a business-unit backfill or an import silently reset the clock across the whole table. Nothing errors; the numbers just quietly improve and the signal goes quiet exactly when it matters.

So the touch list is exactly status, note, skip_reason. owner, business_unit, assignment, duty, due_date, visible_from, period_key, source and subject are deliberately excluded, and three of them are asserted directly:

✓ does NOT advance on an administrative write (business_unit)
✓ does NOT advance on a re-owner
✓ does NOT advance on a re-date
✓ does NOT advance on a re-save with no changes

A context with no pre-image — the whole-operation dispatch of an unscoped predicate write, i.e. the bulk write this hook most needs to survive — stamps nothing at all.

Three platform assumptions, verified rather than assumed

All three held. Measured against a real booted engine, not read off the docs:

AssumptionVerdict
beforeUpdate can see the previous field valuesTrue. The engine binds hookContext.previous from a pre-read of the row before dispatching the beforeUpdate chain, and throws recordNotFoundError if that read comes back empty — so on the by-id path it is always present. Pinned by a probe hook in the suite.
A beforeUpdate stamp lands ahead of validationTrue. Order is beforeUpdatevalidateRecord → readonly strip → evaluateValidationRules. A write carrying only { status: 'done' } therefore commits.
readonly stripping blocks caller values while the hook's write passesTrue, with one edge — see #31. The strip drops a readonly key only when its value is still exactly what the caller supplied, so a hook-derived value replaces a caller-supplied one.

Tests

test/task-hook.test.ts boots a real ObjectQL engine (in-memory driver) with this app's own objectstack.config.ts as the bundle. The handler runs only because AppPlugin found it in the real config, so the suite cannot pass on dead metadata.

Two things worth flagging to a reviewer:

The suite is pinned away from the build artifact. Left at its default, createStandaloneStack resolves dist/objectstack.json under the working directory, and when a local pnpm build has left one there the kernel loads its metadata — objects and hooks — from that file instead of from the config. The suite then reports on the last build rather than on src/, and behaves differently in CI (where pnpm test runs before pnpm build) than on a developer's machine. This is not hypothetical: it is what made the registration ablation come back green. The artifactPath sentinel in beforeAll is what closes it.

A negative control guards the completion assertions.completed_at_required_when_done is asserted to still refuse an insert of a done task, so if that rule ever goes quiet the completion tests stop proving anything.

Both ablations were run to confirm the assertions can fail:

  • stamping last_update_at unconditionally (the classic wrong hook) → 5 failed, including the business_unit assertion
  • removing the entry from dulyHooks11 failed, the registration and every positive behaviour test

Not in this PR

The attachment clause is not implemented — filed as #28. #3 asks for last_update_at to advance on "an attachment", but attachments are sys_attachment rows carrying parent_object/parent_id, so adding one is an insert on that object and no duly_task write happens at all. It needs a second hook on sys_attachment, an isSystem write-back to get past the readonly strip, and answers to product questions #3 did not settle (does removing an attachment count? does an importer's?).

Also filed: #31 (reopening fails to clear completed_at when the caller also sends completed_at: null — an upstream Object.is limitation in the readonly strip, deliberately not worked around here) and #32 (a task cannot be created directly in done — needs a product decision).

Gates

All four green at 864ba78:

pnpm validate ✓ Validation passed (237ms)
pnpm typecheck exit 0
pnpm test Test Files 2 passed (2) · Tests 27 passed (27)
pnpm build ✓ Build complete · dist/objectstack.json (53.3 KB)

The handler is written as one self-contained function so objectstack build lowers it to a metadata body rather than falling back to the legacy bundled runtime module — a fallback that only warns. The lowered body was executed against the real QuickJS sandbox to confirm it behaves identically there, since that is the surface a served artifact runs.

src/objects/task.object.ts was not touched.


Generated by Claude Code

os-warrenand others added 2 commits September 1, 2026 03:51
Left at its default, createStandaloneStack resolves <cwd>/dist/objectstack.json
and the kernel loads its metadata — objects AND hooks — from that file. With a
local pnpm build present the suite then reported on the last build rather than
on src/, and the registration ablation came back green with the barrel entry
deleted. Point the lookup at a sentinel path instead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
@os-warren
os-warren marked this pull request as ready for review September 1, 2026 04:04
@os-warren
os-warren merged commit c05fcd5 into mainSep 1, 2026
1 check passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Task lifecycle hook — completed_at and last_update_at stamping

1 participant

@os-warren
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

Task lifecycle hook — completed_at and last_update_at stamping - #35

Merged
os-warren merged 2 commits into
mainfrom
claude/issue-3-task-hook
Sep 1, 2026
Merged

Task lifecycle hook — completed_at and last_update_at stamping#35
os-warren merged 2 commits into
mainfrom
claude/issue-3-task-hook

Conversation

@os-warren

@os-warrenos-warren commented Sep 1, 2026

Copy link
Copy Markdown
Collaborator

Fixes#3

Two server-owned timestamps on duly_task, written by one hook registered in dulyHooks.

What it does

beforeInsert — stamps last_update_at. A brand-new task has just been touched, by definition.

beforeUpdate

  • transition intodonecompleted_at = now
  • transition out ofdonecompleted_at = null
  • last_update_at = now only when status, note or skip_reasonactually changed against the pre-image

Everything keys off ctx.previous, so these are transitions rather than states. That is what stops a whole-record form re-submitting status: 'done' from overwriting the original completion instant on every save, and what makes "re-saving with no changes" a no-op.

The stagnation signal

last_update_at feeds the "Not moving" view — status in (open, in_progress) AND last_update_at < {14_days_ago}. A hook that stamped on every update would let one bulk re-owner, a business-unit backfill or an import silently reset the clock across the whole table. Nothing errors; the numbers just quietly improve and the signal goes quiet exactly when it matters.

So the touch list is exactly status, note, skip_reason. owner, business_unit, assignment, duty, due_date, visible_from, period_key, source and subject are deliberately excluded, and three of them are asserted directly:

✓ does NOT advance on an administrative write (business_unit)
✓ does NOT advance on a re-owner
✓ does NOT advance on a re-date
✓ does NOT advance on a re-save with no changes

A context with no pre-image — the whole-operation dispatch of an unscoped predicate write, i.e. the bulk write this hook most needs to survive — stamps nothing at all.

Three platform assumptions, verified rather than assumed

All three held. Measured against a real booted engine, not read off the docs:

AssumptionVerdict
beforeUpdate can see the previous field valuesTrue. The engine binds hookContext.previous from a pre-read of the row before dispatching the beforeUpdate chain, and throws recordNotFoundError if that read comes back empty — so on the by-id path it is always present. Pinned by a probe hook in the suite.
A beforeUpdate stamp lands ahead of validationTrue. Order is beforeUpdatevalidateRecord → readonly strip → evaluateValidationRules. A write carrying only { status: 'done' } therefore commits.
readonly stripping blocks caller values while the hook's write passesTrue, with one edge — see #31. The strip drops a readonly key only when its value is still exactly what the caller supplied, so a hook-derived value replaces a caller-supplied one.

Tests

test/task-hook.test.ts boots a real ObjectQL engine (in-memory driver) with this app's own objectstack.config.ts as the bundle. The handler runs only because AppPlugin found it in the real config, so the suite cannot pass on dead metadata.

Two things worth flagging to a reviewer:

The suite is pinned away from the build artifact. Left at its default, createStandaloneStack resolves dist/objectstack.json under the working directory, and when a local pnpm build has left one there the kernel loads its metadata — objects and hooks — from that file instead of from the config. The suite then reports on the last build rather than on src/, and behaves differently in CI (where pnpm test runs before pnpm build) than on a developer's machine. This is not hypothetical: it is what made the registration ablation come back green. The artifactPath sentinel in beforeAll is what closes it.

A negative control guards the completion assertions.completed_at_required_when_done is asserted to still refuse an insert of a done task, so if that rule ever goes quiet the completion tests stop proving anything.

Both ablations were run to confirm the assertions can fail:

  • stamping last_update_at unconditionally (the classic wrong hook) → 5 failed, including the business_unit assertion
  • removing the entry from dulyHooks11 failed, the registration and every positive behaviour test

Not in this PR

The attachment clause is not implemented — filed as #28. #3 asks for last_update_at to advance on "an attachment", but attachments are sys_attachment rows carrying parent_object/parent_id, so adding one is an insert on that object and no duly_task write happens at all. It needs a second hook on sys_attachment, an isSystem write-back to get past the readonly strip, and answers to product questions #3 did not settle (does removing an attachment count? does an importer's?).

Also filed: #31 (reopening fails to clear completed_at when the caller also sends completed_at: null — an upstream Object.is limitation in the readonly strip, deliberately not worked around here) and #32 (a task cannot be created directly in done — needs a product decision).

Gates

All four green at 864ba78:

pnpm validate ✓ Validation passed (237ms)
pnpm typecheck exit 0
pnpm test Test Files 2 passed (2) · Tests 27 passed (27)
pnpm build ✓ Build complete · dist/objectstack.json (53.3 KB)

The handler is written as one self-contained function so objectstack build lowers it to a metadata body rather than falling back to the legacy bundled runtime module — a fallback that only warns. The lowered body was executed against the real QuickJS sandbox to confirm it behaves identically there, since that is the surface a served artifact runs.

src/objects/task.object.ts was not touched.


Generated by Claude Code

os-warrenand others added 2 commits September 1, 2026 03:51
Left at its default, createStandaloneStack resolves <cwd>/dist/objectstack.json
and the kernel loads its metadata — objects AND hooks — from that file. With a
local pnpm build present the suite then reported on the last build rather than
on src/, and the registration ablation came back green with the barrel entry
deleted. Point the lookup at a sentinel path instead.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SqkTcrxUFci7nqXdbBSe2p
@os-warren
os-warren marked this pull request as ready for review September 1, 2026 04:04
@os-warren
os-warren merged commit c05fcd5 into mainSep 1, 2026
1 check passed
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Task lifecycle hook — completed_at and last_update_at stamping

1 participant

@os-warren