Repository files navigation

Duly

Recurring obligation and duty management.

Every role in an organisation owes a set of things on a repeating clock — a monthly return, a quarterly inspection, an annual review, a weekly reconciliation. Most of them are tracked in a spreadsheet, remembered by one person, and discovered late.

Duly turns that spreadsheet into a system: duties are defined once against a role, dispatched automatically each period, completed in one click, and rolled up so every level of management sees the state of play without asking for a status report.

Built on ObjectStack — metadata-driven, Apache-2.0, self-hostable, and an MCP server out of the box.


What makes it different

Most task products let you build this. Duly is opinionated about the ways it goes wrong, and the opinions are enforced by the data model rather than by documentation:

DecisionWhy
A duty is not a task. One duty × one owner × one period = one task, unique by index.Dispatch is idempotent. Re-run it, backfill it, crash halfway — no duplicates, no lock.
Standing duties never generate tasks."Keep the register current" cannot be ticked. Modelling it as a task creates a backlog nobody can close, and users learn to ignore the list.
Due dates are anchored inside the period, with lead time.Otherwise every annual and semi-annual duty lands in the last week of December.
Stagnation is the headline signal, not completion %.last_update_at warns weeks before a due date does. A percentage only describes work that already finished.
Self-declared work is recorded but never scored. The work log is a separate object with no due date and no rollup.One list holding both governed duties and voluntary notes always ends up measuring reporting enthusiasm. Two objects make that impossible rather than merely against the rules.
Managers have exactly one write action: assign.No manager-side status entry, no weekly consolidation form. An assignment fans out to N independent tasks; "3 of 5" is computed, never maintained.
Completion is one click with an undo, and evidence is optional.An evidence gate turns a 5-second tick into a 5-minute chore, and the list stops being used.
Item counts are never ranked or compared.The moment they are, the busiest people log the least.

Quick start

pnpm install
pnpm dev

The Console is at http://localhost:3000/_console/, the REST API at http://localhost:3000/api/v1, and the app is itself an MCP server at /api/v1/mcp. Sign in as admin@objectos.ai / admin123.

Two ways to start it

Duly ships empty. Evaluating it for your own organisation and evaluating the idea are different things, so they are different commands:

CommandWhat you get
pnpm devAn empty Duly. The objects, views and automations are all there; the records are yours to add — define your first duty against a role and watch it dispatch. This is also what a real deployment starts from.
pnpm demoThe same app preloaded with a worked example: Ardenline Group, a fictional manufacturer — three sites, twelve people over a three-level org chart, a catalog of duties, and six months of history behind them, so every view has something in it on the first screen.
pnpm demo:zhThe same worked example in Chinese — 安岭集团, its people, its duty catalog and its history, all in zh-CN, for a demo where the records read the same language as the interface. Identical in every other respect: same objects, same row counts, same history. The account you sign in with is renamed 演示管理员 to match.

pnpm demo prepares the database and then starts the server; it is one command and it works on a clean checkout. Everything it writes is ordinary data, so you can edit or delete any of it.

The two demos are the same fixture in two languages, not two datasets: one org chart, one duty catalog, one history planner, with the display strings resolved through src/data/demo-zh.ts. Machine values — unit codes, period keys, statuses, timezones — are identical in both, which is what keeps a Chinese demo from being a second demo that quietly drifts. The language is chosen at compile time by DULY_DEMO_LOCALE, so switch between them on a database you have already seeded and you will get both organisations in it; rm -rf .objectstack/data first.

To go back to an empty app, delete the local database and start again:

rm -rf .objectstack/data
pnpm dev

Nothing about the fictional organisation is real: every address is on an RFC 2606 reserved domain, and no real company, person, site or regulation is named anywhere in it. The rule holds in Chinese — 安岭集团 is not a company, and every reference the catalog cites is an invented internal document (《集团环境标准 GE-09》第1条), never a national or industry standard.

Every metadata directory is pre-wired into objectstack.config.ts, empty ones included: add your entry to the named array in your own src/<type>/index.ts and leave the config alone. It is the one file parallel branches collide on.

Import your existing list

Every customer already has the list — a spreadsheet per role, usually. Getting it in is the platform's Import button on each object list, not anything Duly wrote: upload, confirm the mapping, import. The CSVs in samples/ are shaped to go straight through it, and they describe the same fictional manufacturer as pnpm demo.

SampleImport it onRows
samples/business-units.csvSetup → People & Organization → Business Units6
samples/people.csvSetup → People & Organization → Users12
samples/catalog-items.csvDuly → Setup → Role catalog21
samples/duties.csvDuly → Setup → All duties19

Three steps

  1. Put your people and units in first. A duty's owner and business unit are looked up by name, so the rows have to exist before the duty file can land. In a real deployment they arrive from your directory; on a fresh pnpm dev database, import business-units.csv and then people.csv through the same Import button.
  2. Import the role catalogcatalog-items.csv on Role catalog. This is the list itself: what each position owes, how often, with how much grace. No lookups, so it goes into an empty app as-is.
  3. Import the dutiesduties.csv on All duties. This is the catalog instantiated onto named people, and it is where the natural keys resolve.

Each step is the same three screens — Upload → Mapping → Preview → import — and the count of created rows is reported at the end, with any refused row named and downloadable.

What the columns have to say

  • Headers are field API names (position_code, due_offset_days). The wizard auto-matches every one of them at high confidence. The Download template link on the Upload step gives you the same columns as labels instead; both are accepted.
  • Lookups are written as names, not ids.owner takes a person's name (Priya Raman) or their email; business_unit takes the unit's name (Northgate Quality — its code will not resolve); catalog_item takes the catalog item's name. This is the same natural-key rule the seed loader uses.
  • A name that matches nothing skips that row and says so, with a Download failed rows file to fix and re-import. Nothing is linked to a best guess.
  • Blank means "leave unset", so the object's defaults apply. That is what lets one file carry all three duty forms: a standing row leaves the five cadence columns empty and lands with them all null, which is exactly what standing_no_frequency requires.
  • Read-only columns are never written. They are visible in the mapping step as — Skip — or (match only), so a column that will not land says so before you import.
  • Re-importing needs the match option.When a row matches an existing record defaults to Always create new; running the same file twice otherwise gives you two copies.

test/import-samples.test.ts holds every sample header to the object's own schema, so renaming a field fails the build instead of quietly importing a blank column.

The full walk, screen by screen, with what each step was measured to do →

Verify before you ship

pnpm validate # protocol schema + CEL predicates + widget bindings
pnpm typecheck # types against @objectstack/spec
pnpm test

ObjectStack metadata fails silently at runtime, not at edit time. Never report a metadata change as done until pnpm validate passes.

Layout

objectstack.config.ts defineStack() — the single entry point
src/objects/ duty · task · catalog_item · assignment · log_entry
src/views/ list / calendar / kanban lenses
src/apps/ src/pages/ navigation and custom screens
src/jobs/ the dispatcher and the alert jobs
src/flows/ src/actions/ assignment fan-out, escalation, one-click completion
src/hooks/ object lifecycle hooks (collected here, not from objects/)
src/functions/ pure callables a `script` flow node resolves by name
src/datasets/ src/dashboards/ the semantic layer and what reads it
src/security/ positions, permission sets, sharing rules
src/mappings/ src/data/ catalog import and seed fixtures
src/translations/ en (source) · zh-CN
scripts/ pnpm demo — prepare the database, then start with the example loaded
samples/ CSVs for the platform's standard Import (see above)
docs/product/ positioning, data model, design principles
docs/import/ the recorded import walk, screen by screen

Documentation

License

Apache-2.0. See LICENSE.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, '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

Repository files navigation

Duly

Recurring obligation and duty management.

Every role in an organisation owes a set of things on a repeating clock — a monthly return, a quarterly inspection, an annual review, a weekly reconciliation. Most of them are tracked in a spreadsheet, remembered by one person, and discovered late.

Duly turns that spreadsheet into a system: duties are defined once against a role, dispatched automatically each period, completed in one click, and rolled up so every level of management sees the state of play without asking for a status report.

Built on ObjectStack — metadata-driven, Apache-2.0, self-hostable, and an MCP server out of the box.


What makes it different

Most task products let you build this. Duly is opinionated about the ways it goes wrong, and the opinions are enforced by the data model rather than by documentation:

DecisionWhy
A duty is not a task. One duty × one owner × one period = one task, unique by index.Dispatch is idempotent. Re-run it, backfill it, crash halfway — no duplicates, no lock.
Standing duties never generate tasks."Keep the register current" cannot be ticked. Modelling it as a task creates a backlog nobody can close, and users learn to ignore the list.
Due dates are anchored inside the period, with lead time.Otherwise every annual and semi-annual duty lands in the last week of December.
Stagnation is the headline signal, not completion %.last_update_at warns weeks before a due date does. A percentage only describes work that already finished.
Self-declared work is recorded but never scored. The work log is a separate object with no due date and no rollup.One list holding both governed duties and voluntary notes always ends up measuring reporting enthusiasm. Two objects make that impossible rather than merely against the rules.
Managers have exactly one write action: assign.No manager-side status entry, no weekly consolidation form. An assignment fans out to N independent tasks; "3 of 5" is computed, never maintained.
Completion is one click with an undo, and evidence is optional.An evidence gate turns a 5-second tick into a 5-minute chore, and the list stops being used.
Item counts are never ranked or compared.The moment they are, the busiest people log the least.

Quick start

pnpm install
pnpm dev

The Console is at http://localhost:3000/_console/, the REST API at http://localhost:3000/api/v1, and the app is itself an MCP server at /api/v1/mcp. Sign in as admin@objectos.ai / admin123.

Two ways to start it

Duly ships empty. Evaluating it for your own organisation and evaluating the idea are different things, so they are different commands:

CommandWhat you get
pnpm devAn empty Duly. The objects, views and automations are all there; the records are yours to add — define your first duty against a role and watch it dispatch. This is also what a real deployment starts from.
pnpm demoThe same app preloaded with a worked example: Ardenline Group, a fictional manufacturer — three sites, twelve people over a three-level org chart, a catalog of duties, and six months of history behind them, so every view has something in it on the first screen.
pnpm demo:zhThe same worked example in Chinese — 安岭集团, its people, its duty catalog and its history, all in zh-CN, for a demo where the records read the same language as the interface. Identical in every other respect: same objects, same row counts, same history. The account you sign in with is renamed 演示管理员 to match.

pnpm demo prepares the database and then starts the server; it is one command and it works on a clean checkout. Everything it writes is ordinary data, so you can edit or delete any of it.

The two demos are the same fixture in two languages, not two datasets: one org chart, one duty catalog, one history planner, with the display strings resolved through src/data/demo-zh.ts. Machine values — unit codes, period keys, statuses, timezones — are identical in both, which is what keeps a Chinese demo from being a second demo that quietly drifts. The language is chosen at compile time by DULY_DEMO_LOCALE, so switch between them on a database you have already seeded and you will get both organisations in it; rm -rf .objectstack/data first.

To go back to an empty app, delete the local database and start again:

rm -rf .objectstack/data
pnpm dev

Nothing about the fictional organisation is real: every address is on an RFC 2606 reserved domain, and no real company, person, site or regulation is named anywhere in it. The rule holds in Chinese — 安岭集团 is not a company, and every reference the catalog cites is an invented internal document (《集团环境标准 GE-09》第1条), never a national or industry standard.

Every metadata directory is pre-wired into objectstack.config.ts, empty ones included: add your entry to the named array in your own src/<type>/index.ts and leave the config alone. It is the one file parallel branches collide on.

Import your existing list

Every customer already has the list — a spreadsheet per role, usually. Getting it in is the platform's Import button on each object list, not anything Duly wrote: upload, confirm the mapping, import. The CSVs in samples/ are shaped to go straight through it, and they describe the same fictional manufacturer as pnpm demo.

SampleImport it onRows
samples/business-units.csvSetup → People & Organization → Business Units6
samples/people.csvSetup → People & Organization → Users12
samples/catalog-items.csvDuly → Setup → Role catalog21
samples/duties.csvDuly → Setup → All duties19

Three steps

  1. Put your people and units in first. A duty's owner and business unit are looked up by name, so the rows have to exist before the duty file can land. In a real deployment they arrive from your directory; on a fresh pnpm dev database, import business-units.csv and then people.csv through the same Import button.
  2. Import the role catalogcatalog-items.csv on Role catalog. This is the list itself: what each position owes, how often, with how much grace. No lookups, so it goes into an empty app as-is.
  3. Import the dutiesduties.csv on All duties. This is the catalog instantiated onto named people, and it is where the natural keys resolve.

Each step is the same three screens — Upload → Mapping → Preview → import — and the count of created rows is reported at the end, with any refused row named and downloadable.

What the columns have to say

  • Headers are field API names (position_code, due_offset_days). The wizard auto-matches every one of them at high confidence. The Download template link on the Upload step gives you the same columns as labels instead; both are accepted.
  • Lookups are written as names, not ids.owner takes a person's name (Priya Raman) or their email; business_unit takes the unit's name (Northgate Quality — its code will not resolve); catalog_item takes the catalog item's name. This is the same natural-key rule the seed loader uses.
  • A name that matches nothing skips that row and says so, with a Download failed rows file to fix and re-import. Nothing is linked to a best guess.
  • Blank means "leave unset", so the object's defaults apply. That is what lets one file carry all three duty forms: a standing row leaves the five cadence columns empty and lands with them all null, which is exactly what standing_no_frequency requires.
  • Read-only columns are never written. They are visible in the mapping step as — Skip — or (match only), so a column that will not land says so before you import.
  • Re-importing needs the match option.When a row matches an existing record defaults to Always create new; running the same file twice otherwise gives you two copies.

test/import-samples.test.ts holds every sample header to the object's own schema, so renaming a field fails the build instead of quietly importing a blank column.

The full walk, screen by screen, with what each step was measured to do →

Verify before you ship

pnpm validate # protocol schema + CEL predicates + widget bindings
pnpm typecheck # types against @objectstack/spec
pnpm test

ObjectStack metadata fails silently at runtime, not at edit time. Never report a metadata change as done until pnpm validate passes.

Layout

objectstack.config.ts defineStack() — the single entry point
src/objects/ duty · task · catalog_item · assignment · log_entry
src/views/ list / calendar / kanban lenses
src/apps/ src/pages/ navigation and custom screens
src/jobs/ the dispatcher and the alert jobs
src/flows/ src/actions/ assignment fan-out, escalation, one-click completion
src/hooks/ object lifecycle hooks (collected here, not from objects/)
src/functions/ pure callables a `script` flow node resolves by name
src/datasets/ src/dashboards/ the semantic layer and what reads it
src/security/ positions, permission sets, sharing rules
src/mappings/ src/data/ catalog import and seed fixtures
src/translations/ en (source) · zh-CN
scripts/ pnpm demo — prepare the database, then start with the example loaded
samples/ CSVs for the platform's standard Import (see above)
docs/product/ positioning, data model, design principles
docs/import/ the recorded import walk, screen by screen

Documentation

License

Apache-2.0. See LICENSE.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, '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

Repository files navigation

Duly

Recurring obligation and duty management.

Every role in an organisation owes a set of things on a repeating clock — a monthly return, a quarterly inspection, an annual review, a weekly reconciliation. Most of them are tracked in a spreadsheet, remembered by one person, and discovered late.

Duly turns that spreadsheet into a system: duties are defined once against a role, dispatched automatically each period, completed in one click, and rolled up so every level of management sees the state of play without asking for a status report.

Built on ObjectStack — metadata-driven, Apache-2.0, self-hostable, and an MCP server out of the box.


What makes it different

Most task products let you build this. Duly is opinionated about the ways it goes wrong, and the opinions are enforced by the data model rather than by documentation:

DecisionWhy
A duty is not a task. One duty × one owner × one period = one task, unique by index.Dispatch is idempotent. Re-run it, backfill it, crash halfway — no duplicates, no lock.
Standing duties never generate tasks."Keep the register current" cannot be ticked. Modelling it as a task creates a backlog nobody can close, and users learn to ignore the list.
Due dates are anchored inside the period, with lead time.Otherwise every annual and semi-annual duty lands in the last week of December.
Stagnation is the headline signal, not completion %.last_update_at warns weeks before a due date does. A percentage only describes work that already finished.
Self-declared work is recorded but never scored. The work log is a separate object with no due date and no rollup.One list holding both governed duties and voluntary notes always ends up measuring reporting enthusiasm. Two objects make that impossible rather than merely against the rules.
Managers have exactly one write action: assign.No manager-side status entry, no weekly consolidation form. An assignment fans out to N independent tasks; "3 of 5" is computed, never maintained.
Completion is one click with an undo, and evidence is optional.An evidence gate turns a 5-second tick into a 5-minute chore, and the list stops being used.
Item counts are never ranked or compared.The moment they are, the busiest people log the least.

Quick start

pnpm install
pnpm dev

The Console is at http://localhost:3000/_console/, the REST API at http://localhost:3000/api/v1, and the app is itself an MCP server at /api/v1/mcp. Sign in as admin@objectos.ai / admin123.

Two ways to start it

Duly ships empty. Evaluating it for your own organisation and evaluating the idea are different things, so they are different commands:

CommandWhat you get
pnpm devAn empty Duly. The objects, views and automations are all there; the records are yours to add — define your first duty against a role and watch it dispatch. This is also what a real deployment starts from.
pnpm demoThe same app preloaded with a worked example: Ardenline Group, a fictional manufacturer — three sites, twelve people over a three-level org chart, a catalog of duties, and six months of history behind them, so every view has something in it on the first screen.
pnpm demo:zhThe same worked example in Chinese — 安岭集团, its people, its duty catalog and its history, all in zh-CN, for a demo where the records read the same language as the interface. Identical in every other respect: same objects, same row counts, same history. The account you sign in with is renamed 演示管理员 to match.

pnpm demo prepares the database and then starts the server; it is one command and it works on a clean checkout. Everything it writes is ordinary data, so you can edit or delete any of it.

The two demos are the same fixture in two languages, not two datasets: one org chart, one duty catalog, one history planner, with the display strings resolved through src/data/demo-zh.ts. Machine values — unit codes, period keys, statuses, timezones — are identical in both, which is what keeps a Chinese demo from being a second demo that quietly drifts. The language is chosen at compile time by DULY_DEMO_LOCALE, so switch between them on a database you have already seeded and you will get both organisations in it; rm -rf .objectstack/data first.

To go back to an empty app, delete the local database and start again:

rm -rf .objectstack/data
pnpm dev

Nothing about the fictional organisation is real: every address is on an RFC 2606 reserved domain, and no real company, person, site or regulation is named anywhere in it. The rule holds in Chinese — 安岭集团 is not a company, and every reference the catalog cites is an invented internal document (《集团环境标准 GE-09》第1条), never a national or industry standard.

Every metadata directory is pre-wired into objectstack.config.ts, empty ones included: add your entry to the named array in your own src/<type>/index.ts and leave the config alone. It is the one file parallel branches collide on.

Import your existing list

Every customer already has the list — a spreadsheet per role, usually. Getting it in is the platform's Import button on each object list, not anything Duly wrote: upload, confirm the mapping, import. The CSVs in samples/ are shaped to go straight through it, and they describe the same fictional manufacturer as pnpm demo.

SampleImport it onRows
samples/business-units.csvSetup → People & Organization → Business Units6
samples/people.csvSetup → People & Organization → Users12
samples/catalog-items.csvDuly → Setup → Role catalog21
samples/duties.csvDuly → Setup → All duties19

Three steps

  1. Put your people and units in first. A duty's owner and business unit are looked up by name, so the rows have to exist before the duty file can land. In a real deployment they arrive from your directory; on a fresh pnpm dev database, import business-units.csv and then people.csv through the same Import button.
  2. Import the role catalogcatalog-items.csv on Role catalog. This is the list itself: what each position owes, how often, with how much grace. No lookups, so it goes into an empty app as-is.
  3. Import the dutiesduties.csv on All duties. This is the catalog instantiated onto named people, and it is where the natural keys resolve.

Each step is the same three screens — Upload → Mapping → Preview → import — and the count of created rows is reported at the end, with any refused row named and downloadable.

What the columns have to say

  • Headers are field API names (position_code, due_offset_days). The wizard auto-matches every one of them at high confidence. The Download template link on the Upload step gives you the same columns as labels instead; both are accepted.
  • Lookups are written as names, not ids.owner takes a person's name (Priya Raman) or their email; business_unit takes the unit's name (Northgate Quality — its code will not resolve); catalog_item takes the catalog item's name. This is the same natural-key rule the seed loader uses.
  • A name that matches nothing skips that row and says so, with a Download failed rows file to fix and re-import. Nothing is linked to a best guess.
  • Blank means "leave unset", so the object's defaults apply. That is what lets one file carry all three duty forms: a standing row leaves the five cadence columns empty and lands with them all null, which is exactly what standing_no_frequency requires.
  • Read-only columns are never written. They are visible in the mapping step as — Skip — or (match only), so a column that will not land says so before you import.
  • Re-importing needs the match option.When a row matches an existing record defaults to Always create new; running the same file twice otherwise gives you two copies.

test/import-samples.test.ts holds every sample header to the object's own schema, so renaming a field fails the build instead of quietly importing a blank column.

The full walk, screen by screen, with what each step was measured to do →

Verify before you ship

pnpm validate # protocol schema + CEL predicates + widget bindings
pnpm typecheck # types against @objectstack/spec
pnpm test

ObjectStack metadata fails silently at runtime, not at edit time. Never report a metadata change as done until pnpm validate passes.

Layout

objectstack.config.ts defineStack() — the single entry point
src/objects/ duty · task · catalog_item · assignment · log_entry
src/views/ list / calendar / kanban lenses
src/apps/ src/pages/ navigation and custom screens
src/jobs/ the dispatcher and the alert jobs
src/flows/ src/actions/ assignment fan-out, escalation, one-click completion
src/hooks/ object lifecycle hooks (collected here, not from objects/)
src/functions/ pure callables a `script` flow node resolves by name
src/datasets/ src/dashboards/ the semantic layer and what reads it
src/security/ positions, permission sets, sharing rules
src/mappings/ src/data/ catalog import and seed fixtures
src/translations/ en (source) · zh-CN
scripts/ pnpm demo — prepare the database, then start with the example loaded
samples/ CSVs for the platform's standard Import (see above)
docs/product/ positioning, data model, design principles
docs/import/ the recorded import walk, screen by screen

Documentation

License

Apache-2.0. See LICENSE.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, '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

Repository files navigation

Duly

Recurring obligation and duty management.

Every role in an organisation owes a set of things on a repeating clock — a monthly return, a quarterly inspection, an annual review, a weekly reconciliation. Most of them are tracked in a spreadsheet, remembered by one person, and discovered late.

Duly turns that spreadsheet into a system: duties are defined once against a role, dispatched automatically each period, completed in one click, and rolled up so every level of management sees the state of play without asking for a status report.

Built on ObjectStack — metadata-driven, Apache-2.0, self-hostable, and an MCP server out of the box.


What makes it different

Most task products let you build this. Duly is opinionated about the ways it goes wrong, and the opinions are enforced by the data model rather than by documentation:

DecisionWhy
A duty is not a task. One duty × one owner × one period = one task, unique by index.Dispatch is idempotent. Re-run it, backfill it, crash halfway — no duplicates, no lock.
Standing duties never generate tasks."Keep the register current" cannot be ticked. Modelling it as a task creates a backlog nobody can close, and users learn to ignore the list.
Due dates are anchored inside the period, with lead time.Otherwise every annual and semi-annual duty lands in the last week of December.
Stagnation is the headline signal, not completion %.last_update_at warns weeks before a due date does. A percentage only describes work that already finished.
Self-declared work is recorded but never scored. The work log is a separate object with no due date and no rollup.One list holding both governed duties and voluntary notes always ends up measuring reporting enthusiasm. Two objects make that impossible rather than merely against the rules.
Managers have exactly one write action: assign.No manager-side status entry, no weekly consolidation form. An assignment fans out to N independent tasks; "3 of 5" is computed, never maintained.
Completion is one click with an undo, and evidence is optional.An evidence gate turns a 5-second tick into a 5-minute chore, and the list stops being used.
Item counts are never ranked or compared.The moment they are, the busiest people log the least.

Quick start

pnpm install
pnpm dev

The Console is at http://localhost:3000/_console/, the REST API at http://localhost:3000/api/v1, and the app is itself an MCP server at /api/v1/mcp. Sign in as admin@objectos.ai / admin123.

Two ways to start it

Duly ships empty. Evaluating it for your own organisation and evaluating the idea are different things, so they are different commands:

CommandWhat you get
pnpm devAn empty Duly. The objects, views and automations are all there; the records are yours to add — define your first duty against a role and watch it dispatch. This is also what a real deployment starts from.
pnpm demoThe same app preloaded with a worked example: Ardenline Group, a fictional manufacturer — three sites, twelve people over a three-level org chart, a catalog of duties, and six months of history behind them, so every view has something in it on the first screen.
pnpm demo:zhThe same worked example in Chinese — 安岭集团, its people, its duty catalog and its history, all in zh-CN, for a demo where the records read the same language as the interface. Identical in every other respect: same objects, same row counts, same history. The account you sign in with is renamed 演示管理员 to match.

pnpm demo prepares the database and then starts the server; it is one command and it works on a clean checkout. Everything it writes is ordinary data, so you can edit or delete any of it.

The two demos are the same fixture in two languages, not two datasets: one org chart, one duty catalog, one history planner, with the display strings resolved through src/data/demo-zh.ts. Machine values — unit codes, period keys, statuses, timezones — are identical in both, which is what keeps a Chinese demo from being a second demo that quietly drifts. The language is chosen at compile time by DULY_DEMO_LOCALE, so switch between them on a database you have already seeded and you will get both organisations in it; rm -rf .objectstack/data first.

To go back to an empty app, delete the local database and start again:

rm -rf .objectstack/data
pnpm dev

Nothing about the fictional organisation is real: every address is on an RFC 2606 reserved domain, and no real company, person, site or regulation is named anywhere in it. The rule holds in Chinese — 安岭集团 is not a company, and every reference the catalog cites is an invented internal document (《集团环境标准 GE-09》第1条), never a national or industry standard.

Every metadata directory is pre-wired into objectstack.config.ts, empty ones included: add your entry to the named array in your own src/<type>/index.ts and leave the config alone. It is the one file parallel branches collide on.

Import your existing list

Every customer already has the list — a spreadsheet per role, usually. Getting it in is the platform's Import button on each object list, not anything Duly wrote: upload, confirm the mapping, import. The CSVs in samples/ are shaped to go straight through it, and they describe the same fictional manufacturer as pnpm demo.

SampleImport it onRows
samples/business-units.csvSetup → People & Organization → Business Units6
samples/people.csvSetup → People & Organization → Users12
samples/catalog-items.csvDuly → Setup → Role catalog21
samples/duties.csvDuly → Setup → All duties19

Three steps

  1. Put your people and units in first. A duty's owner and business unit are looked up by name, so the rows have to exist before the duty file can land. In a real deployment they arrive from your directory; on a fresh pnpm dev database, import business-units.csv and then people.csv through the same Import button.
  2. Import the role catalogcatalog-items.csv on Role catalog. This is the list itself: what each position owes, how often, with how much grace. No lookups, so it goes into an empty app as-is.
  3. Import the dutiesduties.csv on All duties. This is the catalog instantiated onto named people, and it is where the natural keys resolve.

Each step is the same three screens — Upload → Mapping → Preview → import — and the count of created rows is reported at the end, with any refused row named and downloadable.

What the columns have to say

  • Headers are field API names (position_code, due_offset_days). The wizard auto-matches every one of them at high confidence. The Download template link on the Upload step gives you the same columns as labels instead; both are accepted.
  • Lookups are written as names, not ids.owner takes a person's name (Priya Raman) or their email; business_unit takes the unit's name (Northgate Quality — its code will not resolve); catalog_item takes the catalog item's name. This is the same natural-key rule the seed loader uses.
  • A name that matches nothing skips that row and says so, with a Download failed rows file to fix and re-import. Nothing is linked to a best guess.
  • Blank means "leave unset", so the object's defaults apply. That is what lets one file carry all three duty forms: a standing row leaves the five cadence columns empty and lands with them all null, which is exactly what standing_no_frequency requires.
  • Read-only columns are never written. They are visible in the mapping step as — Skip — or (match only), so a column that will not land says so before you import.
  • Re-importing needs the match option.When a row matches an existing record defaults to Always create new; running the same file twice otherwise gives you two copies.

test/import-samples.test.ts holds every sample header to the object's own schema, so renaming a field fails the build instead of quietly importing a blank column.

The full walk, screen by screen, with what each step was measured to do →

Verify before you ship

pnpm validate # protocol schema + CEL predicates + widget bindings
pnpm typecheck # types against @objectstack/spec
pnpm test

ObjectStack metadata fails silently at runtime, not at edit time. Never report a metadata change as done until pnpm validate passes.

Layout

objectstack.config.ts defineStack() — the single entry point
src/objects/ duty · task · catalog_item · assignment · log_entry
src/views/ list / calendar / kanban lenses
src/apps/ src/pages/ navigation and custom screens
src/jobs/ the dispatcher and the alert jobs
src/flows/ src/actions/ assignment fan-out, escalation, one-click completion
src/hooks/ object lifecycle hooks (collected here, not from objects/)
src/functions/ pure callables a `script` flow node resolves by name
src/datasets/ src/dashboards/ the semantic layer and what reads it
src/security/ positions, permission sets, sharing rules
src/mappings/ src/data/ catalog import and seed fixtures
src/translations/ en (source) · zh-CN
scripts/ pnpm demo — prepare the database, then start with the example loaded
samples/ CSVs for the platform's standard Import (see above)
docs/product/ positioning, data model, design principles
docs/import/ the recorded import walk, screen by screen

Documentation

License

Apache-2.0. See LICENSE.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, '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

Repository files navigation

Duly

Recurring obligation and duty management.

Every role in an organisation owes a set of things on a repeating clock — a monthly return, a quarterly inspection, an annual review, a weekly reconciliation. Most of them are tracked in a spreadsheet, remembered by one person, and discovered late.

Duly turns that spreadsheet into a system: duties are defined once against a role, dispatched automatically each period, completed in one click, and rolled up so every level of management sees the state of play without asking for a status report.

Built on ObjectStack — metadata-driven, Apache-2.0, self-hostable, and an MCP server out of the box.


What makes it different

Most task products let you build this. Duly is opinionated about the ways it goes wrong, and the opinions are enforced by the data model rather than by documentation:

DecisionWhy
A duty is not a task. One duty × one owner × one period = one task, unique by index.Dispatch is idempotent. Re-run it, backfill it, crash halfway — no duplicates, no lock.
Standing duties never generate tasks."Keep the register current" cannot be ticked. Modelling it as a task creates a backlog nobody can close, and users learn to ignore the list.
Due dates are anchored inside the period, with lead time.Otherwise every annual and semi-annual duty lands in the last week of December.
Stagnation is the headline signal, not completion %.last_update_at warns weeks before a due date does. A percentage only describes work that already finished.
Self-declared work is recorded but never scored. The work log is a separate object with no due date and no rollup.One list holding both governed duties and voluntary notes always ends up measuring reporting enthusiasm. Two objects make that impossible rather than merely against the rules.
Managers have exactly one write action: assign.No manager-side status entry, no weekly consolidation form. An assignment fans out to N independent tasks; "3 of 5" is computed, never maintained.
Completion is one click with an undo, and evidence is optional.An evidence gate turns a 5-second tick into a 5-minute chore, and the list stops being used.
Item counts are never ranked or compared.The moment they are, the busiest people log the least.

Quick start

pnpm install
pnpm dev

The Console is at http://localhost:3000/_console/, the REST API at http://localhost:3000/api/v1, and the app is itself an MCP server at /api/v1/mcp. Sign in as admin@objectos.ai / admin123.

Two ways to start it

Duly ships empty. Evaluating it for your own organisation and evaluating the idea are different things, so they are different commands:

CommandWhat you get
pnpm devAn empty Duly. The objects, views and automations are all there; the records are yours to add — define your first duty against a role and watch it dispatch. This is also what a real deployment starts from.
pnpm demoThe same app preloaded with a worked example: Ardenline Group, a fictional manufacturer — three sites, twelve people over a three-level org chart, a catalog of duties, and six months of history behind them, so every view has something in it on the first screen.
pnpm demo:zhThe same worked example in Chinese — 安岭集团, its people, its duty catalog and its history, all in zh-CN, for a demo where the records read the same language as the interface. Identical in every other respect: same objects, same row counts, same history. The account you sign in with is renamed 演示管理员 to match.

pnpm demo prepares the database and then starts the server; it is one command and it works on a clean checkout. Everything it writes is ordinary data, so you can edit or delete any of it.

The two demos are the same fixture in two languages, not two datasets: one org chart, one duty catalog, one history planner, with the display strings resolved through src/data/demo-zh.ts. Machine values — unit codes, period keys, statuses, timezones — are identical in both, which is what keeps a Chinese demo from being a second demo that quietly drifts. The language is chosen at compile time by DULY_DEMO_LOCALE, so switch between them on a database you have already seeded and you will get both organisations in it; rm -rf .objectstack/data first.

To go back to an empty app, delete the local database and start again:

rm -rf .objectstack/data
pnpm dev

Nothing about the fictional organisation is real: every address is on an RFC 2606 reserved domain, and no real company, person, site or regulation is named anywhere in it. The rule holds in Chinese — 安岭集团 is not a company, and every reference the catalog cites is an invented internal document (《集团环境标准 GE-09》第1条), never a national or industry standard.

Every metadata directory is pre-wired into objectstack.config.ts, empty ones included: add your entry to the named array in your own src/<type>/index.ts and leave the config alone. It is the one file parallel branches collide on.

Import your existing list

Every customer already has the list — a spreadsheet per role, usually. Getting it in is the platform's Import button on each object list, not anything Duly wrote: upload, confirm the mapping, import. The CSVs in samples/ are shaped to go straight through it, and they describe the same fictional manufacturer as pnpm demo.

SampleImport it onRows
samples/business-units.csvSetup → People & Organization → Business Units6
samples/people.csvSetup → People & Organization → Users12
samples/catalog-items.csvDuly → Setup → Role catalog21
samples/duties.csvDuly → Setup → All duties19

Three steps

  1. Put your people and units in first. A duty's owner and business unit are looked up by name, so the rows have to exist before the duty file can land. In a real deployment they arrive from your directory; on a fresh pnpm dev database, import business-units.csv and then people.csv through the same Import button.
  2. Import the role catalogcatalog-items.csv on Role catalog. This is the list itself: what each position owes, how often, with how much grace. No lookups, so it goes into an empty app as-is.
  3. Import the dutiesduties.csv on All duties. This is the catalog instantiated onto named people, and it is where the natural keys resolve.

Each step is the same three screens — Upload → Mapping → Preview → import — and the count of created rows is reported at the end, with any refused row named and downloadable.

What the columns have to say

  • Headers are field API names (position_code, due_offset_days). The wizard auto-matches every one of them at high confidence. The Download template link on the Upload step gives you the same columns as labels instead; both are accepted.
  • Lookups are written as names, not ids.owner takes a person's name (Priya Raman) or their email; business_unit takes the unit's name (Northgate Quality — its code will not resolve); catalog_item takes the catalog item's name. This is the same natural-key rule the seed loader uses.
  • A name that matches nothing skips that row and says so, with a Download failed rows file to fix and re-import. Nothing is linked to a best guess.
  • Blank means "leave unset", so the object's defaults apply. That is what lets one file carry all three duty forms: a standing row leaves the five cadence columns empty and lands with them all null, which is exactly what standing_no_frequency requires.
  • Read-only columns are never written. They are visible in the mapping step as — Skip — or (match only), so a column that will not land says so before you import.
  • Re-importing needs the match option.When a row matches an existing record defaults to Always create new; running the same file twice otherwise gives you two copies.

test/import-samples.test.ts holds every sample header to the object's own schema, so renaming a field fails the build instead of quietly importing a blank column.

The full walk, screen by screen, with what each step was measured to do →

Verify before you ship

pnpm validate # protocol schema + CEL predicates + widget bindings
pnpm typecheck # types against @objectstack/spec
pnpm test

ObjectStack metadata fails silently at runtime, not at edit time. Never report a metadata change as done until pnpm validate passes.

Layout

objectstack.config.ts defineStack() — the single entry point
src/objects/ duty · task · catalog_item · assignment · log_entry
src/views/ list / calendar / kanban lenses
src/apps/ src/pages/ navigation and custom screens
src/jobs/ the dispatcher and the alert jobs
src/flows/ src/actions/ assignment fan-out, escalation, one-click completion
src/hooks/ object lifecycle hooks (collected here, not from objects/)
src/functions/ pure callables a `script` flow node resolves by name
src/datasets/ src/dashboards/ the semantic layer and what reads it
src/security/ positions, permission sets, sharing rules
src/mappings/ src/data/ catalog import and seed fixtures
src/translations/ en (source) · zh-CN
scripts/ pnpm demo — prepare the database, then start with the example loaded
samples/ CSVs for the platform's standard Import (see above)
docs/product/ positioning, data model, design principles
docs/import/ the recorded import walk, screen by screen

Documentation

License

Apache-2.0. See LICENSE.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, '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

Repository files navigation

Duly

Recurring obligation and duty management.

Every role in an organisation owes a set of things on a repeating clock — a monthly return, a quarterly inspection, an annual review, a weekly reconciliation. Most of them are tracked in a spreadsheet, remembered by one person, and discovered late.

Duly turns that spreadsheet into a system: duties are defined once against a role, dispatched automatically each period, completed in one click, and rolled up so every level of management sees the state of play without asking for a status report.

Built on ObjectStack — metadata-driven, Apache-2.0, self-hostable, and an MCP server out of the box.


What makes it different

Most task products let you build this. Duly is opinionated about the ways it goes wrong, and the opinions are enforced by the data model rather than by documentation:

DecisionWhy
A duty is not a task. One duty × one owner × one period = one task, unique by index.Dispatch is idempotent. Re-run it, backfill it, crash halfway — no duplicates, no lock.
Standing duties never generate tasks."Keep the register current" cannot be ticked. Modelling it as a task creates a backlog nobody can close, and users learn to ignore the list.
Due dates are anchored inside the period, with lead time.Otherwise every annual and semi-annual duty lands in the last week of December.
Stagnation is the headline signal, not completion %.last_update_at warns weeks before a due date does. A percentage only describes work that already finished.
Self-declared work is recorded but never scored. The work log is a separate object with no due date and no rollup.One list holding both governed duties and voluntary notes always ends up measuring reporting enthusiasm. Two objects make that impossible rather than merely against the rules.
Managers have exactly one write action: assign.No manager-side status entry, no weekly consolidation form. An assignment fans out to N independent tasks; "3 of 5" is computed, never maintained.
Completion is one click with an undo, and evidence is optional.An evidence gate turns a 5-second tick into a 5-minute chore, and the list stops being used.
Item counts are never ranked or compared.The moment they are, the busiest people log the least.

Quick start

pnpm install
pnpm dev

The Console is at http://localhost:3000/_console/, the REST API at http://localhost:3000/api/v1, and the app is itself an MCP server at /api/v1/mcp. Sign in as admin@objectos.ai / admin123.

Two ways to start it

Duly ships empty. Evaluating it for your own organisation and evaluating the idea are different things, so they are different commands:

CommandWhat you get
pnpm devAn empty Duly. The objects, views and automations are all there; the records are yours to add — define your first duty against a role and watch it dispatch. This is also what a real deployment starts from.
pnpm demoThe same app preloaded with a worked example: Ardenline Group, a fictional manufacturer — three sites, twelve people over a three-level org chart, a catalog of duties, and six months of history behind them, so every view has something in it on the first screen.
pnpm demo:zhThe same worked example in Chinese — 安岭集团, its people, its duty catalog and its history, all in zh-CN, for a demo where the records read the same language as the interface. Identical in every other respect: same objects, same row counts, same history. The account you sign in with is renamed 演示管理员 to match.

pnpm demo prepares the database and then starts the server; it is one command and it works on a clean checkout. Everything it writes is ordinary data, so you can edit or delete any of it.

The two demos are the same fixture in two languages, not two datasets: one org chart, one duty catalog, one history planner, with the display strings resolved through src/data/demo-zh.ts. Machine values — unit codes, period keys, statuses, timezones — are identical in both, which is what keeps a Chinese demo from being a second demo that quietly drifts. The language is chosen at compile time by DULY_DEMO_LOCALE, so switch between them on a database you have already seeded and you will get both organisations in it; rm -rf .objectstack/data first.

To go back to an empty app, delete the local database and start again:

rm -rf .objectstack/data
pnpm dev

Nothing about the fictional organisation is real: every address is on an RFC 2606 reserved domain, and no real company, person, site or regulation is named anywhere in it. The rule holds in Chinese — 安岭集团 is not a company, and every reference the catalog cites is an invented internal document (《集团环境标准 GE-09》第1条), never a national or industry standard.

Every metadata directory is pre-wired into objectstack.config.ts, empty ones included: add your entry to the named array in your own src/<type>/index.ts and leave the config alone. It is the one file parallel branches collide on.

Import your existing list

Every customer already has the list — a spreadsheet per role, usually. Getting it in is the platform's Import button on each object list, not anything Duly wrote: upload, confirm the mapping, import. The CSVs in samples/ are shaped to go straight through it, and they describe the same fictional manufacturer as pnpm demo.

SampleImport it onRows
samples/business-units.csvSetup → People & Organization → Business Units6
samples/people.csvSetup → People & Organization → Users12
samples/catalog-items.csvDuly → Setup → Role catalog21
samples/duties.csvDuly → Setup → All duties19

Three steps

  1. Put your people and units in first. A duty's owner and business unit are looked up by name, so the rows have to exist before the duty file can land. In a real deployment they arrive from your directory; on a fresh pnpm dev database, import business-units.csv and then people.csv through the same Import button.
  2. Import the role catalogcatalog-items.csv on Role catalog. This is the list itself: what each position owes, how often, with how much grace. No lookups, so it goes into an empty app as-is.
  3. Import the dutiesduties.csv on All duties. This is the catalog instantiated onto named people, and it is where the natural keys resolve.

Each step is the same three screens — Upload → Mapping → Preview → import — and the count of created rows is reported at the end, with any refused row named and downloadable.

What the columns have to say

  • Headers are field API names (position_code, due_offset_days). The wizard auto-matches every one of them at high confidence. The Download template link on the Upload step gives you the same columns as labels instead; both are accepted.
  • Lookups are written as names, not ids.owner takes a person's name (Priya Raman) or their email; business_unit takes the unit's name (Northgate Quality — its code will not resolve); catalog_item takes the catalog item's name. This is the same natural-key rule the seed loader uses.
  • A name that matches nothing skips that row and says so, with a Download failed rows file to fix and re-import. Nothing is linked to a best guess.
  • Blank means "leave unset", so the object's defaults apply. That is what lets one file carry all three duty forms: a standing row leaves the five cadence columns empty and lands with them all null, which is exactly what standing_no_frequency requires.
  • Read-only columns are never written. They are visible in the mapping step as — Skip — or (match only), so a column that will not land says so before you import.
  • Re-importing needs the match option.When a row matches an existing record defaults to Always create new; running the same file twice otherwise gives you two copies.

test/import-samples.test.ts holds every sample header to the object's own schema, so renaming a field fails the build instead of quietly importing a blank column.

The full walk, screen by screen, with what each step was measured to do →

Verify before you ship

pnpm validate # protocol schema + CEL predicates + widget bindings
pnpm typecheck # types against @objectstack/spec
pnpm test

ObjectStack metadata fails silently at runtime, not at edit time. Never report a metadata change as done until pnpm validate passes.

Layout

objectstack.config.ts defineStack() — the single entry point
src/objects/ duty · task · catalog_item · assignment · log_entry
src/views/ list / calendar / kanban lenses
src/apps/ src/pages/ navigation and custom screens
src/jobs/ the dispatcher and the alert jobs
src/flows/ src/actions/ assignment fan-out, escalation, one-click completion
src/hooks/ object lifecycle hooks (collected here, not from objects/)
src/functions/ pure callables a `script` flow node resolves by name
src/datasets/ src/dashboards/ the semantic layer and what reads it
src/security/ positions, permission sets, sharing rules
src/mappings/ src/data/ catalog import and seed fixtures
src/translations/ en (source) · zh-CN
scripts/ pnpm demo — prepare the database, then start with the example loaded
samples/ CSVs for the platform's standard Import (see above)
docs/product/ positioning, data model, design principles
docs/import/ the recorded import walk, screen by screen

Documentation

License

Apache-2.0. See LICENSE.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, '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

Repository files navigation

Duly

Recurring obligation and duty management.

Every role in an organisation owes a set of things on a repeating clock — a monthly return, a quarterly inspection, an annual review, a weekly reconciliation. Most of them are tracked in a spreadsheet, remembered by one person, and discovered late.

Duly turns that spreadsheet into a system: duties are defined once against a role, dispatched automatically each period, completed in one click, and rolled up so every level of management sees the state of play without asking for a status report.

Built on ObjectStack — metadata-driven, Apache-2.0, self-hostable, and an MCP server out of the box.


What makes it different

Most task products let you build this. Duly is opinionated about the ways it goes wrong, and the opinions are enforced by the data model rather than by documentation:

DecisionWhy
A duty is not a task. One duty × one owner × one period = one task, unique by index.Dispatch is idempotent. Re-run it, backfill it, crash halfway — no duplicates, no lock.
Standing duties never generate tasks."Keep the register current" cannot be ticked. Modelling it as a task creates a backlog nobody can close, and users learn to ignore the list.
Due dates are anchored inside the period, with lead time.Otherwise every annual and semi-annual duty lands in the last week of December.
Stagnation is the headline signal, not completion %.last_update_at warns weeks before a due date does. A percentage only describes work that already finished.
Self-declared work is recorded but never scored. The work log is a separate object with no due date and no rollup.One list holding both governed duties and voluntary notes always ends up measuring reporting enthusiasm. Two objects make that impossible rather than merely against the rules.
Managers have exactly one write action: assign.No manager-side status entry, no weekly consolidation form. An assignment fans out to N independent tasks; "3 of 5" is computed, never maintained.
Completion is one click with an undo, and evidence is optional.An evidence gate turns a 5-second tick into a 5-minute chore, and the list stops being used.
Item counts are never ranked or compared.The moment they are, the busiest people log the least.

Quick start

pnpm install
pnpm dev

The Console is at http://localhost:3000/_console/, the REST API at http://localhost:3000/api/v1, and the app is itself an MCP server at /api/v1/mcp. Sign in as admin@objectos.ai / admin123.

Two ways to start it

Duly ships empty. Evaluating it for your own organisation and evaluating the idea are different things, so they are different commands:

CommandWhat you get
pnpm devAn empty Duly. The objects, views and automations are all there; the records are yours to add — define your first duty against a role and watch it dispatch. This is also what a real deployment starts from.
pnpm demoThe same app preloaded with a worked example: Ardenline Group, a fictional manufacturer — three sites, twelve people over a three-level org chart, a catalog of duties, and six months of history behind them, so every view has something in it on the first screen.
pnpm demo:zhThe same worked example in Chinese — 安岭集团, its people, its duty catalog and its history, all in zh-CN, for a demo where the records read the same language as the interface. Identical in every other respect: same objects, same row counts, same history. The account you sign in with is renamed 演示管理员 to match.

pnpm demo prepares the database and then starts the server; it is one command and it works on a clean checkout. Everything it writes is ordinary data, so you can edit or delete any of it.

The two demos are the same fixture in two languages, not two datasets: one org chart, one duty catalog, one history planner, with the display strings resolved through src/data/demo-zh.ts. Machine values — unit codes, period keys, statuses, timezones — are identical in both, which is what keeps a Chinese demo from being a second demo that quietly drifts. The language is chosen at compile time by DULY_DEMO_LOCALE, so switch between them on a database you have already seeded and you will get both organisations in it; rm -rf .objectstack/data first.

To go back to an empty app, delete the local database and start again:

rm -rf .objectstack/data
pnpm dev

Nothing about the fictional organisation is real: every address is on an RFC 2606 reserved domain, and no real company, person, site or regulation is named anywhere in it. The rule holds in Chinese — 安岭集团 is not a company, and every reference the catalog cites is an invented internal document (《集团环境标准 GE-09》第1条), never a national or industry standard.

Every metadata directory is pre-wired into objectstack.config.ts, empty ones included: add your entry to the named array in your own src/<type>/index.ts and leave the config alone. It is the one file parallel branches collide on.

Import your existing list

Every customer already has the list — a spreadsheet per role, usually. Getting it in is the platform's Import button on each object list, not anything Duly wrote: upload, confirm the mapping, import. The CSVs in samples/ are shaped to go straight through it, and they describe the same fictional manufacturer as pnpm demo.

SampleImport it onRows
samples/business-units.csvSetup → People & Organization → Business Units6
samples/people.csvSetup → People & Organization → Users12
samples/catalog-items.csvDuly → Setup → Role catalog21
samples/duties.csvDuly → Setup → All duties19

Three steps

  1. Put your people and units in first. A duty's owner and business unit are looked up by name, so the rows have to exist before the duty file can land. In a real deployment they arrive from your directory; on a fresh pnpm dev database, import business-units.csv and then people.csv through the same Import button.
  2. Import the role catalogcatalog-items.csv on Role catalog. This is the list itself: what each position owes, how often, with how much grace. No lookups, so it goes into an empty app as-is.
  3. Import the dutiesduties.csv on All duties. This is the catalog instantiated onto named people, and it is where the natural keys resolve.

Each step is the same three screens — Upload → Mapping → Preview → import — and the count of created rows is reported at the end, with any refused row named and downloadable.

What the columns have to say

  • Headers are field API names (position_code, due_offset_days). The wizard auto-matches every one of them at high confidence. The Download template link on the Upload step gives you the same columns as labels instead; both are accepted.
  • Lookups are written as names, not ids.owner takes a person's name (Priya Raman) or their email; business_unit takes the unit's name (Northgate Quality — its code will not resolve); catalog_item takes the catalog item's name. This is the same natural-key rule the seed loader uses.
  • A name that matches nothing skips that row and says so, with a Download failed rows file to fix and re-import. Nothing is linked to a best guess.
  • Blank means "leave unset", so the object's defaults apply. That is what lets one file carry all three duty forms: a standing row leaves the five cadence columns empty and lands with them all null, which is exactly what standing_no_frequency requires.
  • Read-only columns are never written. They are visible in the mapping step as — Skip — or (match only), so a column that will not land says so before you import.
  • Re-importing needs the match option.When a row matches an existing record defaults to Always create new; running the same file twice otherwise gives you two copies.

test/import-samples.test.ts holds every sample header to the object's own schema, so renaming a field fails the build instead of quietly importing a blank column.

The full walk, screen by screen, with what each step was measured to do →

Verify before you ship

pnpm validate # protocol schema + CEL predicates + widget bindings
pnpm typecheck # types against @objectstack/spec
pnpm test

ObjectStack metadata fails silently at runtime, not at edit time. Never report a metadata change as done until pnpm validate passes.

Layout

objectstack.config.ts defineStack() — the single entry point
src/objects/ duty · task · catalog_item · assignment · log_entry
src/views/ list / calendar / kanban lenses
src/apps/ src/pages/ navigation and custom screens
src/jobs/ the dispatcher and the alert jobs
src/flows/ src/actions/ assignment fan-out, escalation, one-click completion
src/hooks/ object lifecycle hooks (collected here, not from objects/)
src/functions/ pure callables a `script` flow node resolves by name
src/datasets/ src/dashboards/ the semantic layer and what reads it
src/security/ positions, permission sets, sharing rules
src/mappings/ src/data/ catalog import and seed fixtures
src/translations/ en (source) · zh-CN
scripts/ pnpm demo — prepare the database, then start with the example loaded
samples/ CSVs for the platform's standard Import (see above)
docs/product/ positioning, data model, design principles
docs/import/ the recorded import walk, screen by screen

Documentation

License

Apache-2.0. See LICENSE.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages

, '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

Repository files navigation

Duly

Recurring obligation and duty management.

Every role in an organisation owes a set of things on a repeating clock — a monthly return, a quarterly inspection, an annual review, a weekly reconciliation. Most of them are tracked in a spreadsheet, remembered by one person, and discovered late.

Duly turns that spreadsheet into a system: duties are defined once against a role, dispatched automatically each period, completed in one click, and rolled up so every level of management sees the state of play without asking for a status report.

Built on ObjectStack — metadata-driven, Apache-2.0, self-hostable, and an MCP server out of the box.


What makes it different

Most task products let you build this. Duly is opinionated about the ways it goes wrong, and the opinions are enforced by the data model rather than by documentation:

DecisionWhy
A duty is not a task. One duty × one owner × one period = one task, unique by index.Dispatch is idempotent. Re-run it, backfill it, crash halfway — no duplicates, no lock.
Standing duties never generate tasks."Keep the register current" cannot be ticked. Modelling it as a task creates a backlog nobody can close, and users learn to ignore the list.
Due dates are anchored inside the period, with lead time.Otherwise every annual and semi-annual duty lands in the last week of December.
Stagnation is the headline signal, not completion %.last_update_at warns weeks before a due date does. A percentage only describes work that already finished.
Self-declared work is recorded but never scored. The work log is a separate object with no due date and no rollup.One list holding both governed duties and voluntary notes always ends up measuring reporting enthusiasm. Two objects make that impossible rather than merely against the rules.
Managers have exactly one write action: assign.No manager-side status entry, no weekly consolidation form. An assignment fans out to N independent tasks; "3 of 5" is computed, never maintained.
Completion is one click with an undo, and evidence is optional.An evidence gate turns a 5-second tick into a 5-minute chore, and the list stops being used.
Item counts are never ranked or compared.The moment they are, the busiest people log the least.

Quick start

pnpm install
pnpm dev

The Console is at http://localhost:3000/_console/, the REST API at http://localhost:3000/api/v1, and the app is itself an MCP server at /api/v1/mcp. Sign in as admin@objectos.ai / admin123.

Two ways to start it

Duly ships empty. Evaluating it for your own organisation and evaluating the idea are different things, so they are different commands:

CommandWhat you get
pnpm devAn empty Duly. The objects, views and automations are all there; the records are yours to add — define your first duty against a role and watch it dispatch. This is also what a real deployment starts from.
pnpm demoThe same app preloaded with a worked example: Ardenline Group, a fictional manufacturer — three sites, twelve people over a three-level org chart, a catalog of duties, and six months of history behind them, so every view has something in it on the first screen.
pnpm demo:zhThe same worked example in Chinese — 安岭集团, its people, its duty catalog and its history, all in zh-CN, for a demo where the records read the same language as the interface. Identical in every other respect: same objects, same row counts, same history. The account you sign in with is renamed 演示管理员 to match.

pnpm demo prepares the database and then starts the server; it is one command and it works on a clean checkout. Everything it writes is ordinary data, so you can edit or delete any of it.

The two demos are the same fixture in two languages, not two datasets: one org chart, one duty catalog, one history planner, with the display strings resolved through src/data/demo-zh.ts. Machine values — unit codes, period keys, statuses, timezones — are identical in both, which is what keeps a Chinese demo from being a second demo that quietly drifts. The language is chosen at compile time by DULY_DEMO_LOCALE, so switch between them on a database you have already seeded and you will get both organisations in it; rm -rf .objectstack/data first.

To go back to an empty app, delete the local database and start again:

rm -rf .objectstack/data
pnpm dev

Nothing about the fictional organisation is real: every address is on an RFC 2606 reserved domain, and no real company, person, site or regulation is named anywhere in it. The rule holds in Chinese — 安岭集团 is not a company, and every reference the catalog cites is an invented internal document (《集团环境标准 GE-09》第1条), never a national or industry standard.

Every metadata directory is pre-wired into objectstack.config.ts, empty ones included: add your entry to the named array in your own src/<type>/index.ts and leave the config alone. It is the one file parallel branches collide on.

Import your existing list

Every customer already has the list — a spreadsheet per role, usually. Getting it in is the platform's Import button on each object list, not anything Duly wrote: upload, confirm the mapping, import. The CSVs in samples/ are shaped to go straight through it, and they describe the same fictional manufacturer as pnpm demo.

SampleImport it onRows
samples/business-units.csvSetup → People & Organization → Business Units6
samples/people.csvSetup → People & Organization → Users12
samples/catalog-items.csvDuly → Setup → Role catalog21
samples/duties.csvDuly → Setup → All duties19

Three steps

  1. Put your people and units in first. A duty's owner and business unit are looked up by name, so the rows have to exist before the duty file can land. In a real deployment they arrive from your directory; on a fresh pnpm dev database, import business-units.csv and then people.csv through the same Import button.
  2. Import the role catalogcatalog-items.csv on Role catalog. This is the list itself: what each position owes, how often, with how much grace. No lookups, so it goes into an empty app as-is.
  3. Import the dutiesduties.csv on All duties. This is the catalog instantiated onto named people, and it is where the natural keys resolve.

Each step is the same three screens — Upload → Mapping → Preview → import — and the count of created rows is reported at the end, with any refused row named and downloadable.

What the columns have to say

  • Headers are field API names (position_code, due_offset_days). The wizard auto-matches every one of them at high confidence. The Download template link on the Upload step gives you the same columns as labels instead; both are accepted.
  • Lookups are written as names, not ids.owner takes a person's name (Priya Raman) or their email; business_unit takes the unit's name (Northgate Quality — its code will not resolve); catalog_item takes the catalog item's name. This is the same natural-key rule the seed loader uses.
  • A name that matches nothing skips that row and says so, with a Download failed rows file to fix and re-import. Nothing is linked to a best guess.
  • Blank means "leave unset", so the object's defaults apply. That is what lets one file carry all three duty forms: a standing row leaves the five cadence columns empty and lands with them all null, which is exactly what standing_no_frequency requires.
  • Read-only columns are never written. They are visible in the mapping step as — Skip — or (match only), so a column that will not land says so before you import.
  • Re-importing needs the match option.When a row matches an existing record defaults to Always create new; running the same file twice otherwise gives you two copies.

test/import-samples.test.ts holds every sample header to the object's own schema, so renaming a field fails the build instead of quietly importing a blank column.

The full walk, screen by screen, with what each step was measured to do →

Verify before you ship

pnpm validate # protocol schema + CEL predicates + widget bindings
pnpm typecheck # types against @objectstack/spec
pnpm test

ObjectStack metadata fails silently at runtime, not at edit time. Never report a metadata change as done until pnpm validate passes.

Layout

objectstack.config.ts defineStack() — the single entry point
src/objects/ duty · task · catalog_item · assignment · log_entry
src/views/ list / calendar / kanban lenses
src/apps/ src/pages/ navigation and custom screens
src/jobs/ the dispatcher and the alert jobs
src/flows/ src/actions/ assignment fan-out, escalation, one-click completion
src/hooks/ object lifecycle hooks (collected here, not from objects/)
src/functions/ pure callables a `script` flow node resolves by name
src/datasets/ src/dashboards/ the semantic layer and what reads it
src/security/ positions, permission sets, sharing rules
src/mappings/ src/data/ catalog import and seed fixtures
src/translations/ en (source) · zh-CN
scripts/ pnpm demo — prepare the database, then start with the example loaded
samples/ CSVs for the platform's standard Import (see above)
docs/product/ positioning, data model, design principles
docs/import/ the recorded import walk, screen by screen

Documentation

License

Apache-2.0. See LICENSE.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages